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:
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: ¬edeck::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();