From a7d3084c46540ab3750827e8ebe25f18ee5725ba Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 29 Sep 2026 07:06:46 -0400 Subject: [PATCH] v0.0.11 - Fix zoom shortcuts, shortcut rebinding, bookmark rename, and duplicate new-tab button --- Cargo.lock | 2 +- Cargo.toml | 2 +- VERSION | 2 +- src/key_handler.rs | 364 ++++++++++++++++++++------------------------ src/nostr_bridge.rs | 58 +++++-- src/shortcuts.rs | 305 ++++++++++++++++++++++++++++++------- src/tab_manager.rs | 41 ++--- src/version.rs | 4 +- www/settings.js | 20 ++- 9 files changed, 498 insertions(+), 300 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf088c7..d25f811 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3055,7 +3055,7 @@ dependencies = [ [[package]] name = "sovereign_browser" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index a0ea914..39d8737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "sovereign_browser" -version = "0.0.10" +version = "0.0.11" edition = "2021" license = "MIT" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" diff --git a/VERSION b/VERSION index 7c1886b..2cfabea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.10 +0.0.11 diff --git a/src/key_handler.rs b/src/key_handler.rs index 548756b..0f61d81 100644 --- a/src/key_handler.rs +++ b/src/key_handler.rs @@ -8,16 +8,19 @@ //! //! The webview connection is required because WebKit consumes key events //! when the webview has focus, so a window-only handler never sees them. -//! The window connection is a fallback that fires after the webview handler -//! (event propagation: child → parent → window). +//! The window connection is a fallback. //! -//! The `from_webview` flag distinguishes the two connections so that -//! `sovereign://` internal pages (e.g. the settings page's shortcut -//! capture) can receive raw key events on the webview connection while the -//! window connection still processes browser shortcuts. +//! Key combinations are resolved through the shortcut registry +//! (`shortcuts.rs`), so bindings changed on the settings page take effect +//! immediately. +//! +//! While the settings page is capturing a new shortcut +//! (`shortcuts::shortcuts_capture_active()`), all interception is bypassed +//! so the page's JS receives the raw key combination. use webkit2gtk::*; +use crate::shortcuts; use crate::tab_manager; /// Build a WebContext configured for the currently signed-in user. @@ -34,212 +37,169 @@ pub fn build_context_for_current_user() -> WebContext { ctx } +/// Convert a GDK keyval into the key name used by the shortcut registry. +/// Letters are lowercased, keypad keys are folded onto their main-keyboard +/// equivalents, and `ISO_Left_Tab` (Shift+Tab) becomes `Tab`. +fn key_name(keyval: gdk::keys::Key) -> Option { + let name = keyval.to_lower().name()?.to_string(); + let mapped = match name.as_str() { + "ISO_Left_Tab" | "KP_Tab" => "Tab", + "KP_Add" => "plus", + "KP_Subtract" => "minus", + "KP_Enter" => "Return", + "KP_Multiply" => "asterisk", + "KP_Divide" => "slash", + "KP_Decimal" => "period", + "KP_0" | "KP_Insert" => "0", + "KP_1" => "1", + "KP_2" => "2", + "KP_3" => "3", + "KP_4" => "4", + "KP_5" => "5", + "KP_6" => "6", + "KP_7" => "7", + "KP_8" => "8", + "KP_9" => "9", + other => other, + }; + Some(mapped.to_string()) +} + +fn is_modifier_key(name: &str) -> bool { + matches!( + name, + "Shift_L" | "Shift_R" | "Control_L" | "Control_R" | "Alt_L" | "Alt_R" + | "Meta_L" | "Meta_R" | "Super_L" | "Super_R" | "ISO_Level3_Shift" + | "Caps_Lock" | "Num_Lock" + ) +} + +/// True if Shift is likely required just to produce this key's symbol +/// (e.g. `plus` on a US layout is Shift+=). Letters, digits and named keys +/// like `Tab`/`F5` are not shift-produced. +fn is_shifted_symbol(name: &str) -> bool { + let mut chars = name.chars(); + let single = matches!((chars.next(), chars.next()), (Some(_), None)); + if single { + return !name.chars().all(|c| c.is_ascii_alphanumeric()); + } + matches!( + name, + "plus" | "underscore" | "colon" | "less" | "greater" | "question" + | "exclam" | "at" | "numbersign" | "dollar" | "percent" + | "asciicircum" | "ampersand" | "asterisk" | "parenleft" + | "parenright" | "braceleft" | "braceright" | "bar" | "quotedbl" + | "asciitilde" + ) +} + +/// Run a shortcut action. Returns false if the action is unknown. +fn run_action(action: &str) -> bool { + match action { + "new_tab" => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + let ctx = build_context_for_current_user(); + tab_manager::tab_manager_new_tab(&nb, &ctx, None); + } + } + "new_window" => tab_manager::tab_manager_new_window_blank(), + "open_file" => tab_manager::tab_manager_open_file(), + "close_tab" => tab_manager::tab_manager_close_active(), + "focus_url" => tab_manager::tab_manager_focus_url(), + "next_tab" | "next_tab_pagedown" => tab_manager::tab_manager_next(), + "prev_tab" | "prev_tab_pageup" => tab_manager::tab_manager_prev(), + "reload" | "reload_f5" => tab_manager::tab_manager_reload_active(), + "force_reload" => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + wv.reload_bypass_cache(); + } + } + } + "go_back" => tab_manager::tab_manager_go_back(), + "go_forward" => tab_manager::tab_manager_go_forward(), + "find" => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + // run_javascript is deprecated in webkit2gtk 2.0.2 but + // there is no replacement in the Rust bindings. Suppress. + #[allow(deprecated)] + wv.run_javascript("document.body.innerHTML += '
'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {}); + } + } + } + "open_settings" => tab_manager::tab_manager_open_internal("sovereign://settings"), + "open_processes" | "show_downloads" => { + tab_manager::tab_manager_open_internal("sovereign://processes") + } + "new_identity" => crate::menu::menu_switch_identity(), + "toggle_fullscreen" => tab_manager::tab_manager_toggle_fullscreen(), + "toggle_inspector" => tab_manager::tab_manager_toggle_inspector(), + "toggle_sidebar" => tab_manager::tab_manager_toggle_sidebar(), + "toggle_toolbars" => tab_manager::tab_manager_toggle_toolbars(), + "zoom_in" | "zoom_in_alt" => tab_manager::tab_manager_zoom_in(), + "zoom_out" => tab_manager::tab_manager_zoom_out(), + "zoom_reset" => tab_manager::tab_manager_zoom_reset(), + "bookmark_page" => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + let url = wv.uri().unwrap_or_default().to_string(); + let title = wv.title().unwrap_or_default().to_string(); + let _ = crate::db::db_add_bookmark(&title, &url, ""); + } + } + } + "show_history" => tab_manager::tab_manager_open_internal("sovereign://profile"), + "show_bookmarks" => tab_manager::tab_manager_open_internal("sovereign://bookmarks"), + "quit" => { + crate::session::session_save(); + gtk::main_quit(); + } + _ => return false, + } + true +} + /// Handle a key-press event, dispatching browser-level shortcuts. /// -/// `from_webview` must be `true` when connected to a WebView's +/// `from_webview` is `true` when connected to a WebView's /// `key-press-event` and `false` when connected to the window's. -/// Mirrors the C version (`main.c:501`). -pub fn handle_key_press(event: &gdk::EventKey, from_webview: bool) -> glib::Propagation { - let ctrl = event.state().contains(gdk::ModifierType::CONTROL_MASK); - let shift = event.state().contains(gdk::ModifierType::SHIFT_MASK); - let alt = event.state().contains(gdk::ModifierType::MOD1_MASK); - // Normalize the keyval to lowercase. When Shift is held, GDK delivers - // the uppercase keyval (e.g. `M` for Ctrl+Shift+M), which would never - // match the lowercase `constants::m`. Mirrors the C version's - // `gdk_keyval_to_lower(event->keyval)` (shortcuts.c:199). - let key = event.keyval().to_lower(); - - // When the active tab is showing a sovereign:// internal page (e.g. - // the settings page's keyboard-shortcut capture), bypass shortcut - // interception on the webview connection only — let the event pass - // through to the web page's JS so it can capture arbitrary key combos. - // The window-level connection still processes shortcuts so Ctrl+T, - // Ctrl+N, etc. work on internal pages. Mirrors main.c:516. - if from_webview { - let tabs = tab_manager::tab_manager_get_tabs(); - let active_id = tab_manager::tab_manager_get_active_tab(); - if let Some(tab) = tabs.iter().find(|t| t.id == active_id) { - if tab.url.starts_with("sovereign://") || tab.url.starts_with("about:settings") { - return glib::Propagation::Proceed; - } - } +pub fn handle_key_press(event: &gdk::EventKey, _from_webview: bool) -> glib::Propagation { + // The settings page is recording a new shortcut: let the raw key + // combination reach its JS untouched. + if shortcuts::shortcuts_capture_active() { + return glib::Propagation::Proceed; } - // Alt+Left/Right (back/forward) — no modifier mask check needed. - if alt && !ctrl && !shift { - match key { - gdk::keys::constants::Left => { - tab_manager::tab_manager_go_back(); - return glib::Propagation::Stop; - } - gdk::keys::constants::Right => { - tab_manager::tab_manager_go_forward(); - return glib::Propagation::Stop; - } - _ => {} - } + let state = event.state(); + let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK); + let shift = state.contains(gdk::ModifierType::SHIFT_MASK); + let alt = state.contains(gdk::ModifierType::MOD1_MASK); + let sup = state.contains(gdk::ModifierType::SUPER_MASK); + + let key = match key_name(event.keyval()) { + Some(k) => k, + None => return glib::Propagation::Proceed, + }; + if is_modifier_key(&key) { + return glib::Propagation::Proceed; } - // F-key shortcuts (no modifiers). - if !ctrl && !shift && !alt { - match key { - gdk::keys::constants::F5 => { - tab_manager::tab_manager_reload_active(); - return glib::Propagation::Stop; - } - gdk::keys::constants::F11 => { - tab_manager::tab_manager_toggle_fullscreen(); - return glib::Propagation::Stop; - } - _ => {} - } + let mods = shortcuts::modifiers_string(ctrl, alt, shift, sup); + let mut found = shortcuts::shortcuts_get(&mods, &key); + + // Symbols such as `plus` need Shift on many layouts (Shift+= on US). + // If there's no exact binding, retry with Shift treated as consumed so + // a `Ctrl+plus` binding fires for Ctrl+Shift+=. + if found.is_none() && shift && is_shifted_symbol(&key) { + let mods_no_shift = shortcuts::modifiers_string(ctrl, alt, false, sup); + found = shortcuts::shortcuts_get(&mods_no_shift, &key); } - // Shift+F5 (force reload). - if shift && !ctrl && !alt && key == gdk::keys::constants::F5 { - if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { - if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { - wv.reload_bypass_cache(); - } - } - return glib::Propagation::Stop; - } - - // Ctrl+ key combinations. - if ctrl && !shift && !alt { - match key { - gdk::keys::constants::t => { - if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { - let ctx = build_context_for_current_user(); - tab_manager::tab_manager_new_tab(&nb, &ctx, None); - } - return glib::Propagation::Stop; - } - gdk::keys::constants::n => { - tab_manager::tab_manager_new_window_blank(); - return glib::Propagation::Stop; - } - gdk::keys::constants::o => { - tab_manager::tab_manager_open_file(); - return glib::Propagation::Stop; - } - gdk::keys::constants::w => { - tab_manager::tab_manager_close_active(); - return glib::Propagation::Stop; - } - gdk::keys::constants::l => { - tab_manager::tab_manager_focus_url(); - return glib::Propagation::Stop; - } - gdk::keys::constants::r => { - tab_manager::tab_manager_reload_active(); - return glib::Propagation::Stop; - } - gdk::keys::constants::f => { - if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { - if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { - // WebKit find controller. - // run_javascript is deprecated in webkit2gtk 2.0.2 but - // there is no replacement in the Rust bindings. Suppress. - #[allow(deprecated)] - wv.run_javascript("document.body.innerHTML += '
'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {}); - } - } - return glib::Propagation::Stop; - } - gdk::keys::constants::d => { - // Bookmark current page. - if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { - if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { - let url = wv.uri().unwrap_or_default().to_string(); - let title = wv.title().unwrap_or_default().to_string(); - let _ = crate::db::db_add_bookmark(&title, &url, ""); - } - } - return glib::Propagation::Stop; - } - gdk::keys::constants::h => { - tab_manager::tab_manager_open_internal("sovereign://profile"); - return glib::Propagation::Stop; - } - gdk::keys::constants::b => { - tab_manager::tab_manager_open_internal("sovereign://bookmarks"); - return glib::Propagation::Stop; - } - gdk::keys::constants::j => { - tab_manager::tab_manager_open_internal("sovereign://processes"); - return glib::Propagation::Stop; - } - gdk::keys::constants::q => { - crate::session::session_save(); - gtk::main_quit(); - return glib::Propagation::Stop; - } - gdk::keys::constants::comma => { - tab_manager::tab_manager_open_internal("sovereign://settings"); - return glib::Propagation::Stop; - } - gdk::keys::constants::Tab => { - tab_manager::tab_manager_next(); - return glib::Propagation::Stop; - } - gdk::keys::constants::Page_Down => { - tab_manager::tab_manager_next(); - return glib::Propagation::Stop; - } - gdk::keys::constants::Page_Up => { - tab_manager::tab_manager_prev(); - return glib::Propagation::Stop; - } - gdk::keys::constants::plus | gdk::keys::constants::equal => { - tab_manager::tab_manager_zoom_in(); - return glib::Propagation::Stop; - } - gdk::keys::constants::minus => { - tab_manager::tab_manager_zoom_out(); - return glib::Propagation::Stop; - } - gdk::keys::constants::_0 => { - tab_manager::tab_manager_zoom_reset(); - return glib::Propagation::Stop; - } - gdk::keys::constants::Shift_L | gdk::keys::constants::Shift_R => { - return glib::Propagation::Proceed; - } - _ => {} + if let Some(sc) = found { + if run_action(&sc.action) { + return glib::Propagation::Stop; } } - - // Ctrl+Shift+ key combinations. - if ctrl && shift && !alt { - match key { - gdk::keys::constants::Tab => { - tab_manager::tab_manager_prev(); - return glib::Propagation::Stop; - } - gdk::keys::constants::i => { - tab_manager::tab_manager_toggle_inspector(); - return glib::Propagation::Stop; - } - gdk::keys::constants::a => { - tab_manager::tab_manager_toggle_sidebar(); - return glib::Propagation::Stop; - } - gdk::keys::constants::m => { - tab_manager::tab_manager_toggle_toolbars(); - return glib::Propagation::Stop; - } - gdk::keys::constants::u => { - crate::menu::menu_switch_identity(); - return glib::Propagation::Stop; - } - gdk::keys::constants::Escape => { - tab_manager::tab_manager_open_internal("sovereign://processes"); - return glib::Propagation::Stop; - } - _ => {} - } - } - glib::Propagation::Proceed } diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs index e7da801..2a1f2a6 100644 --- a/src/nostr_bridge.rs +++ b/src/nostr_bridge.rs @@ -93,8 +93,11 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) { } // ── Bookmarks REST API ───────────────────────────────────────── + // Pass `path` (with its query string): the handler reads dir/url/title + // from the query. Passing `route` silently dropped every parameter, so + // rename/move/delete never matched a bookmark. if route.starts_with("bookmarks/") { - handle_bookmarks_api(request, &route[10..]); + handle_bookmarks_api(request, &path[10..]); return; } @@ -605,6 +608,11 @@ fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) { } _ => respond_error_json(request, 404, "Unknown bookmarks route"), } + + // Mutations change what the per-tab bookmark bars show. + if route != "list" { + glib::idle_add_once(crate::tab_manager::tab_manager_refresh_bookmark_bars); + } } /// Build the bookmark tree JSON from a flat bookmark list. @@ -860,7 +868,16 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) { let value = extract_query_param(query, "value").unwrap_or_default(); // Handle shortcut pseudo-keys. + if key == "shortcut.capture" { + // The settings page starts/stops recording a key combination; the + // key handler must not intercept keys meanwhile. + let active = value == "1" || value == "true"; + crate::shortcuts::shortcuts_set_capture(active); + respond_json(request, &serde_json::json!({"key": "shortcut.capture", "value": active}).to_string()); + return; + } if key == "shortcut.reset_all" { + crate::shortcuts::shortcuts_set_capture(false); crate::shortcuts::shortcuts_init(); crate::shortcuts::shortcuts_save(); println!("[settings] shortcut.reset_all"); @@ -869,20 +886,32 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) { } if key == "shortcut.reset" { // Reset a single shortcut by action. - if let Some(action) = extract_query_param(query, "action") { - crate::shortcuts::shortcuts_init(); - crate::shortcuts::shortcuts_save(); - println!("[settings] shortcut.reset {}", action); - respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string()); + let action = extract_query_param(query, "action").unwrap_or_default(); + if !crate::shortcuts::shortcuts_reset(&action) { + respond_error_json(request, 400, "Unknown shortcut action"); return; } - } - if key.starts_with("shortcut.") { - // Set a shortcut: key="shortcut.new_tab", value="t" - // Our Shortcut model uses modifiers+key. For now, reinitialize. - crate::shortcuts::shortcuts_init(); crate::shortcuts::shortcuts_save(); - println!("[settings] shortcut set: {} = {}", key, value); + println!("[settings] shortcut.reset {}", action); + respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string()); + return; + } + if let Some(action) = key.strip_prefix("shortcut.") { + // Set a shortcut: key="shortcut.zoom_in", value="plus" + crate::shortcuts::shortcuts_set_capture(false); + let (mods, keyname) = match crate::shortcuts::parse_accel(&value) { + Some(p) => p, + None => { + respond_error_json(request, 400, "Invalid accelerator"); + return; + } + }; + if !crate::shortcuts::shortcuts_set(action, &mods, &keyname) { + respond_error_json(request, 400, "Unknown shortcut action"); + return; + } + crate::shortcuts::shortcuts_save(); + println!("[settings] shortcut set: {} = {}+{}", action, mods, keyname); respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string()); return; } @@ -970,11 +999,10 @@ fn handle_settings_config_json(request: &URISchemeRequest) { // Keyboard shortcuts. let all_shortcuts = crate::shortcuts::shortcuts_get_all(); let shortcuts: Vec = all_shortcuts.iter().map(|sc| { - let accel = format!("<{}>{}", sc.modifiers, sc.key); serde_json::json!({ "id": sc.action, - "label": sc.action, - "accel": accel, + "label": crate::shortcuts::shortcut_label(&sc.action), + "accel": crate::shortcuts::format_accel(sc), }) }).collect(); diff --git a/src/shortcuts.rs b/src/shortcuts.rs index d7d35ce..e7d413e 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -1,99 +1,288 @@ //! Keyboard shortcuts for sovereign_browser //! //! Port of `shortcuts.c` / `shortcuts.h` from the C project. +//! +//! The registry is an ordered list of bindings, one per action. Each action +//! name is unique so the settings page can address it (change / reset). +//! Key dispatch (`key_handler.rs`) looks up bindings here, so edits made in +//! the settings page take effect immediately. -use std::collections::HashMap; use std::sync::Mutex; use once_cell::sync::Lazy; /// A keyboard shortcut binding. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Shortcut { + /// GDK key name, lowercase for letters (e.g. "t", "Tab", "plus", "F5"). pub key: String, - pub modifiers: String, // "Ctrl", "Ctrl+Shift", etc. + /// Canonical modifier string: any of "Ctrl", "Alt", "Shift", "Super" + /// joined with '+', in that order (e.g. "Ctrl+Shift"). Empty for none. + pub modifiers: String, + /// Unique action identifier. pub action: String, } -/// Global shortcuts registry. -static G_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +/// Global shortcuts registry (ordered for display). +static G_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(default_shortcuts())); -/// Initialize default shortcuts. -/// Mirrors the C version (shortcuts.c:24) with all 20+ shortcuts. -pub fn shortcuts_init() { - let mut shortcuts = G_SHORTCUTS.lock().unwrap(); - shortcuts.clear(); +/// When set, the settings page is capturing a new key combination and the +/// key handler must not intercept keys. Stores the time capture started so +/// it auto-expires if the page never ends it (e.g. navigated away). +static G_CAPTURE_SINCE: Lazy>> = Lazy::new(|| Mutex::new(None)); - let defaults = vec![ - Shortcut { key: "t".to_string(), modifiers: "Ctrl".to_string(), action: "new_tab".to_string() }, - Shortcut { key: "n".to_string(), modifiers: "Ctrl".to_string(), action: "new_window".to_string() }, - Shortcut { key: "o".to_string(), modifiers: "Ctrl".to_string(), action: "open_file".to_string() }, - Shortcut { key: "w".to_string(), modifiers: "Ctrl".to_string(), action: "close_tab".to_string() }, - Shortcut { key: "l".to_string(), modifiers: "Ctrl".to_string(), action: "focus_url".to_string() }, - Shortcut { key: "Tab".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab".to_string() }, - Shortcut { key: "Tab".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "prev_tab".to_string() }, - Shortcut { key: "Page_Down".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab_pagedown".to_string() }, - Shortcut { key: "Page_Up".to_string(), modifiers: "Ctrl".to_string(), action: "prev_tab_pageup".to_string() }, - Shortcut { key: "r".to_string(), modifiers: "Ctrl".to_string(), action: "reload".to_string() }, - Shortcut { key: "F5".to_string(), modifiers: "".to_string(), action: "reload".to_string() }, - Shortcut { key: "F5".to_string(), modifiers: "Shift".to_string(), action: "force_reload".to_string() }, - Shortcut { key: "Left".to_string(), modifiers: "Alt".to_string(), action: "go_back".to_string() }, - Shortcut { key: "Right".to_string(), modifiers: "Alt".to_string(), action: "go_forward".to_string() }, - Shortcut { key: "f".to_string(), modifiers: "Ctrl".to_string(), action: "find".to_string() }, - Shortcut { key: "comma".to_string(), modifiers: "Ctrl".to_string(), action: "open_settings".to_string() }, - Shortcut { key: "Escape".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "open_processes".to_string() }, - Shortcut { key: "u".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "new_identity".to_string() }, - Shortcut { key: "F11".to_string(), modifiers: "".to_string(), action: "toggle_fullscreen".to_string() }, - Shortcut { key: "i".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_inspector".to_string() }, - Shortcut { key: "a".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_sidebar".to_string() }, - Shortcut { key: "m".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_toolbars".to_string() }, - Shortcut { key: "plus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_in".to_string() }, - Shortcut { key: "minus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_out".to_string() }, - Shortcut { key: "0".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_reset".to_string() }, - Shortcut { key: "d".to_string(), modifiers: "Ctrl".to_string(), action: "bookmark_page".to_string() }, - Shortcut { key: "h".to_string(), modifiers: "Ctrl".to_string(), action: "show_history".to_string() }, - Shortcut { key: "b".to_string(), modifiers: "Ctrl".to_string(), action: "show_bookmarks".to_string() }, - Shortcut { key: "j".to_string(), modifiers: "Ctrl".to_string(), action: "show_downloads".to_string() }, - Shortcut { key: "q".to_string(), modifiers: "Ctrl".to_string(), action: "quit".to_string() }, - ]; +/// Maximum time capture mode stays active without being ended. +const CAPTURE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); - for s in defaults { - let key = format!("{}+{}", s.modifiers, s.key); - shortcuts.insert(key, s); +/// Begin/end shortcut capture mode. +pub fn shortcuts_set_capture(active: bool) { + *G_CAPTURE_SINCE.lock().unwrap() = if active { Some(std::time::Instant::now()) } else { None }; +} + +/// True while the settings page is capturing a new shortcut. +pub fn shortcuts_capture_active() -> bool { + let mut g = G_CAPTURE_SINCE.lock().unwrap(); + match *g { + Some(t) if t.elapsed() < CAPTURE_TIMEOUT => true, + Some(_) => { *g = None; false } + None => false, } } -/// Get a shortcut by key combination. +fn sc(key: &str, modifiers: &str, action: &str) -> Shortcut { + Shortcut { key: key.to_string(), modifiers: modifiers.to_string(), action: action.to_string() } +} + +/// The default bindings. Mirrors the C version (shortcuts.c:24). +pub fn default_shortcuts() -> Vec { + vec![ + sc("t", "Ctrl", "new_tab"), + sc("n", "Ctrl", "new_window"), + sc("o", "Ctrl", "open_file"), + sc("w", "Ctrl", "close_tab"), + sc("l", "Ctrl", "focus_url"), + sc("Tab", "Ctrl", "next_tab"), + sc("Tab", "Ctrl+Shift", "prev_tab"), + sc("Page_Down", "Ctrl", "next_tab_pagedown"), + sc("Page_Up", "Ctrl", "prev_tab_pageup"), + sc("r", "Ctrl", "reload"), + sc("F5", "", "reload_f5"), + sc("F5", "Shift", "force_reload"), + sc("Left", "Alt", "go_back"), + sc("Right", "Alt", "go_forward"), + sc("f", "Ctrl", "find"), + sc("comma", "Ctrl", "open_settings"), + sc("Escape", "Ctrl+Shift", "open_processes"), + sc("u", "Ctrl+Shift", "new_identity"), + sc("F11", "", "toggle_fullscreen"), + sc("i", "Ctrl+Shift", "toggle_inspector"), + sc("a", "Ctrl+Shift", "toggle_sidebar"), + sc("m", "Ctrl+Shift", "toggle_toolbars"), + sc("plus", "Ctrl", "zoom_in"), + sc("equal", "Ctrl", "zoom_in_alt"), + sc("minus", "Ctrl", "zoom_out"), + sc("0", "Ctrl", "zoom_reset"), + sc("d", "Ctrl", "bookmark_page"), + sc("h", "Ctrl", "show_history"), + sc("b", "Ctrl", "show_bookmarks"), + sc("j", "Ctrl", "show_downloads"), + sc("q", "Ctrl", "quit"), + ] +} + +/// Human-readable label for an action. +pub fn shortcut_label(action: &str) -> String { + let s = match action { + "new_tab" => "New Tab", + "new_window" => "New Window", + "open_file" => "Open File", + "close_tab" => "Close Tab", + "focus_url" => "Focus Address Bar", + "next_tab" => "Next Tab", + "prev_tab" => "Previous Tab", + "next_tab_pagedown" => "Next Tab (Page Down)", + "prev_tab_pageup" => "Previous Tab (Page Up)", + "reload" => "Reload", + "reload_f5" => "Reload (alternate)", + "force_reload" => "Force Reload", + "go_back" => "Back", + "go_forward" => "Forward", + "find" => "Find in Page", + "open_settings" => "Settings", + "open_processes" => "Processes", + "new_identity" => "Switch Identity", + "toggle_fullscreen" => "Toggle Fullscreen", + "toggle_inspector" => "Toggle Inspector", + "toggle_sidebar" => "Toggle Sidebar", + "toggle_toolbars" => "Toggle Toolbars", + "zoom_in" => "Zoom In", + "zoom_in_alt" => "Zoom In (alternate)", + "zoom_out" => "Zoom Out", + "zoom_reset" => "Reset Zoom", + "bookmark_page" => "Bookmark Page", + "show_history" => "History", + "show_bookmarks" => "Bookmarks", + "show_downloads" => "Downloads", + "quit" => "Quit", + other => other, + }; + s.to_string() +} + +/// Build a canonical modifier string from flags. +pub fn modifiers_string(ctrl: bool, alt: bool, shift: bool, sup: bool) -> String { + let mut parts = Vec::new(); + if ctrl { parts.push("Ctrl"); } + if alt { parts.push("Alt"); } + if shift { parts.push("Shift"); } + if sup { parts.push("Super"); } + parts.join("+") +} + +/// Canonicalize an arbitrary modifier string ("Shift+Ctrl", "Control", …). +fn canonical_modifiers(m: &str) -> String { + let (mut ctrl, mut alt, mut shift, mut sup) = (false, false, false, false); + for p in m.split('+').map(|p| p.trim().to_ascii_lowercase()) { + match p.as_str() { + "ctrl" | "control" | "primary" => ctrl = true, + "alt" | "mod1" => alt = true, + "shift" => shift = true, + "super" | "meta" => sup = true, + _ => {} + } + } + modifiers_string(ctrl, alt, shift, sup) +} + +/// Normalize a key name so letters are lowercase (single chars only). +fn canonical_key(k: &str) -> String { + if k.chars().count() == 1 { k.to_lowercase() } else { k.to_string() } +} + +/// Parse a GTK-style accelerator such as `plus` into +/// `(modifiers, key)`. Also accepts the legacy `plus` form. +pub fn parse_accel(accel: &str) -> Option<(String, String)> { + let mut rest = accel.trim(); + let mut mods = Vec::new(); + while let Some(stripped) = rest.strip_prefix('<') { + let end = stripped.find('>')?; + mods.push(stripped[..end].to_string()); + rest = &stripped[end + 1..]; + } + let key = rest.trim(); + if key.is_empty() { + return None; + } + Some((canonical_modifiers(&mods.join("+")), canonical_key(key))) +} + +/// Format a binding as a GTK-style accelerator (`plus`). +pub fn format_accel(s: &Shortcut) -> String { + let mut out = String::new(); + for p in s.modifiers.split('+').filter(|p| !p.is_empty()) { + match p { + "Ctrl" => out.push_str(""), + other => { out.push('<'); out.push_str(other); out.push('>'); } + } + } + out.push_str(&s.key); + out +} + +/// Reset all shortcuts to their defaults. +pub fn shortcuts_init() { + *G_SHORTCUTS.lock().unwrap() = default_shortcuts(); +} + +/// Find the action bound to a key combination. pub fn shortcuts_get(modifiers: &str, key: &str) -> Option { let shortcuts = G_SHORTCUTS.lock().unwrap(); - let key_combo = format!("{}+{}", modifiers, key); - shortcuts.get(&key_combo).cloned() + shortcuts.iter().find(|s| s.modifiers == modifiers && s.key == key).cloned() } -/// Get all shortcuts. +/// Get all shortcuts (in display order). pub fn shortcuts_get_all() -> Vec { - let shortcuts = G_SHORTCUTS.lock().unwrap(); - shortcuts.values().cloned().collect() + G_SHORTCUTS.lock().unwrap().clone() } -/// Load shortcuts from the database. +/// Rebind an action. Returns false if the action is unknown. +pub fn shortcuts_set(action: &str, modifiers: &str, key: &str) -> bool { + let mut shortcuts = G_SHORTCUTS.lock().unwrap(); + match shortcuts.iter_mut().find(|s| s.action == action) { + Some(s) => { + s.modifiers = canonical_modifiers(modifiers); + s.key = canonical_key(key); + true + } + None => false, + } +} + +/// Reset one action to its default binding. Returns false if unknown. +pub fn shortcuts_reset(action: &str) -> bool { + let def = match default_shortcuts().into_iter().find(|s| s.action == action) { + Some(d) => d, + None => return false, + }; + let mut shortcuts = G_SHORTCUTS.lock().unwrap(); + match shortcuts.iter_mut().find(|s| s.action == action) { + Some(s) => { *s = def; } + None => shortcuts.push(def), + } + true +} + +/// Load shortcuts from the database, overlaying saved bindings on top of +/// the defaults (so newly added actions still get a binding). pub fn shortcuts_load() { + let mut merged = default_shortcuts(); if let Ok(Some(json)) = crate::db::db_kv_get("shortcuts") { if let Ok(loaded) = serde_json::from_str::>(&json) { - let mut shortcuts = G_SHORTCUTS.lock().unwrap(); - shortcuts.clear(); for s in loaded { - let key = format!("{}+{}", s.modifiers, s.key); - shortcuts.insert(key, s); + if let Some(slot) = merged.iter_mut().find(|m| m.action == s.action) { + slot.modifiers = canonical_modifiers(&s.modifiers); + slot.key = canonical_key(&s.key); + } } } } + *G_SHORTCUTS.lock().unwrap() = merged; } /// Save shortcuts to the database. pub fn shortcuts_save() { - let shortcuts = G_SHORTCUTS.lock().unwrap(); - let list: Vec = shortcuts.values().cloned().collect(); + let list = shortcuts_get_all(); if let Ok(json) = serde_json::to_string(&list) { let _ = crate::db::db_kv_set("shortcuts", &json); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_gtk_accel() { + assert_eq!(parse_accel("plus"), Some(("Ctrl+Shift".into(), "plus".into()))); + assert_eq!(parse_accel("T"), Some(("Ctrl+Shift".into(), "t".into()))); + assert_eq!(parse_accel("F5"), Some(("".into(), "F5".into()))); + assert_eq!(parse_accel("Tab"), Some(("Ctrl+Shift".into(), "Tab".into()))); + assert_eq!(parse_accel(""), None); + } + + #[test] + fn format_roundtrip() { + let s = sc("plus", "Ctrl+Shift", "zoom_in"); + let a = format_accel(&s); + assert_eq!(a, "plus"); + assert_eq!(parse_accel(&a), Some(("Ctrl+Shift".into(), "plus".into()))); + } + + #[test] + fn actions_unique() { + let d = default_shortcuts(); + let mut names: Vec<_> = d.iter().map(|s| s.action.clone()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), d.len()); + } +} diff --git a/src/tab_manager.rs b/src/tab_manager.rs index 9d2475c..e5257e7 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -410,21 +410,6 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti bookmark_btn.set_tooltip_text(Some("Bookmark this page")); toolbar.pack_start(&bookmark_btn, false, false, 0); - // New-tab button (at the right end of the toolbar). - let new_tab_btn = gtk::Button::new(); - new_tab_btn.set_relief(gtk::ReliefStyle::None); - new_tab_btn.set_image(Some(>k::Image::from_icon_name( - Some("tab-new-symbolic"), - gtk::IconSize::Menu, - ))); - new_tab_btn.set_tooltip_text(Some("New tab (Ctrl+T)")); - let notebook_clone_nt = notebook.clone(); - let ctx_clone_nt = ctx.clone(); - new_tab_btn.connect_clicked(move |_| { - tab_manager_new_tab(¬ebook_clone_nt, &ctx_clone_nt, None); - }); - toolbar.pack_end(&new_tab_btn, false, false, 0); - // ── Bookmark bar ──────────────────────────────────────────────── let bookmark_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4); bookmark_bar.set_widget_name("bookmark-bar"); @@ -1715,15 +1700,23 @@ fn show_bookmark_dialog(btn: >k::Button, wv: &WebView) { let content = dialog.content_area(); content.set_border_width(12); + let name_label = gtk::Label::new(Some("Name:")); + name_label.set_halign(gtk::Align::Start); + content.pack_start(&name_label, false, false, 4); + + let name_entry = gtk::Entry::new(); + name_entry.set_text(if title.is_empty() { &url } else { &title }); + name_entry.set_activates_default(true); + content.pack_start(&name_entry, false, false, 4); + let info = gtk::Label::new(None); info.set_markup(&format!( - "{}\n{}", - glib::markup_escape_text(if title.is_empty() { "(untitled)" } else { &title }), + "{}", glib::markup_escape_text(&url), )); info.set_line_wrap(true); info.set_halign(gtk::Align::Start); - content.pack_start(&info, false, false, 8); + content.pack_start(&info, false, false, 4); let dir_label = gtk::Label::new(Some("Folder path:")); dir_label.set_halign(gtk::Align::Start); @@ -1745,7 +1738,10 @@ fn show_bookmark_dialog(btn: >k::Button, wv: &WebView) { combo.set_active(Some(0)); content.pack_start(&combo, false, false, 4); + dialog.set_default_response(gtk::ResponseType::Accept); dialog.show_all(); + // Focus the name with its text selected so the user can type over it. + name_entry.grab_focus(); let response = dialog.run(); if response == gtk::ResponseType::Accept { let folder = combo @@ -1753,7 +1749,14 @@ fn show_bookmark_dialog(btn: >k::Button, wv: &WebView) { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "General".to_string()); - let t = if title.is_empty() { url.clone() } else { title }; + let entered = name_entry.text().trim().to_string(); + let t = if !entered.is_empty() { + entered + } else if !title.is_empty() { + title + } else { + url.clone() + }; match bookmarks::bookmarks_add(&t, &url, &folder) { Ok(_) => println!("[bookmarks] Bookmarked '{}' to '{}'", url, folder), Err(e) => eprintln!("[bookmarks] Failed to bookmark: {}", e), diff --git a/src/version.rs b/src/version.rs index d50843c..72e1250 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,7 +1,7 @@ //! Version information for sovereign_browser /// The current version of sovereign_browser (with leading 'v'). -pub const VERSION: &str = "v0.0.10"; +pub const VERSION: &str = "v0.0.11"; /// Major version number. pub const VERSION_MAJOR: u32 = 0; @@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MINOR: u32 = 0; /// Patch version number. -pub const VERSION_PATCH: u32 = 10; +pub const VERSION_PATCH: u32 = 11; diff --git a/www/settings.js b/www/settings.js index cba9b96..ab1dfb4 100644 --- a/www/settings.js +++ b/www/settings.js @@ -183,13 +183,30 @@ function checkDuplicates() { }); } +/* Tell the browser to stop intercepting shortcuts while we record keys. */ +function setCaptureMode(active) { + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?key=shortcut.capture&value=' + + (active ? '1' : '0'), true); + x.send(); +} + +window.addEventListener('pagehide', function() { + if (capturingAction) setCaptureMode(false); +}); + function bindShortcutButtons() { document.querySelectorAll('.sc-change').forEach(function(btn) { btn.addEventListener('click', function() { var action = btn.getAttribute('data-action'); var disp = document.getElementById('sc_' + action); if (!disp) return; - if (capturingDisplay) capturingDisplay.classList.remove('capturing'); + if (capturingDisplay) { + capturingDisplay.classList.remove('capturing'); + capturingDisplay.textContent = + capturingDisplay.getAttribute('data-prev') || ''; + } + setCaptureMode(true); capturingAction = action; capturingDisplay = disp; disp.setAttribute('data-prev', disp.textContent); @@ -248,6 +265,7 @@ document.addEventListener('keydown', function(e) { e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { + setCaptureMode(false); capturingDisplay.classList.remove('capturing'); capturingDisplay.textContent = capturingDisplay.getAttribute('data-prev') || '';