From f6e2b1b96b586760df0b7ad8da39373fe6f9ad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 02:48:35 -0400 Subject: [PATCH] Add shortcut settings page --- crates/ely_app/src/main.rs | 36 +-- crates/ely_app/src/shell/internal_pages.rs | 2 + .../src/shell/internal_pages/settings.rs | 6 + .../src/shell/internal_pages/shortcuts.rs | 288 +++++++++++++++++ crates/ely_app/src/shortcuts.rs | 298 ++++++++++++++++++ crates/ely_browser_core/src/navigation.rs | 8 + crates/ely_browser_core/src/state/commands.rs | 7 +- crates/ely_browser_core/tests/commands.rs | 15 + .../ely_browser_core/tests/settings_routes.rs | 21 ++ 9 files changed, 649 insertions(+), 32 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/shortcuts.rs create mode 100644 crates/ely_app/src/shortcuts.rs diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index cbc76c6..0efe59c 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -1,12 +1,14 @@ mod services; mod shell; +mod shortcuts; use gpui::{ - App, AppContext, Application, Bounds, Focusable, KeyBinding, Menu, MenuItem, SystemMenuType, - WindowBounds, WindowOptions, actions, px, size, + App, AppContext, Application, Bounds, Focusable, Menu, MenuItem, SystemMenuType, WindowBounds, + WindowOptions, actions, px, size, }; use gpui_component_assets::Assets; use shell::ElyShell; +use shortcuts::bind_shortcuts; actions!( ely_app, @@ -33,35 +35,7 @@ fn main() { Application::new().with_assets(Assets).run(|cx: &mut App| { gpui_component::init(cx); cx.on_action(quit); - cx.bind_keys([ - KeyBinding::new("cmd-t", OpenNewTab, None), - KeyBinding::new("ctrl-t", OpenNewTab, None), - KeyBinding::new("cmd-\\", SplitRight, None), - KeyBinding::new("ctrl-\\", SplitRight, None), - KeyBinding::new("cmd-shift-j", OpenDownloads, None), - KeyBinding::new("ctrl-shift-j", OpenDownloads, None), - KeyBinding::new("cmd-y", OpenHistory, None), - KeyBinding::new("ctrl-h", OpenHistory, None), - KeyBinding::new("cmd-escape", OpenTaskManager, None), - KeyBinding::new("shift-escape", OpenTaskManager, None), - KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("ctrl-,", OpenSettings, None), - KeyBinding::new("cmd-l", FocusAddressBar, None), - KeyBinding::new("ctrl-l", FocusAddressBar, None), - KeyBinding::new("cmd-w", CloseCurrentTab, None), - KeyBinding::new("ctrl-w", CloseCurrentTab, None), - KeyBinding::new("cmd-shift-t", RestoreClosedTab, None), - KeyBinding::new("ctrl-shift-t", RestoreClosedTab, None), - KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None), - KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None), - KeyBinding::new("cmd-shift-p", FocusCommandMode, None), - KeyBinding::new("ctrl-shift-p", FocusCommandMode, None), - KeyBinding::new("cmd-shift-]", SelectNextTab, None), - KeyBinding::new("ctrl-tab", SelectNextTab, None), - KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), - KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None), - KeyBinding::new("cmd-q", Quit, None), - ]); + bind_shortcuts(cx); cx.set_menus(vec![ Menu { name: "ELY Browser".into(), diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index fa56b90..432edf6 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -9,6 +9,7 @@ mod plugins; mod profiles; mod reading_list; mod settings; +mod shortcuts; mod sidebar_tabs; mod site_settings; mod sync; @@ -49,6 +50,7 @@ impl ElyShell { "ely://about" => self.render_about_page(snapshot), "ely://settings" => self.render_settings_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), + "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), "ely://settings/sync" => self.render_sync_page(snapshot), diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index 2e24b7d..7ad2f40 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -30,6 +30,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "Profile identity, color, and download policy.", route: "ely://settings/profiles", }, + SettingsRoute { + icon: IconName::SquareTerminal, + title: "Shortcuts", + detail: "Registered key bindings and conflict state.", + route: "ely://settings/shortcuts", + }, SettingsRoute { icon: IconName::Globe, title: "Sync", diff --git a/crates/ely_app/src/shell/internal_pages/shortcuts.rs b/crates/ely_app/src/shell/internal_pages/shortcuts.rs new file mode 100644 index 0000000..738d4b3 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/shortcuts.rs @@ -0,0 +1,288 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use crate::shortcuts::{ + SHORTCUT_ACTIONS, ShortcutAction, ShortcutConflict, ShortcutPlatform, bindings_for_action, + shortcut_conflicts, +}; + +use super::{ElyShell, render_canvas_surface}; + +const SHORTCUT_CATEGORIES: &[&str] = &["Command", "Tabs", "Library", "System", "Application"]; + +impl ElyShell { + pub(super) fn render_shortcuts_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + let conflicts = shortcut_conflicts(); + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_shortcuts_header(snapshot, conflicts.len())) + .child(render_conflict_panel(&conflicts)) + .child(render_shortcut_categories(&conflicts)), + ) + } +} + +fn render_shortcuts_header(snapshot: &BrowserSnapshot, conflict_count: usize) -> AnyElement { + let status = if conflict_count == 0 { + "Ready".to_string() + } else { + format!("{conflict_count} conflicts") + }; + + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Shortcuts")) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(shortcut_status_color(conflict_count))) + .child(shortcut_status_icon(conflict_count)) + .child(status), + ) + .into_any_element() +} + +fn render_conflict_panel(conflicts: &[ShortcutConflict]) -> AnyElement { + let (icon, title, detail, color) = if conflicts.is_empty() { + ( + IconName::CircleCheck, + "No shortcut conflicts", + "Every registered browser shortcut maps to a single action.", + colors::SUCCESS, + ) + } else { + ( + IconName::TriangleAlert, + "Shortcut conflicts", + "Conflicting bindings need a new key before customization is enabled.", + colors::ERROR, + ) + }; + + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_start() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_start() + .gap_3() + .child(div().text_color(rgb(color)).child(icon)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(title), + ) + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(detail)), + ), + ) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(color)) + .child(conflicts.len().to_string()), + ) + .into_any_element() +} + +fn render_shortcut_categories(conflicts: &[ShortcutConflict]) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + SHORTCUT_CATEGORIES + .iter() + .map(|category| render_shortcut_category(category, conflicts)), + ) + .into_any_element() +} + +fn render_shortcut_category(category: &'static str, conflicts: &[ShortcutConflict]) -> AnyElement { + div() + .flex() + .flex_col() + .child(render_category_header(category)) + .children( + SHORTCUT_ACTIONS + .iter() + .copied() + .filter(move |action| action.category() == category) + .map(|action| render_shortcut_row(action, conflicts)), + ) + .into_any_element() +} + +fn render_category_header(category: &'static str) -> AnyElement { + div() + .pt_4() + .pb_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(category) + .into_any_element() +} + +fn render_shortcut_row(action: ShortcutAction, conflicts: &[ShortcutConflict]) -> AnyElement { + let has_conflict = conflicts + .iter() + .any(|conflict| conflict.actions.iter().any(|conflict_action| conflict_action == &action)); + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child( + div() + .text_color(rgb(shortcut_row_icon_color(has_conflict))) + .child(shortcut_row_icon(has_conflict)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(action.label()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(action.command().unwrap_or("Key binding only")), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_3() + .text_xs() + .child(shortcut_platform_label(action, ShortcutPlatform::Macos)) + .child(shortcut_platform_label(action, ShortcutPlatform::WindowsLinux)) + .child(shortcut_row_status(has_conflict)), + ) + .into_any_element() +} + +fn shortcut_platform_label(action: ShortcutAction, platform: ShortcutPlatform) -> AnyElement { + div() + .min_w(px(170.0)) + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED_SOFT)).child(platform.label())) + .child( + div() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(shortcut_keys_label(action, platform)), + ) + .into_any_element() +} + +fn shortcut_row_status(has_conflict: bool) -> AnyElement { + let (label, color) = + if has_conflict { ("Conflict", colors::ERROR) } else { ("Ready", colors::SUCCESS) }; + + div().min_w(px(72.0)).font_semibold().text_color(rgb(color)).child(label).into_any_element() +} + +fn shortcut_keys_label(action: ShortcutAction, platform: ShortcutPlatform) -> String { + let bindings = bindings_for_action(action, platform) + .map(|binding| binding.display_keystroke()) + .collect::>(); + + if bindings.is_empty() { + return "Unassigned".to_string(); + } + + bindings.join(" / ") +} + +fn shortcut_status_color(conflict_count: usize) -> u32 { + if conflict_count == 0 { colors::SUCCESS } else { colors::ERROR } +} + +fn shortcut_status_icon(conflict_count: usize) -> IconName { + if conflict_count == 0 { IconName::CircleCheck } else { IconName::TriangleAlert } +} + +fn shortcut_row_icon(has_conflict: bool) -> IconName { + if has_conflict { IconName::TriangleAlert } else { IconName::SquareTerminal } +} + +fn shortcut_row_icon_color(has_conflict: bool) -> u32 { + if has_conflict { colors::ERROR } else { colors::MUTED_SOFT } +} diff --git a/crates/ely_app/src/shortcuts.rs b/crates/ely_app/src/shortcuts.rs new file mode 100644 index 0000000..9f70c3b --- /dev/null +++ b/crates/ely_app/src/shortcuts.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; + +use gpui::{App, KeyBinding}; + +use crate::{ + CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, + OpenSettings, OpenTaskManager, Quit, RestoreClosedTab, SelectNextTab, SelectPreviousTab, + SplitRight, ToggleFavoriteTab, +}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum ShortcutPlatform { + Macos, + WindowsLinux, +} + +impl ShortcutPlatform { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Macos => "macOS", + Self::WindowsLinux => "Windows/Linux", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum ShortcutAction { + FocusAddressBar, + FocusCommandMode, + OpenNewTab, + CloseCurrentTab, + RestoreClosedTab, + SelectNextTab, + SelectPreviousTab, + SplitRight, + ToggleFavoriteTab, + OpenDownloads, + OpenHistory, + OpenSettings, + OpenTaskManager, + Quit, +} + +impl ShortcutAction { + pub(crate) fn label(self) -> &'static str { + match self { + Self::FocusAddressBar => "Command Bar", + Self::FocusCommandMode => "Command Mode", + Self::OpenNewTab => "New Tab", + Self::CloseCurrentTab => "Close Tab", + Self::RestoreClosedTab => "Restore Closed Tab", + Self::SelectNextTab => "Next Tab", + Self::SelectPreviousTab => "Previous Tab", + Self::SplitRight => "Split Right", + Self::ToggleFavoriteTab => "Toggle Favorite", + Self::OpenDownloads => "Open Downloads", + Self::OpenHistory => "Open History", + Self::OpenSettings => "Open Settings", + Self::OpenTaskManager => "Task Manager", + Self::Quit => "Quit ELY Browser", + } + } + + pub(crate) fn category(self) -> &'static str { + match self { + Self::FocusAddressBar | Self::FocusCommandMode => "Command", + Self::OpenNewTab + | Self::CloseCurrentTab + | Self::RestoreClosedTab + | Self::SelectNextTab + | Self::SelectPreviousTab + | Self::SplitRight + | Self::ToggleFavoriteTab => "Tabs", + Self::OpenDownloads | Self::OpenHistory => "Library", + Self::OpenSettings | Self::OpenTaskManager => "System", + Self::Quit => "Application", + } + } + + pub(crate) fn command(self) -> Option<&'static str> { + match self { + Self::FocusAddressBar => None, + Self::FocusCommandMode => None, + Self::OpenNewTab => Some(">new-tab"), + Self::CloseCurrentTab => Some(">close-tab"), + Self::RestoreClosedTab => Some(">restore-tab"), + Self::SelectNextTab => None, + Self::SelectPreviousTab => None, + Self::SplitRight => Some(">split-right"), + Self::ToggleFavoriteTab => Some(">favorite"), + Self::OpenDownloads => Some(">open-downloads"), + Self::OpenHistory => Some(">open-history"), + Self::OpenSettings => Some(">open-settings"), + Self::OpenTaskManager => Some(">open-task-manager"), + Self::Quit => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ShortcutBinding { + action: ShortcutAction, + platform: ShortcutPlatform, + keystroke: &'static str, +} + +impl ShortcutBinding { + pub(crate) fn display_keystroke(self) -> String { + display_keystroke(self.keystroke, self.platform) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ShortcutConflict { + pub(crate) platform: ShortcutPlatform, + pub(crate) keystroke: &'static str, + pub(crate) actions: Vec, +} + +pub(crate) const SHORTCUT_ACTIONS: &[ShortcutAction] = &[ + ShortcutAction::FocusAddressBar, + ShortcutAction::FocusCommandMode, + ShortcutAction::OpenNewTab, + ShortcutAction::CloseCurrentTab, + ShortcutAction::RestoreClosedTab, + ShortcutAction::SelectNextTab, + ShortcutAction::SelectPreviousTab, + ShortcutAction::SplitRight, + ShortcutAction::ToggleFavoriteTab, + ShortcutAction::OpenDownloads, + ShortcutAction::OpenHistory, + ShortcutAction::OpenSettings, + ShortcutAction::OpenTaskManager, + ShortcutAction::Quit, +]; + +pub(crate) const SHORTCUT_BINDINGS: &[ShortcutBinding] = &[ + shortcut(ShortcutAction::OpenNewTab, ShortcutPlatform::Macos, "cmd-t"), + shortcut(ShortcutAction::OpenNewTab, ShortcutPlatform::WindowsLinux, "ctrl-t"), + shortcut(ShortcutAction::SplitRight, ShortcutPlatform::Macos, "cmd-\\"), + shortcut(ShortcutAction::SplitRight, ShortcutPlatform::WindowsLinux, "ctrl-\\"), + shortcut(ShortcutAction::OpenDownloads, ShortcutPlatform::Macos, "cmd-shift-j"), + shortcut(ShortcutAction::OpenDownloads, ShortcutPlatform::WindowsLinux, "ctrl-shift-j"), + shortcut(ShortcutAction::OpenHistory, ShortcutPlatform::Macos, "cmd-y"), + shortcut(ShortcutAction::OpenHistory, ShortcutPlatform::WindowsLinux, "ctrl-h"), + shortcut(ShortcutAction::OpenTaskManager, ShortcutPlatform::Macos, "cmd-escape"), + shortcut(ShortcutAction::OpenTaskManager, ShortcutPlatform::WindowsLinux, "shift-escape"), + shortcut(ShortcutAction::OpenSettings, ShortcutPlatform::Macos, "cmd-,"), + shortcut(ShortcutAction::OpenSettings, ShortcutPlatform::WindowsLinux, "ctrl-,"), + shortcut(ShortcutAction::FocusAddressBar, ShortcutPlatform::Macos, "cmd-l"), + shortcut(ShortcutAction::FocusAddressBar, ShortcutPlatform::WindowsLinux, "ctrl-l"), + shortcut(ShortcutAction::CloseCurrentTab, ShortcutPlatform::Macos, "cmd-w"), + shortcut(ShortcutAction::CloseCurrentTab, ShortcutPlatform::WindowsLinux, "ctrl-w"), + shortcut(ShortcutAction::RestoreClosedTab, ShortcutPlatform::Macos, "cmd-shift-t"), + shortcut(ShortcutAction::RestoreClosedTab, ShortcutPlatform::WindowsLinux, "ctrl-shift-t"), + shortcut(ShortcutAction::ToggleFavoriteTab, ShortcutPlatform::Macos, "cmd-shift-f"), + shortcut(ShortcutAction::ToggleFavoriteTab, ShortcutPlatform::WindowsLinux, "ctrl-shift-f"), + shortcut(ShortcutAction::FocusCommandMode, ShortcutPlatform::Macos, "cmd-shift-p"), + shortcut(ShortcutAction::FocusCommandMode, ShortcutPlatform::WindowsLinux, "ctrl-shift-p"), + shortcut(ShortcutAction::SelectNextTab, ShortcutPlatform::Macos, "cmd-shift-]"), + shortcut(ShortcutAction::SelectNextTab, ShortcutPlatform::Macos, "cmd-alt-down"), + shortcut(ShortcutAction::SelectNextTab, ShortcutPlatform::WindowsLinux, "ctrl-tab"), + shortcut(ShortcutAction::SelectNextTab, ShortcutPlatform::WindowsLinux, "ctrl-alt-down"), + shortcut(ShortcutAction::SelectPreviousTab, ShortcutPlatform::Macos, "cmd-shift-["), + shortcut(ShortcutAction::SelectPreviousTab, ShortcutPlatform::Macos, "cmd-alt-up"), + shortcut(ShortcutAction::SelectPreviousTab, ShortcutPlatform::WindowsLinux, "ctrl-shift-tab"), + shortcut(ShortcutAction::SelectPreviousTab, ShortcutPlatform::WindowsLinux, "ctrl-alt-up"), + shortcut(ShortcutAction::Quit, ShortcutPlatform::Macos, "cmd-q"), +]; + +const fn shortcut( + action: ShortcutAction, + platform: ShortcutPlatform, + keystroke: &'static str, +) -> ShortcutBinding { + ShortcutBinding { action, platform, keystroke } +} + +pub(crate) fn bind_shortcuts(cx: &mut App) { + cx.bind_keys(SHORTCUT_BINDINGS.iter().map(|binding| binding.key_binding())); +} + +pub(crate) fn bindings_for_action( + action: ShortcutAction, + platform: ShortcutPlatform, +) -> impl Iterator { + SHORTCUT_BINDINGS + .iter() + .copied() + .filter(move |binding| binding.action == action && binding.platform == platform) +} + +pub(crate) fn shortcut_conflicts() -> Vec { + let mut bindings_by_key: BTreeMap<(ShortcutPlatform, &'static str), Vec> = + BTreeMap::new(); + + for binding in SHORTCUT_BINDINGS { + bindings_by_key + .entry((binding.platform, binding.keystroke)) + .or_default() + .push(binding.action); + } + + bindings_by_key + .into_iter() + .filter_map(|((platform, keystroke), actions)| { + (actions.len() > 1).then_some(ShortcutConflict { platform, keystroke, actions }) + }) + .collect() +} + +impl ShortcutBinding { + fn key_binding(self) -> KeyBinding { + match self.action { + ShortcutAction::CloseCurrentTab => { + KeyBinding::new(self.keystroke, CloseCurrentTab, None) + } + ShortcutAction::FocusAddressBar => { + KeyBinding::new(self.keystroke, FocusAddressBar, None) + } + ShortcutAction::FocusCommandMode => { + KeyBinding::new(self.keystroke, FocusCommandMode, None) + } + ShortcutAction::OpenDownloads => KeyBinding::new(self.keystroke, OpenDownloads, None), + ShortcutAction::OpenHistory => KeyBinding::new(self.keystroke, OpenHistory, None), + ShortcutAction::OpenNewTab => KeyBinding::new(self.keystroke, OpenNewTab, None), + ShortcutAction::OpenSettings => KeyBinding::new(self.keystroke, OpenSettings, None), + ShortcutAction::OpenTaskManager => { + KeyBinding::new(self.keystroke, OpenTaskManager, None) + } + ShortcutAction::Quit => KeyBinding::new(self.keystroke, Quit, None), + ShortcutAction::RestoreClosedTab => { + KeyBinding::new(self.keystroke, RestoreClosedTab, None) + } + ShortcutAction::SelectNextTab => KeyBinding::new(self.keystroke, SelectNextTab, None), + ShortcutAction::SelectPreviousTab => { + KeyBinding::new(self.keystroke, SelectPreviousTab, None) + } + ShortcutAction::SplitRight => KeyBinding::new(self.keystroke, SplitRight, None), + ShortcutAction::ToggleFavoriteTab => { + KeyBinding::new(self.keystroke, ToggleFavoriteTab, None) + } + } + } +} + +fn display_keystroke(keystroke: &str, platform: ShortcutPlatform) -> String { + keystroke + .split('-') + .map(|part| display_key_part(part, platform)) + .collect::>() + .join(" + ") +} + +fn display_key_part(part: &str, platform: ShortcutPlatform) -> String { + match part { + "cmd" => "Cmd".to_string(), + "ctrl" => "Ctrl".to_string(), + "alt" if platform == ShortcutPlatform::Macos => "Option".to_string(), + "alt" => "Alt".to_string(), + "shift" => "Shift".to_string(), + "escape" => "Esc".to_string(), + value => value.to_ascii_uppercase(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + SHORTCUT_ACTIONS, SHORTCUT_BINDINGS, ShortcutAction, ShortcutPlatform, bindings_for_action, + shortcut_conflicts, + }; + + #[test] + fn registered_shortcuts_have_no_conflicts() { + assert_eq!(shortcut_conflicts(), Vec::new()); + } + + #[test] + fn open_settings_shortcut_has_platform_bindings() { + let bindings = bindings_for_action(ShortcutAction::OpenSettings, ShortcutPlatform::Macos) + .chain(bindings_for_action( + ShortcutAction::OpenSettings, + ShortcutPlatform::WindowsLinux, + )) + .map(|binding| binding.display_keystroke()) + .collect::>(); + + assert_eq!(bindings, vec!["Cmd + ,".to_string(), "Ctrl + ,".to_string()]); + } + + #[test] + fn every_declared_action_has_a_binding() { + for action in SHORTCUT_ACTIONS { + assert!(SHORTCUT_BINDINGS.iter().any(|binding| binding.action == *action)); + } + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index cb909d2..1b86fac 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -28,6 +28,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), + "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), "ely://settings/profiles" => Some("Profile Settings"), "ely://settings/sync" => Some("Sync Settings"), @@ -137,6 +138,10 @@ pub(crate) fn settings_url() -> Result { internal_page_url("ely://settings") } +pub(crate) fn shortcut_settings_url() -> Result { + internal_page_url("ely://settings/shortcuts") +} + pub(crate) fn sync_status_url() -> Result { internal_page_url("ely://sync/status") } @@ -157,6 +162,9 @@ fn settings_page_route(query: &str) -> Option<&'static str> { "sidebar" | "tabs" | "sidebar tabs" | "sidebar & tabs" => { Some("ely://settings/sidebar-tabs") } + "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { + Some("ely://settings/shortcuts") + } "sync" | "sync settings" => Some("ely://settings/sync"), "profile" | "profiles" | "profile settings" | "profiles settings" => { Some("ely://settings/profiles") diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 245e3f1..57cc6f7 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -8,7 +8,8 @@ use crate::{ about_url, archive_idle_days, archive_url, bookmarks_url, downloads_url, history_url, move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, plugin_detail_url, plugins_url, reading_list_url, search_url, settings_page_url, - settings_url, space_icon, switch_profile_name, sync_status_url, task_manager_url, + settings_url, shortcut_settings_url, space_icon, switch_profile_name, sync_status_url, + task_manager_url, }, }; @@ -181,6 +182,10 @@ impl BrowserCore { self.open_tab(settings_url()?); Ok(true) } + "shortcuts" | "open-shortcuts" | "open shortcuts" => { + self.open_tab(shortcut_settings_url()?); + Ok(true) + } "sync" | "open-sync-status" | "open sync status" => { self.open_tab(sync_status_url()?); Ok(true) diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index c869324..92c72a0 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -141,6 +141,21 @@ fn open_settings_command_opens_settings_page() -> Result<(), Box> { Ok(()) } +#[test] +fn open_shortcuts_command_opens_shortcuts_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-shortcuts"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-shortcuts".to_string()))); + assert_eq!(active_tab.title(), "Shortcut Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/shortcuts"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn open_sync_status_command_opens_sync_status_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index 7c3cec5..33a50ef 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -23,3 +23,24 @@ fn settings_scoped_search_opens_sidebar_tabs_page() -> Result<(), Box assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn settings_scoped_search_opens_shortcuts_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings shortcuts"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "shortcuts".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Shortcut Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/shortcuts"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}