Column resize and fit-to-content #41

Open
opened 2026-11-12 17:26:57 +01:00 by omp-deck · 0 comments
Collaborator

Part of the dynamic-columns work. Depends on #39 (grid layout, cell model, header) and #40 (column state in the query).

Why

With column widths expressed as a single track list and persisted in the query, resizing becomes the one interaction worth real client-side code. Hide and reorder are plain links; wrap is a class toggle. Resize is the remaining gesture, and fit is its natural companion.

Scope

Drag. Handles on the column header cell edges. Dragging writes --log-cols on the list, so the header and every row move together in one frame with no per-element JavaScript — this is the direct payoff of the variable-driven layout. On release, convert the pixel delta to ch and history.replaceState the new cols value. No navigation and no round trip: widths are pure CSS.

Resize model. Metadata columns carry explicit widths; the message absorbs the remainder (minmax(<floor>, 1fr) wrapped, max-content unwrapped). Dragging a metadata edge therefore takes from or gives back to the message.

Floor and clamping. The message floor is a hard safety net — a track that resolves to zero width wraps pre-wrap text one character per line and makes rows hundreds of lines tall. The floor is reachable three ways: a drag, a large fit, and simply narrowing the window. The drag is clamped so a user cannot scroll themselves into it; the floor still has to exist for the other two. When the tracks genuinely do not fit, the list scrolls horizontally even in wrap mode — incoherent but legible, and far better than the alternative.

Fit. Computed server-side. The server has every cell's text at render time and the log is monospace, so a width-aware character count maps exactly to ch — no DOM measurement, no font metrics, no approximation. Use a display-width measure (unicode-width convention) rather than chars().count(): CJK occupies roughly two columns and combining marks none, and a user-supplied field is exactly where that shows up.

fit exists in two forms:

  • A keyword in the query (app:fit), re-resolved on every render, so a column can be declared fit-by-default per view.
  • A double-click gesture on a header edge. The server emits the computed fit width on each header cell (e.g. data-fit), so the gesture applies instantly and replaceStates, with no round trip.

The split falls out naturally: the gesture that is inherently numeric (drag) writes a number, the gesture that means "you decide" (double-click) writes the keyword.

Bounding fit. Each column declares a maximum. fit is max(width) over the column, so one pathological value — a 2KB label from a misconfigured shipper — would otherwise set the width for every row, and in wrap mode would eat the space the message needs. Beyond the cap the cell ellipsises as today. This matters most for fit-by-default columns, where it happens without the user asking.

Under tail, the running max lives in the stream handler for the life of the connection. That is request-scoped state, not the "in-memory user state" invariant 4 forbids — but modelling what is currently in the client's DOM would be, so do not. Consequences to accept: the max only ratchets upward, it still counts rows the client's DOM cap may have evicted, and a reconnect resets it. fit under tail is approximate by nature.

Acceptance

  • Dragging a header edge resizes the column live and the URL reflects it afterwards.
  • The message cannot be driven below its floor by dragging.
  • ?cols=app:fit sizes the column to its widest value, capped by the column's declared maximum.
  • Double-click fits without a page load.
  • Wide characters are measured by display width, not character count.

Out of scope

An entry/scroll anchor in the URL (deferred until time ranges exist). Per-row height hints for content-visibility — deferred with the scale work.

Verification note

The fit computation and width parsing are pure functions and belong in unit tests. The drag behaviour, the clamp and the overflow case are not observable to the current harness and there is no browser in CI; prefer making the hazards unreachable over relying on review.

Part of the dynamic-columns work. **Depends on** #39 (grid layout, cell model, header) and #40 (column state in the query). ## Why With column widths expressed as a single track list and persisted in the query, resizing becomes the one interaction worth real client-side code. Hide and reorder are plain links; wrap is a class toggle. Resize is the remaining gesture, and `fit` is its natural companion. ## Scope **Drag.** Handles on the column header cell edges. Dragging writes `--log-cols` on the list, so the header and every row move together in one frame with no per-element JavaScript — this is the direct payoff of the variable-driven layout. On release, convert the pixel delta to `ch` and `history.replaceState` the new `cols` value. No navigation and no round trip: widths are pure CSS. **Resize model.** Metadata columns carry explicit widths; the message absorbs the remainder (`minmax(<floor>, 1fr)` wrapped, `max-content` unwrapped). Dragging a metadata edge therefore takes from or gives back to the message. **Floor and clamping.** The message floor is a hard safety net — a track that resolves to zero width wraps `pre-wrap` text one character per line and makes rows hundreds of lines tall. The floor is reachable three ways: a drag, a large `fit`, and simply narrowing the window. The drag is clamped so a user cannot scroll themselves into it; the floor still has to exist for the other two. When the tracks genuinely do not fit, the list scrolls horizontally even in wrap mode — incoherent but legible, and far better than the alternative. **Fit.** Computed server-side. The server has every cell's text at render time and the log is monospace, so a width-aware character count maps exactly to `ch` — no DOM measurement, no font metrics, no approximation. Use a display-width measure (`unicode-width` convention) rather than `chars().count()`: CJK occupies roughly two columns and combining marks none, and a user-supplied field is exactly where that shows up. `fit` exists in two forms: - **A keyword in the query** (`app:fit`), re-resolved on every render, so a column can be declared fit-by-default per view. - **A double-click gesture** on a header edge. The server emits the computed fit width on each header cell (e.g. `data-fit`), so the gesture applies instantly and `replaceState`s, with no round trip. The split falls out naturally: the gesture that is inherently numeric (drag) writes a number, the gesture that means "you decide" (double-click) writes the keyword. **Bounding `fit`.** Each column declares a maximum. `fit` is `max(width)` over the column, so one pathological value — a 2KB label from a misconfigured shipper — would otherwise set the width for every row, and in wrap mode would eat the space the message needs. Beyond the cap the cell ellipsises as today. This matters most for fit-by-default columns, where it happens without the user asking. **Under tail**, the running max lives in the stream handler for the life of the connection. That is request-scoped state, not the "in-memory user state" invariant 4 forbids — but modelling what is currently in the client's DOM would be, so do not. Consequences to accept: the max only ratchets upward, it still counts rows the client's DOM cap may have evicted, and a reconnect resets it. `fit` under tail is approximate by nature. ## Acceptance - Dragging a header edge resizes the column live and the URL reflects it afterwards. - The message cannot be driven below its floor by dragging. - `?cols=app:fit` sizes the column to its widest value, capped by the column's declared maximum. - Double-click fits without a page load. - Wide characters are measured by display width, not character count. ## Out of scope An entry/scroll anchor in the URL (deferred until time ranges exist). Per-row height hints for `content-visibility` — deferred with the scale work. ## Verification note The `fit` computation and width parsing are pure functions and belong in unit tests. The drag behaviour, the clamp and the overflow case are not observable to the current harness and there is no browser in CI; prefer making the hazards unreachable over relying on review.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/logview#41
No description provided.