Skip to content

plan: composer wrap — finite max_size_content.w so long lines wrap instead of h-scrolling #706

Description

@btipling

Status: IMPLEMENTED

Source issue: #702
Layers: Harness Wasm only (no Vercel backend, no DOM, no protocol bump)


Summary

The composer textEntry uses max_size_content.w = dvui.max_float_safe (2,000,000 px — effectively unbounded) so a 0 width + 2×COMPOSER_TE_PAD bake would not cap the internal layout at 10 px. But max_float_safe lets the internal TextLayout believe it has infinite width, so break_lines = true never fires — the widget h-scrolls instead of wrapping.

The fix: replace max_float_safe with a finite content width computed from the available viewport width (avail.w).


Baseline (live main at plan-review)

Symbol File:line Value
Composer textEntry max_size_content.w ui.zig:696 dvui.max_float_safe = 2,000,000
break_lines ui.zig:686 true
multiline ui.zig:685 true
Composer chrome box ui.zig:595-602 .expand = .horizontal, dir horizontal
Icon button width ui.zig:752 TOUCH_H = 40 px
textEntry margin.w ui.zig:700 8 px
avail viewport size ui.zig:156 root.data().contentRect().justSize()
COMPOSER_TE_PAD metrics.zig:22 5 px
Queue row multiline queue_band.zig:165 false (out of scope)
COMPOSER_INPUT_MAX_H metrics.zig:28 120 px

Design

Root cause

max_size_content.w = max_float_safe makes the TextEntryWidget negotiate an effectively unbounded max width. Inside, the ScrollAreaWidget → TextLayoutWidget chain receives a rect wide enough that break_lines never triggers word-wrap — the text stays on one line and the internal horizontal scroll activates.

Strategy A (chosen): replace max_float_safe with avail.w

// Before:
.max_size_content = .{ .w = dvui.max_float_safe, .h = metrics.COMPOSER_INPUT_MAX_H - 2 * metrics.COMPOSER_TE_PAD },

// After:
.max_size_content = .{ .w = @max(120, avail.w), .h = metrics.COMPOSER_INPUT_MAX_H - 2 * metrics.COMPOSER_TE_PAD },
  • avail.w is the viewport width — always available by this point in frame() (line 156).
  • The textEntry receives avail.w − TOUCH_H − 8 px from the flex layout (icon + margin).
  • With max_size_content.w = avail.w, the TextLayout's self.data().rect.w is bounded, and break_lines wraps at the actual allocated width.
  • Floor of 120 matches min_size_content.w — prevents pathological zero-width wraplosion.

Why not subtract the icon column

max_size_content is a negotiation cap, not the actual allocation. The flex layout already subtracts the icon column. Setting the cap to avail.w is generous — the actual allocated width will be less, and break_lines wraps at that actual width. Subtracting the icon column would double-count.

No other changes

  • COMPOSER_TE_PAD bake still works: dvui adds padding.x + padding.w into max_size_content, but avail.w is large enough that avail.w + 10 is still finite and wrapping triggers.
  • The dynamic hug height sampling (composer_last_h) automatically picks up the wrapped height — no code changes needed there.
  • Idle single-line (Enter only) still hugs ~44 px; multi-line paste grows the chrome up to 124 px cap.

Layers

Layer Change
Harness (Wasm) ui.zig — one line: dvui.max_float_safe@max(120, avail.w)
Vercel backend No change
DOM No change

No protocol bump. No bridge changes. No new exports.


Testing

Unit tests

No host-testable module exists for frame() layout — the composer_text module tests text normalization only. Manual smoke plan:

  1. Long URL: Type/paste a 200+ char URL with no newline → line wraps at field width, chrome grows, no h-scroll
  2. Multi-line paste: Paste 10 lines → chrome grows up to 124 px, then vertical internal scroll
  3. Idle single line: Short message → ~44 px chrome, no growth
  4. Narrow viewport: Resize canvas to ~400 px → wrap point narrows, chrome grows taller (fewer chars per line), no h-scroll
  5. Send unchanged: Ctrl+Enter sends; Enter inserts newline — no regression
  6. Queue row: Single-line queue editor unchanged (multiline = false)

Zig gates

Pure Zig change (ui.zig only) — standard harness gates:

zig fmt --check native/harness/src/ui.zig
zig build harness -Doptimize=Debug
zig build test-rich-invariants
# + build-harness CI (wasm32-freestanding release)

No TS changes → no vitest / typecheck / di-gate / npm run build.


Ops & docs

  • Cloud ops: N/A — no Production mutate
  • Docs: docs/harness-limits.md — Wrap / grow row: update to note max_size_content.w is finite (viewport-width). No new caps. Update the code comment that currently defends max_float_safe.
  • Caps: No new caps, no existing cap changed. avail.w is a live viewport measurement, not a static constant.
  • Protocol: No bump — Wasm-only, no bridge export change.

Risk assessment

Low. Single-line change, no new imports, no alloc, no bridge. The @max(120, avail.w) floor preserves the existing min-width behavior on pathological zero-width canvases. If avail.w is too narrow (e.g. a 200 px wide window), wrapping happens at ~128 px after icon + margin — still legible.

Edge cases

Case Behavior
Fullscreen 1920 px textEntry wraps at ~1848 px — effectively no wrap, same as now
800 px viewport textEntry wraps at ~728 px — readable width
400 px viewport textEntry wraps at ~328 px — narrow but functional
200 px viewport (pathological) Floor 120 px + icon 40 px + margin 8 px = 168 px > 200 — tight but break_lines fires at the allocated width

Review (plan-review)

Scores

Axis Score Notes
Correctness 4 Algorithm correct; line numbers drifted (fixed in baseline table)
Performance 5 One @max call in existing paint path; no alloc/I/O/bridge
Architectural soundness 5 Wasm-only; reuses avail.w already in scope; no protocol bump; no bridge changes
Testing 3 No host-testable unit test (same as all ui.frame() changes) — manual smoke 6 scenarios is proportional
Cloud ops N/A No Production mutate
Living docs 4 harness-limits.md wrap row + code comment both identified
Cap governance 5 No new caps; avail.w is a live viewport measurement, not a static constant

Findings

# Sev Issue Resolution
1 Minor (L8) Line numbers drifted: multiline at 685 not 670, break_lines at 686 not 671, margin at 700 not 685, max_size_content at 696 not 681 Fixed in baseline table
2 Minor (L8) Doc comment at lines 690–694 ("Width max is max_float_safe…") defends the current max_float_safe value — must be updated when the value changes Implementer: update comment to explain @max(120, avail.w) — "finite viewport-width cap so break_lines fires; floor 120 guards zero-width canvases"

Ready for /implement-plan #706 (done).


Implementation

  • PR: #709
  • Commit: 39073b7
  • Branch: plan/composer-wrap

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requestharnessHarness / agent UI

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions