v0.0.11 - Fix zoom shortcuts, shortcut rebinding, bookmark rename, and duplicate new-tab button
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
+132
-172
@@ -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
@@ -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
@@ -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
@@ -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"));
|
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(>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 ────────────────────────────────────────────────
|
// ── 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: >k::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: >k::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: >k::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
@@ -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
@@ -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') || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user