Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Heading nodes carry an `id` (slug). The renderer for `Heading` emits a `<box id=
- `viewerColumnWidth = (hasToc ? termWidth - tocWidth : termWidth) - 2` (2 cols for the viewer scrollbar + paddingRight).
- `contentWidth = min(CONTENT_MAX_WIDTH, viewerColumnWidth)` — exposed via context so block renderers can size to it.
- Memoises an `AppState` object into `AppStateContext` so descendants read state via `useAppState()`.
- Wires `useKeyboard` → `mapKey(ev, focus, { searchActive })` → `dispatch(action, state, toc, headingIds, renderer.height, onQuit)`. When `focus === 'search'`, `App` skips dispatch entirely — `SearchInput` owns its own `useKeyboard`.
- Wires `useKeyboard` → `mapKey(ev, focus, { searchActive, helpOpen })` → `dispatch(action, commands)`. When `focus === 'search'`, `App` skips dispatch entirely — `SearchInput` owns its own `useKeyboard`.
- Runs two effects:
- When the search index/pattern changes, jump less-style: scroll the match line to a few context rows (`JUMP_CONTEXT_ROWS`) below the breadcrumb overlay of its nearest preceding heading (`matchScrollTarget` + `jumpToMatch`).
- On first paint (and whenever `headingIds` changes), populate `visibleHeadingIds` once via the viewer handle so the sticky header's hide-when-visible rule fires before any keypress.
Expand Down
10 changes: 9 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { tocVisibleContentWidth, toggleTocExpanded, FILE_ROW_ID } from './lib/to
import { foldOffset } from './lib/heading-resolution'
import { findVisibleHeadingIds } from './lib/viewport-geometry'
import { SearchBar } from './components/SearchBar'
import { HelpPanel } from './components/HelpPanel'
import { StickyHeader } from './components/StickyHeader'
import { StatusLine } from './components/StatusLine'
import { CONTENT_MAX_WIDTH, VIEWER_OVERHEAD } from './styles/layout'
Expand Down Expand Up @@ -71,6 +72,7 @@ export function App({
const [search, setSearch] = useState<SearchState | null>(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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -261,6 +264,7 @@ export function App({
expanded: setExpanded,
toggleMouse,
toggleTocVisible,
toggleHelp,
toggleExpanded,
},
onQuit: () => {
Expand Down Expand Up @@ -294,6 +298,7 @@ export function App({
nav.back,
toggleMouse,
toggleTocVisible,
toggleHelp,
toggleExpanded,
onOpenEditor,
],
Expand All @@ -314,6 +319,7 @@ export function App({
historyDepth: nav.historyDepth,
backLabel,
status,
helpVisible,
commands,
}),
[
Expand All @@ -329,6 +335,7 @@ export function App({
nav.historyDepth,
backLabel,
status,
helpVisible,
commands,
],
)
Expand All @@ -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)
Expand All @@ -366,6 +373,7 @@ export function App({
<box flexDirection="row" flexGrow={1} overflow="hidden" position="relative">
<StickyHeader toc={toc} fileLabel={fileLabel} onCrumbClick={onCrumbClick} />
<SearchBar toc={toc} fileLabel={fileLabel} />
<HelpPanel />
<Viewer
nodes={nodes}
frontmatter={frontmatter}
Expand Down
1 change: 1 addition & 0 deletions src/app/RenderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function RenderView({
historyDepth: 0,
backLabel: undefined,
status: { kind: 'idle' },
helpVisible: false,
commands,
}),
[width, contentMaxWidth, commands],
Expand Down
48 changes: 48 additions & 0 deletions src/app/components/HelpPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from 'bun:test'
import { createTestRenderer } from '@opentui/core/testing'
import { createRoot } from '@opentui/react'
import { App } from '../App'
import { buildTree } from '../lib/ast'

const FIXTURE = ['# Title', '', 'Some body text.', '', '## Section', '', 'More text.'].join('\n')

test('? opens the help panel and ? closes it', async () => {
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(
<App
nodes={nodes}
toc={toc}
headingIds={headingIds}
frontmatter={[]}
headingLines={{}}
fileLabel="t/fix.md"
/>,
)
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()
})
70 changes: 70 additions & 0 deletions src/app/components/HelpPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<box
position="absolute"
bottom={0}
left={0}
width={width}
zIndex={20}
flexDirection="column"
backgroundColor={theme.background}
paddingBottom={1}
>
<box flexDirection="row">
<text fg={theme.border}>{`${RULE_GLYPH} `}</text>
<text fg={theme.heading} attributes={TextAttributes.BOLD}>
{HELP_TITLE}
</text>
<text fg={theme.border}>{` ${rule}`}</text>
</box>
<box flexDirection="row" paddingTop={1}>
{columns.map(col => (
<box key={col[0]?.group} flexDirection="column" flexGrow={1} paddingX={1}>
{col.map((section, i) => (
<box key={section.group} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
<text fg={theme.foregroundMuted} attributes={TextAttributes.BOLD}>
{section.group}
</text>
{section.hints.map(h => (
<box key={`${section.group}:${h.keys}:${h.desc}`} flexDirection="row">
<text fg={theme.heading}>{h.keys.padEnd(KEY_COL)}</text>
<text fg={theme.foreground}>{h.desc}</text>
</box>
))}
</box>
))}
</box>
))}
</box>
</box>
)
}
8 changes: 0 additions & 8 deletions src/app/components/SearchBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
: ''
Expand Down
1 change: 1 addition & 0 deletions src/app/components/StickyHeader.back.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeStub(overrides: Partial<AppState> = {}): AppState {
historyDepth: 0,
contentMaxWidth: 80,
status: { kind: 'idle' },
helpVisible: false,
...overrides,
}
}
Expand Down
1 change: 1 addition & 0 deletions src/app/components/StickyHeader.crumb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeStub(overrides: Partial<AppState> = {}): AppState {
historyDepth: 0,
contentMaxWidth: 80,
status: { kind: 'idle' },
helpVisible: false,
...overrides,
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/app/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
13 changes: 6 additions & 7 deletions src/app/lib/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function makeDeps(
expanded: mock(),
toggleMouse: mock(),
toggleTocVisible: mock(),
toggleHelp: mock(),
toggleExpanded: mock(),
}
const deps: CommandDeps = {
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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')
})
Expand All @@ -305,7 +306,6 @@ describe('createCommands.stepMatch', () => {
pattern: 'x',
matches: [m(), m(), m()],
index: 2,
dir: 'forward',
committed: true,
},
},
Expand All @@ -320,7 +320,6 @@ describe('createCommands.stepMatch', () => {
pattern: 'x',
matches: [m(), m(), m()],
index: 0,
dir: 'forward',
committed: true,
},
},
Expand All @@ -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()
Expand Down
14 changes: 8 additions & 6 deletions src/app/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type CommandDeps = {
expanded: (m: Map<string, boolean>) => void
toggleMouse: () => void
toggleTocVisible: () => void
toggleHelp: () => void
toggleExpanded: (id: string) => void
}
onQuit: () => void
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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')
},
Expand Down Expand Up @@ -275,6 +276,7 @@ export function createNoopCommands(): Commands {
toggleCursorExpanded: noop,
toggleExpanded: noop,
toggleTocVisible: noop,
toggleHelp: noop,
startSearch: noop,
applySearchPattern: noop,
stepMatch: noop,
Expand Down
Loading