Skip to content

Commit ccffa1b

Browse files
authored
Merge pull request #382 from Splode/feat/local-shortcuts
2 parents 692e67a + bfd937f commit ccffa1b

29 files changed

Lines changed: 873 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
### Bug Fixes
55

66
* **App freeze / crash on launch when "Show in System Tray" is enabled on KDE Plasma 6 / Wayland** — enabling the system tray on a Linux system without `libayatana-appindicator3` or `libappindicator3` installed caused `libappindicator-sys` to panic inside an `extern "C"` function, which aborts the process. Because the setting is persisted before the crash, every subsequent launch would abort before the window could appear. The fix probes for the shared library via `dlopen` before calling `TrayIconBuilder::build()` and returns early with a warning when it is absent. The System Tray section in Settings → System is now hidden entirely on Linux systems where the library is not found, preventing the setting from being enabled in the first place. The required library can be installed on Arch / Manjaro with `sudo pacman -S libayatana-appindicator`.
7+
### Shortcuts
8+
9+
* **Local keyboard shortcuts** — a set of keyboard shortcuts now activates while the main timer window has focus, with no system-wide registration required. Default bindings: Space (pause/resume), Left Arrow (reset current round), Right Arrow (skip round), Down Arrow (volume down), Up Arrow (volume up), M (mute toggle), F11 (fullscreen toggle). All seven bindings are re-mappable in Settings → Shortcuts under the new Local Shortcuts section, which appears above the existing Global Shortcuts section. Bindings persist across restarts and are restored to defaults when Reset All Settings is used.
710

811
### UI
912

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-02
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
## Context
2+
3+
Pomotroid has an existing global shortcuts system that registers OS-level hotkeys via `tauri-plugin-global-shortcut`. Those shortcuts are stored in SQLite, exposed through the `Settings` struct, and editable in the Settings → Shortcuts section (`ShortcutsSection.svelte`).
4+
5+
Local shortcuts are a different, complementary mechanism: they fire only while the app window has focus, need no OS-level registration, and are handled entirely in the frontend via standard `keydown` event listeners. The actions they invoke (timer toggle, skip, reset, volume change, mute, fullscreen) are already available through existing IPC commands.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- 7 default local shortcuts active whenever any Pomotroid window has focus
11+
- All 7 bindings are user-configurable via Settings → Shortcuts
12+
- Bindings persist in SQLite alongside global shortcut bindings
13+
- "Reset All Settings" resets local bindings to defaults
14+
- Shortcut bindings update live: changing a binding takes effect without restart
15+
- No separate enable/disable toggle (local shortcuts are always active while focused)
16+
17+
**Non-Goals:**
18+
- Modifier-key chording for local shortcuts (single keys only, consistent with defaults like Space, Arrow keys, M, F11)
19+
- Per-window shortcut overrides (same bindings apply to both main and settings windows)
20+
- Local shortcut conflict detection with global shortcuts
21+
- Disabling individual shortcuts (user can leave a binding blank/unbound to effectively disable)
22+
23+
## Decisions
24+
25+
### 1. Pure frontend keydown listeners — no Rust involvement for dispatch
26+
27+
**Decision**: Handle keydown in Svelte via `document.addEventListener('keydown', ...)`. When a matching key is pressed, call the existing IPC functions (`timerToggle()`, `timerSkip()`, `timerReset()`, `volumeSet()`, `settingsSet()`, etc.) directly from the listener.
28+
29+
**Why over Rust-side handling**: The Tauri `tauri-plugin-global-shortcut` plugin is the right tool for OS-level shortcuts, but for focus-scoped shortcuts the browser's own keyboard event model is simpler and sufficient. There is no IPC round-trip needed just to dispatch — the frontend already has all the IPC wrappers it needs.
30+
31+
**Alternative**: Register shortcuts in Rust using `Window::on_window_event` focus guards. Rejected: adds complexity, introduces a Rust→frontend callback path for no benefit.
32+
33+
### 2. Store bindings in Settings struct as plain key strings
34+
35+
**Decision**: Add 7 new fields to the `Settings` struct (e.g. `local_shortcut_toggle: String`) with DB keys like `local_shortcut_toggle`. Each value is a key string (e.g. `" "`, `"ArrowLeft"`, `"F11"`, `"m"`) matching the browser `KeyboardEvent.key` property.
36+
37+
**Why `KeyboardEvent.key` over `KeyboardEvent.code`**: `key` is layout-aware and maps to what the user expects ("Space", not "Space" vs "KeySpace"). Single-character keys like `m` are unambiguous. Arrow keys and function keys have stable `key` names across platforms.
38+
39+
**Why not a JSON blob**: The existing settings pattern stores flat key/value strings. Keeping the same shape makes settings reset, migration, and the `settings_set` IPC flow consistent with the rest of the codebase.
40+
41+
### 3. Key capture UI reuses global shortcut recorder pattern
42+
43+
**Decision**: The local shortcuts section in `ShortcutsSection.svelte` uses the same single-key recorder input pattern already used for global shortcuts — click to focus, press a key, the binding is saved. Difference: local shortcuts record the raw `KeyboardEvent.key` value rather than a Tauri accelerator string.
44+
45+
**Why**: The UX is already proven and familiar. Minor adaptation needed: global shortcut recording listens for a Tauri accelerator format; local shortcut recording listens for `KeyboardEvent.key` directly.
46+
47+
### 4. Listener mounted on `document` in `+page.svelte` (main window) and `settings/+page.svelte`
48+
49+
**Decision**: The keydown handler lives in both page root components, added in `onMount` and removed in `onDestroy`. It reads the current local shortcut settings from the reactive settings store.
50+
51+
**Why both pages**: Both windows can be focused. The settings window should also respond to shortcuts (e.g., user adjusts volume while in settings).
52+
53+
**Guard**: The handler should skip if the focused element is an `<input>` or `<textarea>` to avoid interfering with typing in shortcut capture fields.
54+
55+
### 5. Fullscreen via Tauri `appWindow.setFullscreen()` toggle
56+
57+
**Decision**: F11 calls `getCurrentWindow().setFullscreen(!isFullscreen)`, reading current fullscreen state from a local reactive variable updated via `Window.onResized` or `Window.isFullscreen()`.
58+
59+
**Why**: Tauri exposes `setFullscreen` on the `WebviewWindow` API. This is the correct cross-platform way; native OS F11 handling is bypassed in Tauri's decoration-free window mode anyway.
60+
61+
### 6. Volume up/down as fixed increments (±5%)
62+
63+
**Decision**: Each Up/Down Arrow press adjusts volume by 0.05 (5%) clamped to [0.0, 1.0]. Volume is then saved with `settingsSet('volume', ...)`.
64+
65+
**Why 5%**: Consistent with typical media application increments. Larger steps feel coarse; smaller steps require too many presses.
66+
67+
## Risks / Trade-offs
68+
69+
- **Key conflicts with browser defaults** → Arrow keys may scroll the page in some contexts. Calling `event.preventDefault()` in the handler mitigates this. Must be careful not to swallow keys when an input is focused.
70+
- **Settings window shortcut duplication** → Both windows mount listeners, so both will fire if both have focus simultaneously (impossible in practice; OS enforces single focus). Low risk.
71+
- **KeyboardEvent.key normalization** → On some platforms/locales, single-character keys may differ in case. Storing and comparing in lowercase for letter keys avoids mismatches.
72+
- **Binding an already-used key** → No conflict detection in v1. A key bound to both a local and global shortcut will fire both if the app is focused. Acceptable for now; conflict UI is explicitly a non-goal.
73+
74+
## Migration Plan
75+
76+
1. Add DB migration (increment schema version, insert 7 new key/value rows with defaults)
77+
2. Add 7 fields to `Settings` struct and `defaults.rs`
78+
3. Update `types.ts` to match
79+
4. Wire keydown listeners in both page components, reading from settings store
80+
5. Expand `ShortcutsSection.svelte` with local shortcut capture rows
81+
6. Verify "Reset All Settings" path resets the 7 new keys to defaults (handled by existing `settings_reset` command which re-runs `defaults.rs`)
82+
83+
Rollback: remove migration (or let it be — extra DB rows are harmless), revert frontend changes. No data loss risk.
84+
85+
## Open Questions
86+
87+
- Should the Space bar shortcut be suppressed on the settings page to avoid accidental timer toggle while a user types? Likely yes — the `input`/`textarea` focus guard handles this.
88+
- Should we display the current key binding next to each action in the main timer window as a tooltip? Out of scope for v1 but worth noting.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Why
2+
3+
Pomotroid already supports global shortcuts that work system-wide, but lacks any keyboard shortcuts that activate while the app window is focused. Users who keep the app visible on screen have no way to control the timer, volume, or round state with the keyboard — they must reach for the mouse even for common actions like pause/resume or volume adjustment.
4+
5+
## What Changes
6+
7+
- Introduce a new local shortcuts system: a set of keyboard shortcuts active only while a Pomotroid window has focus
8+
- Default bindings: Space (pause/resume), Left Arrow (reset current round), Right Arrow (skip round), Down Arrow (volume down), Up Arrow (volume up), M (mute toggle), F11 (fullscreen toggle)
9+
- All local shortcuts are re-mappable in Settings → Shortcuts alongside global shortcuts
10+
- "Reset All Settings" resets local shortcut bindings to defaults along with all other settings
11+
- Local shortcuts are always active when the app is focused (no separate enable/disable toggle — unlike global shortcuts)
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
- `local-shortcuts`: Keyboard shortcuts active while the application window is focused, covering pause/resume, round reset, round skip, volume up/down, mute, and fullscreen toggle — all re-mappable via Settings
17+
18+
### Modified Capabilities
19+
- `shortcuts`: The existing global shortcuts spec must be extended: Settings → Shortcuts section now displays both global and local shortcut bindings; "Reset All Settings" must also reset local shortcut bindings to defaults
20+
21+
## Impact
22+
23+
- **Frontend**: `ShortcutsSection.svelte` expanded to show local shortcut bindings below global ones; keydown event listeners added on main and settings window roots; IPC wrappers for new local-shortcut commands
24+
- **Backend**: New `Settings` fields for local shortcut key bindings; DB migration adds keys (e.g. `local_shortcut_toggle`, `local_shortcut_reset`, etc.); `settings/defaults.rs` updated; `commands.rs` wires up shortcut actions; `settings_reset` handler also resets local shortcut defaults
25+
- **Types**: `src/lib/types.ts` updated to mirror new `Settings` fields
26+
- **Capabilities file**: No new Tauri plugin permissions required (keydown handling is pure frontend via Svelte)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Local shortcuts are active while any app window has focus
4+
The system SHALL handle a configurable set of keyboard shortcuts that fire when the user presses a bound key while any Pomotroid window (main or settings) has OS focus. Local shortcuts SHALL NOT fire when the focus is inside a text input element.
5+
6+
#### Scenario: Shortcut fires in main window
7+
- **WHEN** the main timer window has OS focus
8+
- **AND** the user presses the key bound to pause/resume
9+
- **THEN** the timer SHALL toggle between running and paused
10+
11+
#### Scenario: Shortcut fires in settings window
12+
- **WHEN** the settings window has OS focus
13+
- **AND** the user presses the key bound to volume up
14+
- **THEN** the volume SHALL increase by 5%
15+
16+
#### Scenario: Shortcut does not fire when input is focused
17+
- **WHEN** a text input or shortcut capture field has keyboard focus
18+
- **AND** the user presses a key that is bound to a local shortcut
19+
- **THEN** the shortcut action SHALL NOT execute and the keypress SHALL be handled normally by the input
20+
21+
---
22+
23+
### Requirement: Default local shortcut bindings
24+
The system SHALL provide the following default local shortcut bindings on all platforms:
25+
- Pause/Resume: Space
26+
- Reset current round: ArrowLeft
27+
- Skip round: ArrowRight
28+
- Volume down: ArrowDown
29+
- Volume up: ArrowUp
30+
- Mute toggle: m
31+
- Fullscreen toggle: F11
32+
33+
#### Scenario: First launch defaults
34+
- **WHEN** the application is launched for the first time with no existing settings
35+
- **THEN** all seven local shortcut bindings SHALL match the defaults listed above
36+
37+
#### Scenario: Existing bindings preserved across launches
38+
- **WHEN** the user has customized one or more local shortcut bindings and restarts the app
39+
- **THEN** the customized bindings SHALL be restored from the database
40+
41+
---
42+
43+
### Requirement: Local shortcut actions
44+
Each local shortcut SHALL invoke a specific action:
45+
46+
- **Pause/Resume**: toggles the timer between running and paused (same as `timer_toggle` IPC command)
47+
- **Reset current round**: resets the current timer round to its full duration without advancing sequence (same as `timer_reset` IPC command)
48+
- **Skip round**: ends the current round and advances to the next in sequence (same as `timer_skip` IPC command)
49+
- **Volume down**: decreases volume by 5 percentage points, clamped to 0.0
50+
- **Volume up**: increases volume by 5 percentage points, clamped to 1.0
51+
- **Mute toggle**: toggles the volume between 0.0 and the last non-zero volume level
52+
- **Fullscreen toggle**: toggles the main window between fullscreen and its previous size/position
53+
54+
#### Scenario: Volume up at maximum
55+
- **WHEN** the volume is at 1.0 (100%)
56+
- **AND** the user presses the volume up shortcut
57+
- **THEN** the volume SHALL remain at 1.0 (no overflow)
58+
59+
#### Scenario: Volume down at minimum
60+
- **WHEN** the volume is at 0.0 (0%)
61+
- **AND** the user presses the volume down shortcut
62+
- **THEN** the volume SHALL remain at 0.0 (no underflow)
63+
64+
#### Scenario: Mute restores previous volume
65+
- **WHEN** the volume is at 0.6 (60%)
66+
- **AND** the user presses the mute shortcut
67+
- **THEN** the volume SHALL be set to 0.0
68+
- **WHEN** the user presses the mute shortcut again
69+
- **THEN** the volume SHALL be restored to 0.6
70+
71+
#### Scenario: Fullscreen toggle
72+
- **WHEN** the main window is in windowed mode
73+
- **AND** the user presses the fullscreen shortcut
74+
- **THEN** the main window SHALL enter fullscreen mode
75+
- **WHEN** the user presses the fullscreen shortcut again
76+
- **THEN** the main window SHALL exit fullscreen and return to windowed mode
77+
78+
---
79+
80+
### Requirement: Local shortcuts are re-mappable in Settings
81+
The system SHALL allow users to change any local shortcut binding via Settings → Shortcuts. Each binding field SHALL record the next keypress (excluding modifier-only keys) as the new binding. The new binding SHALL be saved immediately and take effect without restart.
82+
83+
#### Scenario: User rebinds pause/resume
84+
- **WHEN** the user clicks the pause/resume local shortcut field in Settings → Shortcuts
85+
- **AND** presses the P key
86+
- **THEN** the binding SHALL be updated to "p" in the database
87+
- **AND** pressing P while the main window is focused SHALL toggle the timer
88+
89+
#### Scenario: Binding takes effect immediately
90+
- **WHEN** the user saves a new local shortcut binding
91+
- **AND** the settings window remains open
92+
- **THEN** pressing the newly bound key SHALL immediately trigger the corresponding action (no restart required)
93+
94+
---
95+
96+
### Requirement: Reset All Settings restores local shortcut defaults
97+
When the user resets all settings to defaults, all local shortcut bindings SHALL be reverted to their default values.
98+
99+
#### Scenario: Reset restores default bindings
100+
- **WHEN** the user triggers "Reset All Settings" via the Settings menu
101+
- **THEN** all seven local shortcut bindings SHALL revert to their defaults (Space, ArrowLeft, ArrowRight, ArrowDown, ArrowUp, m, F11)
102+
- **AND** any custom bindings the user had configured SHALL be discarded
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Reset All Settings disables global shortcuts
4+
When the user resets all settings to defaults, `global_shortcuts_enabled` SHALL be `false`, any previously registered global shortcuts SHALL be unregistered, and all local shortcut bindings SHALL be reverted to their default values (Space, ArrowLeft, ArrowRight, ArrowDown, ArrowUp, m, F11).
5+
6+
#### Scenario: Reset All Settings disables global shortcuts and restores local defaults
7+
- **WHEN** the user resets all settings to defaults
8+
- **THEN** `global_shortcuts_enabled` SHALL be `false` and any previously registered shortcuts SHALL be unregistered
9+
- **AND** all seven local shortcut bindings SHALL revert to their defaults

0 commit comments

Comments
 (0)