Skip to content
Merged
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
298 changes: 298 additions & 0 deletions docs/adr/0044-menu-system-and-shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
# ADR 0044: Menu System and Keyboard Shortcuts

## Status

Proposed

## Context

All application menus are custom Vue dropdowns, not Tauri's native
`Menu` API. They have grown organically and are inconsistent today:

- **Main window `File`** has two items: `Settings` (opens the account
editor, works) and `Close` (only closes the open dropdown, does
nothing else, a handler-name collision that looks like the menu item
is broken).
- **Main window `View`** has ten or more items with mixed formatting.
Some live under a bold group heading (`Message View Position`,
`Theme`), some sit loose with no grouping (`Show Message Pane`,
`Threading`, `Message Filters`, `Hide Window Decorations`).
Capitalization is inconsistent (`Show/Hide Message Pane`, `Threading`
vs `Message Filters`).
- **Compose window** renders six placeholder `<span>` elements labelled
`File`, `Edit`, `View`, `Options`, `Tools`, `Help`. None have click
handlers, dropdowns, or state. Pure decoration.
- **Calendar sidebar** hosts three live settings below the calendar list
(week start day, time format, display timezone), plus per-calendar
visibility toggles. These are real app-wide preferences crammed into a
context where the user expects calendar list management.
- **No Preferences page exists.** The `Settings` route is accounts only.
Persistent app preferences live partly in the `View` menu, partly in
the calendar sidebar, with no single home.
- **Keyboard shortcuts are not wired anywhere.** Power users cannot save
a draft, send, or toggle the message pane from the keyboard.

The structural choice most worth documenting: we stick with Vue
dropdowns rather than adopting Tauri's native `Menu` API. Native menus
integrate with the OS menu bar on macOS and would give us free
accelerator support, but they reset our visual language, cannot be
styled, and are awkward when the app hosts multiple windows (compose,
main, future preferences) that each want their own menu. Vue dropdowns
keep us in charge of the look and let every window own its menus
directly.

## Decision

Adopt a single menu specification across all windows, add a dedicated
Preferences window for app-wide settings, and wire keyboard shortcuts at
the window level.

### Conventions

| Convention | Meaning |
| ---------------------- | ------------------------------------------------------------------------------ |
| Title Case | All menu labels. |
| `…` suffix | Item opens a window, dialog, or prompt. Omit for immediate actions. |
| `✓` prefix | Boolean toggle; shown when the toggle is on. |
| `●` prefix | Radio selection inside a mutually exclusive group; shown on the active option. |
| Bold group heading | Non-clickable heading that introduces a mutually exclusive group. |
| `────` separator | Divides logically distinct sections within a menu. |
| Right-aligned shortcut | Accelerator label, rendered dim. Wired at runtime. |

Platform modifier: `Ctrl` on Linux and Windows, `Cmd` on macOS. Resolved
at runtime; the ADR and source strings write `Ctrl+X` throughout, the
renderer substitutes `⌘X` on macOS.

### Main window

#### File

```
Preferences… Ctrl+,
────────────────────────────
Close Window Ctrl+W
Quit Ctrl+Q
```

Account management is no longer a top-level File item, and it is not a
Preferences section either. It stays accessible from its own button in
the left pane, following Thunderbird's pattern.

#### View

```
Show Message Pane ✓ Ctrl+\
Message Pane Position
● Right
● Bottom
● Tabs
────────────────────────────
Threaded View ✓ Ctrl+T
Message Filters…
────────────────────────────
Hide Title Bar ✓
```

Renames:

- `Show/Hide Message Pane` becomes `Show Message Pane`. The checkmark
communicates state; the label names the thing, not the verb.
- `Threading` becomes `Threaded View`, parallel to `Show Message Pane`.
- `Hide Window Decorations` becomes `Hide Title Bar`. That is what the
toggle actually does; calling it "decorations" hides the intent behind
jargon.
- Theme moves out of the View menu and into Preferences > General. Theme
is a persistent preference, not a transient view state.

#### Help

Deferred. Can be added in a later phase if we need `About Chithi…`,
`Keyboard Shortcuts…`, `Report Issue…`. Not blocking the refactor.

### Preferences window

New window, opened from `File > Preferences…` or `Ctrl+,`. Left sidebar
of sections, right-hand detail panel. Reuses existing Vue components
where it can.

- **General**: Theme (● System / ● Light / ● Dark).
- **Mail**: default threaded view, default message pane position.
- **Calendar**: week starts on, time format, display timezone. Moved out
of the calendar sidebar, which thereafter hosts only the calendar list
and its per-calendar toggles.

Persistence stays in `localStorage` for the settings that already live
there. A later ADR can move to `tauri-plugin-store` for file-backed
config if that becomes worth it.

### Compose window

#### File

```
Save Draft Ctrl+S
Send Ctrl+Return
────────────────────────────
Close Window Ctrl+W
```

#### Edit

```
Undo Ctrl+Z
Redo Ctrl+Shift+Z
────────────────────────────
Cut Ctrl+X
Copy Ctrl+C
Paste Ctrl+V
────────────────────────────
Select All Ctrl+A
```

Edit items dispatch to `document.execCommand` for the standard clipboard
and history operations. The keyboard shortcuts match the OS defaults, so
the menu is effectively documentation; the shortcuts work whether or not
the user opens the menu.

#### View

```
Show Cc ✓
Show Bcc ✓
```

#### Options

```
Attach File… Ctrl+Shift+A
```

#### Help

```
About Chithi…
```

**Dropped**: Compose > Tools. Nothing in the app belongs there.

### Keyboard shortcut implementation

- Each window attaches a single `keydown` listener on mount and removes
it on unmount. No document-level global listeners that outlive the
window.
- Shortcuts are expressed as a table mapping `{ key, ctrl, shift, alt }`
to a handler. The same handler is invoked by both the menu click and
the keystroke, so menu-item and shortcut behaviour cannot drift.
- Platform modifier normalisation lives in one helper. Labels render
`Ctrl+X` on Linux/Windows and `⌘X` on macOS; event handlers match
`event.metaKey` on macOS, `event.ctrlKey` elsewhere.
- Unknown shortcuts fall through to the browser default, so platform
text-editing shortcuts in the compose textarea keep working.

### Mobile chrome

The `mobile` branch ships a separate chrome for small viewports. `App.vue`
picks between `DesktopShell.vue` and `MobileShell.vue` based on the
`platform` store (threshold at 720 px width, plus runtime platform
detection via `@tauri-apps/plugin-os`). The menu spec above is
**desktop-only**; mobile has no menu bar at all.

On mobile the primary surfaces are:

- `MobileAppBar` at the top of each screen: title plus `leading` and
`trailing` slots for per-screen action buttons. Anywhere the desktop
menus expose an action (Compose, Preferences, Filters) the mobile
equivalent is a button rendered into one of these slots by the active
screen.
- `MobileTabBar` at the bottom: persistent navigation between Mail,
Calendar, Contacts, Filters, Settings.
- `ComposeSheet` replaces the compose window. Compose is a sheet
presented over the current screen, not a separate Tauri window, so
the Compose-window File/Edit/View/Options/Help menus from this spec
do not apply there.
- `FolderDrawer` replaces the sidebar folder tree.

Mapping desktop menu items to mobile equivalents:

| Desktop menu item | Mobile equivalent |
| --- | --- |
| File > Preferences… | Settings tab (`MobileTabBar`) |
| File > Close Window / Quit | OS back gesture, system app switcher |
| View > Show Message Pane | n/a: mobile is always single-pane |
| View > Message Pane Position | n/a: no split layout on mobile |
| View > Threaded View | Persistent preference, surfaced in Settings tab |
| View > Message Filters… | Filters tab |
| View > Hide Title Bar | n/a: native title bar is OS-managed |
| Compose > File > Save Draft / Send | Buttons in the `ComposeSheet` header |
| Compose > Edit > Cut / Copy / Paste | Native text-input gestures |
| Compose > View > Show Cc / Show Bcc | Buttons/toggles inside `ComposeSheet` |
| Compose > Options > Attach File… | Action button in `ComposeSheet` |

Items with no mobile equivalent (layout toggles, window-chrome toggles)
are simply absent from the mobile UI rather than being rendered and
greyed out. The shared preferences that mean something on both
platforms (theme, default threaded view, week start, time format,
display timezone) live in the same Pinia stores and localStorage keys
and are surfaced differently per platform.

Keyboard shortcuts: the window-level `keydown` listener is still
installed on mobile so that iPad / Android with an external keyboard
keep working. Shortcuts that drive absent UI (for example the
show-message-pane toggle) are registered as no-ops on mobile rather
than conditionally skipped, so the dispatch table stays identical
across platforms.

## Consequences

- A new `PreferencesView.vue` component and `/preferences` route ship.
- The calendar sidebar loses three settings blocks. It becomes a purer
calendar-list view.
- The `MenuBar.vue` component grows a shortcut registry and the matching
keydown listener. Menu item click handlers and shortcut handlers
collapse to a single dispatch table.
- The compose window gains a functional menu bar. The placeholder
`<span>` rendering is replaced.
- `Hide Window Decorations` renames to `Hide Title Bar` in user-visible
strings. The underlying `uiStore.decorationsEnabled` key is unchanged
so stored state survives.
- Theme moves out of the View menu. Users who changed theme from there
will find it under Preferences > General instead.
- Shortcuts are display-only on first render until the keydown listener
wires up. We ship the listener and the display together; no
half-state.
- Mobile chrome (`MobileShell`, `MobileAppBar`, `MobileTabBar`,
`ComposeSheet`) is unaffected structurally, but every phase PR must
surface the corresponding mobile action in the right app-bar slot or
Settings tab so desktop and mobile stay in sync on what the user can
do.

## Alternatives considered

- **Native Tauri menus via `Menu::new()`.** Gives us the macOS menu bar,
free accelerators, and OS integration. Costs: we cannot style them to
match the rest of the UI, cross-window state (which compose menu
toggles are on?) is harder to share, and the visual language between
the top menu and in-app dropdowns would diverge. Rejected because the
app is a single-user desktop client, not a polished macOS-native
product; consistency matters more than OS integration.
- **Keep the current structure and fix only the broken items.** Cheapest
path. Rejected because the inconsistency keeps compounding as we add
menu items; "consistent language" was a user-level requirement, not a
cosmetic one.
- **Move every persistent setting into Preferences, including the
message pane layout toggles.** Cleanest by convention. Rejected
because the layout toggles are genuinely view state that the user
wants to flip quickly; hiding them two windows deep would hurt daily
use.

## Rollout

Three PRs, each self-contained.

1. Main window menu cleanup plus functional `File > Close Window` and
`Quit`. Normalised View menu. Shortcut table for main window.
2. Preferences window. Moves calendar-sidebar settings and theme.
3. Compose window menus and shortcuts. Drops the placeholder Tools
entry.

Each phase lands as its own PR with the ADR as its north star.
Loading