diff --git a/README.md b/README.md index 91c1f11..026b680 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ gh attestation verify --repo chmouel/liseur-desktop There isn't one, until you ask: press Alt and it appears. Everything in it has a shortcut anyway — `Ctrl+O` to open a book, `Ctrl+Shift+O` to add a -folder, `Ctrl+,` for the settings, `Ctrl+F` to search. On macOS the menu +folder, `Ctrl+,` for the settings, `Ctrl+F` to search the shelf or the book you +are in. On macOS the menu lives in the system bar as usual. ## Vim mode diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e0e41ff..084cf31 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -44,6 +44,11 @@ Library UX conventions carried over from Android: - Continue Reading card: cover + label + title + author + progress bar + percent - Book card: cover + badges (download/server/finished) + title + author +The settings panel belongs to the application shell rather than to the +library, and paints over whichever screen is showing. Anything scoped to one +screen — the shelf's search, the reader's panels — stays with that screen and +claims the menu accelerator only while it is mounted. + ## Vim mode An optional keyboard grammar for people who already have one in their diff --git a/src/main/menu.ts b/src/main/menu.ts index f14af79..58cb230 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -39,7 +39,8 @@ export function setupMenu(): void { label: 'View', submenu: [ { - label: 'Search Library', + // The shelf or the book, whichever is on screen. + label: 'Search', accelerator: 'CmdOrCtrl+F', click: () => send('liseur:menu:search'), }, diff --git a/src/renderer/app/App.tsx b/src/renderer/app/App.tsx index c468f47..1c6d840 100644 --- a/src/renderer/app/App.tsx +++ b/src/renderer/app/App.tsx @@ -1,8 +1,10 @@ -import { createSignal, onMount, Show, type JSX } from 'solid-js' +import { createSignal, onCleanup, onMount, Show, type JSX } from 'solid-js' import { initTheme } from './theme' import { initLibrary, useLibraryStore } from '../library/store' import { LibraryScreen } from '../library/LibraryScreen' import { ReaderScreen } from '../reader/ReaderScreen' +import { SettingsScreen } from '../settings/SettingsScreen' +import { closeSettings, openSettings, settingsOpen } from '../settings/state' import { initVimMode } from '../vim/vim' import { observeLongTasks, perf } from '../perf/perf' @@ -55,23 +57,32 @@ export function App(): JSX.Element { initVimMode() observeLongTasks() watchForNewServerBooks() + // Settings belong to the application, not to the shelf: Ctrl+, has to + // answer while a book is open too. + onCleanup(window.liseur.app.onMenu((action) => action === 'settings' && openSettings())) void maybeResumeLastBook(setOpenBookId) done() }) return ( - setOpenBookId(id)} />}> - {(bookId) => ( - { - setOpenBookId(null) - // Progress changed while reading: refresh what the user sees. - void library.refresh() - void library.refreshContinueReading() - }} - /> - )} - + <> + setOpenBookId(id)} />}> + {(bookId) => ( + { + setOpenBookId(null) + // Progress changed while reading: refresh what the user sees. + void library.refresh() + void library.refreshContinueReading() + }} + /> + )} + + {/* Above whichever screen is showing, so it opens over a book too. */} + + + + ) } diff --git a/src/renderer/library/LibraryScreen.tsx b/src/renderer/library/LibraryScreen.tsx index 90fbc02..7507ed2 100644 --- a/src/renderer/library/LibraryScreen.tsx +++ b/src/renderer/library/LibraryScreen.tsx @@ -3,7 +3,7 @@ import type { LibraryFilter, LibrarySortKey } from '@shared/domain/types' import { useLibraryStore } from './store' import { VirtualBookGrid } from './VirtualBookGrid' import { ContinueReading } from './ContinueReading' -import { SettingsScreen } from '../settings/SettingsScreen' +import { openSettings, settingsOpen } from '../settings/state' import { StatsScreen } from '../stats/StatsScreen' import { useTheme } from '../app/theme' import { computeColumns, GRID_CARD_WIDTH, GRID_GAP, GRID_ROW_HEIGHT } from './virtualize' @@ -26,7 +26,6 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }): const [searchOpen, setSearchOpen] = createSignal(false) const [selectedIndex, setSelectedIndex] = createSignal(-1) - const [settingsOpen, setSettingsOpen] = createSignal(false) const [statsOpen, setStatsOpen] = createSignal(false) const [sortMenuOpen, setSortMenuOpen] = createSignal(false) const [helpOpen, setHelpOpen] = createSignal(false) @@ -88,10 +87,9 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }): } return } - if (settingsOpen()) { - if (e.key === 'Escape' || (vimMode() && e.key === 'q')) setSettingsOpen(false) - return - } + // The settings panel is the application's, not the shelf's, and it + // handles its own keys — including the ones that close it. + if (settingsOpen()) return if (statsOpen()) { if (e.key === 'Escape' || (vimMode() && e.key === 'q')) setStatsOpen(false) return @@ -244,7 +242,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }): setStatsOpen(true) break case 'settings': - setSettingsOpen(true) + openSettings() break case 'help': setHelpOpen(true) @@ -265,11 +263,11 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }): props.onOpenBook(book.id) } - // Menu accelerators from main (Ctrl/Cmd+F, Ctrl/Cmd+,). - window.liseur.app.onMenu((action) => { - if (action === 'search') openSearch() - if (action === 'settings') setSettingsOpen(true) - }) + // Ctrl/Cmd+F searches whatever is on screen, so each screen claims it for + // itself; settings are the application's and are handled in the shell. + // Unsubscribing matters: this screen is mounted again every time a book is + // closed, and the listeners would otherwise pile up. + onCleanup(window.liseur.app.onMenu((action) => action === 'search' && openSearch())) // Document-level key handling: Solid delegates element handlers, and keys // targeted at (e.g. after a form closes) never pass through the @@ -373,17 +371,13 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }): class="icon-button" aria-label="Settings" title="Settings (Ctrl/Cmd+,)" - onClick={() => setSettingsOpen(true)} + onClick={openSettings} > ⋮ - - setSettingsOpen(false)} /> - - setStatsOpen(false)} /> diff --git a/src/renderer/reader/ReaderScreen.tsx b/src/renderer/reader/ReaderScreen.tsx index 045ec9c..a41cd8a 100644 --- a/src/renderer/reader/ReaderScreen.tsx +++ b/src/renderer/reader/ReaderScreen.tsx @@ -28,6 +28,7 @@ import { MAX_MEASURE, type MarginPreset, } from './reader-theme' +import { settingsOpen } from '../settings/state' /** Margin presets in the order they read: most text to least. */ const MARGIN_NAMES = ['narrow', 'normal', 'wide'] as const @@ -683,6 +684,11 @@ export function ReaderScreen(props: { bookId: string; onClose: () => void }): JS function onKeydown(e: ForwardedKeyEvent): void { // Any key press reveals the chrome (hidden-reading mode is pointer- AND // keyboard-dismissable). + // The settings panel opens over the book. While it is up it owns the + // keyboard: turning pages or closing the book underneath it is never + // what the keystroke was for. + if (settingsOpen()) return + showChrome() if (helpOpen()) { @@ -935,6 +941,11 @@ export function ReaderScreen(props: { bookId: string; onClose: () => void }): JS })() } + // Ctrl/Cmd+F means "search what I am looking at", and what you are looking + // at here is a book. The shelf claims the same accelerator while it is the + // one on screen; only one of the two is ever mounted. + onCleanup(window.liseur.app.onMenu((action) => action === 'search' && togglePopover('search'))) + // Key handling at document level: clicks land on iframes/zones and must // not swallow page turns. One listener only (registered here, not on the // root div — both would fire for the same event). diff --git a/src/renderer/settings/SettingsScreen.tsx b/src/renderer/settings/SettingsScreen.tsx index 9e37946..c9e79f8 100644 --- a/src/renderer/settings/SettingsScreen.tsx +++ b/src/renderer/settings/SettingsScreen.tsx @@ -45,7 +45,24 @@ export function SettingsScreen(props: { onClose: () => void }): JSX.Element { } } + // The panel owns the keyboard while it is up, whatever is behind it. Both + // screens listen for keys on the document, and a page must not turn — nor a + // book close — under an open settings panel. Escape closes it from inside a + // field too, which a ticked checkbox used to make impossible. + function onKeydown(e: KeyboardEvent): void { + const target = e.target as HTMLElement | null + const inField = + target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.tagName === 'SELECT' + if (e.key === 'Escape' || (vimMode() && e.key === 'q' && !inField)) { + e.preventDefault() + e.stopPropagation() + props.onClose() + } + } + onMount(() => { + document.addEventListener('keydown', onKeydown) + onCleanup(() => document.removeEventListener('keydown', onKeydown)) void refresh() const off = window.liseur.sync.onStateChanged((s) => setState(s)) onCleanup(off) @@ -154,18 +171,7 @@ export function SettingsScreen(props: { onClose: () => void }): JSX.Element { } return ( -