From 5c4ecb5a4a3dd2992cdb1f990b2d16e47b6ffedd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Nov 2025 19:30:48 +0000 Subject: [PATCH] Add Cmd+K quick channel switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a Slack-style quick switcher for navigating between channels: Features: - Press Cmd+K (or Ctrl+K) to open the channel switcher modal - Search/filter channels by name - Navigate with arrow keys (↑/↓) - Select with Enter or click - Shows unread count badges - Highlights currently selected channel - Dark overlay background for focus - Keyboard shortcuts shown at bottom The switcher provides fast keyboard-driven navigation between channels, matching the Slack UX pattern that users expect. --- crates/notedeck_columns/src/app.rs | 45 ++- .../src/ui/channel_switcher.rs | 258 ++++++++++++++++++ crates/notedeck_columns/src/ui/mod.rs | 2 + 3 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 crates/notedeck_columns/src/ui/channel_switcher.rs diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs index 5eebf00..a6a715f 100644 --- a/crates/notedeck_columns/src/app.rs +++ b/crates/notedeck_columns/src/app.rs @@ -51,6 +51,7 @@ pub struct Damus { pub channels_cache: crate::channels::ChannelsCache, pub relay_config: crate::relay_config::RelayConfig, pub channel_dialog: ui::ChannelDialog, + pub channel_switcher: ui::ChannelSwitcher, pub view_state: ViewState, pub drafts: Drafts, pub timeline_cache: TimelineCache, @@ -220,16 +221,26 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con // Handle keyboard shortcuts ctx.input(|i| { - // Escape to close dialog - if i.key_pressed(egui::Key::Escape) && damus.channel_dialog.is_open { - damus.channel_dialog.close(); + // Escape to close dialogs + if i.key_pressed(egui::Key::Escape) { + if damus.channel_dialog.is_open { + damus.channel_dialog.close(); + } else if damus.channel_switcher.is_open { + damus.channel_switcher.close(); + } } // Cmd+N / Ctrl+N to open new channel dialog let cmd_n = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::N); - if cmd_n && !damus.channel_dialog.is_open { + if cmd_n && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open { damus.channel_dialog.open(); } + + // Cmd+K / Ctrl+K to open channel switcher + let cmd_k = (i.modifiers.command || i.modifiers.ctrl) && i.key_pressed(egui::Key::K); + if cmd_k && !damus.channel_dialog.is_open && !damus.channel_switcher.is_open { + damus.channel_switcher.open(); + } }); if damus.columns(app_ctx.accounts).columns().is_empty() { @@ -461,6 +472,30 @@ fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui:: } } + // Show channel switcher (Cmd+K) + if let Some(switcher_action) = damus.channel_switcher.show( + ui.ctx(), + app_ctx.i18n, + &damus.channels_cache, + app_ctx.accounts, + ) { + match switcher_action { + ui::ChannelSwitcherAction::SelectChannel(idx) => { + // Select the channel + damus + .channels_cache + .active_channels_mut(app_ctx.i18n, app_ctx.accounts) + .select_channel(idx); + + // Save channel state + storage::save_channels_cache(app_ctx.path, &damus.channels_cache); + } + ui::ChannelSwitcherAction::Close => { + // Switcher was closed, nothing to do + } + } + } + // We use this for keeping timestamps and things up to date //ui.ctx().request_repaint_after(Duration::from_secs(5)); @@ -650,6 +685,7 @@ impl Damus { channels_cache, relay_config, channel_dialog: ui::ChannelDialog::default(), + channel_switcher: ui::ChannelSwitcher::default(), unrecognized_args, jobs, threads, @@ -706,6 +742,7 @@ impl Damus { channels_cache, relay_config, channel_dialog: ui::ChannelDialog::default(), + channel_switcher: ui::ChannelSwitcher::default(), unrecognized_args: BTreeSet::default(), jobs: JobsCache::default(), threads: Threads::default(), diff --git a/crates/notedeck_columns/src/ui/channel_switcher.rs b/crates/notedeck_columns/src/ui/channel_switcher.rs new file mode 100644 index 0000000..b015362 --- /dev/null +++ b/crates/notedeck_columns/src/ui/channel_switcher.rs @@ -0,0 +1,258 @@ +use egui::{Color32, Key, Modifiers, RichText, ScrollArea, TextEdit, Vec2}; + +use notedeck::{tr, Accounts, Localization}; + +use crate::channels::ChannelsCache; + +pub struct ChannelSwitcher { + pub is_open: bool, + pub search_query: String, + pub selected_index: usize, +} + +pub enum ChannelSwitcherAction { + SelectChannel(usize), + Close, +} + +impl ChannelSwitcher { + pub fn new() -> Self { + Self { + is_open: false, + search_query: String::new(), + selected_index: 0, + } + } + + pub fn open(&mut self) { + self.is_open = true; + self.search_query.clear(); + self.selected_index = 0; + } + + pub fn close(&mut self) { + self.is_open = false; + } + + pub fn show( + &mut self, + ctx: &egui::Context, + i18n: &mut Localization, + channels_cache: &ChannelsCache, + accounts: &Accounts, + ) -> Option { + if !self.is_open { + return None; + } + + let mut action: Option = None; + + // Modal background + egui::Area::new(egui::Id::new("channel_switcher_overlay")) + .fixed_pos(egui::Pos2::ZERO) + .interactable(true) + .show(ctx, |ui| { + let screen_rect = ctx.screen_rect(); + ui.allocate_ui_at_rect(screen_rect, |ui| { + // Dark overlay + ui.painter().rect_filled( + screen_rect, + 0.0, + Color32::from_black_alpha(180), + ); + + // Handle click on overlay to close + if ui.interact(screen_rect, egui::Id::new("overlay"), egui::Sense::click()).clicked() { + action = Some(ChannelSwitcherAction::Close); + } + }); + }); + + // Switcher window + egui::Window::new(tr!(i18n, "Quick Switcher", "Channel switcher dialog title")) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_TOP, Vec2::new(0.0, 100.0)) + .fixed_size(Vec2::new(500.0, 400.0)) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(16.0); + + // Search input + let search_response = ui.add( + TextEdit::singleline(&mut self.search_query) + .hint_text(tr!( + i18n, + "Search channels...", + "Placeholder for channel search" + )) + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Body), + ); + + // Auto-focus search field + search_response.request_focus(); + + // Handle keyboard navigation + ui.input(|i| { + if i.key_pressed(Key::Escape) { + action = Some(ChannelSwitcherAction::Close); + } + + if i.key_pressed(Key::ArrowDown) { + let channels = channels_cache.active_channels(accounts); + if self.selected_index < channels.num_channels().saturating_sub(1) { + self.selected_index += 1; + } + } + + if i.key_pressed(Key::ArrowUp) { + self.selected_index = self.selected_index.saturating_sub(1); + } + + if i.key_pressed(Key::Enter) { + action = Some(ChannelSwitcherAction::SelectChannel(self.selected_index)); + } + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Channel list + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let channels = channels_cache.active_channels(accounts); + let query_lower = self.search_query.to_lowercase(); + + let mut visible_idx = 0; + for (idx, channel) in channels.channels.iter().enumerate() { + // Filter by search query + if !query_lower.is_empty() + && !channel.name.to_lowercase().contains(&query_lower) + { + continue; + } + + let is_selected = visible_idx == self.selected_index; + let is_current = idx == channels.selected; + + let mut frame = egui::Frame::new() + .inner_margin(egui::Margin::symmetric(12, 8)) + .corner_radius(4.0); + + if is_selected { + frame = frame.fill(ui.visuals().selection.bg_fill); + } else if is_current { + frame = frame.fill(ui.visuals().faint_bg_color); + } + + let response = frame.show(ui, |ui| { + ui.horizontal(|ui| { + // Channel icon + ui.label(RichText::new("# ").size(16.0)); + + // Channel name + let mut text = RichText::new(&channel.name).size(14.0); + if is_selected { + text = text.strong(); + } + ui.label(text); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Show unread badge if any + if channel.unread_count > 0 { + let count_text = if channel.unread_count > 99 { + "99+".to_string() + } else { + channel.unread_count.to_string() + }; + + ui.label( + RichText::new(count_text) + .size(11.0) + .color(ui.visuals().strong_text_color()), + ); + } + }); + }); + }); + + // Handle click on channel + let full_response = ui.interact( + response.response.rect, + egui::Id::new(("channel_item", idx)), + egui::Sense::click(), + ); + + if full_response.clicked() { + action = Some(ChannelSwitcherAction::SelectChannel(idx)); + } + + // Update selected index on hover + if full_response.hovered() { + self.selected_index = visible_idx; + } + + visible_idx += 1; + } + + // Show empty state if no results + if visible_idx == 0 && !query_lower.is_empty() { + ui.add_space(20.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(tr!( + i18n, + "No channels found", + "Empty search results" + )) + .size(14.0) + .color(ui.visuals().weak_text_color()), + ); + }); + } + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Help text + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + ui.label( + RichText::new(tr!(i18n, "↑↓ to navigate", "Keyboard shortcut hint")) + .size(11.0) + .color(ui.visuals().weak_text_color()), + ); + ui.label( + RichText::new(tr!(i18n, "↵ to select", "Keyboard shortcut hint")) + .size(11.0) + .color(ui.visuals().weak_text_color()), + ); + ui.label( + RichText::new(tr!(i18n, "esc to close", "Keyboard shortcut hint")) + .size(11.0) + .color(ui.visuals().weak_text_color()), + ); + }); + }); + }); + + // Close switcher if action was taken + if action.is_some() { + self.close(); + } + + action + } +} + +impl Default for ChannelSwitcher { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs index c2adf69..61c2109 100644 --- a/crates/notedeck_columns/src/ui/mod.rs +++ b/crates/notedeck_columns/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod accounts; pub mod add_column; pub mod channel_dialog; pub mod channel_sidebar; +pub mod channel_switcher; pub mod chat_view; pub mod column; pub mod configure_deck; @@ -29,6 +30,7 @@ pub mod widgets; pub use accounts::AccountsView; pub use channel_dialog::{ChannelDialog, ChannelDialogAction}; pub use channel_sidebar::{ChannelSidebar, ChannelSidebarAction}; +pub use channel_switcher::{ChannelSwitcher, ChannelSwitcherAction}; pub use chat_view::ChatView; pub use note::{PostReplyView, PostView}; pub use preview::{Preview, PreviewApp, PreviewConfig};