diff --git a/README.md b/README.md index 3af55be..5ca964a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ An interactive terminal markdown viewer - **Table-of-contents sidebar** - a collapsible tree of the document's headings. - **Sticky headers** - as you scroll past a heading, its ancestors stay pinned at the top so you always know where you are. - **Ergonomic navigation** - header navigation, page up/down, half-page up/down, and mouse scrolling. -- **Search** forward and backward, `less`-style. +- **Search** the document, `less`-style. +- **Keyboard help** - press `?` for a panel listing every shortcut. - **Link following** - open links to other markdown files in place, with a back stack to return. - **Editor integration** - press `e` to open the current document in `$EDITOR` at the current position. - **Images** appear as a labeled, clickable link. @@ -86,8 +87,9 @@ Helix, and TextMate are recognized; unknown editors get the POSIX `+N file` conv | `d` / `u` | Half page down / up | | `g` / `G` | Top / bottom | | `n` / `N` | Next / previous heading (or search match) | -| `/` / `?` | Search forward / backward | +| `/` | Search | | `Esc` | Clear search | +| `?` | Toggle the keyboard-shortcuts panel | | `e` | Open current doc in `$EDITOR` | | `Backspace` | Go back (after following a link) | | `Tab` | Focus the table-of-contents sidebar | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b575bc..b94a8f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +75,7 @@ Heading nodes carry an `id` (slug). The renderer for `Heading` emits a `(null) const [mouseEnabled, setMouseEnabled] = useState(false) const [tocVisible, setTocVisible] = useState(true) + const [helpVisible, setHelpVisible] = useState(false) // Opaque panel over the viewer while a swapped-in doc mounts and repositions, // so the reader never sees it painted at scrollTop 0 before the jump lands. const [covering, setCovering] = useState(false) @@ -121,6 +123,7 @@ export function App({ ) const toggleMouse = useCallback(() => setMouseEnabled(m => !m), []) const toggleTocVisible = useCallback(() => setTocVisible(v => !v), []) + const toggleHelp = useCallback(() => setHelpVisible(v => !v), []) const isTocShown = toc.length > 0 && tocVisible const { width: termWidth } = useTerminalDimensions() @@ -261,6 +264,7 @@ export function App({ expanded: setExpanded, toggleMouse, toggleTocVisible, + toggleHelp, toggleExpanded, }, onQuit: () => { @@ -294,6 +298,7 @@ export function App({ nav.back, toggleMouse, toggleTocVisible, + toggleHelp, toggleExpanded, onOpenEditor, ], @@ -314,6 +319,7 @@ export function App({ historyDepth: nav.historyDepth, backLabel, status, + helpVisible, commands, }), [ @@ -329,6 +335,7 @@ export function App({ nav.historyDepth, backLabel, status, + helpVisible, commands, ], ) @@ -349,7 +356,7 @@ export function App({ useKeyboard(ev => { if (focus === 'search') return // SearchBar handles its own keys while typing - run(mapKey(ev, focus, { searchActive: !!search })) + run(mapKey(ev, focus, { searchActive: !!search, helpOpen: helpVisible })) }) const dispatchTocAction = (action: Action) => run(action) @@ -366,6 +373,7 @@ export function App({ + { + const { nodes, toc, headingIds } = buildTree(FIXTURE) + const { renderer, mockInput, flush, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 80, + height: 24, + }) + const settle = async () => { + await flush({ maxPasses: 20 }) + await new Promise(r => setTimeout(r, 30)) + await renderOnce() + } + + createRoot(renderer).render( + , + ) + await settle() + + await mockInput.typeText('x') // throwaway: first key eaten by the capability handshake + await settle() + + await mockInput.typeText('?') + await settle() + let frame = captureCharFrame() + expect(frame).toContain('Keyboard Shortcuts') + expect(frame).toContain('Scroll line') + + await mockInput.typeText('?') + await settle() + frame = captureCharFrame() + expect(frame).not.toContain('Scroll line') + + renderer.destroy() +}) diff --git a/src/app/components/HelpPanel.tsx b/src/app/components/HelpPanel.tsx new file mode 100644 index 0000000..7584d83 --- /dev/null +++ b/src/app/components/HelpPanel.tsx @@ -0,0 +1,70 @@ +import { TextAttributes } from '@opentui/core' +import { useTerminalDimensions } from '@opentui/react' +import { useAppState } from '../state' +import { HINTS } from '../lib/keys' +import { groupHints, layoutColumns } from '../lib/help-layout' +import { theme } from '../styles/theme' + +// Fixed key-column width so descriptions line up across rows. +const KEY_COL = 12 +// Below this terminal width a second column would cramp descriptions into ragged +// wraps, so the panel stays single-column (and taller) instead. +const MIN_TWO_COLUMN_WIDTH = 72 +// Labeled top rule: a run of RULE_GLYPH fills the width around the title. +const RULE_GLYPH = '━' +const HELP_TITLE = 'Keyboard Shortcuts' + +export function HelpPanel() { + const { helpVisible } = useAppState() + const { width } = useTerminalDimensions() + if (!helpVisible) return null + + const sections = groupHints(HINTS) + const columns = layoutColumns(sections, width >= MIN_TWO_COLUMN_WIDTH) + // 3 = the leading "━ " lead-in plus one space before the trailing rule. + const rule = RULE_GLYPH.repeat(Math.max(0, width - HELP_TITLE.length - 3)) + + // A drawer sliding up from the bottom: no box border, just a labeled top rule. + // The opaque background masks the document behind it, so the rule alone reads as + // the boundary. Spans the full terminal width (over the TOC too) since the panel + // is modal — all keys are swallowed while open — giving two columns clean room. + return ( + + + {`${RULE_GLYPH} `} + + {HELP_TITLE} + + {` ${rule}`} + + + {columns.map(col => ( + + {col.map((section, i) => ( + + + {section.group} + + {section.hints.map(h => ( + + {h.keys.padEnd(KEY_COL)} + {h.desc} + + ))} + + ))} + + ))} + + + ) +} diff --git a/src/app/components/SearchBar.test.tsx b/src/app/components/SearchBar.test.tsx index b0d2048..2c72918 100644 --- a/src/app/components/SearchBar.test.tsx +++ b/src/app/components/SearchBar.test.tsx @@ -137,14 +137,6 @@ test('escape while typing dismisses the overlay and highlights', async () => { renderer.destroy() }) -test('? opens the overlay with the ? prefix', async () => { - const { renderer, mockInput, settle, captureCharFrame } = await setup() - await mockInput.typeText('?') - await settle() - expect(topRow(captureCharFrame)).toContain('?') - renderer.destroy() -}) - test('overlay adopts the breadcrumb bg when the breadcrumb is showing', async () => { const long = ['# Title', '', ...Array.from({ length: 40 }, (_, i) => `filler ${i}\n`)].join('\n') const { renderer, mockInput, settle, captureCharFrame, captureSpans } = await setup(long) diff --git a/src/app/components/SearchBar.tsx b/src/app/components/SearchBar.tsx index 2c7d8b0..a84442d 100644 --- a/src/app/components/SearchBar.tsx +++ b/src/app/components/SearchBar.tsx @@ -36,7 +36,7 @@ export function SearchBar({ toc, fileLabel }: { toc: TocEntry[]; fileLabel?: str }).length > 0 const surfaceBg = breadcrumbShowing ? theme.stickyBg : theme.background const bg = isMiss ? theme.searchBarNoMatchBg : surfaceBg - const label = search.dir === 'forward' ? '/' : '?' + const label = '/' const counter = hasPattern ? `${search.matches.length ? search.index + 1 : 0} of ${search.matches.length}` : '' diff --git a/src/app/components/StickyHeader.back.test.tsx b/src/app/components/StickyHeader.back.test.tsx index 20462a7..3f1a6c2 100644 --- a/src/app/components/StickyHeader.back.test.tsx +++ b/src/app/components/StickyHeader.back.test.tsx @@ -23,6 +23,7 @@ function makeStub(overrides: Partial = {}): AppState { historyDepth: 0, contentMaxWidth: 80, status: { kind: 'idle' }, + helpVisible: false, ...overrides, } } diff --git a/src/app/components/StickyHeader.crumb.test.tsx b/src/app/components/StickyHeader.crumb.test.tsx index cf231ac..70d7e74 100644 --- a/src/app/components/StickyHeader.crumb.test.tsx +++ b/src/app/components/StickyHeader.crumb.test.tsx @@ -23,6 +23,7 @@ function makeStub(overrides: Partial = {}): AppState { historyDepth: 0, contentMaxWidth: 80, status: { kind: 'idle' }, + helpVisible: false, ...overrides, } } diff --git a/src/app/components/Viewer.tsx b/src/app/components/Viewer.tsx index 1835513..409e581 100644 --- a/src/app/components/Viewer.tsx +++ b/src/app/components/Viewer.tsx @@ -177,11 +177,10 @@ export function Viewer({ getGeometry: () => geom, getScrollMarks: ({ matches, activeIndex }) => resolveScrollMarks(geom, tailRef.current, projectionsRef.current, { matches, activeIndex }), - seedMatchIndex: ({ matches, dir }) => + seedMatchIndex: ({ matches }) => seedMatchIndex({ matchYs: matches.map(m => resolveMatchY(geom, m, projectionsRef.current)), viewportTop: geom.viewportTop, - dir, }), jumpToMatch: params => { pendingRef.current = applyMatch(params) ? null : { kind: 'match', params } diff --git a/src/app/lib/commands.test.ts b/src/app/lib/commands.test.ts index a5c9bbf..908b7c8 100644 --- a/src/app/lib/commands.test.ts +++ b/src/app/lib/commands.test.ts @@ -78,6 +78,7 @@ function makeDeps( expanded: mock(), toggleMouse: mock(), toggleTocVisible: mock(), + toggleHelp: mock(), toggleExpanded: mock(), } const deps: CommandDeps = { @@ -266,7 +267,7 @@ describe('createCommands.clearSearch', () => { const { deps, set } = makeDeps({ read: { focus: 'search', - search: { pattern: 'x', matches: [], index: -1, dir: 'forward', committed: true }, + search: { pattern: 'x', matches: [], index: -1, committed: true }, }, }) createCommands(deps).clearSearch() @@ -277,7 +278,7 @@ describe('createCommands.clearSearch', () => { const { deps, set } = makeDeps({ read: { focus: 'viewer', - search: { pattern: 'x', matches: [], index: -1, dir: 'forward', committed: true }, + search: { pattern: 'x', matches: [], index: -1, committed: true }, }, }) createCommands(deps).clearSearch() @@ -289,9 +290,9 @@ describe('createCommands.clearSearch', () => { describe('createCommands.startSearch', () => { test('opens an empty uncommitted search and focuses the input', () => { const { deps, set } = makeDeps() - createCommands(deps).startSearch('backward') + createCommands(deps).startSearch() expect(set.search).toHaveBeenCalledWith( - expect.objectContaining({ dir: 'backward', committed: false, pattern: '' }), + expect.objectContaining({ committed: false, pattern: '', index: -1 }), ) expect(set.focus).toHaveBeenCalledWith('search') }) @@ -305,7 +306,6 @@ describe('createCommands.stepMatch', () => { pattern: 'x', matches: [m(), m(), m()], index: 2, - dir: 'forward', committed: true, }, }, @@ -320,7 +320,6 @@ describe('createCommands.stepMatch', () => { pattern: 'x', matches: [m(), m(), m()], index: 0, - dir: 'forward', committed: true, }, }, @@ -333,7 +332,7 @@ describe('createCommands.stepMatch', () => { describe('createCommands.applySearchPattern', () => { test('sets matches + seeds index; commit moves focus to viewer', () => { const { deps, set } = makeDeps({ - read: { search: { pattern: '', matches: [], index: -1, dir: 'forward', committed: false } }, + read: { search: { pattern: '', matches: [], index: -1, committed: false } }, }) createCommands(deps).applySearchPattern({ pattern: 'x', commit: true }) expect(set.search).toHaveBeenCalled() diff --git a/src/app/lib/commands.ts b/src/app/lib/commands.ts index bec8e63..465214b 100644 --- a/src/app/lib/commands.ts +++ b/src/app/lib/commands.ts @@ -31,6 +31,7 @@ export type CommandDeps = { expanded: (m: Map) => void toggleMouse: () => void toggleTocVisible: () => void + toggleHelp: () => void toggleExpanded: (id: string) => void } onQuit: () => void @@ -54,7 +55,8 @@ export type Commands = { toggleCursorExpanded(): void toggleExpanded(id: string): void toggleTocVisible(): void - startSearch(dir: 'forward' | 'backward'): void + toggleHelp(): void + startSearch(): void applySearchPattern(p: { pattern: string; commit: boolean }): void stepMatch(dir: 1 | -1): void clearSearch(): void @@ -184,9 +186,10 @@ export function createCommands(deps: CommandDeps): Commands { if (read.tocVisible && read.focus === 'sidebar') set.focus('viewer') set.toggleTocVisible() }, + toggleHelp: () => set.toggleHelp(), - startSearch: dir => { - set.search({ pattern: '', matches: [], index: -1, dir, committed: false }) + startSearch: () => { + set.search({ pattern: '', matches: [], index: -1, committed: false }) set.focus('search') }, // Recompute matches from the passed `pattern`, not `read.search.pattern`: the @@ -196,9 +199,7 @@ export function createCommands(deps: CommandDeps): Commands { const s = read.search if (!s) return const matches = findMatches(doc.nodes, pattern) - const index = matches.length - ? (viewerRef.current?.seedMatchIndex({ matches, dir: s.dir }) ?? 0) - : -1 + const index = matches.length ? (viewerRef.current?.seedMatchIndex({ matches }) ?? 0) : -1 set.search({ ...s, pattern, matches, index, committed: commit }) if (commit) set.focus('viewer') }, @@ -275,6 +276,7 @@ export function createNoopCommands(): Commands { toggleCursorExpanded: noop, toggleExpanded: noop, toggleTocVisible: noop, + toggleHelp: noop, startSearch: noop, applySearchPattern: noop, stepMatch: noop, diff --git a/src/app/lib/dispatch.test.ts b/src/app/lib/dispatch.test.ts index fc71e98..e0777e1 100644 --- a/src/app/lib/dispatch.test.ts +++ b/src/app/lib/dispatch.test.ts @@ -19,6 +19,7 @@ function makeCommands(): Commands { toggleCursorExpanded: mock(), toggleExpanded: mock(), toggleTocVisible: mock(), + toggleHelp: mock(), startSearch: mock(), applySearchPattern: mock(), stepMatch: mock(), @@ -107,10 +108,10 @@ describe('dispatch routing', () => { dispatch({ kind: 'quit' }, c) expect(c.quit).toHaveBeenCalled() }) - test('startSearch → startSearch(dir)', () => { + test('startSearch → startSearch()', () => { const c = makeCommands() - dispatch({ kind: 'startSearch', dir: 'forward' }, c) - expect(c.startSearch).toHaveBeenCalledWith('forward') + dispatch({ kind: 'startSearch' }, c) + expect(c.startSearch).toHaveBeenCalled() }) test('nextMatch/prevMatch → stepMatch(±1)', () => { const c = makeCommands() @@ -144,6 +145,11 @@ describe('dispatch routing', () => { dispatch({ kind: 'toggleTocVisible' }, c) expect(c.toggleTocVisible).toHaveBeenCalled() }) + test('toggleHelp → toggleHelp()', () => { + const c = makeCommands() + dispatch({ kind: 'toggleHelp' }, c) + expect(c.toggleHelp).toHaveBeenCalled() + }) test('noop → nothing', () => { const c = makeCommands() dispatch({ kind: 'noop' }, c) diff --git a/src/app/lib/dispatch.ts b/src/app/lib/dispatch.ts index 1d4e1fb..1b16508 100644 --- a/src/app/lib/dispatch.ts +++ b/src/app/lib/dispatch.ts @@ -36,7 +36,7 @@ export function dispatch(action: Action, c: Commands): void { case 'tocToggleId': return c.toggleExpanded(action.id) case 'startSearch': - return c.startSearch(action.dir) + return c.startSearch() case 'nextMatch': return c.stepMatch(1) case 'prevMatch': @@ -51,6 +51,8 @@ export function dispatch(action: Action, c: Commands): void { return c.goBack() case 'toggleTocVisible': return c.toggleTocVisible() + case 'toggleHelp': + return c.toggleHelp() case 'noop': return } diff --git a/src/app/lib/help-layout.test.ts b/src/app/lib/help-layout.test.ts new file mode 100644 index 0000000..f063a53 --- /dev/null +++ b/src/app/lib/help-layout.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { groupHints, layoutColumns, GROUP_ORDER } from './help-layout' +import type { HintSection } from './help-layout' +import { HINTS } from './keys' +import type { HintGroup } from './keys' + +describe('groupHints', () => { + test('orders sections canonically and drops empty groups', () => { + const sections = groupHints(HINTS) + expect(sections.map(s => s.group)).toEqual(GROUP_ORDER) + for (const s of sections) expect(s.hints.length).toBeGreaterThan(0) + }) +}) + +describe('layoutColumns', () => { + const mk = (group: HintGroup, n: number): HintSection => ({ + group, + hints: Array.from({ length: n }, (_, i) => ({ + keys: `k${i}`, + desc: 'd', + group, + focus: 'viewer', + probes: [], + })), + }) + + test('single column when two columns are disallowed (narrow terminal)', () => { + expect(layoutColumns(groupHints(HINTS), false)).toHaveLength(1) + }) + test('splits into two columns when allowed, placing every section', () => { + const sections = groupHints(HINTS) + const cols = layoutColumns(sections, true) + expect(cols).toHaveLength(2) + const placed = cols.reduce((n, col) => n + col.length, 0) + expect(placed).toBe(sections.length) + }) + test('stays single column when there is only one section', () => { + expect(layoutColumns([mk('Navigation', 4)], true)).toHaveLength(1) + }) + test('balances the columns by row count, keeping canonical order', () => { + // rows: Nav=1+3=4, Search=1+3=4, General=1+2=3 → total 11, target 6. + const sections = [mk('Navigation', 3), mk('Search', 3), mk('General', 2)] + const cols = layoutColumns(sections, true) + expect(cols[0]?.map(s => s.group)).toEqual(['Navigation', 'Search']) + expect(cols[1]?.map(s => s.group)).toEqual(['General']) + expect(cols.flat().map(s => s.group)).toEqual(['Navigation', 'Search', 'General']) + }) +}) diff --git a/src/app/lib/help-layout.ts b/src/app/lib/help-layout.ts new file mode 100644 index 0000000..83380e8 --- /dev/null +++ b/src/app/lib/help-layout.ts @@ -0,0 +1,42 @@ +import type { Hint, HintGroup } from './keys' + +export const GROUP_ORDER: HintGroup[] = ['General', 'Navigation', 'Search', 'TOC & Sidebar'] + +export type HintSection = { group: HintGroup; hints: Hint[] } + +/** Groups hints in canonical order, dropping groups with no hints. */ +export function groupHints(hints: Hint[]): HintSection[] { + return GROUP_ORDER.map(group => ({ + group, + hints: hints.filter(h => h.group === group), + })).filter(s => s.hints.length > 0) +} + +/** + * Lays the sections out in one or two columns (kept whole, canonical order). Uses + * two balanced columns whenever `twoColumns` is set and there are at least two + * sections — so a wide terminal fills its horizontal space instead of running one + * tall column. Otherwise a single column. The split fills the left column to + * roughly half the total rows, then the rest go right. + */ +export function layoutColumns(sections: HintSection[], twoColumns: boolean): HintSection[][] { + if (!twoColumns || sections.length < 2) return [sections] + + const total = sections.reduce((n, s) => n + sectionRows(s), 0) + const target = Math.ceil(total / 2) + const left: HintSection[] = [] + const right: HintSection[] = [] + let used = 0 + for (const s of sections) { + if (right.length === 0 && used < target) { + left.push(s) + used += sectionRows(s) + } else { + right.push(s) + } + } + return right.length > 0 ? [left, right] : [left] +} + +// A section draws one label row plus one row per hint. +const sectionRows = (s: HintSection): number => 1 + s.hints.length diff --git a/src/app/lib/keys.test.ts b/src/app/lib/keys.test.ts index 82844fa..d0bbedf 100644 --- a/src/app/lib/keys.test.ts +++ b/src/app/lib/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { mapKey } from './keys' +import { mapKey, HINTS } from './keys' import type { KeyEvent } from '@opentui/core' const k = (over: Partial = {}): KeyEvent => @@ -31,8 +31,8 @@ describe('mapKey (viewer focus)', () => { test('tab -> focusSidebar', () => { expect(mapKey(k({ name: 'tab' }), 'viewer')).toEqual({ kind: 'focusSidebar' }) }) - test('/ -> startSearch forward', () => { - expect(mapKey(k({ name: '/' }), 'viewer')).toEqual({ kind: 'startSearch', dir: 'forward' }) + test('/ -> startSearch', () => { + expect(mapKey(k({ name: '/' }), 'viewer')).toEqual({ kind: 'startSearch' }) }) test('n -> nextMatch when search active, nextHeading otherwise', () => { expect(mapKey(k({ name: 'n' }), 'viewer', { searchActive: true })).toEqual({ @@ -65,6 +65,9 @@ describe('mapKey (viewer focus)', () => { test('backspace -> goBack', () => { expect(mapKey(k({ name: 'backspace' }), 'viewer')).toEqual({ kind: 'goBack' }) }) + test('? -> toggleHelp (viewer)', () => { + expect(mapKey(k({ name: '?' }), 'viewer')).toEqual({ kind: 'toggleHelp' }) + }) }) describe('mapKey (sidebar focus)', () => { @@ -83,4 +86,39 @@ describe('mapKey (sidebar focus)', () => { test('e -> noop (not bound in sidebar)', () => { expect(mapKey(k({ name: 'e' }), 'sidebar')).toEqual({ kind: 'noop' }) }) + test('? -> toggleHelp (sidebar)', () => { + expect(mapKey(k({ name: '?' }), 'sidebar')).toEqual({ kind: 'toggleHelp' }) + }) +}) + +describe('mapKey (help open)', () => { + test('arbitrary key -> noop', () => { + expect(mapKey(k({ name: 'j' }), 'viewer', { helpOpen: true })).toEqual({ kind: 'noop' }) + }) + test('escape -> toggleHelp', () => { + expect(mapKey(k({ name: 'escape' }), 'viewer', { helpOpen: true })).toEqual({ + kind: 'toggleHelp', + }) + }) + test('? -> toggleHelp', () => { + expect(mapKey(k({ name: '?' }), 'viewer', { helpOpen: true })).toEqual({ kind: 'toggleHelp' }) + }) + test('ctrl-c -> quit even when help open', () => { + expect(mapKey(k({ name: 'c', ctrl: true }), 'viewer', { helpOpen: true })).toEqual({ + kind: 'quit', + }) + }) + test('q -> quit when help open', () => { + expect(mapKey(k({ name: 'q' }), 'viewer', { helpOpen: true })).toEqual({ kind: 'quit' }) + }) +}) + +describe('HINTS stay in sync with mapKey', () => { + for (const h of HINTS) { + for (const p of h.probes) { + test(`"${h.keys}" (${h.group}/${h.focus}) ${JSON.stringify(p.ev)} maps to ${p.action}`, () => { + expect(mapKey(k(p.ev), h.focus, p.ctx).kind).toBe(p.action) + }) + } + } }) diff --git a/src/app/lib/keys.ts b/src/app/lib/keys.ts index 737fe00..006833e 100644 --- a/src/app/lib/keys.ts +++ b/src/app/lib/keys.ts @@ -20,23 +20,33 @@ export type Action = | { kind: 'tocUp' } | { kind: 'tocDown' } | { kind: 'toggleTocVisible' } - | { kind: 'startSearch'; dir: 'forward' | 'backward' } + | { kind: 'startSearch' } | { kind: 'nextMatch' } | { kind: 'prevMatch' } | { kind: 'clearSearch' } | { kind: 'toggleMouse' } | { kind: 'openEditor' } | { kind: 'goBack' } + | { kind: 'toggleHelp' } | { kind: 'noop' } -export type Ctx = { searchActive?: boolean } +export type Ctx = { searchActive?: boolean; helpOpen?: boolean } export function mapKey(ev: KeyEvent, focus: Focus, ctx: Ctx = {}): Action { if (ev.name === 'c' && ev.ctrl) return { kind: 'quit' } + if (ctx.helpOpen) return mapHelpOpen(ev) if (focus === 'sidebar') return mapSidebar(ev) return mapViewer(ev, ctx) } +// While the help panel is modal, only its close keys and quit act; everything +// else is swallowed so keystrokes don't scroll the masked content behind it. +function mapHelpOpen(ev: KeyEvent): Action { + if (ev.name === '?' || ev.name === 'escape') return { kind: 'toggleHelp' } + if (ev.name === 'q') return { kind: 'quit' } + return { kind: 'noop' } +} + function mapViewer(ev: KeyEvent, ctx: Ctx): Action { switch (ev.name) { case 'q': @@ -68,9 +78,9 @@ function mapViewer(ev: KeyEvent, ctx: Ctx): Action { case 't': return { kind: 'toggleTocVisible' } case '/': - return { kind: 'startSearch', dir: 'forward' } + return { kind: 'startSearch' } case '?': - return { kind: 'startSearch', dir: 'backward' } + return { kind: 'toggleHelp' } case 'n': if (ev.shift) return ctx.searchActive ? { kind: 'prevMatch' } : { kind: 'prevHeading' } return ctx.searchActive ? { kind: 'nextMatch' } : { kind: 'nextHeading' } @@ -103,7 +113,170 @@ function mapSidebar(ev: KeyEvent): Action { return { kind: 'toggleTocVisible' } case 'escape': return { kind: 'focusViewer' } + case '?': + return { kind: 'toggleHelp' } default: return { kind: 'noop' } } } + +export type HintGroup = 'Navigation' | 'Search' | 'TOC & Sidebar' | 'General' + +export type HintProbe = { ev: Partial; ctx?: Ctx; action: Action['kind'] } + +/** + * One documented shortcut. `probes` lets a test assert that every key in the + * displayed `keys` string still maps to the Action the help claims (drift guard): + * one probe per key, each a partial KeyEvent the test's `k()` helper completes. + */ +export type Hint = { + keys: string + desc: string + group: HintGroup + focus: Focus + probes: HintProbe[] +} + +export const HINTS: Hint[] = [ + // Navigation + { + keys: 'j / k', + desc: 'Scroll line', + group: 'Navigation', + focus: 'viewer', + probes: [ + { ev: { name: 'j' }, action: 'scrollLine' }, + { ev: { name: 'k' }, action: 'scrollLine' }, + ], + }, + { + keys: 'd / u', + desc: 'Half page', + group: 'Navigation', + focus: 'viewer', + probes: [ + { ev: { name: 'd' }, action: 'scrollHalf' }, + { ev: { name: 'u' }, action: 'scrollHalf' }, + ], + }, + { + keys: 'Space / b', + desc: 'Page down / up', + group: 'Navigation', + focus: 'viewer', + probes: [ + { ev: { name: 'space' }, action: 'scrollPage' }, + { ev: { name: 'b' }, action: 'scrollPage' }, + ], + }, + { + keys: 'g / G', + desc: 'Top / bottom', + group: 'Navigation', + focus: 'viewer', + probes: [ + { ev: { name: 'g' }, action: 'top' }, + { ev: { name: 'g', shift: true }, action: 'bottom' }, + ], + }, + { + keys: 'n / N', + desc: 'Next / prev heading', + group: 'Navigation', + focus: 'viewer', + probes: [ + { ev: { name: 'n' }, ctx: { searchActive: false }, action: 'nextHeading' }, + { ev: { name: 'n', shift: true }, ctx: { searchActive: false }, action: 'prevHeading' }, + ], + }, + { + keys: 'Backspace', + desc: 'Back', + group: 'Navigation', + focus: 'viewer', + probes: [{ ev: { name: 'backspace' }, action: 'goBack' }], + }, + // Search + { + keys: '/', + desc: 'Search', + group: 'Search', + focus: 'viewer', + probes: [{ ev: { name: '/' }, action: 'startSearch' }], + }, + { + keys: 'n / N', + desc: 'Next / prev match', + group: 'Search', + focus: 'viewer', + probes: [ + { ev: { name: 'n' }, ctx: { searchActive: true }, action: 'nextMatch' }, + { ev: { name: 'n', shift: true }, ctx: { searchActive: true }, action: 'prevMatch' }, + ], + }, + { + keys: 'Esc', + desc: 'Clear search', + group: 'Search', + focus: 'viewer', + probes: [{ ev: { name: 'escape' }, action: 'clearSearch' }], + }, + // TOC & Sidebar + { + keys: 'Tab', + desc: 'Focus sidebar', + group: 'TOC & Sidebar', + focus: 'viewer', + probes: [{ ev: { name: 'tab' }, action: 'focusSidebar' }], + }, + { + keys: 't', + desc: 'Show / hide sidebar', + group: 'TOC & Sidebar', + focus: 'viewer', + probes: [{ ev: { name: 't' }, action: 'toggleTocVisible' }], + }, + { + keys: 'Space', + desc: 'Expand / collapse', + group: 'TOC & Sidebar', + focus: 'sidebar', + probes: [{ ev: { name: 'space' }, action: 'tocToggle' }], + }, + { + keys: 'Enter', + desc: 'Jump to heading', + group: 'TOC & Sidebar', + focus: 'sidebar', + probes: [{ ev: { name: 'return' }, action: 'tocSelect' }], + }, + // General + { + keys: '?', + desc: 'Toggle this help', + group: 'General', + focus: 'viewer', + probes: [{ ev: { name: '?' }, action: 'toggleHelp' }], + }, + { + keys: 'e', + desc: 'Open in editor', + group: 'General', + focus: 'viewer', + probes: [{ ev: { name: 'e' }, action: 'openEditor' }], + }, + { + keys: 'm', + desc: 'Toggle mouse', + group: 'General', + focus: 'viewer', + probes: [{ ev: { name: 'm' }, action: 'toggleMouse' }], + }, + { + keys: 'q', + desc: 'Quit', + group: 'General', + focus: 'viewer', + probes: [{ ev: { name: 'q' }, action: 'quit' }], + }, +] diff --git a/src/app/lib/match-nav.test.ts b/src/app/lib/match-nav.test.ts index c25511b..4abdc6a 100644 --- a/src/app/lib/match-nav.test.ts +++ b/src/app/lib/match-nav.test.ts @@ -56,28 +56,16 @@ describe('seedMatchIndex', () => { const viewport = { viewportTop: 100 } test('forward picks the first match at or below the viewport top', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [10, 105, 110, 200], dir: 'forward' })).toBe(1) - expect(seedMatchIndex({ ...viewport, matchYs: [10, 100, 200], dir: 'forward' })).toBe(1) + expect(seedMatchIndex({ ...viewport, matchYs: [10, 105, 110, 200] })).toBe(1) + expect(seedMatchIndex({ ...viewport, matchYs: [10, 100, 200] })).toBe(1) }) test('forward wraps to the first match when all matches are above', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [10, 50], dir: 'forward' })).toBe(0) - }) - - test('backward picks the last match above the viewport top', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [10, 50, 130, 200], dir: 'backward' })).toBe(1) - }) - - test('backward ignores visible matches at or below the top', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [10, 105, 110], dir: 'backward' })).toBe(0) - }) - - test('backward wraps to the last match when all matches are below', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [130, 200], dir: 'backward' })).toBe(1) + expect(seedMatchIndex({ ...viewport, matchYs: [10, 50] })).toBe(0) }) test('skips unresolvable match positions', () => { - expect(seedMatchIndex({ ...viewport, matchYs: [null, 105], dir: 'forward' })).toBe(1) + expect(seedMatchIndex({ ...viewport, matchYs: [null, 105] })).toBe(1) }) }) diff --git a/src/app/lib/match-nav.ts b/src/app/lib/match-nav.ts index e3c616e..c09ab41 100644 --- a/src/app/lib/match-nav.ts +++ b/src/app/lib/match-nav.ts @@ -38,27 +38,21 @@ export function matchJumpDelta(params: { } /** - * Seed index for a freshly committed search, less-style: forward takes the - * first match at or below the viewport top (wrapping to the first match); - * backward takes the last match above it (wrapping to the last). `matchYs` - * holds each match's resolved screen line (null when unresolvable), in match order. + * Seed index for a freshly committed search, less-style: the first match at or + * below the viewport top, wrapping to the first match. `matchYs` holds each + * match's resolved screen line (null when unresolvable), in match order. */ export function seedMatchIndex(params: { matchYs: (number | null)[] viewportTop: number - dir: 'forward' | 'backward' }): number { - const { matchYs, viewportTop, dir } = params - let firstAtOrBelow = -1 - let lastAbove = -1 + const { matchYs, viewportTop } = params for (let i = 0; i < matchYs.length; i++) { const y = matchYs[i] if (y === null || y === undefined) continue - if (firstAtOrBelow < 0 && y >= viewportTop) firstAtOrBelow = i - if (y < viewportTop) lastAbove = i + if (y >= viewportTop) return i } - if (dir === 'forward') return firstAtOrBelow >= 0 ? firstAtOrBelow : 0 - return lastAbove >= 0 ? lastAbove : matchYs.length - 1 + return 0 } /** diff --git a/src/app/state.ts b/src/app/state.ts index b747bde..d510b7e 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -57,10 +57,10 @@ export type ScrollboxHandle = { topOffset?: number }) => void /** - * Seed index for a freshly committed search: the nearest match in the search - * direction relative to the viewport top (wrapping). See `seedMatchIndex`. + * Seed index for a freshly committed search: the first match at or below the + * viewport top, wrapping to the first. See `seedMatchIndex`. */ - seedMatchIndex: (params: { matches: Match[]; dir: 'forward' | 'backward' }) => number + seedMatchIndex: (params: { matches: Match[] }) => number /** Registers a callback fired after every vertical scroll change. Returns an unsubscribe. */ subscribeScroll: (cb: () => void) => () => void /** Current vertical scroll offset (content-space top), for history snapshots. */ @@ -71,7 +71,6 @@ export type SearchState = { pattern: string matches: Match[] index: number - dir: 'forward' | 'backward' /** False while the pattern is being typed; true once Enter commits. Only a committed search may scroll the viewer. */ committed: boolean } @@ -113,6 +112,9 @@ export type AppState = { /** Bottom statusline state; idle shows the viewmd badge + filename. */ status: Status + /** Whether the keyboard-shortcuts help panel is open. */ + helpVisible: boolean + commands: Commands }