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
14 changes: 11 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ Heading nodes carry an `id` (slug). The renderer for `Heading` emits a `<box id=
- Derives `stateRef = useLatest(view)` (`src/app/lib/useLatest.ts`) — a ref updated to the latest `ViewState` on every render — so effectful code can read current state without becoming a render dependency.
- Holds a `useRef<ScrollboxHandle>` (`viewerRef`) for imperative scroll calls — see [Imperative scroll](#imperative-scroll).
- Computes layout each render from `useTerminalDimensions`:
- `tocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer).
- `tocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer). The TOC always auto-sizes to its visible content.
- `effectiveContentMax = contentWidthOverride ?? contentMaxWidth` — a session-only `contentWidthOverride` (set by dragging the content/TOC seam, see §9 TOC) lifts the configured cap when non-null; it is not persisted.
- `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.
- `contentWidth = min(effectiveContentMax, viewerColumnWidth)` — exposed via context so block renderers can size to it. `effectiveContentMax` also flows to context as `contentMaxWidth`, so the Viewer's inner cap lifts with the drag.
- Memoises an `AppState` object into `AppStateContext` so descendants read state via `useAppState()`.
- 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:
Expand All @@ -88,7 +89,8 @@ Layout (rendered tree):
<box flexDirection=row flexGrow=1 overflow=hidden position=relative>
<StickyHeader /> ← absolute overlay; top/left 0; zIndex 10
<Viewer /> ← scrollbox, contentWidth + overhead
{hasToc && <box width=tocWidth><Toc /></box>}
{hasToc && <box width=tocWidth position=relative><Toc /><ResizeHandle /></box>}
{isResizing && <box absolute full-screen zIndex 1000/>} ← drag shield
</box>
<StatusLine /> ← height 1
</box>
Expand Down Expand Up @@ -224,6 +226,12 @@ Jumps (`tocSelect`, `nextHeading`/`prevHeading`) call `scrollChildToTop(id, ance

Width is computed by `tocContentWidth` in `toc-util.ts` (`INDENT_PER_LEVEL * (level-1) + MARKER_WIDTH + inlineVisibleWidth(inline)`), clamped in `App.tsx`.

### Drag-resize

The content/TOC seam carries an invisible 1-col `ResizeHandle` (`src/app/components/ResizeHandle.tsx`), absolutely positioned on the TOC pane's left edge so it costs no column; hovering reveals a `▏` bar. It only forwards its mousedown — `App` (`onSeamDown`) owns the gesture. Dragging sets `view.contentWidthOverride` from the seam column (`contentWidth = event.x - VIEWER_OVERHEAD`, clamped so content keeps ≥ MIN_CONTENT_WIDTH and the auto-width TOC keeps its cols — see `contentWidthFromSeamX` in `src/app/lib/sidebar-resize.ts`). Because the TOC is auto-width and packs immediately right of the content, dragging the seam grows/shrinks the content in both directions and the TOC rides along — reclaiming the dead space a capped content leaves on wide terminals. A double-click on the seam clears the override back to the configured cap.

While resizing, `App` mounts a transparent full-screen **drag shield** (`position=absolute`, `zIndex 1000`) whose `onMouseDrag` drives the resize and whose `onMouseUp`/`onMouseDragEnd` ends it. Why a shield rather than the handle itself: OpenTUI binds drag-**capture** to the hit-target of the _first_ `drag` event (not the `mousedown` target), and a real terminal's first motion has already left the 1-col handle. A stable full-screen owner also absorbs the trailing mouseup of a double-click, so the reset (which shifts the TOC left under the cursor) never lands a stray click on a TOC row. The row box carries the same handlers as a fallback for the first event before the shield mounts. A drag clears the double-click timer so a drag's own mousedown never pairs with the next click. The handle's `<text>` sets `selectable={false}` so the mousedown doesn't start a text selection that would hijack the drag.

## 10. Search (`src/app/lib/search.ts`, `match-nav.ts`, `components/SearchInput.tsx`)

`findMatches(nodes, pattern)` walks the AST and returns `Match[]`, each carrying:
Expand Down
90 changes: 85 additions & 5 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { dirname, resolve } from 'node:path'
import { flushSync, useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
import { MouseButton } from '@opentui/core'
import type { MouseEvent } from '@opentui/core'
import { AppStateContext, HeadingStateContext } from './state'
import type { AppState, HeadingState, ScrollboxHandle, Status } from './state'
import type { Action } from './lib/keys'
Expand All @@ -22,6 +24,8 @@ import { SearchBar } from './components/SearchBar'
import { HelpPanel } from './components/HelpPanel'
import { StickyHeader } from './components/StickyHeader'
import { StatusLine } from './components/StatusLine'
import { ResizeHandle } from './components/ResizeHandle'
import { contentWidthFromSeamX, isDoubleClick } from './lib/sidebar-resize'
import { CONTENT_MAX_WIDTH, VIEWER_OVERHEAD } from './styles/layout'
import { theme } from './styles/theme'
import type { LoadedDocument } from './lib/loadDocument'
Expand Down Expand Up @@ -72,6 +76,20 @@ export function App({
// so the reader never sees it painted at scrollTop 0 before the jump lands.
const [covering, setCovering] = useState(false)

// Sidebar drag-resize. The 1-col handle only fires the mousedown; the drag
// stream is owned by a full-screen shield mounted while `isResizing` (see the
// JSX below). OpenTUI binds drag-capture to the first drag event's hit-target
// and the pointer immediately leaves the 1-col handle, so capturing on a
// stable, full-screen element is the only way to receive the whole drag
// regardless of whether the viewer or the TOC sits under the cursor. The ref
// mirrors the state so the drag handler no-ops if it ever fires outside a resize.
const isResizingRef = useRef(false)
const [isResizing, setIsResizing] = useState(false)
// Tracks the previous seam mousedown so a second within the window resets to
// auto. Owned here, not in the handle, so the reset fires whether the second
// mousedown lands on the handle or on the drag shield mounted over it.
const lastSeamDownAtRef = useRef<number | null>(null)

// At startup the H1 (if any) sits at the top of the viewport — seed it so
// the overlay's hide-when-visible rule fires on the first paint.
const seedVisible = useMemo<Set<string>>(
Expand Down Expand Up @@ -130,11 +148,15 @@ export function App({
Math.floor(termWidth * 0.4),
Math.max(16, tocVisibleContentWidth(toc, view.expanded) + TOC_PADDING),
)
// Dragging the content/TOC seam sets a session-only content max-width override
// (double-click on the handle clears it). It lifts the configured cap so the
// reclaimed columns on wide terminals become readable content, not dead space.
const effectiveContentMax = view.contentWidthOverride ?? contentMaxWidth
const viewerColumnWidth = Math.max(
1,
(isTocShown ? termWidth - tocWidth : termWidth) - VIEWER_OVERHEAD,
)
const contentWidth = Math.min(contentMaxWidth, viewerColumnWidth)
const contentWidth = Math.min(effectiveContentMax, viewerColumnWidth)

// Stable across nav; rebuilt only when the doc (toc/fileLabel) changes.
const fold = useMemo(() => createFold({ toc, fileLabel }), [toc, fileLabel])
Expand Down Expand Up @@ -290,7 +312,7 @@ export function App({
tocCursorId: view.tocCursorId,
search: view.search,
contentWidth,
contentMaxWidth,
contentMaxWidth: effectiveContentMax,
dir: nav.doc.dir,
historyDepth: nav.historyDepth,
trailLabels,
Expand All @@ -305,7 +327,7 @@ export function App({
view.search,
view.helpVisible,
contentWidth,
contentMaxWidth,
effectiveContentMax,
nav.doc.dir,
nav.historyDepth,
trailLabels,
Expand Down Expand Up @@ -341,11 +363,44 @@ export function App({
const onAncestorClick = (id: string) =>
id === FILE_ROW_ID ? dispatchTocAction({ kind: 'top' }) : onEntryJump(id)

const onSeamDown = (event: MouseEvent) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
const now = Date.now()
const isReset = isDoubleClick({ now, lastDownAt: lastSeamDownAtRef.current })
lastSeamDownAtRef.current = isReset ? null : now
if (isReset) actions.setContentWidthOverride(null)
// Mount the shield on every seam press (reset included) so the trailing
// mouseup lands on it, not on a TOC row that the reset just shifted under
// the cursor. onResizeEnd (its mouseup) tears it down.
isResizingRef.current = true
setIsResizing(true)
}
const onResizeDrag = (event: MouseEvent) => {
if (!isResizingRef.current) return
event.stopPropagation()
// A drag ends the double-click chain: without this, the drag's own mousedown
// would pair with the next click and fire a reset one press too early.
lastSeamDownAtRef.current = null
actions.setContentWidthOverride(contentWidthFromSeamX({ x: event.x, termWidth, tocWidth }))
}
const onResizeEnd = () => {
isResizingRef.current = false
setIsResizing(false)
}

return (
<AppStateContext.Provider value={appState}>
<HeadingStateContext.Provider value={headingState}>
<box flexDirection="column" height="100%">
<box flexDirection="row" flexGrow={1} overflow="hidden" position="relative">
<box
flexDirection="row"
flexGrow={1}
overflow="hidden"
position="relative"
onMouseDrag={onResizeDrag}
onMouseUp={onResizeEnd}
>
<StickyHeader toc={toc} fileLabel={fileLabel} onAncestorClick={onAncestorClick} />
<SearchBar toc={toc} fileLabel={fileLabel} />
<HelpPanel />
Expand Down Expand Up @@ -376,10 +431,35 @@ export function App({
settles. `visible={false}` still frees the column so the viewer reclaims
the width. */}
{toc.length > 0 && (
<box width={tocWidth} border={false} visible={isTocShown} flexDirection="column">
<box
width={tocWidth}
border={false}
visible={isTocShown}
flexDirection="column"
position="relative"
>
<Toc toc={toc} onEntryJump={onEntryJump} onEntryToggle={onEntryToggle} />
<ResizeHandle onSeamMouseDown={onSeamDown} />
</box>
)}
{/* Drag shield: while resizing, a transparent full-screen box on top
captures the whole drag so it works regardless of whether the
pointer is over the viewer or the TOC. Without it, OpenTUI binds
the drag to whatever content sits under the cursor at first motion. */}
{isResizing && (
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1000}
onMouseDown={onSeamDown}
onMouseDrag={onResizeDrag}
onMouseUp={onResizeEnd}
onMouseDragEnd={onResizeEnd}
/>
)}
</box>
<StatusLine fileLabel={fileLabel} />
</box>
Expand Down
166 changes: 166 additions & 0 deletions src/app/components/ResizeHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { test, expect } from 'bun:test'
import { createTestRenderer, createMockMouse, MouseButtons } from '@opentui/core/testing'
import { createRoot } from '@opentui/react'
import { App } from '../App'
import { buildTree } from '../lib/ast'

// "Sibling" is pushed well below the initial viewport by filler lines, so it
// only ever appears in the TOC pane — never in the viewer's visible rows. The
// TOC packs immediately right of the content, so its left edge (and thus
// Sibling's column) tracks the content width: a wider content pushes it right.
const FIXTURE = [
'# Title',
'',
...Array.from({ length: 30 }, (_, i) => `filler ${i}`),
'',
'## Parent',
'',
'### Child',
'',
...Array.from({ length: 40 }, (_, i) => `more filler ${i}`),
'',
'## Sibling',
'',
'sibling body text',
].join('\n')

// Wider than CONTENT_MAX_WIDTH (100) so content starts capped with slack on the
// right — the exact wide-terminal case where the seam drag must grow content.
async function renderApp() {
const { nodes, toc, headingIds } = buildTree(FIXTURE)
const { renderer, flush, renderOnce, captureCharFrame } = await createTestRenderer({
width: 140,
height: 20,
})
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={[]}
fileLabel="t/fix.md"
headingLines={{}}
/>,
)
await settle()
return { renderer, settle, captureCharFrame }
}

/** Locates the row/col of the first (and only) occurrence of `label`. */
function findLabelRowCol(lines: string[], label: string): { row: number; col: number } {
for (let row = 0; row < lines.length; row++) {
const col = lines[row]?.indexOf(label) ?? -1
if (col >= 0) return { row, col }
}
throw new Error(`label "${label}" not found in frame`)
}

/**
* The resize handle is a normally-invisible 1-col strip at the content/TOC seam
* that only renders a '▏' glyph while hovered. Sweeping leftward from a known
* TOC-pane column hovers each candidate column until the glyph appears, which
* locates the handle's absolute terminal column without hardcoding widths.
*/
async function findHandleCol(
mouse: ReturnType<typeof createMockMouse>,
settle: () => Promise<void>,
captureCharFrame: () => string,
row: number,
searchFrom: number,
): Promise<number> {
for (let col = searchFrom; col >= 0; col--) {
await mouse.moveTo(col, row)
await settle()
const line = captureCharFrame().split('\n')[row] ?? ''
if (line[col] === '▏') return col
}
throw new Error('resize handle column not found')
}

// Uses the built-in mouse.drag(), whose first interpolated motion event lands
// OFF the 1-col handle — exactly how a real terminal reports a drag. OpenTUI
// binds drag-capture to the first drag event's hit-target, so resize must
// survive the pointer immediately leaving the handle's column; the full-screen
// drag shield is what makes that reliable in either direction.

test('dragging the seam right grows the content (TOC edge moves right)', async () => {
const { renderer, settle, captureCharFrame } = await renderApp()
const mouse = createMockMouse(renderer)

const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)

await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.LEFT)
await settle()

const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
expect(after.col).toBeGreaterThan(before.col)

renderer.destroy()
})

test('dragging the seam left shrinks the content (TOC edge moves left)', async () => {
const { renderer, settle, captureCharFrame } = await renderApp()
const mouse = createMockMouse(renderer)

const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)

await mouse.drag(handleCol, before.row, handleCol - 12, before.row, MouseButtons.LEFT)
await settle()

const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
expect(after.col).toBeLessThan(before.col)

renderer.destroy()
})

test('double-click on the handle resets the content to its cap', async () => {
const { renderer, settle, captureCharFrame } = await renderApp()
const mouse = createMockMouse(renderer)

const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)

await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.LEFT)
await settle()

const grown = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
expect(grown.col).toBeGreaterThan(before.col)

const newHandleCol = await findHandleCol(
mouse,
settle,
captureCharFrame,
grown.row,
grown.col - 1,
)
await mouse.doubleClick(newHandleCol, grown.row, MouseButtons.LEFT)
await settle()

const reset = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
expect(reset.col).toBe(before.col)

renderer.destroy()
})

test('right-button drag does not resize', async () => {
const { renderer, settle, captureCharFrame } = await renderApp()
const mouse = createMockMouse(renderer)

const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)

await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.RIGHT)
await settle()

const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
expect(after.col).toBe(before.col)

renderer.destroy()
})
Loading