notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 7e88b01ae27c0415863fe38bd147ac4479e176fe
parent a70200261825518dd3f0c9bc9c2c5e7a85822505
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 27 Feb 2026 12:05:52 -0800

chrome: move mobile toolbar from Columns to Chrome level

Move the mobile toolbar (Home, Chat, Search, Notifications) out of
notedeck_columns and into notedeck_chrome so it's available regardless
of which app is active. Chat button is feature-gated behind "messages".

Also move search_button to notedeck_ui::icons for reuse, and add clean
navigation methods (navigate_home, navigate_search, etc.) to Damus
instead of exposing internal ToolbarAction types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_chrome/src/chrome.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/app.rs | 159++++++++++++++++++++++++++-----------------------------------------------------
Mcrates/notedeck_columns/src/ui/mod.rs | 1-
Mcrates/notedeck_columns/src/ui/side_panel.rs | 58+---------------------------------------------------------
Dcrates/notedeck_columns/src/ui/toolbar.rs | 67-------------------------------------------------------------------
Mcrates/notedeck_ui/src/app_images.rs | 8++++++++
Mcrates/notedeck_ui/src/icons.rs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 326 insertions(+), 234 deletions(-)

diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -294,6 +294,45 @@ impl Chrome { } } + #[cfg(feature = "messages")] + fn switch_to_messages(&mut self) { + for (i, app) in self.apps.iter().enumerate() { + if let NotedeckApp::Messages(_) = app { + self.active = i as i32; + if let Some(opened) = self.opened.get_mut(i) { + *opened = true; + } + } + } + } + + fn process_toolbar_action(&mut self, action: ChromeToolbarAction, ctx: &mut AppContext) { + match action { + ChromeToolbarAction::Home => { + self.switch_to_columns(); + if let Some(columns) = self.get_columns_app() { + columns.navigate_home(ctx); + } + } + #[cfg(feature = "messages")] + ChromeToolbarAction::Chat => { + self.switch_to_messages(); + } + ChromeToolbarAction::Search => { + self.switch_to_columns(); + if let Some(columns) = self.get_columns_app() { + columns.navigate_search(ctx); + } + } + ChromeToolbarAction::Notifications => { + self.switch_to_columns(); + if let Some(columns) = self.get_columns_app() { + columns.navigate_notifications(ctx); + } + } + } + } + pub fn set_active(&mut self, app: i32) { self.active = app; if let Some(opened) = self.opened.get_mut(app as usize) { @@ -399,13 +438,30 @@ impl Chrome { 0.0 }; + let is_mobile = notedeck::ui::is_narrow(ui.ctx()); + let toolbar_height = if is_mobile { + toolbar_visibility_height(skb_anim.skb_rect, ui) + } else { + 0.0 + }; + + let unseen_notifications = if is_mobile { + self.get_columns_app() + .map(|c| c.has_unseen_notifications(ctx.accounts)) + .unwrap_or(false) + } else { + false + }; + // if the soft keyboard is open, shrink the chrome contents let mut action: Option<ChromePanelAction> = None; + let mut toolbar_action: Option<ChromeToolbarAction> = None; // build a strip to carve out the soft keyboard inset let prev_spacing = ui.spacing().item_spacing; ui.spacing_mut().item_spacing.y = 0.0; StripBuilder::new(ui) .size(Size::remainder()) + .size(Size::exact(toolbar_height)) .size(Size::exact(keyboard_height)) .vertical(|mut strip| { // the actual content, shifted up because of the soft keyboard @@ -414,6 +470,13 @@ impl Chrome { action = self.panel(ctx, ui, keyboard_height); }); + // mobile toolbar + strip.cell(|ui| { + if toolbar_height > 0.0 { + toolbar_action = chrome_toolbar(ui, unseen_notifications); + } + }); + // the filler space taken up by the soft keyboard strip.cell(|ui| { // keyboard-visibility virtual keyboard @@ -437,6 +500,10 @@ impl Chrome { } } + if let Some(tb_action) = toolbar_action { + self.process_toolbar_action(tb_action, ctx); + } + action } } @@ -477,6 +544,133 @@ impl notedeck::App for Chrome { } } +const TOOLBAR_HEIGHT: f32 = 48.0; + +#[derive(Debug, Eq, PartialEq)] +enum ChromeToolbarAction { + Home, + #[cfg(feature = "messages")] + Chat, + Search, + Notifications, +} + +/// Compute the animated toolbar height, auto-hiding on scroll and +/// when the soft keyboard is open. +fn toolbar_visibility_height(skb_rect: Option<Rect>, ui: &mut Ui) -> f32 { + let toolbar_visible_id = egui::Id::new("chrome_toolbar_visible"); + + let scroll_delta = ui.ctx().input(|i| i.smooth_scroll_delta.y); + let velocity_threshold = 1.0; + + if scroll_delta > velocity_threshold { + ui.ctx() + .data_mut(|d| d.insert_temp(toolbar_visible_id, true)); + } else if scroll_delta < -velocity_threshold { + ui.ctx() + .data_mut(|d| d.insert_temp(toolbar_visible_id, false)); + } + + let toolbar_visible = ui + .ctx() + .data(|d| d.get_temp::<bool>(toolbar_visible_id)) + .unwrap_or(true); + + let toolbar_anim = ui + .ctx() + .animate_bool_responsive(toolbar_visible_id.with("anim"), toolbar_visible); + + if skb_rect.is_none() { + TOOLBAR_HEIGHT * toolbar_anim + } else { + 0.0 + } +} + +/// Render the Chrome mobile toolbar (Home, Chat, Search, Notifications). +fn chrome_toolbar(ui: &mut Ui, unseen_notifications: bool) -> Option<ChromeToolbarAction> { + use egui_tabs::{TabColor, Tabs}; + use notedeck_ui::icons::{home_button, notifications_button, search_button}; + + let rect = ui.available_rect_before_wrap(); + ui.painter().hline( + rect.x_range(), + rect.top(), + ui.visuals().widgets.noninteractive.bg_stroke, + ); + + if !ui.visuals().dark_mode { + ui.painter().rect( + rect, + 0, + notedeck_ui::colors::ALMOST_WHITE, + egui::Stroke::new(0.0, Color32::TRANSPARENT), + egui::StrokeKind::Inside, + ); + } + + let has_chat = cfg!(feature = "messages"); + let mut next_index = 0; + let home_index = next_index; + next_index += 1; + let chat_index = if has_chat { + let i = next_index; + next_index += 1; + Some(i) + } else { + None + }; + let search_index = next_index; + next_index += 1; + let notif_index = next_index; + let tab_count = notif_index + 1; + + let rs = Tabs::new(tab_count as i32) + .selected(0) + .hover_bg(TabColor::none()) + .selected_fg(TabColor::none()) + .selected_bg(TabColor::none()) + .height(TOOLBAR_HEIGHT) + .layout(Layout::centered_and_justified(egui::Direction::TopDown)) + .show(ui, |ui, state| { + let index = state.index(); + let btn_size: f32 = 20.0; + + if index == home_index { + if home_button(ui, btn_size).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); + } + } else if index == search_index { + if ui + .add(search_button(ui.visuals().text_color(), 2.0, false)) + .clicked() + { + return Some(ChromeToolbarAction::Search); + } + } else if index == notif_index + && notifications_button(ui, btn_size, unseen_notifications).clicked() + { + return Some(ChromeToolbarAction::Notifications); + } + + None + }) + .inner(); + + for maybe_r in rs { + if maybe_r.inner.is_some() { + return maybe_r.inner; + } + } + + None +} + fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { let text = if notedeck::ui::is_compiled_as_mobile() { tr!( diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -11,8 +11,7 @@ use crate::{ support::Support, timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, timeline_loader::{TimelineLoader, TimelineLoaderMsg}, - toolbar::unseen_notification, - ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction}, + ui::{self, DesktopSidePanel, SidePanelAction}, view_state::ViewState, Result, }; @@ -609,12 +608,25 @@ impl Damus { &self.unrecognized_args } - pub fn toolbar_height() -> f32 { - 48.0 + /// Navigate to the Home (contact list) timeline. + pub fn navigate_home(&mut self, ctx: &mut AppContext) { + crate::toolbar::ToolbarAction::Home.process(self, ctx); } - pub fn initially_selected_toolbar_index() -> i32 { - 0 + /// Navigate to the Search view. + pub fn navigate_search(&mut self, ctx: &mut AppContext) { + crate::toolbar::ToolbarAction::Search.process(self, ctx); + } + + /// Navigate to the Notifications timeline. + pub fn navigate_notifications(&mut self, ctx: &mut AppContext) { + crate::toolbar::ToolbarAction::Notifications.process(self, ctx); + } + + /// Check if there are unseen notifications. + pub fn has_unseen_notifications(&mut self, accounts: &notedeck::Accounts) -> bool { + let active_col = self.columns(accounts).selected as usize; + crate::toolbar::unseen_notification(self, accounts, active_col) } } @@ -649,127 +661,58 @@ fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { } */ -/// Logic that handles toolbar visibility -fn toolbar_visibility_height(skb_rect: Option<egui::Rect>, ui: &mut egui::Ui) -> f32 { - // Auto-hide toolbar when scrolling down - let toolbar_visible_id = egui::Id::new("toolbar_visible"); - - // Detect scroll direction using egui input state - let scroll_delta = ui.ctx().input(|i| i.smooth_scroll_delta.y); - let velocity_threshold = 1.0; - - // Update toolbar visibility based on scroll direction - if scroll_delta > velocity_threshold { - // Scrolling up (content moving down) - show toolbar - ui.ctx() - .data_mut(|d| d.insert_temp(toolbar_visible_id, true)); - } else if scroll_delta < -velocity_threshold { - // Scrolling down (content moving up) - hide toolbar - ui.ctx() - .data_mut(|d| d.insert_temp(toolbar_visible_id, false)); - } - - let toolbar_visible = ui - .ctx() - .data(|d| d.get_temp::<bool>(toolbar_visible_id)) - .unwrap_or(true); // Default to visible - - let toolbar_anim = ui - .ctx() - .animate_bool_responsive(toolbar_visible_id.with("anim"), toolbar_visible); - - if skb_rect.is_none() { - Damus::toolbar_height() * toolbar_anim - } else { - 0.0 - } -} - #[profiling::function] fn render_damus_mobile( app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui, ) -> AppResponse { - //let routes = app.timelines[0].routes.clone(); - let mut can_take_drag_from = Vec::new(); let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; let mut app_action: Option<AppAction> = None; - // don't show toolbar if soft keyboard is open - let skb_rect = app_ctx.soft_keyboard_rect( - ui.ctx().screen_rect(), - notedeck::SoftKeyboardContext::platform(ui.ctx()), - ); - let toolbar_height = toolbar_visibility_height(skb_rect, ui); - StripBuilder::new(ui) - .size(Size::remainder()) // top cell - .size(Size::exact(toolbar_height)) // bottom cell - .vertical(|mut strip| { - strip.cell(|ui| { - let rect = ui.available_rect_before_wrap(); - if !app.columns(app_ctx.accounts).columns().is_empty() { - let resp = nav::render_nav( - active_col, - ui.available_rect_before_wrap(), - app, - app_ctx, - ui, - ); + let rect = ui.available_rect_before_wrap(); + if !app.columns(app_ctx.accounts).columns().is_empty() { + let resp = nav::render_nav( + active_col, + ui.available_rect_before_wrap(), + app, + app_ctx, + ui, + ); - can_take_drag_from.extend(resp.can_take_drag_from()); + can_take_drag_from.extend(resp.can_take_drag_from()); - let r = resp.process_render_nav_response(app, app_ctx, ui); - if let Some(r) = r { - match r { - ProcessNavResult::SwitchOccurred => { - if !app.options.contains(AppOptions::TmpColumns) { - storage::save_decks_cache(app_ctx.path, &app.decks_cache); - } - } - - ProcessNavResult::PfpClicked => { - app_action = Some(AppAction::ToggleChrome); - } - - ProcessNavResult::SwitchAccount(pubkey) => { - // Add as pubkey-only account if not already present - let kp = enostr::Keypair::only_pubkey(pubkey); - let _ = app_ctx.accounts.add_account(kp); - - app_ctx.select_account(&pubkey); - setup_selected_account_timeline_subs( - &mut app.timeline_cache, - app_ctx, - ); - } - - ProcessNavResult::ExternalNoteAction(note_action) => { - app_action = Some(AppAction::Note(note_action)); - } - } + let r = resp.process_render_nav_response(app, app_ctx, ui); + if let Some(r) = r { + match r { + ProcessNavResult::SwitchOccurred => { + if !app.options.contains(AppOptions::TmpColumns) { + storage::save_decks_cache(app_ctx.path, &app.decks_cache); } } - hovering_post_button(ui, app, app_ctx, rect); - }); - - strip.cell(|ui| 'brk: { - if toolbar_height <= 0.0 { - break 'brk; + ProcessNavResult::PfpClicked => { + app_action = Some(AppAction::ToggleChrome); } - let unseen_notif = unseen_notification(app, app_ctx.accounts, active_col); + ProcessNavResult::SwitchAccount(pubkey) => { + // Add as pubkey-only account if not already present + let kp = enostr::Keypair::only_pubkey(pubkey); + let _ = app_ctx.accounts.add_account(kp); - if skb_rect.is_none() { - let resp = toolbar(ui, unseen_notif); - if let Some(action) = resp { - action.process(app, app_ctx); - } + app_ctx.select_account(&pubkey); + setup_selected_account_timeline_subs(&mut app.timeline_cache, app_ctx); } - }); - }); + + ProcessNavResult::ExternalNoteAction(note_action) => { + app_action = Some(AppAction::Note(note_action)); + } + } + } + } + + hovering_post_button(ui, app, app_ctx, rect); AppResponse::action(app_action).drag(can_take_drag_from) } diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -20,7 +20,6 @@ pub mod side_panel; pub mod support; pub mod thread; pub mod timeline; -pub mod toolbar; pub mod tos; pub mod wallet; pub mod welcome; diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -509,63 +509,7 @@ fn add_column_button() -> impl Widget { } pub fn search_button_impl(color: egui::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 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, - notedeck_ui::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 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 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() - } else { - color - }; - 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, - ); - - helper - .take_animation_response() - .on_hover_cursor(CursorIcon::PointingHand) - .on_hover_text("Open search") - } + notedeck_ui::icons::search_button(color, line_width, is_active) } pub fn search_button(current_route: Option<&Route>) -> impl Widget + '_ { diff --git a/crates/notedeck_columns/src/ui/toolbar.rs b/crates/notedeck_columns/src/ui/toolbar.rs @@ -1,67 +0,0 @@ -use egui::{Color32, Layout}; -use notedeck_ui::icons::{home_button, notifications_button}; - -use crate::{toolbar::ToolbarAction, ui::side_panel::search_button_impl, Damus}; - -#[profiling::function] -pub fn toolbar(ui: &mut egui::Ui, unseen_notification: bool) -> Option<ToolbarAction> { - use egui_tabs::{TabColor, Tabs}; - - let rect = ui.available_rect_before_wrap(); - ui.painter().hline( - rect.x_range(), - rect.top(), - ui.visuals().widgets.noninteractive.bg_stroke, - ); - - if !ui.visuals().dark_mode { - ui.painter().rect( - rect, - 0, - notedeck_ui::colors::ALMOST_WHITE, - egui::Stroke::new(0.0, Color32::TRANSPARENT), - egui::StrokeKind::Inside, - ); - } - - let rs = Tabs::new(3) - .selected(Damus::initially_selected_toolbar_index()) - .hover_bg(TabColor::none()) - .selected_fg(TabColor::none()) - .selected_bg(TabColor::none()) - .height(Damus::toolbar_height()) - .layout(Layout::centered_and_justified(egui::Direction::TopDown)) - .show(ui, |ui, state| { - let index = state.index(); - - let mut action: Option<ToolbarAction> = None; - - let btn_size: f32 = 20.0; - if index == 0 { - if home_button(ui, btn_size).clicked() { - action = Some(ToolbarAction::Home); - } - } else if index == 1 - && ui - .add(search_button_impl(ui.visuals().text_color(), 2.0, false)) - .clicked() - { - action = Some(ToolbarAction::Search) - } else if index == 2 - && notifications_button(ui, btn_size, unseen_notification).clicked() - { - action = Some(ToolbarAction::Notifications); - } - - action - }) - .inner(); - - for maybe_r in rs { - if maybe_r.inner.is_some() { - return maybe_r.inner; - } - } - - None -} diff --git a/crates/notedeck_ui/src/app_images.rs b/crates/notedeck_ui/src/app_images.rs @@ -155,6 +155,14 @@ pub fn new_message_image() -> Image<'static> { Image::new(include_image!("../../../assets/icons/new-message.svg")) } +pub fn chat_dark_image() -> Image<'static> { + new_message_image() +} + +pub fn chat_light_image() -> Image<'static> { + new_message_image().tint(Color32::BLACK) +} + pub fn new_deck_image() -> Image<'static> { Image::new(include_image!( "../../../assets/icons/new_deck_icon_4x_dark.png" diff --git a/crates/notedeck_ui/src/icons.rs b/crates/notedeck_ui/src/icons.rs @@ -1,4 +1,4 @@ -use egui::{vec2, Color32, Stroke}; +use egui::{vec2, Color32, CursorIcon, Stroke, Widget}; use crate::{app_images, AnimationHelper}; @@ -46,6 +46,17 @@ pub fn notifications_button( ) } +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, + ) +} + pub fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response { expanding_button( "home-button", @@ -88,6 +99,66 @@ pub fn expanding_button( helper.take_animation_response() } +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 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 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 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() + } else { + color + }; + 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, + ); + + helper + .take_animation_response() + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("Open search") + } +} + fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) { let center = rect.center(); let top_right = rect.right_top();