From 79f0d4efe2df0a521a8b390273d002af0441b485 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 13 Aug 2026 17:44:53 -0400 Subject: [PATCH 1/2] feat(sidebar): drag-resize the TOC with a mouse handle Invisible 1-col grab zone on the sidebar's left edge. Drag sets a session-only width override (view.tocWidthOverride); double-click resets to auto width. A full-screen drag shield owns the drag stream so capture survives the pointer leaving the 1-col handle on the first motion. Width override lives in the view-state store; clamp helpers are pure in sidebar-resize.ts. --- docs/ARCHITECTURE.md | 12 +- src/app/App.tsx | 69 ++++++++- src/app/components/ResizeHandle.test.tsx | 171 +++++++++++++++++++++++ src/app/components/ResizeHandle.tsx | 66 +++++++++ src/app/lib/commands.test.ts | 2 + src/app/lib/sidebar-resize.test.ts | 36 +++++ src/app/lib/sidebar-resize.ts | 28 ++++ src/app/lib/view-state.ts | 10 ++ 8 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 src/app/components/ResizeHandle.test.tsx create mode 100644 src/app/components/ResizeHandle.tsx create mode 100644 src/app/lib/sidebar-resize.test.ts create mode 100644 src/app/lib/sidebar-resize.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82da155..8339609 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,7 +72,8 @@ 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). + - `autoTocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer). + - `tocWidth = tocWidthOverride ?? autoTocWidth` — a session-only `tocWidthOverride` (set by dragging the sidebar's left edge, see §9 TOC) takes precedence 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. - Memoises an `AppState` object into `AppStateContext` so descendants read state via `useAppState()`. @@ -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 sidebar's left edge carries an invisible 1-col `ResizeHandle` (`src/app/components/ResizeHandle.tsx`), absolutely positioned so it costs no column. Its mousedown only _starts_ a resize (or, on a double-click, clears the override back to auto width); hovering reveals a `▏` bar. While resizing, `App` mounts a transparent full-screen **drag shield** (`position=absolute`, `zIndex 1000`) whose `onMouseDrag` sets `view.tocWidthOverride` (`width = termWidth - event.x`, clamped so the sidebar keeps ≥ 16 cols and the viewer ≥ 20 — see `src/app/lib/sidebar-resize.ts`) and whose `onMouseUp`/`onMouseDragEnd` ends the resize. + +Why a full-screen 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. Capturing on a stable, full-screen element makes the whole drag land on one owner regardless of whether the pointer is over the viewer (dragging left) or the TOC (dragging right). The row box carries the same handlers as a fallback for the first event before the shield mounts. 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..bc0fbc9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { dirname, resolve } from 'node:path' import { flushSync, useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react' +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 +23,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 { sidebarWidthFromDragX } 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 +75,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) + const startResize = () => { + isResizingRef.current = true + setIsResizing(true) + } + // 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>( @@ -126,10 +143,12 @@ export function App({ // Size the TOC to its visible content, but never below 16 cols nor above 40% // of the terminal. Measuring only visible rows lets collapsing a wide subtree // shrink the sidebar so the viewer reclaims the freed columns. - const tocWidth = Math.min( + const autoTocWidth = Math.min( Math.floor(termWidth * 0.4), Math.max(16, tocVisibleContentWidth(toc, view.expanded) + TOC_PADDING), ) + // Manual drag width wins until reset (double-click on the handle) or process exit. + const tocWidth = view.tocWidthOverride ?? autoTocWidth const viewerColumnWidth = Math.max( 1, (isTocShown ? termWidth - tocWidth : termWidth) - VIEWER_OVERHEAD, @@ -341,11 +360,28 @@ export function App({ const onAncestorClick = (id: string) => id === FILE_ROW_ID ? dispatchTocAction({ kind: 'top' }) : onEntryJump(id) + const onResizeDrag = (event: MouseEvent) => { + if (!isResizingRef.current) return + event.stopPropagation() + actions.setTocWidthOverride(sidebarWidthFromDragX({ x: event.x, termWidth })) + } + const onResizeEnd = () => { + isResizingRef.current = false + setIsResizing(false) + } + return ( - + @@ -376,10 +412,37 @@ export function App({ settles. `visible={false}` still frees the column so the viewer reclaims the width. */} {toc.length > 0 && ( - + + actions.setTocWidthOverride(null)} + /> )} + {/* 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..c68de51 --- /dev/null +++ b/src/app/components/ResizeHandle.test.tsx @@ -0,0 +1,171 @@ +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 — +// making its column an unambiguous proxy for the TOC pane's left edge. +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') + +async function renderApp() { + const { nodes, toc, headingIds } = buildTree(FIXTURE) + const { renderer, flush, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 80, + 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 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 padding/marker 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. + +test('left-drag from the handle widens the sidebar (left 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 - 8, before.row, MouseButtons.LEFT) + await settle() + + const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + expect(after.col).toBeLessThan(before.col) + + renderer.destroy() +}) + +test('right-drag narrows the sidebar back (left edge moves right)', async () => { + const { renderer, settle, captureCharFrame } = await renderApp() + const mouse = createMockMouse(renderer) + + // Widen first — the fixture's TOC is at the 16-col floor, so there is no room + // to narrow until we grow it. + const start = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + const h0 = await findHandleCol(mouse, settle, captureCharFrame, start.row, start.col - 1) + await mouse.drag(h0, start.row, h0 - 10, start.row, MouseButtons.LEFT) + await settle() + + const widened = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + const h1 = await findHandleCol(mouse, settle, captureCharFrame, widened.row, widened.col - 1) + + // Drag right: the pointer moves into the TOC, a different capture target than + // the widen case — this is what the full-screen drag shield makes reliable. + await mouse.drag(h1, widened.row, h1 + 10, widened.row, MouseButtons.LEFT) + await settle() + + const narrowed = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + expect(narrowed.col).toBeGreaterThan(widened.col) + + renderer.destroy() +}) + +test('double-click on the handle resets the sidebar to auto width', 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 - 8, before.row, MouseButtons.LEFT) + await settle() + + const widened = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + expect(widened.col).toBeLessThan(before.col) + + const newHandleCol = await findHandleCol( + mouse, + settle, + captureCharFrame, + widened.row, + widened.col - 1, + ) + await mouse.doubleClick(newHandleCol, widened.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 - 8, 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..0dcb882 --- /dev/null +++ b/src/app/components/ResizeHandle.tsx @@ -0,0 +1,66 @@ +import { useRef, useState } from 'react' +import { MouseButton } from '@opentui/core' +import type { MouseEvent } from '@opentui/core' +import { useTerminalDimensions } from '@opentui/react' +import { isDoubleClick } from '../lib/sidebar-resize' +import { theme } from '../styles/theme' + +type ResizeHandleProps = { + /** Begin a drag-resize (left mousedown on the handle, not a double-click). */ + onResizeStart: () => void + /** Clear the width override back to auto (double-click on the handle). */ + onReset: () => void +} + +/** + * Invisible 1-col grab zone overlaid on 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. + * + * The handle only STARTS the resize on mousedown; the drag stream itself is + * tracked by a full-width ancestor (see 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. Double-click clears the override back to auto width. + */ +export function ResizeHandle({ onResizeStart, onReset }: ResizeHandleProps) { + const { height: termHeight } = useTerminalDimensions() + const [isHovered, setIsHovered] = useState(false) + const lastDownAtRef = useRef(null) + + const onMouseDown = (event: MouseEvent) => { + if (event.button !== MouseButton.LEFT) return + event.stopPropagation() + const now = Date.now() + if (isDoubleClick({ now, lastDownAt: lastDownAtRef.current })) { + onReset() + lastDownAtRef.current = null + return + } + lastDownAtRef.current = now + onResizeStart() + } + + 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..0bac96b 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(), + setTocWidthOverride: mock(), } const doc = { nodes: [], toc, headingIds, ...overrides.doc } const viewState: ViewState = { @@ -103,6 +104,7 @@ function makeDeps( tocVisible: true, helpVisible: false, mouseEnabled: false, + tocWidthOverride: 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..6bd00a9 --- /dev/null +++ b/src/app/lib/sidebar-resize.test.ts @@ -0,0 +1,36 @@ +import { test, expect } from 'bun:test' +import { + sidebarWidthFromDragX, + isDoubleClick, + MIN_TOC_WIDTH, + MIN_VIEWER_WIDTH, + DOUBLE_CLICK_MS, +} from './sidebar-resize' + +test('sidebarWidthFromDragX: mid-range width is termWidth - x', () => { + expect(sidebarWidthFromDragX({ x: 70, termWidth: 100 })).toBe(30) +}) + +test('sidebarWidthFromDragX: clamps to MIN_TOC_WIDTH when dragged too far right', () => { + expect(sidebarWidthFromDragX({ x: 98, termWidth: 100 })).toBe(MIN_TOC_WIDTH) +}) + +test('sidebarWidthFromDragX: clamps so the viewer keeps MIN_VIEWER_WIDTH cols', () => { + expect(sidebarWidthFromDragX({ x: 1, termWidth: 100 })).toBe(100 - MIN_VIEWER_WIDTH) +}) + +test('sidebarWidthFromDragX: narrow terminals never invert the clamp', () => { + expect(sidebarWidthFromDragX({ x: 5, termWidth: 30 })).toBe(MIN_TOC_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..8371cb4 --- /dev/null +++ b/src/app/lib/sidebar-resize.ts @@ -0,0 +1,28 @@ +/** Minimum sidebar width in columns (matches the existing auto-fit floor). */ +export const MIN_TOC_WIDTH = 16 +/** Columns the Viewer must always retain, capping how wide a manual drag can grow the sidebar. */ +export const MIN_VIEWER_WIDTH = 20 +/** Two `down` events within this window count as a double-click (reset). */ +export const DOUBLE_CLICK_MS = 400 + +/** + * Sidebar sits on the right, so its width is `termWidth - x` where `x` is the + * absolute drag column. Clamped to keep the sidebar >= MIN_TOC_WIDTH and the + * viewer >= MIN_VIEWER_WIDTH. On very narrow terminals the lower bound wins. + */ +export function sidebarWidthFromDragX({ x, termWidth }: { x: number; termWidth: number }): number { + const raw = termWidth - x + const max = Math.max(MIN_TOC_WIDTH, termWidth - MIN_VIEWER_WIDTH) + return Math.min(max, Math.max(MIN_TOC_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..27e1f47 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 sidebar width override in cols; null uses the auto-computed width. Session-only. */ + tocWidthOverride: number | null } export type ViewActions = { @@ -27,6 +29,7 @@ export type ViewActions = { toggleMouse: () => void toggleTocVisible: () => void toggleHelp: () => void + setTocWidthOverride: (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, + tocWidthOverride: 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 setTocWidthOverride = useCallback( + (n: number | null) => setState(s => ({ ...s, tocWidthOverride: n })), + [], + ) const actions = useMemo( () => ({ @@ -90,6 +98,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, + setTocWidthOverride, }), [ focus, @@ -102,6 +111,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, + setTocWidthOverride, ], ) From 1d350b018abe9497f0a4f2f0a28db790e8256ab3 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 13 Aug 2026 17:44:54 -0400 Subject: [PATCH 2/2] chore: resize content width via the content/TOC seam Drag the seam to set a session-only content max-width override, lifting the configured cap so wide terminals reclaim the dead space as readable content in both drag directions. The TOC stays auto-width and rides the seam. Double-click clears the override. App owns the gesture (onSeamDown) so double-click reset works whether the second press lands on the handle or the drag shield; a drag clears the double-click timer so a drag's mousedown never pairs with the next click. --- docs/ARCHITECTURE.md | 10 ++-- src/app/App.tsx | 49 ++++++++++++------ src/app/components/ResizeHandle.test.tsx | 63 +++++++++++------------- src/app/components/ResizeHandle.tsx | 42 +++++----------- src/app/lib/commands.test.ts | 4 +- src/app/lib/sidebar-resize.test.ts | 31 ++++++------ src/app/lib/sidebar-resize.ts | 31 +++++++----- src/app/lib/view-state.ts | 16 +++--- 8 files changed, 125 insertions(+), 121 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8339609..e661a02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,10 +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`: - - `autoTocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer). - - `tocWidth = tocWidthOverride ?? autoTocWidth` — a session-only `tocWidthOverride` (set by dragging the sidebar's left edge, see §9 TOC) takes precedence when non-null; it is not persisted. + - `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: @@ -228,9 +228,9 @@ Width is computed by `tocContentWidth` in `toc-util.ts` (`INDENT_PER_LEVEL * (le ### Drag-resize -The sidebar's left edge carries an invisible 1-col `ResizeHandle` (`src/app/components/ResizeHandle.tsx`), absolutely positioned so it costs no column. Its mousedown only _starts_ a resize (or, on a double-click, clears the override back to auto width); hovering reveals a `▏` bar. While resizing, `App` mounts a transparent full-screen **drag shield** (`position=absolute`, `zIndex 1000`) whose `onMouseDrag` sets `view.tocWidthOverride` (`width = termWidth - event.x`, clamped so the sidebar keeps ≥ 16 cols and the viewer ≥ 20 — see `src/app/lib/sidebar-resize.ts`) and whose `onMouseUp`/`onMouseDragEnd` ends the 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. -Why a full-screen 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. Capturing on a stable, full-screen element makes the whole drag land on one owner regardless of whether the pointer is over the viewer (dragging left) or the TOC (dragging right). The row box carries the same handlers as a fallback for the first event before the shield mounts. The handle's `` sets `selectable={false}` so the mousedown doesn't start a text selection that would hijack the drag. +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`) diff --git a/src/app/App.tsx b/src/app/App.tsx index bc0fbc9..3287d2f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ 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' @@ -24,7 +25,7 @@ import { HelpPanel } from './components/HelpPanel' import { StickyHeader } from './components/StickyHeader' import { StatusLine } from './components/StatusLine' import { ResizeHandle } from './components/ResizeHandle' -import { sidebarWidthFromDragX } from './lib/sidebar-resize' +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' @@ -84,10 +85,10 @@ export function App({ // 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) - const startResize = () => { - isResizingRef.current = true - setIsResizing(true) - } + // 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. @@ -143,17 +144,19 @@ export function App({ // Size the TOC to its visible content, but never below 16 cols nor above 40% // of the terminal. Measuring only visible rows lets collapsing a wide subtree // shrink the sidebar so the viewer reclaims the freed columns. - const autoTocWidth = Math.min( + const tocWidth = Math.min( Math.floor(termWidth * 0.4), Math.max(16, tocVisibleContentWidth(toc, view.expanded) + TOC_PADDING), ) - // Manual drag width wins until reset (double-click on the handle) or process exit. - const tocWidth = view.tocWidthOverride ?? autoTocWidth + // 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]) @@ -309,7 +312,7 @@ export function App({ tocCursorId: view.tocCursorId, search: view.search, contentWidth, - contentMaxWidth, + contentMaxWidth: effectiveContentMax, dir: nav.doc.dir, historyDepth: nav.historyDepth, trailLabels, @@ -324,7 +327,7 @@ export function App({ view.search, view.helpVisible, contentWidth, - contentMaxWidth, + effectiveContentMax, nav.doc.dir, nav.historyDepth, trailLabels, @@ -360,10 +363,26 @@ 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() - actions.setTocWidthOverride(sidebarWidthFromDragX({ x: event.x, termWidth })) + // 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 @@ -420,10 +439,7 @@ export function App({ position="relative" > - actions.setTocWidthOverride(null)} - /> + )} {/* Drag shield: while resizing, a transparent full-screen box on top @@ -438,6 +454,7 @@ export function App({ width="100%" height="100%" zIndex={1000} + onMouseDown={onSeamDown} onMouseDrag={onResizeDrag} onMouseUp={onResizeEnd} onMouseDragEnd={onResizeEnd} diff --git a/src/app/components/ResizeHandle.test.tsx b/src/app/components/ResizeHandle.test.tsx index c68de51..8ffe654 100644 --- a/src/app/components/ResizeHandle.test.tsx +++ b/src/app/components/ResizeHandle.test.tsx @@ -5,8 +5,9 @@ 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 — -// making its column an unambiguous proxy for the TOC pane's left edge. +// 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', '', @@ -23,10 +24,12 @@ const FIXTURE = [ '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: 80, + width: 140, height: 20, }) const settle = async () => { @@ -58,10 +61,10 @@ function findLabelRowCol(lines: string[], label: string): { row: number; col: nu } /** - * The resize handle is a normally-invisible 1-col strip 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 padding/marker widths. + * 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, @@ -82,70 +85,62 @@ async function findHandleCol( // 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. +// survive the pointer immediately leaving the handle's column; the full-screen +// drag shield is what makes that reliable in either direction. -test('left-drag from the handle widens the sidebar (left edge moves left)', async () => { +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 - 8, before.row, MouseButtons.LEFT) + 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) + expect(after.col).toBeGreaterThan(before.col) renderer.destroy() }) -test('right-drag narrows the sidebar back (left edge moves right)', async () => { +test('dragging the seam left shrinks the content (TOC edge moves left)', async () => { const { renderer, settle, captureCharFrame } = await renderApp() const mouse = createMockMouse(renderer) - // Widen first — the fixture's TOC is at the 16-col floor, so there is no room - // to narrow until we grow it. - const start = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') - const h0 = await findHandleCol(mouse, settle, captureCharFrame, start.row, start.col - 1) - await mouse.drag(h0, start.row, h0 - 10, start.row, MouseButtons.LEFT) - await settle() - - const widened = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') - const h1 = await findHandleCol(mouse, settle, captureCharFrame, widened.row, widened.col - 1) + const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1) - // Drag right: the pointer moves into the TOC, a different capture target than - // the widen case — this is what the full-screen drag shield makes reliable. - await mouse.drag(h1, widened.row, h1 + 10, widened.row, MouseButtons.LEFT) + await mouse.drag(handleCol, before.row, handleCol - 12, before.row, MouseButtons.LEFT) await settle() - const narrowed = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') - expect(narrowed.col).toBeGreaterThan(widened.col) + const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + expect(after.col).toBeLessThan(before.col) renderer.destroy() }) -test('double-click on the handle resets the sidebar to auto width', async () => { +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 - 8, before.row, MouseButtons.LEFT) + await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.LEFT) await settle() - const widened = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') - expect(widened.col).toBeLessThan(before.col) + const grown = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') + expect(grown.col).toBeGreaterThan(before.col) const newHandleCol = await findHandleCol( mouse, settle, captureCharFrame, - widened.row, - widened.col - 1, + grown.row, + grown.col - 1, ) - await mouse.doubleClick(newHandleCol, widened.row, MouseButtons.LEFT) + await mouse.doubleClick(newHandleCol, grown.row, MouseButtons.LEFT) await settle() const reset = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') @@ -161,7 +156,7 @@ test('right-button drag does not resize', async () => { 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 - 8, before.row, MouseButtons.RIGHT) + await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.RIGHT) await settle() const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling') diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx index 0dcb882..836ed11 100644 --- a/src/app/components/ResizeHandle.tsx +++ b/src/app/components/ResizeHandle.tsx @@ -1,45 +1,27 @@ -import { useRef, useState } from 'react' -import { MouseButton } from '@opentui/core' +import { useState } from 'react' import type { MouseEvent } from '@opentui/core' import { useTerminalDimensions } from '@opentui/react' -import { isDoubleClick } from '../lib/sidebar-resize' import { theme } from '../styles/theme' type ResizeHandleProps = { - /** Begin a drag-resize (left mousedown on the handle, not a double-click). */ - onResizeStart: () => void - /** Clear the width override back to auto (double-click on the handle). */ - onReset: () => void + /** 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 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. + * 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. * - * The handle only STARTS the resize on mousedown; the drag stream itself is - * tracked by a full-width ancestor (see App). OpenTUI binds drag-capture to the - * hit-target of the *first* drag event, and a real terminal's first motion + * 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. Double-click clears the override back to auto width. + * drag events itself. */ -export function ResizeHandle({ onResizeStart, onReset }: ResizeHandleProps) { +export function ResizeHandle({ onSeamMouseDown }: ResizeHandleProps) { const { height: termHeight } = useTerminalDimensions() const [isHovered, setIsHovered] = useState(false) - const lastDownAtRef = useRef(null) - - const onMouseDown = (event: MouseEvent) => { - if (event.button !== MouseButton.LEFT) return - event.stopPropagation() - const now = Date.now() - if (isDoubleClick({ now, lastDownAt: lastDownAtRef.current })) { - onReset() - lastDownAtRef.current = null - return - } - lastDownAtRef.current = now - onResizeStart() - } return ( setIsHovered(true)} onMouseOut={() => setIsHovered(false)} > diff --git a/src/app/lib/commands.test.ts b/src/app/lib/commands.test.ts index 0bac96b..4d7a5a0 100644 --- a/src/app/lib/commands.test.ts +++ b/src/app/lib/commands.test.ts @@ -91,7 +91,7 @@ function makeDeps( toggleMouse: mock(), toggleTocVisible: mock(), toggleHelp: mock(), - setTocWidthOverride: mock(), + setContentWidthOverride: mock(), } const doc = { nodes: [], toc, headingIds, ...overrides.doc } const viewState: ViewState = { @@ -104,7 +104,7 @@ function makeDeps( tocVisible: true, helpVisible: false, mouseEnabled: false, - tocWidthOverride: null, + 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 index 6bd00a9..1801743 100644 --- a/src/app/lib/sidebar-resize.test.ts +++ b/src/app/lib/sidebar-resize.test.ts @@ -1,26 +1,27 @@ import { test, expect } from 'bun:test' -import { - sidebarWidthFromDragX, - isDoubleClick, - MIN_TOC_WIDTH, - MIN_VIEWER_WIDTH, - DOUBLE_CLICK_MS, -} from './sidebar-resize' +import { contentWidthFromSeamX, isDoubleClick, DOUBLE_CLICK_MS } from './sidebar-resize' +import { VIEWER_OVERHEAD } from '../styles/layout' +import { MIN_CONTENT_WIDTH } from './config' -test('sidebarWidthFromDragX: mid-range width is termWidth - x', () => { - expect(sidebarWidthFromDragX({ x: 70, termWidth: 100 })).toBe(30) +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('sidebarWidthFromDragX: clamps to MIN_TOC_WIDTH when dragged too far right', () => { - expect(sidebarWidthFromDragX({ x: 98, termWidth: 100 })).toBe(MIN_TOC_WIDTH) +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('sidebarWidthFromDragX: clamps so the viewer keeps MIN_VIEWER_WIDTH cols', () => { - expect(sidebarWidthFromDragX({ x: 1, termWidth: 100 })).toBe(100 - MIN_VIEWER_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('sidebarWidthFromDragX: narrow terminals never invert the clamp', () => { - expect(sidebarWidthFromDragX({ x: 5, termWidth: 30 })).toBe(MIN_TOC_WIDTH) +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', () => { diff --git a/src/app/lib/sidebar-resize.ts b/src/app/lib/sidebar-resize.ts index 8371cb4..873163e 100644 --- a/src/app/lib/sidebar-resize.ts +++ b/src/app/lib/sidebar-resize.ts @@ -1,19 +1,28 @@ -/** Minimum sidebar width in columns (matches the existing auto-fit floor). */ -export const MIN_TOC_WIDTH = 16 -/** Columns the Viewer must always retain, capping how wide a manual drag can grow the sidebar. */ -export const MIN_VIEWER_WIDTH = 20 +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 /** - * Sidebar sits on the right, so its width is `termWidth - x` where `x` is the - * absolute drag column. Clamped to keep the sidebar >= MIN_TOC_WIDTH and the - * viewer >= MIN_VIEWER_WIDTH. On very narrow terminals the lower bound wins. + * 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 sidebarWidthFromDragX({ x, termWidth }: { x: number; termWidth: number }): number { - const raw = termWidth - x - const max = Math.max(MIN_TOC_WIDTH, termWidth - MIN_VIEWER_WIDTH) - return Math.min(max, Math.max(MIN_TOC_WIDTH, raw)) +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. */ diff --git a/src/app/lib/view-state.ts b/src/app/lib/view-state.ts index 27e1f47..3033ad4 100644 --- a/src/app/lib/view-state.ts +++ b/src/app/lib/view-state.ts @@ -14,8 +14,8 @@ export type ViewState = { tocVisible: boolean helpVisible: boolean mouseEnabled: boolean - /** Manual sidebar width override in cols; null uses the auto-computed width. Session-only. */ - tocWidthOverride: number | null + /** Manual content max-width override in cols; null uses the configured cap. Session-only. */ + contentWidthOverride: number | null } export type ViewActions = { @@ -29,7 +29,7 @@ export type ViewActions = { toggleMouse: () => void toggleTocVisible: () => void toggleHelp: () => void - setTocWidthOverride: (n: number | null) => void + setContentWidthOverride: (n: number | null) => void } export function useViewState({ seedVisible }: { seedVisible: Set }): { @@ -46,7 +46,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { tocVisible: true, helpVisible: false, mouseEnabled: false, - tocWidthOverride: null, + contentWidthOverride: null, })) const focus = useCallback((f: Focus) => setState(s => ({ ...s, focus: f })), []) @@ -81,8 +81,8 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { [], ) const toggleHelp = useCallback(() => setState(s => ({ ...s, helpVisible: !s.helpVisible })), []) - const setTocWidthOverride = useCallback( - (n: number | null) => setState(s => ({ ...s, tocWidthOverride: n })), + const setContentWidthOverride = useCallback( + (n: number | null) => setState(s => ({ ...s, contentWidthOverride: n })), [], ) @@ -98,7 +98,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, - setTocWidthOverride, + setContentWidthOverride, }), [ focus, @@ -111,7 +111,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): { toggleMouse, toggleTocVisible, toggleHelp, - setTocWidthOverride, + setContentWidthOverride, ], )