Add Cmd+K quick channel switcher

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.
This commit is contained in:
Claude
2025-11-12 19:30:48 +00:00
parent d70f3d2801
commit 5c4ecb5a4a
3 changed files with 301 additions and 4 deletions

View File

@@ -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(),

View File

@@ -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<ChannelSwitcherAction> {
if !self.is_open {
return None;
}
let mut action: Option<ChannelSwitcherAction> = 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()
}
}

View File

@@ -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};