diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82da155..e661a02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,9 +72,10 @@ Heading nodes carry an `id` (slug). The renderer for `Heading` emits a `` (`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: @@ -88,7 +89,8 @@ Layout (rendered tree): ← absolute overlay; top/left 0; zIndex 10 ← scrollbox, contentWidth + overhead - {hasToc && } + {hasToc && } + {isResizing && } ← drag shield ← height 1 @@ -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 `` 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: diff --git a/src/app/App.tsx b/src/app/App.tsx index 8998230..3287d2f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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' @@ -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' @@ -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(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>( @@ -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]) @@ -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, @@ -305,7 +327,7 @@ export function App({ view.search, view.helpVisible, contentWidth, - contentMaxWidth, + effectiveContentMax, nav.doc.dir, nav.historyDepth, trailLabels, @@ -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 ( - + @@ -376,10 +431,35 @@ export function App({ settles. `visible={false}` still frees the column so the viewer reclaims the width. */} {toc.length > 0 && ( - + + )} + {/* 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 && ( + + )} diff --git a/src/app/components/ResizeHandle.test.tsx b/src/app/components/ResizeHandle.test.tsx new file mode 100644 index 0000000..8ffe654 --- /dev/null +++ b/src/app/components/ResizeHandle.test.tsx @@ -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( + , + ) + 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, + settle: () => Promise, + captureCharFrame: () => string, + row: number, + searchFrom: number, +): Promise { + 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() +}) diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx new file mode 100644 index 0000000..836ed11 --- /dev/null +++ b/src/app/components/ResizeHandle.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import type { MouseEvent } from '@opentui/core' +import { useTerminalDimensions } from '@opentui/react' +import { theme } from '../styles/theme' + +type ResizeHandleProps = { + /** Forwards a mousedown on the seam to `App`, which owns start/reset/double-click. */ + onSeamMouseDown: (event: MouseEvent) => void +} + +/** + * Invisible 1-col grab zone overlaid on the content/TOC seam (the sidebar's left + * edge). Absolute positioning keeps it out of flex layout so it costs no column. + * Hover reveals a thin bar so the otherwise-invisible zone is discoverable. + * + * It only forwards the mousedown; `App` owns the resize/reset/double-click logic + * and the drag stream (see the drag shield in `App`). OpenTUI binds drag-capture + * to the hit-target of the *first* drag event, and a real terminal's first motion + * already leaves this 1-col strip — so the handle cannot reliably receive the + * drag events itself. + */ +export function ResizeHandle({ onSeamMouseDown }: ResizeHandleProps) { + const { height: termHeight } = useTerminalDimensions() + const [isHovered, setIsHovered] = useState(false) + + return ( + setIsHovered(true)} + onMouseOut={() => setIsHovered(false)} + > + {/* Text is selectable by default; a mousedown on selectable content + starts a text selection that hijacks the drag stream. Opt out so the + mousedown cleanly starts our resize instead. Width:1 wraps one char + per row, so repeating the glyph fills the column's full height; + overflow beyond the box is clipped. */} + + {(isHovered ? '▏' : ' ').repeat(termHeight)} + + + ) +} diff --git a/src/app/lib/commands.test.ts b/src/app/lib/commands.test.ts index 5f310ea..4d7a5a0 100644 --- a/src/app/lib/commands.test.ts +++ b/src/app/lib/commands.test.ts @@ -91,6 +91,7 @@ function makeDeps( toggleMouse: mock(), toggleTocVisible: mock(), toggleHelp: mock(), + setContentWidthOverride: mock(), } const doc = { nodes: [], toc, headingIds, ...overrides.doc } const viewState: ViewState = { @@ -103,6 +104,7 @@ function makeDeps( tocVisible: true, helpVisible: false, mouseEnabled: false, + contentWidthOverride: null, ...overrides.state, } const deps: CommandDeps = { diff --git a/src/app/lib/sidebar-resize.test.ts b/src/app/lib/sidebar-resize.test.ts new file mode 100644 index 0000000..1801743 --- /dev/null +++ b/src/app/lib/sidebar-resize.test.ts @@ -0,0 +1,37 @@ +import { test, expect } from 'bun:test' +import { contentWidthFromSeamX, isDoubleClick, DOUBLE_CLICK_MS } from './sidebar-resize' +import { VIEWER_OVERHEAD } from '../styles/layout' +import { MIN_CONTENT_WIDTH } from './config' + +test('contentWidthFromSeamX: mid-range width is the seam column minus viewer overhead', () => { + expect(contentWidthFromSeamX({ x: 100, termWidth: 200, tocWidth: 24 })).toBe( + 100 - VIEWER_OVERHEAD, + ) +}) + +test('contentWidthFromSeamX: clamps to MIN_CONTENT_WIDTH when dragged too far left', () => { + expect(contentWidthFromSeamX({ x: 5, termWidth: 200, tocWidth: 24 })).toBe(MIN_CONTENT_WIDTH) +}) + +test('contentWidthFromSeamX: clamps so the auto-width TOC keeps its columns', () => { + // Farthest-right seam leaves exactly termWidth - tocWidth - overhead for content. + expect(contentWidthFromSeamX({ x: 500, termWidth: 200, tocWidth: 24 })).toBe( + 200 - 24 - VIEWER_OVERHEAD, + ) +}) + +test('contentWidthFromSeamX: narrow terminals never invert the clamp', () => { + expect(contentWidthFromSeamX({ x: 5, termWidth: 30, tocWidth: 24 })).toBe(MIN_CONTENT_WIDTH) +}) + +test('isDoubleClick: true when prior down is within the window', () => { + expect(isDoubleClick({ now: 1000, lastDownAt: 1000 - (DOUBLE_CLICK_MS - 1) })).toBe(true) +}) + +test('isDoubleClick: false when prior down is too old', () => { + expect(isDoubleClick({ now: 1000, lastDownAt: 1000 - (DOUBLE_CLICK_MS + 1) })).toBe(false) +}) + +test('isDoubleClick: false when there was no prior down', () => { + expect(isDoubleClick({ now: 1000, lastDownAt: null })).toBe(false) +}) diff --git a/src/app/lib/sidebar-resize.ts b/src/app/lib/sidebar-resize.ts new file mode 100644 index 0000000..873163e --- /dev/null +++ b/src/app/lib/sidebar-resize.ts @@ -0,0 +1,37 @@ +import { VIEWER_OVERHEAD } from '../styles/layout' +import { MIN_CONTENT_WIDTH } from './config' + +/** Two `down` events within this window count as a double-click (reset). */ +export const DOUBLE_CLICK_MS = 400 + +/** + * Content sits on the left starting at column 0; the sidebar seam (and the grab + * handle) is at its right edge, `contentWidth + VIEWER_OVERHEAD`. So the content + * width a drag to absolute column `x` asks for is `x - VIEWER_OVERHEAD`. Clamped + * to keep content >= MIN_CONTENT_WIDTH and leave the auto-width TOC (`tocWidth`) + * its columns; on very narrow terminals the lower bound wins. + */ +export function contentWidthFromSeamX({ + x, + termWidth, + tocWidth, +}: { + x: number + termWidth: number + tocWidth: number +}): number { + const raw = x - VIEWER_OVERHEAD + const max = Math.max(MIN_CONTENT_WIDTH, termWidth - tocWidth - VIEWER_OVERHEAD) + return Math.min(max, Math.max(MIN_CONTENT_WIDTH, raw)) +} + +/** True when `now` follows a recorded prior `down` within DOUBLE_CLICK_MS. */ +export function isDoubleClick({ + now, + lastDownAt, +}: { + now: number + lastDownAt: number | null +}): boolean { + return lastDownAt !== null && now - lastDownAt <= DOUBLE_CLICK_MS +} diff --git a/src/app/lib/view-state.ts b/src/app/lib/view-state.ts index 7ead610..3033ad4 100644 --- a/src/app/lib/view-state.ts +++ b/src/app/lib/view-state.ts @@ -14,6 +14,8 @@ export type ViewState = { tocVisible: boolean helpVisible: boolean mouseEnabled: boolean + /** Manual content max-width override in cols; null uses the configured cap. Session-only. */ + contentWidthOverride: number | null } export type ViewActions = { @@ -27,6 +29,7 @@ export type ViewActions = { toggleMouse: () => void toggleTocVisible: () => void toggleHelp: () => void + setContentWidthOverride: (n: number | null) => void } export function useViewState({ seedVisible }: { seedVisible: Set }): { @@ -43,6 +46,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { tocVisible: true, helpVisible: false, mouseEnabled: false, + contentWidthOverride: null, })) const focus = useCallback((f: Focus) => setState(s => ({ ...s, focus: f })), []) @@ -77,6 +81,10 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { [], ) const toggleHelp = useCallback(() => setState(s => ({ ...s, helpVisible: !s.helpVisible })), []) + const setContentWidthOverride = useCallback( + (n: number | null) => setState(s => ({ ...s, contentWidthOverride: n })), + [], + ) const actions = useMemo( () => ({ @@ -90,6 +98,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, + setContentWidthOverride, }), [ focus, @@ -102,6 +111,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, + setContentWidthOverride, ], )