fix(tui): record view settings take typed values
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Node CI / verify (push) Successful in 3m48s
QRun-IO/ci-policy: Shared Node CI / secrets (push) Successful in 29s
QRun-IO/ci-policy: Shared Node CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / node-4 (push) Successful in 24m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s

The record view's v dialog was a flat filterable list of 15 pre-baked
choices (label width +-4, five child row counts, the collaboration panel
listed three times). It is now a settings form like the grid's View
settings: Columns cycles auto/1/2, Label width (8-60) and Child list rows
(1-50) take typed digits or +/- with Backspace back to the default, one
Collaboration panel row, and Reset to defaults with a confirm. Persistence
keys are unchanged, so saved settings still load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-01 12:42:11 -06:00
1 parent c670509b09
commit 79ba68eea3
5 files changed
+209 -36

No files matched your search

+4 -1
View File
@@ -368,7 +368,10 @@ in parentheses mark the changes made after the candidate.
- The UI's record view keeps its layout when paging record to record: each child list is a box of fixed
height (5 data rows by default, padded when shorter, scrolled inside with "rows 1-5 of 12" when longer),
empty lists still draw their box, fields and columns are placed from the metadata, and the row count is a
per-table view setting (`v`, `childRows`) with a default in `ui.childRows`. An association a section's
per-table view setting (`v`, `childRows`) with a default in `ui.childRows`. The `v` dialog is a settings
form like the grid's View settings: Columns cycles auto, 1, 2; Label width (8-60) and Child list rows (1-50) take typed
digits or +/- and Backspace returns them to auto or the profile default; one Collaboration panel row cycles auto, on,
off; `r` resets with a confirm; `*` marks values changed from the default An association a section's
`childRecordList` widget lists shows once, in the widget's place and under its label (0f7f536).
- The UI's filter dialog reads escaped quotes like core's grammar (213c605); before the redesign a
true/false quick filter could be set to true but not changed from the quick row (067d97c).
+7 -4
View File
@@ -249,9 +249,12 @@ on a list, PgDn/PgUp scroll it a box at a time and Down/Up (`j`/`k`) a row at it
still step records; Enter opens the child under the cursor. A section whose `childRecordList` widget lists
an association (by `manageAssociationName` or the association's `joinName`, as the Next UI binds it) shows
that association there, once, under the section's label and with the widget's columns; other associations
follow the sections, and a child's own child lists come last. The row count is a per-table view setting
(`v`, saved as `childRows` in `ui.views.record:<table>`), defaulting to `ui.childRows` of the profile, else
the global `ui.childRows`, else 5.
follow the sections, and a child's own child lists come last. The record view settings (`v`) are a form
like the grid's View settings, saved per table in `ui.views.record:<table>`: Columns (auto, 1, 2 with Space or
←→), Label width (`labelWidth`, 8 to 60: type digits, `+`/`-` or ←→ step by one, Backspace returns to auto),
Child list rows (`childRows`, 1 to 50, the same keys; Backspace returns to the default, `ui.childRows` of the
profile, else the global `ui.childRows`, else 5), the Collaboration panel when the extension is present, and
Reset to defaults (`r`, with a confirm). `*` marks a value changed from the default; Enter saves, Esc cancels.
Nothing is drawn outside its container: every line is cut to the cells it has (measured in terminal cells,
so wide characters count double) and a cut ends in `…`; dialogs never extend past the screen, and key help
@@ -288,7 +291,7 @@ fit, timeline and the follow state go first, the viewers and the tags last. On t
more a Collaboration panel on the right (36 columns) shows the tags, the latest two comments (author, age,
first line), the open tasks and the next due date, the files, the followers and everyone viewing now with
what they are doing; the strip is then the keys only. The panel is a record view setting (`v`:
Collaboration panel auto, on or off; on shows it whenever the record keeps 60 columns). Presence changes
Collaboration panel row: auto, on or off; on shows it whenever the record keeps 60 columns). Presence changes
redraw the strip and the panel live. Every strip segment and panel row is a click zone that presses its key
(`t` `C` `P` `K` `T` `W`; the panel's heading lists everything, a viewer shows who is here). `a` and F9 list
comments (list, add, reply), follow or stop following, attachments (list, download, upload, delete), the
@@ -0,0 +1,178 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readViewSettings, writeViewSettings, type ViewSettings } from '@qrun-io/qqq-core'
import type { Key } from '../keys'
import { charOf } from '../keys'
import type { AppStore } from '../store'
import { theme } from '../theme'
import type { Overlay } from '../types'
import { KEY_SEP, fitKeyLine, pad, type KeyToken } from '../util/text'
import { overlayId } from './basic'
import { Frame, Line } from './frame'
import { pickRow } from '../mouse/keys'
import { Zone } from '../mouse/zone'
export const LABEL_WIDTH_RANGE = { min: 8, max: 60 }
export const CHILD_ROWS_RANGE = { min: 1, max: 50 }
/** The dialog's key help; a higher `drop` goes first when the width cannot fit all of it in three lines. */
export const RECORD_VIEW_SETTINGS_KEYS: KeyToken[] = [
{ text: 'Space or ←→ changes', drop: 0 },
{ text: 'digits type a number', drop: 1 },
{ text: '+/- step', drop: 1 },
{ text: 'Backspace default', drop: 2 },
{ text: '* changed from the default', drop: 3 },
{ text: 'r reset to defaults', drop: 2 },
{ text: 'Enter saves for this table', drop: 0 },
{ text: 'Esc cancels', drop: 0 },
]
type Kind = 'columns' | 'labelWidth' | 'childRows' | 'collabPanel' | 'reset'
const COLUMNS: Array<1 | 2 | undefined> = [undefined, 1, 2]
const PANELS: Array<'auto' | 'on' | 'off'> = ['auto', 'on', 'off']
/** Clamps a number into a range (rounded to a whole number). */
export function clampTo(n: number, range: { min: number; max: number }): number {
return Math.max(range.min, Math.min(range.max, Math.round(n)))
}
/**
* How a record is shown, saved per table (`record:<table>`): one or two columns of fields, the label width,
* the rows of each child list and the collaboration panel. Numbers take typed digits and +/- steps.
*/
export class RecordViewSettingsOverlay implements Overlay {
readonly id = overlayId('recordview')
view: ViewSettings
row = 0
/** Digits typed into the selected number row, committed (clamped) when the row changes or the dialog saves. */
typed: string | undefined
/**
* @param autoLabelWidth what the label width is when unset (stepping starts there)
* @param defaultChildRows the profile's child list rows
*/
constructor(private store: AppStore, readonly tableLabel: string, readonly key: string, private autoLabelWidth: number, readonly defaultChildRows: number, readonly collab: boolean, private onDone: () => void) {
this.view = { ...readViewSettings(key) }
}
kinds(): Kind[] {
return ['columns', 'labelWidth', 'childRows', ...(this.collab ? (['collabPanel'] as const) : []), 'reset']
}
kind(): Kind {
return this.kinds()[this.row]!
}
/** What each row shows, whether it differs from the default, and what it does. */
rows(): Array<{ kind: Kind; label: string; value: string; changed: boolean; help: string }> {
const typedFor = (k: Kind) => (this.typed !== undefined && this.kind() === k ? `${this.typed}_` : undefined)
const v = this.view
const all = {
columns: { label: 'Columns', value: v.recordColumns ? String(v.recordColumns) : 'auto', changed: v.recordColumns !== undefined, help: 'columns of fields · auto uses two when there is room · Space or ←→ changes' },
labelWidth: { label: 'Label width', value: typedFor('labelWidth') ?? (v.labelWidth !== undefined ? String(v.labelWidth) : 'auto'), changed: v.labelWidth !== undefined, help: `cells for field labels (${LABEL_WIDTH_RANGE.min}-${LABEL_WIDTH_RANGE.max}) · type digits or +/- · Backspace for auto` },
childRows: { label: 'Child list rows', value: typedFor('childRows') ?? (v.childRows !== undefined ? String(v.childRows) : `profile default (${this.defaultChildRows})`), changed: v.childRows !== undefined, help: `data rows in each child list (${CHILD_ROWS_RANGE.min}-${CHILD_ROWS_RANGE.max}) · type digits or +/- · Backspace for the profile default` },
collabPanel: { label: 'Collaboration panel', value: v.collabPanel === 'on' || v.collabPanel === 'off' ? v.collabPanel : 'auto (140+ columns)', changed: v.collabPanel === 'on' || v.collabPanel === 'off', help: 'the side panel of tags, comments, tasks and who is here · Space or ←→ changes' },
reset: { label: 'Reset to defaults', value: '', changed: false, help: 'clears every record view setting of this table · Space or r asks first' },
}
return this.kinds().map((kind) => ({ kind, ...all[kind] }))
}
/** Applies typed digits to the setting they belong to. */
commit(): void {
if (this.typed === undefined) return
const k = this.kind()
const text = this.typed
this.typed = undefined
if (text === '') return
if (k === 'labelWidth') this.view.labelWidth = clampTo(Number(text), LABEL_WIDTH_RANGE)
if (k === 'childRows') this.view.childRows = clampTo(Number(text), CHILD_ROWS_RANGE)
}
move(row: number): void {
this.commit()
this.row = Math.max(0, Math.min(this.kinds().length - 1, row))
}
/** Space and the arrows: cycle a choice, or step a number by one. */
change(delta: number): void {
this.commit()
const k = this.kind()
if (k === 'columns') this.view.recordColumns = cycle(COLUMNS, this.view.recordColumns, delta)
else if (k === 'collabPanel') {
const next = cycle(PANELS, this.view.collabPanel ?? 'auto', delta)
if (next === 'auto') delete this.view.collabPanel
else this.view.collabPanel = next
} else if (k === 'labelWidth') this.view.labelWidth = clampTo((this.view.labelWidth ?? this.autoLabelWidth) + delta, LABEL_WIDTH_RANGE)
else if (k === 'childRows') this.view.childRows = clampTo((this.view.childRows ?? this.defaultChildRows) + delta, CHILD_ROWS_RANGE)
else if (k === 'reset' && delta) this.askReset()
}
askReset(): void {
this.store.confirm(`Reset the record view settings of ${this.tableLabel}?`, () => {
writeViewSettings(this.key, undefined)
this.store.closeOverlay(this)
this.onDone()
})
}
save(): void {
this.commit()
writeViewSettings(this.key, { ...this.view })
this.store.closeOverlay(this)
this.onDone()
}
onKey(key: Key): void {
const ch = charOf(key)
const k = this.kind()
const numeric = k === 'labelWidth' || k === 'childRows'
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.name === 'return' || (key.ctrl && key.name === 's')) return this.save()
if (key.name === 'down') return this.move(this.row + 1)
if (key.name === 'up') return this.move(this.row - 1)
if (key.name === 'right' || key.name === 'space') return this.change(1)
if (key.name === 'left') return this.change(-1)
if (numeric && ch && /^[0-9]$/.test(ch)) {
const next = (this.typed ?? '') + ch
this.typed = next.replace(/^0+(?=\d)/, '').slice(0, 2)
} else if (numeric && key.name === 'backspace') {
if (this.typed) this.typed = this.typed.slice(0, -1)
else {
this.typed = undefined
if (k === 'labelWidth') delete this.view.labelWidth
else delete this.view.childRows
}
} else if (numeric && ch === '+') this.change(1)
else if (numeric && ch === '-') this.change(-1)
else if (ch === 'r') this.askReset()
}
render(sw: number, sh: number) {
const t = theme()
const width = Math.min(sw - 4, 72)
const inner = width - 2
const rows = this.rows()
const keys = fitKeyLine(RECORD_VIEW_SETTINGS_KEYS, inner - 2, 3)
// border 2 + blank 1 + help 1 + key lines
const chrome = 4 + keys.length
return (
<Frame title={`Record view settings · ${this.tableLabel} · ${rows.length} of ${rows.length} settings`} width={width} height={rows.length + chrome} screenWidth={sw} screenHeight={sh}>
{rows.map((r, i) => {
const sel = i === this.row
return <Zone key={r.kind} {...pickRow(() => { this.move(i) }, () => { this.move(i); this.change(1) })}><text flexShrink={0} wrapMode="none" fg={sel ? t.selFg : r.kind === 'reset' ? t.acc : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${r.changed ? '*' : ' '}${pad(r.label, 22)} ${r.value}`, inner)}</text></Zone>
})}
<Line text="" width={inner} />
<Line text={` ${fitKeyLine((rows[this.row]?.help ?? '').split(KEY_SEP), inner - 2, 1)[0] ?? ''}`} width={inner} fg={t.acc} />
{keys.map((line, i) => <Line key={`k${i}`} text={` ${line}`} width={inner} fg={t.dim} />)}
</Frame>
)
}
}
function cycle<T>(list: T[], current: T, delta: number): T {
const i = Math.max(0, list.indexOf(current))
return list[(i + (delta < 0 ? -1 : 1) + list.length) % list.length]!
}
+10 -27
View File
@@ -21,6 +21,7 @@ import type { Key } from '../keys'
import { charOf } from '../keys'
import { openForm, openProcess, openRecord, permits } from '../nav'
import { ChoiceOverlay } from '../overlays/basic'
import { RecordViewSettingsOverlay } from '../overlays/record-view-settings'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
@@ -322,7 +323,7 @@ export class RecordScreen implements Screen {
}
const inner = this.listWidth()
const view = this.view()
const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values({ ...meta.fields, ...Object.fromEntries((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => this.fullMeta?.fields[n]).map((n) => [n, this.fullMeta!.fields[n]!])) }).map((f) => f.label.length + 1)))
const labelWidth = view.labelWidth ?? this.autoLabelWidth()
rows.push({ text: `${rec.recordLabel ?? this.primaryKey}${this.cursor ? ` (${this.cursor.describe()})` : ''}`, fg: t.fg, bold: true })
// collaboration (tags, comments, who is here) lives in the strip above the key line and the side panel, not here
const tables: Record<string, TableMetaLike | undefined> = Object.fromEntries([...this.childMeta].map(([k, v]) => [k, v as unknown as TableMetaLike]))
@@ -808,33 +809,15 @@ export class RecordScreen implements Screen {
return out
}
/** The label width when none is saved: the longest label of the shown fields, 8 to 28 cells. */
autoLabelWidth(): number {
const meta = this.meta
if (!meta) return 20
return Math.min(28, Math.max(8, ...Object.values({ ...meta.fields, ...Object.fromEntries((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => this.fullMeta?.fields[n]).map((n) => [n, this.fullMeta!.fields[n]!])) }).map((f) => f.label.length + 1)))
}
viewSettings(): Overlay | undefined {
const key = `record:${this.tableName}`
const v = readViewSettings(key)
const choices = [
{ label: 'Automatic columns (two when there is room)', value: 'auto' },
{ label: 'One column', value: '1' },
{ label: 'Two columns', value: '2' },
{ label: 'Label width: narrower (-4)', value: 'narrow' },
{ label: 'Label width: wider (+4)', value: 'wide' },
...[3, 5, 8, 10, 15].map((n) => ({ label: `Child list rows: ${n}${v.childRows === n ? ' (current)' : ''}`, value: `rows:${n}` })),
{ label: `Child list rows: profile default (${readChildRows(this.store.status.profile || undefined)})`, value: 'rows:default' },
// the side panel only exists with the collaboration extension
...(this.store.extensions.has('collaboration') ? (['auto', 'on', 'off'] as const).map((p) => ({ label: `Collaboration panel: ${p}${p === 'auto' ? ' (140 columns or more)' : ''}${(v.collabPanel ?? 'auto') === p ? ' (current)' : ''}`, value: `panel:${p}` })) : []),
{ label: 'Reset record view settings', value: 'reset' },
]
return new ChoiceOverlay(this.store, `Record view settings · ${this.meta?.label ?? this.tableName}`, choices, (value) => {
const next = { ...v }
if (value === 'auto') delete next.recordColumns
else if (value === '1' || value === '2') next.recordColumns = Number(value) as 1 | 2
else if (value === 'rows:default') delete next.childRows
else if (value === 'panel:auto') delete next.collabPanel
else if (value.startsWith('panel:')) next.collabPanel = value.slice(6) as 'on' | 'off'
else if (value.startsWith('rows:')) next.childRows = Number(value.slice(5))
else if (value === 'narrow' || value === 'wide') next.labelWidth = Math.max(6, Math.min(40, (v.labelWidth ?? 20) + (value === 'wide' ? 4 : -4)))
writeViewSettings(key, value === 'reset' ? undefined : next)
this.build()
})
return new RecordViewSettingsOverlay(this.store, this.meta?.label ?? this.tableName, `record:${this.tableName}`, this.autoLabelWidth(), readChildRows(this.store.status.profile || undefined), this.store.extensions.has('collaboration'), () => this.build())
}
/** The whole value of the field(s) on the row under the cursor, wrapped in a dialog (field rows cut values with …). */
+10 -4
View File
@@ -14,6 +14,7 @@ import { RecordCollaboration } from '../src/collab'
import type { RecordScreen } from '../src/screens/record'
import { readViewSettings } from '@qrun-io/qqq-core'
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab'
import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings'
import { fakeFetch } from './fake-server'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
@@ -214,13 +215,18 @@ test('record view at 160 columns: the collaboration panel (tags, latest comments
await waitFor(h, () => h!.store.overlays.length > 0)
await escape()
// the view setting (v): off hides the panel at 160 columns, the full strip comes back, and it is saved
h.store.open(screen.viewSettings()!)
await type('panel: off')
const settings = screen.viewSettings() as RecordViewSettingsOverlay
h.store.open(settings)
settings.row = settings.kinds().indexOf('collabPanel')
await press(h, () => h!.setup.mockInput.typeText(' '))
await press(h, () => h!.setup.mockInput.typeText(' '))
await enter()
f = await waitFor(h, (x) => x.includes('Tags: rush, vip (t) · 1 comment (C)') && !x.includes('Viewing now'))
expect(readViewSettings('record:person').collabPanel).toBe('off')
h.store.open(screen.viewSettings()!)
await type('panel: auto')
const again = screen.viewSettings() as RecordViewSettingsOverlay
h.store.open(again)
again.row = again.kinds().indexOf('collabPanel')
await press(h, () => h!.setup.mockInput.typeText(' '))
await enter()
await waitFor(h, (x) => x.includes('Viewing now'))
expect(readViewSettings('record:person').collabPanel).toBeUndefined()