Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/configurable-global-window-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Make the global show/hide window shortcut (previously hardcoded to Ctrl+Shift+S) configurable in Settings > Keyboard Shortcuts, and disable it by default so Sable never claims a global hotkey unless you assign one yourself.
53 changes: 49 additions & 4 deletions src-tauri/src/desktop/menu.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
#[cfg(target_os = "macos")]
use tauri::Emitter;
use serde_json::json;
use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt;

use crate::desktop::settings::{DESKTOP_SETTINGS_PATH, GLOBAL_WINDOW_SHORTCUT_KEY};

#[cfg(target_os = "macos")]
pub const SETTINGS_MENU_ID: &str = "settings";

pub const TOGGLE_WINDOW_ACCELERATOR: &str = "CmdOrCtrl+Shift+S";

// Extend the standard menu (Edit submenu for webview copy/paste, Quit, Close)
// with a Settings item.
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -63,10 +65,53 @@ pub fn global_shortcut_plugin() -> tauri::plugin::TauriPlugin<crate::BrowserEngi
.build()
}

fn get_global_window_shortcut(app: &AppHandle<crate::BrowserEngine>) -> Option<String> {
let store = app.store(DESKTOP_SETTINGS_PATH).ok()?;
// Absent, null and empty all mean "unregistered": no shortcut exists until
// the user assigns one.
store
.get(GLOBAL_WINDOW_SHORTCUT_KEY)
.and_then(|value| value.as_str().map(str::to_owned))
.filter(|binding| !binding.is_empty())
}

pub fn register_global_shortcuts(app: &AppHandle<crate::BrowserEngine>) {
use tauri_plugin_global_shortcut::GlobalShortcutExt;

if let Err(error) = app.global_shortcut().register(TOGGLE_WINDOW_ACCELERATOR) {
log::warn!("Failed to register global show/hide shortcut: {error}");
match get_global_window_shortcut(app) {
Some(binding) => {
if let Err(error) = app.global_shortcut().register(binding.as_str()) {
log::warn!("Failed to register global show/hide shortcut {binding}: {error}");
}
}
None => log::info!("Global show/hide shortcut is unassigned"),
}
}

#[tauri::command]
pub fn set_global_window_shortcut(
app: AppHandle<crate::BrowserEngine>,
shortcut: Option<String>,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::GlobalShortcutExt;

let store = app
.store(DESKTOP_SETTINGS_PATH)
.map_err(|error| error.to_string())?;

match &shortcut {
Some(binding) => store.set(GLOBAL_WINDOW_SHORTCUT_KEY, json!(binding)),
None => store.set(GLOBAL_WINDOW_SHORTCUT_KEY, json!(null)),
}
store.save().map_err(|error| error.to_string())?;

app.global_shortcut()
.unregister_all()
.map_err(|error| error.to_string())?;
if let Some(binding) = &shortcut {
app.global_shortcut()
.register(binding.as_str())
.map_err(|error| error.to_string())?;
}
Ok(())
}
1 change: 1 addition & 0 deletions src-tauri/src/desktop/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub(crate) const SHOW_SYSTEM_TRAY_ICON_KEY: &str = "showSystemTrayIcon";
pub(crate) const USE_CUSTOM_TITLE_BAR_KEY: &str = "useCustomTitleBar";
pub(crate) const SPELLCHECK_KEY: &str = "spellcheck";
pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning";
pub(crate) const GLOBAL_WINDOW_SHORTCUT_KEY: &str = "globalWindowShortcut";

pub(crate) const fn use_custom_title_bar_default() -> bool {
cfg!(target_os = "windows")
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ pub fn run() {
desktop::tray::get_desktop_runtime_state,
#[cfg(desktop)]
desktop::tray::sync_desktop_settings,
#[cfg(desktop)]
desktop::menu::set_global_window_shortcut,
#[cfg(target_os = "linux")]
desktop::tray_badge::set_tray_badge,
#[cfg(windows)]
Expand Down
38 changes: 35 additions & 3 deletions src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { isTauri } from '@tauri-apps/api/core';
import { setGlobalWindowShortcut } from '$generated/tauri/commands';
import { Box, Button, Scroll, Text, config } from 'folds';
import { PageContent, SettingsSectionPage } from '$components/page';
import { SequenceCard, SequenceCardStyle } from '$components/sequence-card';
Expand All @@ -11,11 +13,14 @@ import {
findShortcutConflict,
formatShortcut,
getShortcutBinding,
toTauriAccelerator,
} from '../../../keyboard/shortcuts';
import type { ShortcutDefinition, ShortcutId } from '../../../keyboard/shortcuts';

const CATEGORIES = ['General', 'Navigation', 'Messages'] as const;

const GLOBAL_WINDOW_SHORTCUT_ID: ShortcutId = 'app.toggleWindowVisibility';

function ShortcutKeys({ binding }: { binding: string | null }) {
const label = formatShortcut(binding);
return (
Expand Down Expand Up @@ -126,6 +131,25 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu
[setOverrides]
);

// Global shortcuts must register with the OS before they are persisted, so a
// binding the OS rejects never lands in the settings.
const applyBinding = useCallback(
async (id: ShortcutId, binding: string | null) => {
if (id === GLOBAL_WINDOW_SHORTCUT_ID && isTauri()) {
try {
await setGlobalWindowShortcut({
shortcut: binding === null ? null : toTauriAccelerator(binding),
});
} catch {
setError('That shortcut could not be registered on this device.');
return;
}
}
updateOverride(id, binding);
},
[updateOverride]
);

useEffect(() => {
const id = editingId;
if (!id) return undefined;
Expand All @@ -139,7 +163,7 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu
return;
}
if (event.key === 'Backspace' || event.key === 'Delete') {
updateOverride(id, null);
void applyBinding(id, null);
return;
}
const binding = captureShortcut(event);
Expand All @@ -149,12 +173,20 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu
setError(`Already used by “${conflict.label}” in this context.`);
return;
}
updateOverride(id, binding);
void applyBinding(id, binding);
};

window.addEventListener('keydown', handleCapture, true);
return () => window.removeEventListener('keydown', handleCapture, true);
}, [editingId, overrides, updateOverride]);
}, [editingId, overrides, applyBinding]);

useEffect(() => {
if (!isTauri()) return;
const binding = getShortcutBinding(GLOBAL_WINDOW_SHORTCUT_ID, overrides);
setGlobalWindowShortcut({
shortcut: binding === null ? null : toTauriAccelerator(binding),
}).catch(() => undefined);
}, [overrides]);

return (
<SettingsSectionPage
Expand Down
4 changes: 4 additions & 0 deletions src/app/generated/tauri/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export async function saveMediaToPhotos(params: types.SaveMediaToPhotosParams):
return invoke('save_media_to_photos', params);
}

export async function setGlobalWindowShortcut(params: types.SetGlobalWindowShortcutParams): Promise<void> {
return invoke('set_global_window_shortcut', params);
}

export async function setImmersiveMode(params: types.SetImmersiveModeParams): Promise<void> {
return invoke('set_immersive_mode', params);
}
Expand Down
5 changes: 5 additions & 0 deletions src/app/generated/tauri/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ export interface SaveMediaToPhotosParams {
[key: string]: unknown;
}

export interface SetGlobalWindowShortcutParams {
shortcut?: string | null;
[key: string]: unknown;
}

export interface SetImmersiveModeParams {
enabled: boolean;
[key: string]: unknown;
Expand Down
25 changes: 25 additions & 0 deletions src/app/keyboard/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getShortcutBinding,
matchesShortcut,
sanitizeShortcutOverrides,
toTauriAccelerator,
} from './shortcuts';

const keyEvent = (key: string, init: Partial<KeyboardEvent> = {}) =>
Expand Down Expand Up @@ -60,4 +61,28 @@ describe('keyboard shortcuts', () => {
})
).toEqual({ 'composer.bold': 'alt+b', 'composer.italic': null });
});

it('leaves the global window shortcut unassigned by default', () => {
expect(getShortcutBinding('app.toggleWindowVisibility', {})).toBe(null);
});

it('converts captured bindings to Tauri accelerator strings', () => {
expect(toTauriAccelerator('mod+shift+s')).toBe('CmdOrCtrl+Shift+S');
expect(toTauriAccelerator('ctrl+alt+c')).toBe('Ctrl+Alt+C');
expect(toTauriAccelerator('alt+space')).toBe('Alt+Space');
expect(toTauriAccelerator('shift+arrowup')).toBe('Shift+Up');
expect(toTauriAccelerator('mod+f')).toBe('CmdOrCtrl+F');
expect(toTauriAccelerator('mod+escape')).toBe('CmdOrCtrl+Esc');
});

it('round-trips captured shortcuts through the Tauri format', () => {
for (const [key, init, expected] of [
['S', { ctrlKey: true, shiftKey: true }, 'CmdOrCtrl+Shift+S'],
['C', { ctrlKey: true, altKey: true }, 'CmdOrCtrl+Alt+C'],
['ArrowUp', { shiftKey: true }, 'Shift+Up'],
] as const) {
const captured = captureShortcut(keyEvent(key, init))!;
expect(toTauriAccelerator(captured)).toBe(expected);
}
});
});
34 changes: 33 additions & 1 deletion src/app/keyboard/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type ShortcutId =
| 'app.searchMessages'
| 'app.openBookmarks'
| 'app.createRoom'
| 'app.toggleWindowVisibility'
| 'navigation.nextUnread'
| 'navigation.cycleNextUnread'
| 'navigation.cyclePreviousUnread'
Expand Down Expand Up @@ -38,7 +39,7 @@ export type ShortcutDefinition = {
id: ShortcutId;
label: string;
category: 'General' | 'Navigation' | 'Messages';
defaultBinding: string;
defaultBinding: string | null;
scope: ShortcutScope;
allowInEditable?: boolean;
};
Expand Down Expand Up @@ -66,6 +67,14 @@ export const SHORTCUTS: readonly ShortcutDefinition[] = [
defaultBinding: 'mod+shift+n',
scope: 'global',
},
{
id: 'app.toggleWindowVisibility',
label: 'Show/Hide Sable',
category: 'General',
// Opt-in: disabled by default so Sable never claims a system-wide key on its own.
defaultBinding: null,
scope: 'global',
},
{
id: 'navigation.nextUnread',
label: 'Jump to the highest-priority unread room',
Expand Down Expand Up @@ -294,6 +303,29 @@ export const captureShortcut = (event: ShortcutEvent): string | undefined => {
return [...modifiers, key].join('+');
};

export const toTauriAccelerator = (binding: string): string => {
return binding
.split('+')
.map((part) => {
const lower = part.toLowerCase();
if (lower === 'mod' || lower === 'cmd' || lower === 'command') return 'CmdOrCtrl';
if (lower === 'control' || lower === 'ctrl') return 'Ctrl';
if (lower === 'shift') return 'Shift';
if (lower === 'alt' || lower === 'option') return 'Alt';
if (lower === 'meta') return 'Super';
if (lower === 'space') return 'Space';
if (lower === 'arrowup') return 'Up';
if (lower === 'arrowdown') return 'Down';
if (lower === 'arrowleft') return 'Left';
if (lower === 'arrowright') return 'Right';
if (lower === 'escape') return 'Esc';
if (lower === 'enter') return 'Enter';
if (lower === 'tab') return 'Tab';
return lower.length === 1 ? lower.toUpperCase() : lower;
})
.join('+');
};

export const findShortcutConflict = (
id: ShortcutId,
binding: string,
Expand Down
Loading