commit e2b687e4894afa14c07e037f9c36b6e1a417480f
parent 090c93d73b7562e20e1129c4c85bc14209995501
Author: William Casarin <jb55@jb55.com>
Date: Fri, 27 Feb 2026 12:40:06 -0800
chrome: painter-drawn toolbar icons with active/filled state
Replace SVG-based toolbar icons with painter-drawn versions for home
(house), chat (envelope), search (magnifying glass), and notifications
(bell). Icons fill in solid when their tab is active. Track the active
toolbar tab based on the current app and Columns route.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
3 files changed, 345 insertions(+), 101 deletions(-)
diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs
@@ -333,6 +333,23 @@ impl Chrome {
}
}
+ /// Returns which ChromeToolbarAction is currently "active" based on
+ /// the active app and its route. Used to highlight the current tab.
+ fn active_toolbar_tab(&self, accounts: ¬edeck::Accounts) -> Option<ChromeToolbarAction> {
+ let active_app = &self.apps[self.active as usize];
+ match active_app {
+ #[cfg(feature = "messages")]
+ NotedeckApp::Messages(_) => Some(ChromeToolbarAction::Chat),
+ NotedeckApp::Columns(columns) => match columns.active_toolbar_tab(accounts) {
+ Some(0) => Some(ChromeToolbarAction::Home),
+ Some(1) => Some(ChromeToolbarAction::Search),
+ Some(2) => Some(ChromeToolbarAction::Notifications),
+ _ => None,
+ },
+ _ => None,
+ }
+ }
+
pub fn set_active(&mut self, app: i32) {
self.active = app;
if let Some(opened) = self.opened.get_mut(app as usize) {
@@ -438,19 +455,22 @@ impl Chrome {
0.0
};
- let is_mobile = notedeck::ui::is_narrow(ui.ctx());
- let toolbar_height = if is_mobile {
+ let is_narrow = notedeck::ui::is_narrow(ui.ctx());
+ let toolbar_height = if is_narrow {
toolbar_visibility_height(skb_anim.skb_rect, ui)
} else {
0.0
};
- let unseen_notifications = if is_mobile {
- self.get_columns_app()
+ let (unseen_notifications, active_toolbar_tab) = if is_narrow {
+ let unseen = self
+ .get_columns_app()
.map(|c| c.has_unseen_notifications(ctx.accounts))
- .unwrap_or(false)
+ .unwrap_or(false);
+ let active = self.active_toolbar_tab(ctx.accounts);
+ (unseen, active)
} else {
- false
+ (false, None)
};
// if the soft keyboard is open, shrink the chrome contents
@@ -473,7 +493,8 @@ impl Chrome {
// mobile toolbar
strip.cell(|ui| {
if toolbar_height > 0.0 {
- toolbar_action = chrome_toolbar(ui, unseen_notifications);
+ toolbar_action =
+ chrome_toolbar(ui, unseen_notifications, active_toolbar_tab);
}
});
@@ -588,7 +609,11 @@ fn toolbar_visibility_height(skb_rect: Option<Rect>, ui: &mut Ui) -> f32 {
}
/// Render the Chrome mobile toolbar (Home, Chat, Search, Notifications).
-fn chrome_toolbar(ui: &mut Ui, unseen_notifications: bool) -> Option<ChromeToolbarAction> {
+fn chrome_toolbar(
+ ui: &mut Ui,
+ unseen_notifications: bool,
+ active_tab: Option<ChromeToolbarAction>,
+) -> Option<ChromeToolbarAction> {
use egui_tabs::{TabColor, Tabs};
use notedeck_ui::icons::{home_button, notifications_button, search_button};
@@ -637,25 +662,31 @@ fn chrome_toolbar(ui: &mut Ui, unseen_notifications: bool) -> Option<ChromeToolb
let btn_size: f32 = 20.0;
if index == home_index {
- if home_button(ui, btn_size).clicked() {
+ let active = active_tab == Some(ChromeToolbarAction::Home);
+ if home_button(ui, btn_size, active).clicked() {
return Some(ChromeToolbarAction::Home);
}
} else if Some(index) == chat_index {
#[cfg(feature = "messages")]
- if notedeck_ui::icons::chat_button(ui, btn_size).clicked() {
- return Some(ChromeToolbarAction::Chat);
+ {
+ let active = active_tab == Some(ChromeToolbarAction::Chat);
+ if notedeck_ui::icons::chat_button(ui, btn_size, active).clicked() {
+ return Some(ChromeToolbarAction::Chat);
+ }
}
} else if index == search_index {
+ let active = active_tab == Some(ChromeToolbarAction::Search);
if ui
- .add(search_button(ui.visuals().text_color(), 2.0, false))
+ .add(search_button(ui.visuals().text_color(), 2.0, active))
.clicked()
{
return Some(ChromeToolbarAction::Search);
}
- } else if index == notif_index
- && notifications_button(ui, btn_size, unseen_notifications).clicked()
- {
- return Some(ChromeToolbarAction::Notifications);
+ } else if index == notif_index {
+ let active = active_tab == Some(ChromeToolbarAction::Notifications);
+ if notifications_button(ui, btn_size, active, unseen_notifications).clicked() {
+ return Some(ChromeToolbarAction::Notifications);
+ }
}
None
diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs
@@ -628,6 +628,29 @@ impl Damus {
let active_col = self.columns(accounts).selected as usize;
crate::toolbar::unseen_notification(self, accounts, active_col)
}
+
+ /// Returns which toolbar tab matches the current active route.
+ /// 0=Home, 1=Search, 2=Notifications, None=no match
+ pub fn active_toolbar_tab(&self, accounts: ¬edeck::Accounts) -> Option<u8> {
+ use crate::timeline::kind::ListKind;
+ use crate::timeline::TimelineKind;
+
+ let cols = self.columns(accounts);
+ let active_col = cols.selected as usize;
+ let top = cols.column(active_col).router().top();
+ let pk = accounts.get_selected_account().keypair().pubkey;
+
+ match top {
+ Route::Timeline(TimelineKind::List(ListKind::Contact(contact_pk)))
+ if contact_pk == pk =>
+ {
+ Some(0)
+ }
+ Route::Search => Some(1),
+ Route::Timeline(TimelineKind::Notifications(notif_pk)) if notif_pk == pk => Some(2),
+ _ => None,
+ }
+ }
}
fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
diff --git a/crates/notedeck_ui/src/icons.rs b/crates/notedeck_ui/src/icons.rs
@@ -1,6 +1,6 @@
-use egui::{vec2, Color32, CursorIcon, Stroke, Widget};
+use egui::{pos2, vec2, Color32, CursorIcon, Pos2, Stroke, Widget};
-use crate::{app_images, AnimationHelper};
+use crate::AnimationHelper;
pub static ICON_WIDTH: f32 = 40.0;
pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2;
@@ -31,131 +31,291 @@ pub fn search_icon(size: f32, height: f32) -> impl egui::Widget {
}
}
+fn toolbar_icon_color(ui: &egui::Ui, is_active: bool) -> Color32 {
+ if is_active {
+ ui.visuals().strong_text_color()
+ } else {
+ ui.visuals().text_color()
+ }
+}
+
+/// Painter-drawn bell icon for notifications (filled when active)
pub fn notifications_button(
ui: &mut egui::Ui,
size: f32,
+ is_active: bool,
unseen_indicator: bool,
) -> egui::Response {
- expanding_button(
- "notifications-button",
- size,
- app_images::notifications_light_image(),
- app_images::notifications_dark_image(),
- ui,
- unseen_indicator,
- )
-}
+ let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
+ let helper = AnimationHelper::new(ui, "notifications-button", vec2(max_size, max_size));
+ let rect = helper.get_animation_rect();
+ let painter = ui.painter_at(rect);
+ let center = rect.center();
+ let s = helper.scale_1d_pos(size);
+ let color = toolbar_icon_color(ui, is_active);
+ let stroke_width = helper.scale_1d_pos(1.5);
-pub fn chat_button(ui: &mut egui::Ui, size: f32) -> egui::Response {
- expanding_button(
- "chat-button",
- size,
- app_images::chat_light_image(),
- app_images::chat_dark_image(),
- ui,
- false,
- )
-}
+ draw_bell(&painter, center, s, color, stroke_width, is_active);
+
+ if unseen_indicator {
+ let indicator_rect = rect.shrink((max_size - s) / 2.0);
+ paint_unseen_indicator(ui, indicator_rect, helper.scale_1d_pos(3.0));
+ }
-pub fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response {
- expanding_button(
- "home-button",
- size,
- app_images::home_light_image(),
- app_images::home_dark_image(),
- ui,
- false,
- )
+ helper.take_animation_response()
}
-pub fn expanding_button(
- name: &'static str,
- img_size: f32,
- light_img: egui::Image,
- dark_img: egui::Image,
- ui: &mut egui::Ui,
- unseen_indicator: bool,
-) -> egui::Response {
- let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
- let img = if ui.visuals().dark_mode {
- dark_img
+fn draw_bell(
+ painter: &egui::Painter,
+ center: Pos2,
+ s: f32,
+ color: Color32,
+ stroke_width: f32,
+ filled: bool,
+) {
+ let bell_top = center.y - s * 0.4;
+ let bell_bottom = center.y + s * 0.25;
+ let dome_center = pos2(center.x, center.y - s * 0.1);
+ let dome_radius = s * 0.3;
+ let flare_half_w = s * 0.42;
+
+ let n_arc = 12;
+ let mut pts: Vec<Pos2> = Vec::with_capacity(n_arc + 4);
+
+ for i in 0..=n_arc {
+ let t = std::f32::consts::PI + (std::f32::consts::PI * i as f32 / n_arc as f32);
+ pts.push(pos2(
+ dome_center.x + dome_radius * t.cos(),
+ dome_center.y + dome_radius * t.sin(),
+ ));
+ }
+ pts.push(pos2(center.x + flare_half_w, bell_bottom));
+ pts.push(pos2(center.x - flare_half_w, bell_bottom));
+
+ if filled {
+ painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
} else {
- light_img
- };
+ let stroke = Stroke::new(stroke_width, color);
+ let n = pts.len();
+ for i in 0..n {
+ painter.line_segment([pts[i], pts[(i + 1) % n]], stroke);
+ }
+ }
- let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size));
+ // Clapper
+ let clapper_center = pos2(center.x, bell_bottom + s * 0.12);
+ let clapper_radius = s * 0.08;
+ if filled {
+ painter.circle_filled(clapper_center, clapper_radius, color);
+ } else {
+ painter.circle_stroke(
+ clapper_center,
+ clapper_radius,
+ Stroke::new(stroke_width, color),
+ );
+ }
- let cur_img_size = helper.scale_1d_pos(img_size);
+ // Nub on top
+ painter.circle_filled(pos2(center.x, bell_top), s * 0.05, color);
+}
- let paint_rect = helper
- .get_animation_rect()
- .shrink((max_size - cur_img_size) / 2.0);
- img.paint_at(ui, paint_rect);
+/// Painter-drawn envelope icon for chat/messages (filled when active)
+pub fn chat_button(ui: &mut egui::Ui, size: f32, is_active: bool) -> egui::Response {
+ let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
+ let helper = AnimationHelper::new(ui, "chat-button", vec2(max_size, max_size));
+ let rect = helper.get_animation_rect();
+ let painter = ui.painter_at(rect);
+ let center = rect.center();
+ let s = helper.scale_1d_pos(size);
+ let color = toolbar_icon_color(ui, is_active);
+ let stroke_width = helper.scale_1d_pos(1.5);
- if unseen_indicator {
- paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0));
+ draw_envelope(&painter, ui, center, s, color, stroke_width, is_active);
+
+ helper.take_animation_response()
+}
+
+fn draw_envelope(
+ painter: &egui::Painter,
+ ui: &egui::Ui,
+ center: Pos2,
+ s: f32,
+ color: Color32,
+ stroke_width: f32,
+ filled: bool,
+) {
+ let half_w = s * 0.5;
+ let half_h = s * 0.35;
+ let env_rect = egui::Rect::from_center_size(center, vec2(half_w * 2.0, half_h * 2.0));
+ let rounding = s * 0.08;
+ let flap_tip = pos2(center.x, center.y + s * 0.05);
+
+ if filled {
+ painter.rect_filled(env_rect, rounding, color);
+ let bg = if ui.visuals().dark_mode {
+ ui.visuals().window_fill
+ } else {
+ Color32::WHITE
+ };
+ let flap = vec![
+ pos2(env_rect.left(), env_rect.top()),
+ flap_tip,
+ pos2(env_rect.right(), env_rect.top()),
+ ];
+ painter.add(egui::Shape::convex_polygon(flap, bg, Stroke::NONE));
+ } else {
+ let stroke = Stroke::new(stroke_width, color);
+ painter.rect_stroke(env_rect, rounding, stroke, egui::StrokeKind::Inside);
+ painter.line_segment([pos2(env_rect.left(), env_rect.top()), flap_tip], stroke);
+ painter.line_segment([pos2(env_rect.right(), env_rect.top()), flap_tip], stroke);
}
+}
+
+/// Painter-drawn home icon (house outline, filled when active)
+pub fn home_button(ui: &mut egui::Ui, size: f32, is_active: bool) -> egui::Response {
+ let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
+ let helper = AnimationHelper::new(ui, "home-button", vec2(max_size, max_size));
+ let rect = helper.get_animation_rect();
+ let painter = ui.painter_at(rect);
+ let center = rect.center();
+ let s = helper.scale_1d_pos(size);
+ let color = toolbar_icon_color(ui, is_active);
+ let stroke_width = helper.scale_1d_pos(1.5);
+
+ draw_house(&painter, ui, center, s, color, stroke_width, is_active);
helper.take_animation_response()
}
-pub fn search_button(color: Color32, line_width: f32, is_active: bool) -> impl Widget {
+fn draw_house(
+ painter: &egui::Painter,
+ ui: &egui::Ui,
+ center: Pos2,
+ s: f32,
+ color: Color32,
+ stroke_width: f32,
+ filled: bool,
+) {
+ let roof_top = pos2(center.x, center.y - s * 0.45);
+ let roof_left = pos2(center.x - s * 0.5, center.y - s * 0.02);
+ let roof_right = pos2(center.x + s * 0.5, center.y - s * 0.02);
+
+ let body_top = center.y - s * 0.02;
+ let body_bottom = center.y + s * 0.4;
+ let body_left = center.x - s * 0.38;
+ let body_right = center.x + s * 0.38;
+
+ let door_w = if filled { s * 0.2 } else { s * 0.15 };
+ let door_h = if filled { s * 0.28 } else { s * 0.25 };
+
+ if filled {
+ let roof = vec![roof_top, roof_left, roof_right];
+ painter.add(egui::Shape::convex_polygon(roof, color, Stroke::NONE));
+ let body = vec![
+ pos2(body_left, body_top),
+ pos2(body_left, body_bottom),
+ pos2(body_right, body_bottom),
+ pos2(body_right, body_top),
+ ];
+ painter.add(egui::Shape::convex_polygon(body, color, Stroke::NONE));
+ // Door cutout
+ let bg = if ui.visuals().dark_mode {
+ ui.visuals().window_fill
+ } else {
+ Color32::WHITE
+ };
+ let door = vec![
+ pos2(center.x - door_w, body_bottom),
+ pos2(center.x - door_w, body_bottom - door_h),
+ pos2(center.x + door_w, body_bottom - door_h),
+ pos2(center.x + door_w, body_bottom),
+ ];
+ painter.add(egui::Shape::convex_polygon(door, bg, Stroke::NONE));
+ } else {
+ let stroke = Stroke::new(stroke_width, color);
+ // Roof
+ painter.line_segment([roof_top, roof_left], stroke);
+ painter.line_segment([roof_top, roof_right], stroke);
+ // Roof base connecting to walls
+ painter.line_segment([roof_left, pos2(body_left, body_top)], stroke);
+ painter.line_segment([roof_right, pos2(body_right, body_top)], stroke);
+ // Walls
+ painter.line_segment(
+ [pos2(body_left, body_top), pos2(body_left, body_bottom)],
+ stroke,
+ );
+ painter.line_segment(
+ [pos2(body_left, body_bottom), pos2(body_right, body_bottom)],
+ stroke,
+ );
+ painter.line_segment(
+ [pos2(body_right, body_bottom), pos2(body_right, body_top)],
+ stroke,
+ );
+ // Door outline
+ painter.line_segment(
+ [
+ pos2(center.x - door_w, body_bottom),
+ pos2(center.x - door_w, body_bottom - door_h),
+ ],
+ stroke,
+ );
+ painter.line_segment(
+ [
+ pos2(center.x - door_w, body_bottom - door_h),
+ pos2(center.x + door_w, body_bottom - door_h),
+ ],
+ stroke,
+ );
+ painter.line_segment(
+ [
+ pos2(center.x + door_w, body_bottom - door_h),
+ pos2(center.x + door_w, body_bottom),
+ ],
+ stroke,
+ );
+ }
+}
+
+pub fn search_button(_color: Color32, line_width: f32, is_active: bool) -> impl Widget {
move |ui: &mut egui::Ui| -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
- let min_line_width_circle = line_width;
- let min_line_width_handle = line_width;
+ let lw = if is_active {
+ line_width + 0.5
+ } else {
+ line_width
+ };
let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
-
let painter = ui.painter_at(helper.get_animation_rect());
- if is_active {
- let circle_radius = max_size / 2.0;
- painter.circle(
- helper.get_animation_rect().center(),
- circle_radius,
- crate::side_panel_active_bg(ui),
- Stroke::NONE,
- );
- }
-
- let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
- let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
+ let cur_lw = helper.scale_1d_pos(lw);
let min_outer_circle_radius = helper.scale_radius(15.0);
let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
- let min_handle_length = 7.0;
- let cur_handle_length = helper.scale_1d_pos(min_handle_length);
-
+ let cur_handle_length = helper.scale_1d_pos(7.0);
let circle_center = helper.scale_from_center(-2.0, -2.0);
let handle_vec = vec2(
std::f32::consts::FRAC_1_SQRT_2,
std::f32::consts::FRAC_1_SQRT_2,
);
-
let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
let handle_pos_2 =
circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
- let icon_color = if is_active {
- ui.visuals().strong_text_color()
+ let icon_color = toolbar_icon_color(ui, is_active);
+ let stroke = Stroke::new(cur_lw, icon_color);
+ let fill = if is_active {
+ icon_color
} else {
- color
+ Color32::TRANSPARENT
};
- let circle_stroke = Stroke::new(cur_line_width_circle, icon_color);
- let handle_stroke = Stroke::new(cur_line_width_handle, icon_color);
-
- painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
- painter.circle(
- circle_center,
- min_outer_circle_radius,
- ui.style().visuals.widgets.inactive.weak_bg_fill,
- circle_stroke,
- );
+
+ painter.line_segment([handle_pos_1, handle_pos_2], stroke);
+ painter.circle(circle_center, min_outer_circle_radius, fill, stroke);
helper
.take_animation_response()
.on_hover_cursor(CursorIcon::PointingHand)
- .on_hover_text("Open search")
}
}
@@ -173,3 +333,33 @@ fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) {
let painter = ui.painter_at(rect);
painter.circle_filled(midpoint, radius, crate::colors::PINK);
}
+
+/// Image-based expanding button used for side panel icons.
+pub fn expanding_button(
+ name: &'static str,
+ img_size: f32,
+ light_img: egui::Image,
+ dark_img: egui::Image,
+ ui: &mut egui::Ui,
+ unseen_indicator: bool,
+) -> egui::Response {
+ let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE;
+ let img = if ui.visuals().dark_mode {
+ dark_img
+ } else {
+ light_img
+ };
+
+ let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size));
+ let cur_img_size = helper.scale_1d_pos(img_size);
+ let paint_rect = helper
+ .get_animation_rect()
+ .shrink((max_size - cur_img_size) / 2.0);
+ img.paint_at(ui, paint_rect);
+
+ if unseen_indicator {
+ paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0));
+ }
+
+ helper.take_animation_response()
+}