From 5c9f1b51162544b18f7299e182efe42bef512112 Mon Sep 17 00:00:00 2001 From: StudentWeis Date: Mon, 25 May 2026 10:29:06 +0800 Subject: [PATCH] feat(gui): add category-switch keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+Right / Alt+Left cycle the content filter (All → Text → Image → Files → All), and Alt+F toggles the favorites-only filter. The two dimensions remain orthogonal, so users can scope to e.g. "favorited images" entirely from the keyboard. Implemented as proper GPUI actions registered via bind_keys so the favorites toggle also fires while the search input is focused (Tab and Alt+arrow are intercepted by the Input context, so cycling is list-focused — consistent with existing j/k/h/l). Refs #135 --- assets/locales/en.toml | 2 ++ assets/locales/ja.toml | 2 ++ assets/locales/zh-CN.toml | 2 ++ src/app.rs | 8 +++-- src/gui/board.rs | 4 +-- src/gui/board/actions.rs | 42 +++++++++++++++++++++++-- src/gui/board/render.rs | 3 ++ src/gui/board/search.rs | 66 +++++++++++++++++++++++++++++++++++++++ src/gui/panel/help.rs | 10 ++++++ 9 files changed, 133 insertions(+), 6 deletions(-) diff --git a/assets/locales/en.toml b/assets/locales/en.toml index b5654dc..37ac907 100644 --- a/assets/locales/en.toml +++ b/assets/locales/en.toml @@ -105,5 +105,7 @@ help_confirm = "Confirm selected item" help_confirm_plain_text = "Confirm selected item as plain text" help_delete = "Delete selected" help_favorite = "Toggle favorite" +help_cycle_filter = "Cycle category filter" +help_toggle_favorites_filter = "Toggle favorites filter" help_hide = "Hide window" help_pin = "Toggle window pin" diff --git a/assets/locales/ja.toml b/assets/locales/ja.toml index 8e6c0ce..e3224cb 100644 --- a/assets/locales/ja.toml +++ b/assets/locales/ja.toml @@ -106,5 +106,7 @@ help_confirm = "選択項目を確認" help_confirm_plain_text = "選択項目をプレーンテキストとして確認" help_delete = "選択項目を削除" help_favorite = "お気に入りを切り替え" +help_cycle_filter = "カテゴリーフィルターを循環" +help_toggle_favorites_filter = "お気に入りフィルターを切り替え" help_hide = "ウィンドウを隠す" help_pin = "ウィンドウのピン留めを切り替え" diff --git a/assets/locales/zh-CN.toml b/assets/locales/zh-CN.toml index 90d287f..f54612b 100644 --- a/assets/locales/zh-CN.toml +++ b/assets/locales/zh-CN.toml @@ -106,5 +106,7 @@ help_confirm = "确认选中项" help_confirm_plain_text = "以纯文本确认选中项" help_delete = "删除选中项" help_favorite = "切换收藏" +help_cycle_filter = "循环切换分类过滤" +help_toggle_favorites_filter = "切换收藏过滤" help_hide = "隐藏窗口" help_pin = "切换窗口置顶" diff --git a/src/app.rs b/src/app.rs index bc97b9d..0aa48ff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,8 +24,9 @@ use crate::{ config::{AutoStartManager, Settings}, constants::APP_NAME, gui::board::{ - Active, ConfirmSelection, ConfirmSelectionPlainText, Hide, Quit, RopyBoard, SelectLeft, - SelectNext, SelectPrev, SelectRight, + Active, ConfirmSelection, ConfirmSelectionPlainText, CycleFilterNext, CycleFilterPrev, + Hide, Quit, RopyBoard, SelectLeft, SelectNext, SelectPrev, SelectRight, + ToggleFavoritesFilter, }, i18n::I18n, repository::{ClipboardRecord, ClipboardRepository, GlobalRepository, backend::StorageBackend}, @@ -235,6 +236,9 @@ fn bind_application_keys(cx: &mut App) { KeyBinding::new("down", SelectNext, None), KeyBinding::new("enter", ConfirmSelection, None), KeyBinding::new("shift-enter", ConfirmSelectionPlainText, None), + KeyBinding::new("alt-right", CycleFilterNext, None), + KeyBinding::new("alt-left", CycleFilterPrev, None), + KeyBinding::new("alt-f", ToggleFavoritesFilter, None), ]); } diff --git a/src/gui/board.rs b/src/gui/board.rs index 8a2b079..1160976 100644 --- a/src/gui/board.rs +++ b/src/gui/board.rs @@ -27,8 +27,8 @@ use std::{ // Re-export utilities for external use pub(crate) use actions::{ - Active, ConfirmSelection, ConfirmSelectionPlainText, Hide, Quit, SelectLeft, SelectNext, - SelectPrev, SelectRight, + Active, ConfirmSelection, ConfirmSelectionPlainText, CycleFilterNext, CycleFilterPrev, Hide, + Quit, SelectLeft, SelectNext, SelectPrev, SelectRight, ToggleFavoritesFilter, }; use filtering::{ClearConfirmAction, filter_and_sort_record_indices}; use gpui::{ diff --git a/src/gui/board/actions.rs b/src/gui/board/actions.rs index 408c2d1..3a3fae5 100644 --- a/src/gui/board/actions.rs +++ b/src/gui/board/actions.rs @@ -4,7 +4,7 @@ use crate::{ config::LayoutMode, gui::{ active_window, - board::{ActivePanel, RopyBoard}, + board::{ActivePanel, RopyBoard, search::next_content_filter}, constants::default_window_size, hide_window, panel::settings, @@ -40,7 +40,10 @@ mod generated_actions { SelectNext, ConfirmSelection, ConfirmSelectionPlainText, - DeleteRecord + DeleteRecord, + CycleFilterNext, + CycleFilterPrev, + ToggleFavoritesFilter ] ); } @@ -225,6 +228,41 @@ impl RopyBoard { self.confirm_record_as_plain_text(window, cx, self.selected_index); } + pub(crate) fn on_cycle_filter_next( + &mut self, + _: &CycleFilterNext, + _window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let next = next_content_filter(self.filter_state.content_filter, true); + self.filter_state.content_filter = next; + self.sync_filtered_records_and_reveal(cx); + cx.notify(); + } + + pub(crate) fn on_cycle_filter_prev( + &mut self, + _: &CycleFilterPrev, + _window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let next = next_content_filter(self.filter_state.content_filter, false); + self.filter_state.content_filter = next; + self.sync_filtered_records_and_reveal(cx); + cx.notify(); + } + + pub(crate) fn on_toggle_favorites_filter( + &mut self, + _: &ToggleFavoritesFilter, + _window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + self.toggle_favorites_only(); + self.sync_filtered_records_and_reveal(cx); + cx.notify(); + } + pub(crate) fn on_delete_record( &mut self, _: &DeleteRecord, diff --git a/src/gui/board/render.rs b/src/gui/board/render.rs index 5c383c6..178e2be 100644 --- a/src/gui/board/render.rs +++ b/src/gui/board/render.rs @@ -48,6 +48,9 @@ impl Render for RopyBoard { .on_action(cx.listener(Self::on_confirm_selection)) .on_action(cx.listener(Self::on_confirm_selection_plain_text)) .on_action(cx.listener(Self::on_delete_record)) + .on_action(cx.listener(Self::on_cycle_filter_next)) + .on_action(cx.listener(Self::on_cycle_filter_prev)) + .on_action(cx.listener(Self::on_toggle_favorites_filter)) .on_key_down(cx.listener(Self::on_key_down)) .on_key_up(cx.listener(Self::on_key_up)) .child(render_header(self, cx)) diff --git a/src/gui/board/search.rs b/src/gui/board/search.rs index 876d70a..e64adf6 100644 --- a/src/gui/board/search.rs +++ b/src/gui/board/search.rs @@ -38,6 +38,32 @@ pub(crate) enum ContentFilter { Files, } +/// Cycle to the adjacent `ContentFilter` in display order. +/// +/// Order matches the filter buttons in the search bar header: `All → Text → +/// Image → Files → All`. `forward = true` advances; `false` walks backward. +pub(crate) const fn next_content_filter(current: ContentFilter, forward: bool) -> ContentFilter { + const CYCLE: [ContentFilter; 4] = [ + ContentFilter::All, + ContentFilter::Text, + ContentFilter::Image, + ContentFilter::Files, + ]; + let len = CYCLE.len(); + let index = match current { + ContentFilter::All => 0, + ContentFilter::Text => 1, + ContentFilter::Image => 2, + ContentFilter::Files => 3, + }; + let next = if forward { + (index + 1) % len + } else { + (index + len - 1) % len + }; + CYCLE[next] +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct SearchOptions { pub(crate) case_sensitive: bool, @@ -391,6 +417,46 @@ mod tests { HashSet::new() } + #[test] + fn next_content_filter_cycles_forward() { + assert_eq!( + next_content_filter(ContentFilter::All, true), + ContentFilter::Text + ); + assert_eq!( + next_content_filter(ContentFilter::Text, true), + ContentFilter::Image + ); + assert_eq!( + next_content_filter(ContentFilter::Image, true), + ContentFilter::Files + ); + assert_eq!( + next_content_filter(ContentFilter::Files, true), + ContentFilter::All + ); + } + + #[test] + fn next_content_filter_cycles_backward() { + assert_eq!( + next_content_filter(ContentFilter::All, false), + ContentFilter::Files + ); + assert_eq!( + next_content_filter(ContentFilter::Files, false), + ContentFilter::Image + ); + assert_eq!( + next_content_filter(ContentFilter::Image, false), + ContentFilter::Text + ); + assert_eq!( + next_content_filter(ContentFilter::Text, false), + ContentFilter::All + ); + } + /// Helper: build a mixed set of test records (2 text + 1 image) fn mixed_records() -> Vec { vec![ diff --git a/src/gui/panel/help.rs b/src/gui/panel/help.rs index 19544d1..981b1e1 100644 --- a/src/gui/panel/help.rs +++ b/src/gui/panel/help.rs @@ -78,6 +78,16 @@ const SHORTCUTS: &[ShortcutRow] = &[ label_key: "help_favorite", grid_only: false, }, + ShortcutRow { + key: "Alt+← / Alt+→", + label_key: "help_cycle_filter", + grid_only: false, + }, + ShortcutRow { + key: "Alt+F", + label_key: "help_toggle_favorites_filter", + grid_only: false, + }, ShortcutRow { key: "P", label_key: "help_pin",