v0.0.11 - Fix zoom shortcuts, shortcut rebinding, bookmark rename, and duplicate new-tab button

This commit is contained in:
Laan Tungir
2026-09-29 07:06:46 -04:00
parent 07d41e05da
commit a7d3084c46
9 changed files with 498 additions and 300 deletions
Generated
+1 -1
View File
@@ -3055,7 +3055,7 @@ dependencies = [
[[package]] [[package]]
name = "sovereign_browser" name = "sovereign_browser"
version = "0.0.10" version = "0.0.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -6,7 +6,7 @@ members = [
[package] [package]
name = "sovereign_browser" name = "sovereign_browser"
version = "0.0.10" version = "0.0.11"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity"
+1 -1
View File
@@ -1 +1 @@
0.0.10 0.0.11
+132 -172
View File
@@ -8,16 +8,19 @@
//! //!
//! The webview connection is required because WebKit consumes key events //! The webview connection is required because WebKit consumes key events
//! when the webview has focus, so a window-only handler never sees them. //! 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 //! The window connection is a fallback.
//! (event propagation: child → parent → window).
//! //!
//! The `from_webview` flag distinguishes the two connections so that //! Key combinations are resolved through the shortcut registry
//! `sovereign://` internal pages (e.g. the settings page's shortcut //! (`shortcuts.rs`), so bindings changed on the settings page take effect
//! capture) can receive raw key events on the webview connection while the //! immediately.
//! window connection still processes browser shortcuts. //!
//! 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 webkit2gtk::*;
use crate::shortcuts;
use crate::tab_manager; use crate::tab_manager;
/// Build a WebContext configured for the currently signed-in user. /// Build a WebContext configured for the currently signed-in user.
@@ -34,121 +37,110 @@ pub fn build_context_for_current_user() -> WebContext {
ctx ctx
} }
/// Handle a key-press event, dispatching browser-level shortcuts. /// Convert a GDK keyval into the key name used by the shortcut registry.
/// /// Letters are lowercased, keypad keys are folded onto their main-keyboard
/// `from_webview` must be `true` when connected to a WebView's /// equivalents, and `ISO_Left_Tab` (Shift+Tab) becomes `Tab`.
/// `key-press-event` and `false` when connected to the window's. fn key_name(keyval: gdk::keys::Key) -> Option<String> {
/// Mirrors the C version (`main.c:501`). let name = keyval.to_lower().name()?.to_string();
pub fn handle_key_press(event: &gdk::EventKey, from_webview: bool) -> glib::Propagation { let mapped = match name.as_str() {
let ctrl = event.state().contains(gdk::ModifierType::CONTROL_MASK); "ISO_Left_Tab" | "KP_Tab" => "Tab",
let shift = event.state().contains(gdk::ModifierType::SHIFT_MASK); "KP_Add" => "plus",
let alt = event.state().contains(gdk::ModifierType::MOD1_MASK); "KP_Subtract" => "minus",
// Normalize the keyval to lowercase. When Shift is held, GDK delivers "KP_Enter" => "Return",
// the uppercase keyval (e.g. `M` for Ctrl+Shift+M), which would never "KP_Multiply" => "asterisk",
// match the lowercase `constants::m`. Mirrors the C version's "KP_Divide" => "slash",
// `gdk_keyval_to_lower(event->keyval)` (shortcuts.c:199). "KP_Decimal" => "period",
let key = event.keyval().to_lower(); "KP_0" | "KP_Insert" => "0",
"KP_1" => "1",
// When the active tab is showing a sovereign:// internal page (e.g. "KP_2" => "2",
// the settings page's keyboard-shortcut capture), bypass shortcut "KP_3" => "3",
// interception on the webview connection only — let the event pass "KP_4" => "4",
// through to the web page's JS so it can capture arbitrary key combos. "KP_5" => "5",
// The window-level connection still processes shortcuts so Ctrl+T, "KP_6" => "6",
// Ctrl+N, etc. work on internal pages. Mirrors main.c:516. "KP_7" => "7",
if from_webview { "KP_8" => "8",
let tabs = tab_manager::tab_manager_get_tabs(); "KP_9" => "9",
let active_id = tab_manager::tab_manager_get_active_tab(); other => other,
if let Some(tab) = tabs.iter().find(|t| t.id == active_id) { };
if tab.url.starts_with("sovereign://") || tab.url.starts_with("about:settings") { Some(mapped.to_string())
return glib::Propagation::Proceed;
}
}
} }
// Alt+Left/Right (back/forward) — no modifier mask check needed. fn is_modifier_key(name: &str) -> bool {
if alt && !ctrl && !shift { matches!(
match key { name,
gdk::keys::constants::Left => { "Shift_L" | "Shift_R" | "Control_L" | "Control_R" | "Alt_L" | "Alt_R"
tab_manager::tab_manager_go_back(); | "Meta_L" | "Meta_R" | "Super_L" | "Super_R" | "ISO_Level3_Shift"
return glib::Propagation::Stop; | "Caps_Lock" | "Num_Lock"
} )
gdk::keys::constants::Right => {
tab_manager::tab_manager_go_forward();
return glib::Propagation::Stop;
}
_ => {}
}
} }
// F-key shortcuts (no modifiers). /// True if Shift is likely required just to produce this key's symbol
if !ctrl && !shift && !alt { /// (e.g. `plus` on a US layout is Shift+=). Letters, digits and named keys
match key { /// like `Tab`/`F5` are not shift-produced.
gdk::keys::constants::F5 => { fn is_shifted_symbol(name: &str) -> bool {
tab_manager::tab_manager_reload_active(); let mut chars = name.chars();
return glib::Propagation::Stop; let single = matches!((chars.next(), chars.next()), (Some(_), None));
} if single {
gdk::keys::constants::F11 => { return !name.chars().all(|c| c.is_ascii_alphanumeric());
tab_manager::tab_manager_toggle_fullscreen();
return glib::Propagation::Stop;
}
_ => {}
} }
matches!(
name,
"plus" | "underscore" | "colon" | "less" | "greater" | "question"
| "exclam" | "at" | "numbersign" | "dollar" | "percent"
| "asciicircum" | "ampersand" | "asterisk" | "parenleft"
| "parenright" | "braceleft" | "braceright" | "bar" | "quotedbl"
| "asciitilde"
)
} }
// Shift+F5 (force reload). /// Run a shortcut action. Returns false if the action is unknown.
if shift && !ctrl && !alt && key == gdk::keys::constants::F5 { 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(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) {
wv.reload_bypass_cache(); wv.reload_bypass_cache();
} }
} }
return glib::Propagation::Stop;
} }
"go_back" => tab_manager::tab_manager_go_back(),
// Ctrl+ key combinations. "go_forward" => tab_manager::tab_manager_go_forward(),
if ctrl && !shift && !alt { "find" => {
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(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { 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 // run_javascript is deprecated in webkit2gtk 2.0.2 but
// there is no replacement in the Rust bindings. Suppress. // there is no replacement in the Rust bindings. Suppress.
#[allow(deprecated)] #[allow(deprecated)]
wv.run_javascript("document.body.innerHTML += '<div id=\"sb-find-bar\" style=\"position:fixed;top:0;left:0;right:0;z-index:9999;background:#333;color:#fff;padding:8px;\"><input id=\"sb-find-input\" type=\"text\" placeholder=\"Find in page…\" style=\"width:80%;padding:4px;\"></div>'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {}); wv.run_javascript("document.body.innerHTML += '<div id=\"sb-find-bar\" style=\"position:fixed;top:0;left:0;right:0;z-index:9999;background:#333;color:#fff;padding:8px;\"><input id=\"sb-find-input\" type=\"text\" placeholder=\"Find in page…\" style=\"width:80%;padding:4px;\"></div>'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {});
} }
} }
return glib::Propagation::Stop;
} }
gdk::keys::constants::d => { "open_settings" => tab_manager::tab_manager_open_internal("sovereign://settings"),
// Bookmark current page. "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(nb) = tab_manager::tab_manager_get_active_notebook() {
if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) {
let url = wv.uri().unwrap_or_default().to_string(); let url = wv.uri().unwrap_or_default().to_string();
@@ -156,90 +148,58 @@ pub fn handle_key_press(event: &gdk::EventKey, from_webview: bool) -> glib::Prop
let _ = crate::db::db_add_bookmark(&title, &url, ""); let _ = crate::db::db_add_bookmark(&title, &url, "");
} }
} }
return glib::Propagation::Stop;
} }
gdk::keys::constants::h => { "show_history" => tab_manager::tab_manager_open_internal("sovereign://profile"),
tab_manager::tab_manager_open_internal("sovereign://profile"); "show_bookmarks" => tab_manager::tab_manager_open_internal("sovereign://bookmarks"),
return glib::Propagation::Stop; "quit" => {
}
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(); crate::session::session_save();
gtk::main_quit(); gtk::main_quit();
return glib::Propagation::Stop;
} }
gdk::keys::constants::comma => { _ => return false,
tab_manager::tab_manager_open_internal("sovereign://settings");
return glib::Propagation::Stop;
} }
gdk::keys::constants::Tab => { true
tab_manager::tab_manager_next();
return glib::Propagation::Stop;
} }
gdk::keys::constants::Page_Down => {
tab_manager::tab_manager_next(); /// Handle a key-press event, dispatching browser-level shortcuts.
return glib::Propagation::Stop; ///
} /// `from_webview` is `true` when connected to a WebView's
gdk::keys::constants::Page_Up => { /// `key-press-event` and `false` when connected to the window's.
tab_manager::tab_manager_prev(); pub fn handle_key_press(event: &gdk::EventKey, _from_webview: bool) -> glib::Propagation {
return glib::Propagation::Stop; // The settings page is recording a new shortcut: let the raw key
} // combination reach its JS untouched.
gdk::keys::constants::plus | gdk::keys::constants::equal => { if shortcuts::shortcuts_capture_active() {
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; return glib::Propagation::Proceed;
} }
_ => {}
} 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;
} }
// Ctrl+Shift+ key combinations. let mods = shortcuts::modifiers_string(ctrl, alt, shift, sup);
if ctrl && shift && !alt { let mut found = shortcuts::shortcuts_get(&mods, &key);
match key {
gdk::keys::constants::Tab => { // Symbols such as `plus` need Shift on many layouts (Shift+= on US).
tab_manager::tab_manager_prev(); // If there's no exact binding, retry with Shift treated as consumed so
return glib::Propagation::Stop; // a `Ctrl+plus` binding fires for Ctrl+Shift+=.
} if found.is_none() && shift && is_shifted_symbol(&key) {
gdk::keys::constants::i => { let mods_no_shift = shortcuts::modifiers_string(ctrl, alt, false, sup);
tab_manager::tab_manager_toggle_inspector(); found = shortcuts::shortcuts_get(&mods_no_shift, &key);
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;
}
_ => {}
}
} }
if let Some(sc) = found {
if run_action(&sc.action) {
return glib::Propagation::Stop;
}
}
glib::Propagation::Proceed glib::Propagation::Proceed
} }
+39 -11
View File
@@ -93,8 +93,11 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
} }
// ── Bookmarks REST API ───────────────────────────────────────── // ── 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/") { if route.starts_with("bookmarks/") {
handle_bookmarks_api(request, &route[10..]); handle_bookmarks_api(request, &path[10..]);
return; return;
} }
@@ -605,6 +608,11 @@ fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) {
} }
_ => respond_error_json(request, 404, "Unknown bookmarks route"), _ => 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. /// 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(); let value = extract_query_param(query, "value").unwrap_or_default();
// Handle shortcut pseudo-keys. // 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" { if key == "shortcut.reset_all" {
crate::shortcuts::shortcuts_set_capture(false);
crate::shortcuts::shortcuts_init(); crate::shortcuts::shortcuts_init();
crate::shortcuts::shortcuts_save(); crate::shortcuts::shortcuts_save();
println!("[settings] shortcut.reset_all"); println!("[settings] shortcut.reset_all");
@@ -869,20 +886,32 @@ fn handle_settings_set(request: &URISchemeRequest, query: &str) {
} }
if key == "shortcut.reset" { if key == "shortcut.reset" {
// Reset a single shortcut by action. // Reset a single shortcut by action.
if let Some(action) = extract_query_param(query, "action") { let action = extract_query_param(query, "action").unwrap_or_default();
crate::shortcuts::shortcuts_init(); if !crate::shortcuts::shortcuts_reset(&action) {
respond_error_json(request, 400, "Unknown shortcut action");
return;
}
crate::shortcuts::shortcuts_save(); crate::shortcuts::shortcuts_save();
println!("[settings] shortcut.reset {}", action); println!("[settings] shortcut.reset {}", action);
respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string()); respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string());
return; return;
} }
if let Some(action) = key.strip_prefix("shortcut.") {
// Set a shortcut: key="shortcut.zoom_in", value="<Control><Shift>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;
} }
if key.starts_with("shortcut.") {
// Set a shortcut: key="shortcut.new_tab", value="<Control>t"
// Our Shortcut model uses modifiers+key. For now, reinitialize.
crate::shortcuts::shortcuts_init();
crate::shortcuts::shortcuts_save(); crate::shortcuts::shortcuts_save();
println!("[settings] shortcut set: {} = {}", key, value); println!("[settings] shortcut set: {} = {}+{}", action, mods, keyname);
respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string()); respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string());
return; return;
} }
@@ -970,11 +999,10 @@ fn handle_settings_config_json(request: &URISchemeRequest) {
// Keyboard shortcuts. // Keyboard shortcuts.
let all_shortcuts = crate::shortcuts::shortcuts_get_all(); let all_shortcuts = crate::shortcuts::shortcuts_get_all();
let shortcuts: Vec<serde_json::Value> = all_shortcuts.iter().map(|sc| { let shortcuts: Vec<serde_json::Value> = all_shortcuts.iter().map(|sc| {
let accel = format!("<{}>{}", sc.modifiers, sc.key);
serde_json::json!({ serde_json::json!({
"id": sc.action, "id": sc.action,
"label": sc.action, "label": crate::shortcuts::shortcut_label(&sc.action),
"accel": accel, "accel": crate::shortcuts::format_accel(sc),
}) })
}).collect(); }).collect();
+249 -60
View File
@@ -1,99 +1,288 @@
//! Keyboard shortcuts for sovereign_browser //! Keyboard shortcuts for sovereign_browser
//! //!
//! Port of `shortcuts.c` / `shortcuts.h` from the C project. //! 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 std::sync::Mutex;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
/// A keyboard shortcut binding. /// A keyboard shortcut binding.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Shortcut { pub struct Shortcut {
/// GDK key name, lowercase for letters (e.g. "t", "Tab", "plus", "F5").
pub key: String, 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, pub action: String,
} }
/// Global shortcuts registry. /// Global shortcuts registry (ordered for display).
static G_SHORTCUTS: Lazy<Mutex<HashMap<String, Shortcut>>> = Lazy::new(|| Mutex::new(HashMap::new())); static G_SHORTCUTS: Lazy<Mutex<Vec<Shortcut>>> = Lazy::new(|| Mutex::new(default_shortcuts()));
/// Initialize default shortcuts. /// When set, the settings page is capturing a new key combination and the
/// Mirrors the C version (shortcuts.c:24) with all 20+ shortcuts. /// 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<Mutex<Option<std::time::Instant>>> = Lazy::new(|| Mutex::new(None));
/// Maximum time capture mode stays active without being ended.
const CAPTURE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// 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,
}
}
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<Shortcut> {
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 `<Control><Shift>plus` into
/// `(modifiers, key)`. Also accepts the legacy `<Ctrl+Shift>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 (`<Control><Shift>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("<Control>"),
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() { pub fn shortcuts_init() {
let mut shortcuts = G_SHORTCUTS.lock().unwrap(); *G_SHORTCUTS.lock().unwrap() = default_shortcuts();
shortcuts.clear();
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() },
];
for s in defaults {
let key = format!("{}+{}", s.modifiers, s.key);
shortcuts.insert(key, s);
}
} }
/// Get a shortcut by key combination. /// Find the action bound to a key combination.
pub fn shortcuts_get(modifiers: &str, key: &str) -> Option<Shortcut> { pub fn shortcuts_get(modifiers: &str, key: &str) -> Option<Shortcut> {
let shortcuts = G_SHORTCUTS.lock().unwrap(); let shortcuts = G_SHORTCUTS.lock().unwrap();
let key_combo = format!("{}+{}", modifiers, key); shortcuts.iter().find(|s| s.modifiers == modifiers && s.key == key).cloned()
shortcuts.get(&key_combo).cloned()
} }
/// Get all shortcuts. /// Get all shortcuts (in display order).
pub fn shortcuts_get_all() -> Vec<Shortcut> { pub fn shortcuts_get_all() -> Vec<Shortcut> {
let shortcuts = G_SHORTCUTS.lock().unwrap(); G_SHORTCUTS.lock().unwrap().clone()
shortcuts.values().cloned().collect()
} }
/// 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() { pub fn shortcuts_load() {
let mut merged = default_shortcuts();
if let Ok(Some(json)) = crate::db::db_kv_get("shortcuts") { if let Ok(Some(json)) = crate::db::db_kv_get("shortcuts") {
if let Ok(loaded) = serde_json::from_str::<Vec<Shortcut>>(&json) { if let Ok(loaded) = serde_json::from_str::<Vec<Shortcut>>(&json) {
let mut shortcuts = G_SHORTCUTS.lock().unwrap();
shortcuts.clear();
for s in loaded { for s in loaded {
let key = format!("{}+{}", s.modifiers, s.key); if let Some(slot) = merged.iter_mut().find(|m| m.action == s.action) {
shortcuts.insert(key, s); slot.modifiers = canonical_modifiers(&s.modifiers);
slot.key = canonical_key(&s.key);
} }
} }
} }
} }
*G_SHORTCUTS.lock().unwrap() = merged;
}
/// Save shortcuts to the database. /// Save shortcuts to the database.
pub fn shortcuts_save() { pub fn shortcuts_save() {
let shortcuts = G_SHORTCUTS.lock().unwrap(); let list = shortcuts_get_all();
let list: Vec<Shortcut> = shortcuts.values().cloned().collect();
if let Ok(json) = serde_json::to_string(&list) { if let Ok(json) = serde_json::to_string(&list) {
let _ = crate::db::db_kv_set("shortcuts", &json); let _ = crate::db::db_kv_set("shortcuts", &json);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_gtk_accel() {
assert_eq!(parse_accel("<Control><Shift>plus"), Some(("Ctrl+Shift".into(), "plus".into())));
assert_eq!(parse_accel("<Shift><Control>T"), Some(("Ctrl+Shift".into(), "t".into())));
assert_eq!(parse_accel("F5"), Some(("".into(), "F5".into())));
assert_eq!(parse_accel("<Ctrl+Shift>Tab"), Some(("Ctrl+Shift".into(), "Tab".into())));
assert_eq!(parse_accel("<Control>"), None);
}
#[test]
fn format_roundtrip() {
let s = sc("plus", "Ctrl+Shift", "zoom_in");
let a = format_accel(&s);
assert_eq!(a, "<Control><Shift>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());
}
}
+22 -19
View File
@@ -410,21 +410,6 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
bookmark_btn.set_tooltip_text(Some("Bookmark this page")); bookmark_btn.set_tooltip_text(Some("Bookmark this page"));
toolbar.pack_start(&bookmark_btn, false, false, 0); 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(&gtk::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(&notebook_clone_nt, &ctx_clone_nt, None);
});
toolbar.pack_end(&new_tab_btn, false, false, 0);
// ── Bookmark bar ──────────────────────────────────────────────── // ── Bookmark bar ────────────────────────────────────────────────
let bookmark_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4); let bookmark_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
bookmark_bar.set_widget_name("bookmark-bar"); bookmark_bar.set_widget_name("bookmark-bar");
@@ -1715,15 +1700,23 @@ fn show_bookmark_dialog(btn: &gtk::Button, wv: &WebView) {
let content = dialog.content_area(); let content = dialog.content_area();
content.set_border_width(12); 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); let info = gtk::Label::new(None);
info.set_markup(&format!( info.set_markup(&format!(
"<b>{}</b>\n<span size='small' color='#888'>{}</span>", "<span size='small' color='#888'>{}</span>",
glib::markup_escape_text(if title.is_empty() { "(untitled)" } else { &title }),
glib::markup_escape_text(&url), glib::markup_escape_text(&url),
)); ));
info.set_line_wrap(true); info.set_line_wrap(true);
info.set_halign(gtk::Align::Start); 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:")); let dir_label = gtk::Label::new(Some("Folder path:"));
dir_label.set_halign(gtk::Align::Start); dir_label.set_halign(gtk::Align::Start);
@@ -1745,7 +1738,10 @@ fn show_bookmark_dialog(btn: &gtk::Button, wv: &WebView) {
combo.set_active(Some(0)); combo.set_active(Some(0));
content.pack_start(&combo, false, false, 4); content.pack_start(&combo, false, false, 4);
dialog.set_default_response(gtk::ResponseType::Accept);
dialog.show_all(); 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(); let response = dialog.run();
if response == gtk::ResponseType::Accept { if response == gtk::ResponseType::Accept {
let folder = combo let folder = combo
@@ -1753,7 +1749,14 @@ fn show_bookmark_dialog(btn: &gtk::Button, wv: &WebView) {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "General".to_string()); .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) { match bookmarks::bookmarks_add(&t, &url, &folder) {
Ok(_) => println!("[bookmarks] Bookmarked '{}' to '{}'", url, folder), Ok(_) => println!("[bookmarks] Bookmarked '{}' to '{}'", url, folder),
Err(e) => eprintln!("[bookmarks] Failed to bookmark: {}", e), Err(e) => eprintln!("[bookmarks] Failed to bookmark: {}", e),
+2 -2
View File
@@ -1,7 +1,7 @@
//! Version information for sovereign_browser //! Version information for sovereign_browser
/// The current version of sovereign_browser (with leading 'v'). /// 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. /// Major version number.
pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MAJOR: u32 = 0;
@@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0;
pub const VERSION_MINOR: u32 = 0; pub const VERSION_MINOR: u32 = 0;
/// Patch version number. /// Patch version number.
pub const VERSION_PATCH: u32 = 10; pub const VERSION_PATCH: u32 = 11;
+19 -1
View File
@@ -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() { function bindShortcutButtons() {
document.querySelectorAll('.sc-change').forEach(function(btn) { document.querySelectorAll('.sc-change').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var action = btn.getAttribute('data-action'); var action = btn.getAttribute('data-action');
var disp = document.getElementById('sc_' + action); var disp = document.getElementById('sc_' + action);
if (!disp) return; 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; capturingAction = action;
capturingDisplay = disp; capturingDisplay = disp;
disp.setAttribute('data-prev', disp.textContent); disp.setAttribute('data-prev', disp.textContent);
@@ -248,6 +265,7 @@ document.addEventListener('keydown', function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.key === 'Escape') { if (e.key === 'Escape') {
setCaptureMode(false);
capturingDisplay.classList.remove('capturing'); capturingDisplay.classList.remove('capturing');
capturingDisplay.textContent = capturingDisplay.textContent =
capturingDisplay.getAttribute('data-prev') || ''; capturingDisplay.getAttribute('data-prev') || '';