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
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:
1 parent
c670509b09
commit
79ba68eea3
5 files changed
+209
-36
No files matched your search
+4
-1
@@ -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).
|
||||
|
||||
@@ -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]!
|
||||
}
|
||||
@@ -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 …). */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user