fix(panel): clip the filter chip's tag so it stops pushing the scope total off #20

Merged
yuris merged 1 commit from fix/clip-filter-chip into main 2026-09-02 08:54:40 +00:00
Owner

Reported with the tag Coding Test Practice: the filter chip's width grew with the tag
until the scope total beside it was pushed past the panel's right edge.

Before After
Today All time [Coding Test Practice ✕] 0:15 — total clipped off Today All time [Coding Test… ✕] 0:15

Why the chip cannot shrink, and cannot elide

The row is fixed and nothing in it gives: free space is grow-only
(flex.cpp:532 clamps it at zero), arrangement is an unclamped cursor += item.main
(flex.cpp:737), and a node does not clip its children (node.h:211).

ui.button accepts neither maxWidth nor maxLines (ui_tree_reconciler.cpp:471-475,
against kLabel at :457-460 which accepts both). A bare width would not produce an
ellipsis either, and the reason is not that the behaviour is undefined — it is well
defined, and it is worse. A width on a button pins min and max width
(:1404-1407) and does arm setEllipsize(TextEllipsize::End) on the inner label
(button.cpp:707), but Button never calls setMaxLines, so Label::m_userMaxLines
stays 0 (label.h:143), effectiveMaxLines is 0 (label.cpp:503), and Pango receives
lineBudget = kHardMaxLines = 500 under PANGO_WRAP_WORD_CHAR
(cairo_text_renderer.cpp:436-451). A width-capped button wraps — the ellipsis
arrives past 500 lines — and inside a min/max-pinned height = CHIP_H that is a
two-line label in a 22px box.

A ui.label does elide, with maxWidth and maxLines >= 1: the same block, the
branch where the line budget is the caller's. But a label takes no onClick, and
clicking the chip is what clears the filter. A row does take one
(ui_tree_reconciler.cpp:371, :449) — so a clickable wrapper is available — and
buildScope's own comment says why the chip is one button rather than a tinted row
wrapping a label: a button's radius is the theme's and no plugin prop reaches it, so a
rounder wrapper could only ever disagree with the hover rectangle drawn inside it.

So the string has to arrive already short. Fmt.clip(text, max), in the pure core.

Why cells, not code points

max is a budget in display cells: a Wide or Fullwidth code point (Unicode East
Asian Width classes W and F, UAX #11) spends two, everything else spends one, and the
ellipsis costs one of its own. The first version of this counted code points, which is
the same bug in another script:

tag Fmt.clip(tag, 12), code-point budget cols Fmt.clip(tag, 12), cell budget cols
Coding Test Practice Coding Test… 12 Coding Test… 12
日本語のタグはとても長い unchanged 24 日本語のタ… 11
프로젝트관리시스템개발용 unchanged 24 프로젝트관… 11

Two is an over-estimate on purpose, and the over-estimate is the point. There is no cell
to be exact about: the panel's text is shaped by Pango from the real per-glyph advances of
whatever shell.font_family resolves to (cairo_text_renderer.cpp:411 sets the font,
:472-474 takes the extents), which defaults to "sans-serif"
(config_types.h:1070), and ui.button has no fontFamily prop to pin a monospaced one.
Measured off the font files on both stacks a user can land in:

face Latin advance Wide advance ratio
DejaVu Sans + Noto Sans CJK (the live default sans-serif) 0.51–0.59 em for real tag text 1.00 em ideograph, 0.92 em Hangul syllable 1.56x–1.96x
JetBrainsMono Nerd Font + Noto Sans Mono CJK flat 0.60 em 1.00 em / 0.92 em 1.53x–1.67x

Both are under 2, so charging two always over-spends and never under-spends, and
over-spending is the side that keeps a fixed row inside its frame. In em, against the
6.948 em that Coding Test… occupies — the string the budget was calibrated on —
日本語のタグはとても長い was 12.000 em unclipped (1.73x) and is 6.000 em clipped.

What this is not is a pixel bound. A proportional font has no cell, so MMMMMMMMMMMM is
twelve cells and still too wide, and an emoji spends one while rendering wider than two
(Noto Color Emoji is 1.245 em); Emoji_Presentation is a scattered table this
deliberately does not carry. Nothing at this plugin API can measure text, so the budget is
calibrated rather than computed, and what the cell count buys is that the error stays on
one side of it.

The cut still lands on a code point, not a byte: string.sub on a byte index would split a
multi-byte character and hand the shell an invalid UTF-8 sequence, so the walk carries the
byte index of the code point it stops on. A string that is already invalid has no width
(Fmt.cols answers nil, the same answer utf8.len gives) and is returned untouched —
timew still matches on it, and clipping is cosmetic.

Where the clip lives, and where it deliberately does not

  • The builder, not the model. panel.luau already owns width knowledge (its own comment:
    widths and alignment stay in the region builders); the model keeps the whole tag, because
    that is what timew filters on. Asserted: clipped.filter == LONG_TAG and the filter still
    selects one row by the full tag.
  • Not the trend caption, which takes the same tag unclipped. It owns a row with nothing
    to its right, so it has no total to push off — and it is a ui.label, which could take
    maxWidth and maxLines if it ever needs them. Verified on screen: Last 14 days · 日本語のタグはとても長い
    fits RIGHT_W with room to spare, in the same frame as the clipped chip. A mutation that
    clips it too is killed at test.luau:1964.

The budget, and the guard under it

CHIP_TAG_MAX = 12, measured rather than derived: CHROME_W 287, less four 4px gaps, less
DUR_W 56, less All time and the widest day button — which is a dated one, Mon 31/08
measuring wider than Yesterday, and the chrome the fix was verified against live. Nothing
at this API measures text, so a derived expression would trade one honestly measured number
for three guessed ones.

What was missing was any notice when the measurement goes stale, so state 5b now pins the
four numbers the 12 was measured against, off the tree the suite already walks:

assert(
	rightColumn().children[5].props.width == 287
		and rightColumn().children[5].props.gap == 4
		and scopeKids()[#scopeKids()].props.width == 56
		and scopeKids()[3].props.fontSize == 11,
	"CHIP_TAG_MAX = 12 was measured at CHROME_W 287 / gap 4 / DUR_W 56 / chip fontSize 11; one of those moved -- re-measure the budget"
)

Exported for one assert. compare() computes its expectation as
Fmt.clip(m.filter, panel.CHIP_TAG_MAX), so raising the budget cannot leave that line
behind — and state 5b pins the constant itself with a literal ("Coding Test… ✕"), so the
exported constant is not a tautology in both directions at once. The bound assert in
compare() measures Fmt.cols, not utf8.len: in code points it reported both CJK tags
above as fitting.

Verified live

Real IPC on the running shell, in the reported chrome and in two more scripts. One frame
carries the before and the after at once, since the trend caption takes the tag unclipped:

  • 日本語のタグはとても長い → caption shows all 12 code points, chip shows 日本語のタ… ✕, 0s fully inside the panel
  • 프로젝트관리시스템개발용 → chip shows 프로젝트관… ✕, 0s fully inside the panel
  • Coding Test Practice → chip shows Coding Test… ✕, 0:15 fully inside the panel, byte-identical to the shipped behaviour
  • widest day button (Mon 31/08) → chip clipped, total fully inside the panel

Latin output is byte-identical to the code-point version: 200,000 random narrow-only
strings (Latin plus the Latin-1 supplement) across budgets 1–20, zero differences. So state
5b's literal and every compare() expectation stand unchanged.

Mutations

cp -r copy per mutation, never the live checkout:

Mutation Result
clip removed from the chip killed :1982
budget raised to 99 killed :2322
budget lowered to 8 killed :2322
budget 13 killed :2322
budget 11 killed :2322
Fmt.clip made a no-op killed :182
clip drops the ellipsis killed :182
clip cuts on a byte index (cell budget kept) killed :194
clip off by one (cut, not cut - 1) killed :182
string.sub start index 1 -> 2 killed :182
ellipsis U+2026 -> three ASCII dots killed :182
ellipsis cell not reserved (max - 1 -> max) killed :182
clip also applied to the trend caption killed :1964
Fmt.cols validity guard in bytes (utf8.len -> #text) killed :179
Fmt.cols counts bytes (the cell walk -> #text) killed :171
cells -> code points (the shipped defect, restored) killed :171
every glyph charged two killed :181
Hangul syllable range dropped killed :201
CJK range dropped killed :171
Hangul Jamo range dropped killed :175
fullwidth forms range dropped killed :175
supplementary ideographic planes dropped killed :175
export 12, builder clips at 99 killed :1982
export 99, builder clips at 12 killed :1982
no-op clip with the unit asserts stripped killed :1997
code-point budget with the unit asserts stripped killed :2357
DUR_W 56 -> 120 killed :2336
chip fontSize 11 -> 22 killed :2336
scope row gap 4 -> 8 killed :2336
CHROME_W moved (SCROLLBAR_GUTTER 14 -> 20) killed :1685
plugin.toml panel width 578 -> 320 SURVIVED

One survivor, and it is not this change's: nothing ties the manifest's panel width to
LEFT_W + RIGHT_W. That hole predates this PR and belongs in its own change.

Gates

viewModel: 13 states, 1870 field comparisons
ok
0 errors, 0 warnings

12 -> 13 states: state 5b is its own fixture rather than a wider tag on state 5, because
state 5 pins how many rows the predicate keeps and 5b pins what the chip does with width —
one fixture serving both would let a change to either rewrite the other's expectation. The
field-comparison count is unchanged from the first revision: every assert added here is a
plain assert, and the one same() that changed swapped utf8.len for Fmt.cols in
place.

For a reviewer

  • Challenge the two. A Wide glyph measures 1.53x–1.96x a Latin one on the two stacks
    above, and the budget charges 2. That is safe but slack: at CHIP_TAG_MAX = 12 a CJK
    chip renders about 6.0 em where the Latin one it was calibrated against renders 6.9 em,
    so wide tags clip slightly earlier than they need to. 2 is the terminal convention and
    the only integer that is an upper bound in both stacks; a fractional weight would be more
    accurate and less honest, since neither number is measurable from Lua.
  • The budget is still calibrated, not computed. The guard fails when its four inputs
    move, which is strictly better than nothing, but it cannot notice the shell font changing
    — shell.font_family is not on the tree the suite walks, and ui.button has no
    fontFamily prop to read one off.
  • The new do block exists because the file is at Luau's 200-register file-scope ceiling —
    three more file-scope locals overflow it, reproduced as
    Out of local registers ... exceeded limit 200.
Reported with the tag `Coding Test Practice`: the filter chip's width grew with the tag until the scope total beside it was pushed past the panel's right edge. | Before | After | | --- | --- | | `Today All time [Coding Test Practice ✕] 0:15` — total clipped off | `Today All time [Coding Test… ✕] 0:15` | ## Why the chip cannot shrink, and cannot elide The row is fixed and nothing in it gives: free space is grow-only (`flex.cpp:532` clamps it at zero), arrangement is an unclamped `cursor += item.main` (`flex.cpp:737`), and a node does not clip its children (`node.h:211`). `ui.button` accepts neither `maxWidth` nor `maxLines` (`ui_tree_reconciler.cpp:471-475`, against `kLabel` at `:457-460` which accepts both). A bare `width` would not produce an ellipsis either, and the reason is not that the behaviour is undefined — it is well defined, and it is worse. A `width` on a button pins min and max width (`:1404-1407`) and does arm `setEllipsize(TextEllipsize::End)` on the inner label (`button.cpp:707`), but `Button` never calls `setMaxLines`, so `Label::m_userMaxLines` stays 0 (`label.h:143`), `effectiveMaxLines` is 0 (`label.cpp:503`), and Pango receives `lineBudget = kHardMaxLines = 500` under `PANGO_WRAP_WORD_CHAR` (`cairo_text_renderer.cpp:436-451`). **A width-capped button wraps** — the ellipsis arrives past 500 lines — and inside a min/max-pinned `height = CHIP_H` that is a two-line label in a 22px box. A `ui.label` *does* elide, with `maxWidth` and `maxLines >= 1`: the same block, the branch where the line budget is the caller's. But a label takes no `onClick`, and clicking the chip is what clears the filter. A `row` does take one (`ui_tree_reconciler.cpp:371`, `:449`) — so a clickable wrapper is available — and `buildScope`'s own comment says why the chip is one button rather than a tinted row wrapping a label: a button's radius is the theme's and no plugin prop reaches it, so a rounder wrapper could only ever disagree with the hover rectangle drawn inside it. So the string has to arrive already short. `Fmt.clip(text, max)`, in the pure core. ## Why cells, not code points `max` is a budget in **display cells**: a Wide or Fullwidth code point (Unicode East Asian Width classes `W` and `F`, UAX #11) spends two, everything else spends one, and the ellipsis costs one of its own. The first version of this counted code points, which is the same bug in another script: | tag | `Fmt.clip(tag, 12)`, code-point budget | cols | `Fmt.clip(tag, 12)`, cell budget | cols | | --- | --- | --- | --- | --- | | `Coding Test Practice` | `Coding Test…` | 12 | `Coding Test…` | 12 | | `日本語のタグはとても長い` | *unchanged* | **24** | `日本語のタ…` | 11 | | `프로젝트관리시스템개발용` | *unchanged* | **24** | `프로젝트관…` | 11 | Two is an over-estimate on purpose, and the over-estimate is the point. There is no cell to be exact about: the panel's text is shaped by Pango from the real per-glyph advances of whatever `shell.font_family` resolves to (`cairo_text_renderer.cpp:411` sets the font, `:472-474` takes the extents), which defaults to `"sans-serif"` (`config_types.h:1070`), and `ui.button` has no `fontFamily` prop to pin a monospaced one. Measured off the font files on both stacks a user can land in: | face | Latin advance | Wide advance | ratio | | --- | --- | --- | --- | | DejaVu Sans + Noto Sans CJK (the live default `sans-serif`) | 0.51–0.59 em for real tag text | 1.00 em ideograph, 0.92 em Hangul syllable | 1.56x–1.96x | | JetBrainsMono Nerd Font + Noto Sans Mono CJK | flat 0.60 em | 1.00 em / 0.92 em | 1.53x–1.67x | Both are under 2, so charging two always over-spends and never under-spends, and over-spending is the side that keeps a fixed row inside its frame. In em, against the 6.948 em that `Coding Test…` occupies — the string the budget was calibrated on — `日本語のタグはとても長い` was 12.000 em unclipped (1.73x) and is 6.000 em clipped. What this is not is a pixel bound. A proportional font has no cell, so `MMMMMMMMMMMM` is twelve cells and still too wide, and an emoji spends one while rendering wider than two (Noto Color Emoji is 1.245 em); `Emoji_Presentation` is a scattered table this deliberately does not carry. Nothing at this plugin API can measure text, so the budget is calibrated rather than computed, and what the cell count buys is that the error stays on one side of it. The cut still lands on a code point, not a byte: `string.sub` on a byte index would split a multi-byte character and hand the shell an invalid UTF-8 sequence, so the walk carries the byte index of the code point it stops on. A string that is already invalid has no width (`Fmt.cols` answers `nil`, the same answer `utf8.len` gives) and is returned untouched — timew still matches on it, and clipping is cosmetic. ## Where the clip lives, and where it deliberately does not - **The builder**, not the model. `panel.luau` already owns width knowledge (its own comment: widths and alignment stay in the region builders); the model keeps the whole tag, because that is what timew filters on. Asserted: `clipped.filter == LONG_TAG` and the filter still selects one row by the full tag. - **Not the trend caption**, which takes the same tag unclipped. It owns a row with nothing to its right, so it has no total to push off — and it is a `ui.label`, which could take `maxWidth` and `maxLines` if it ever needs them. Verified on screen: `Last 14 days · 日本語のタグはとても長い` fits `RIGHT_W` with room to spare, in the same frame as the clipped chip. A mutation that clips it too is killed at `test.luau:1964`. ## The budget, and the guard under it `CHIP_TAG_MAX = 12`, measured rather than derived: `CHROME_W` 287, less four 4px gaps, less `DUR_W` 56, less `All time` and the widest day button — which is a dated one, `Mon 31/08` measuring wider than `Yesterday`, and the chrome the fix was verified against live. Nothing at this API measures text, so a derived expression would trade one honestly measured number for three guessed ones. What was missing was any notice when the measurement goes stale, so state 5b now pins the four numbers the 12 was measured against, off the tree the suite already walks: ```lua assert( rightColumn().children[5].props.width == 287 and rightColumn().children[5].props.gap == 4 and scopeKids()[#scopeKids()].props.width == 56 and scopeKids()[3].props.fontSize == 11, "CHIP_TAG_MAX = 12 was measured at CHROME_W 287 / gap 4 / DUR_W 56 / chip fontSize 11; one of those moved -- re-measure the budget" ) ``` Exported for one assert. `compare()` computes its expectation as `Fmt.clip(m.filter, panel.CHIP_TAG_MAX)`, so raising the budget cannot leave that line behind — and state 5b pins the constant itself with a literal (`"Coding Test… ✕"`), so the exported constant is not a tautology in both directions at once. The bound assert in `compare()` measures `Fmt.cols`, not `utf8.len`: in code points it reported both CJK tags above as fitting. ## Verified live Real IPC on the running shell, in the reported chrome and in two more scripts. One frame carries the before and the after at once, since the trend caption takes the tag unclipped: - `日本語のタグはとても長い` → caption shows all 12 code points, chip shows `日本語のタ… ✕`, `0s` fully inside the panel - `프로젝트관리시스템개발용` → chip shows `프로젝트관… ✕`, `0s` fully inside the panel - `Coding Test Practice` → chip shows `Coding Test… ✕`, `0:15` fully inside the panel, byte-identical to the shipped behaviour - widest day button (`Mon 31/08`) → chip clipped, total fully inside the panel Latin output is byte-identical to the code-point version: 200,000 random narrow-only strings (Latin plus the Latin-1 supplement) across budgets 1–20, zero differences. So state 5b's literal and every `compare()` expectation stand unchanged. ## Mutations `cp -r` copy per mutation, never the live checkout: | Mutation | Result | | --- | --- | | clip removed from the chip | killed `:1982` | | budget raised to 99 | killed `:2322` | | budget lowered to 8 | killed `:2322` | | budget 13 | killed `:2322` | | budget 11 | killed `:2322` | | `Fmt.clip` made a no-op | killed `:182` | | clip drops the ellipsis | killed `:182` | | clip cuts on a byte index (cell budget kept) | killed `:194` | | clip off by one (`cut`, not `cut - 1`) | killed `:182` | | `string.sub` start index 1 -> 2 | killed `:182` | | ellipsis U+2026 -> three ASCII dots | killed `:182` | | ellipsis cell not reserved (`max - 1` -> `max`) | killed `:182` | | clip also applied to the trend caption | killed `:1964` | | `Fmt.cols` validity guard in bytes (`utf8.len` -> `#text`) | killed `:179` | | `Fmt.cols` counts bytes (the cell walk -> `#text`) | killed `:171` | | cells -> code points (the shipped defect, restored) | killed `:171` | | every glyph charged two | killed `:181` | | Hangul syllable range dropped | killed `:201` | | CJK range dropped | killed `:171` | | Hangul Jamo range dropped | killed `:175` | | fullwidth forms range dropped | killed `:175` | | supplementary ideographic planes dropped | killed `:175` | | export 12, builder clips at 99 | killed `:1982` | | export 99, builder clips at 12 | killed `:1982` | | no-op clip with the unit asserts stripped | killed `:1997` | | code-point budget with the unit asserts stripped | killed `:2357` | | `DUR_W` 56 -> 120 | killed `:2336` | | chip `fontSize` 11 -> 22 | killed `:2336` | | scope row `gap` 4 -> 8 | killed `:2336` | | `CHROME_W` moved (`SCROLLBAR_GUTTER` 14 -> 20) | killed `:1685` | | `plugin.toml` panel `width` 578 -> 320 | **SURVIVED** | One survivor, and it is not this change's: nothing ties the manifest's panel `width` to `LEFT_W + RIGHT_W`. That hole predates this PR and belongs in its own change. ## Gates ``` viewModel: 13 states, 1870 field comparisons ok 0 errors, 0 warnings ``` 12 -> 13 states: state 5b is its own fixture rather than a wider tag on state 5, because state 5 pins how many rows the predicate keeps and 5b pins what the chip does with width — one fixture serving both would let a change to either rewrite the other's expectation. The field-comparison count is unchanged from the first revision: every assert added here is a plain `assert`, and the one `same()` that changed swapped `utf8.len` for `Fmt.cols` in place. ## For a reviewer - **Challenge the two.** A Wide glyph measures 1.53x–1.96x a Latin one on the two stacks above, and the budget charges 2. That is safe but slack: at `CHIP_TAG_MAX = 12` a CJK chip renders about 6.0 em where the Latin one it was calibrated against renders 6.9 em, so wide tags clip slightly earlier than they need to. 2 is the terminal convention and the only integer that is an upper bound in both stacks; a fractional weight would be more accurate and less honest, since neither number is measurable from Lua. - **The budget is still calibrated, not computed.** The guard fails when its four inputs move, which is strictly better than nothing, but it cannot notice the shell font changing — `shell.font_family` is not on the tree the suite walks, and `ui.button` has no `fontFamily` prop to read one off. - The new `do` block exists because the file is at Luau's 200-register file-scope ceiling — three more file-scope locals overflow it, reproduced as `Out of local registers ... exceeded limit 200`.
The chip is the only child of the scope row with no width of its own, and the
row's width is fixed at CHROME_W. So the chip's natural width grew with the tag
until the total on its right was pushed past the panel's edge -- reported with
"Coding Test Practice", 20 characters against a row that has room for about 12.

The chip cannot be made to shrink instead. `ui.button` accepts neither
`maxWidth` nor `maxLines` (ui_tree_reconciler.cpp:471-475), nothing in the UI
tree elides, and the chip has to stay a button because only box, image and
button take `onClick`. So the string has to arrive already short.

Fmt.clip counts code points rather than bytes: a tag is whatever the user
typed, and string.sub on a byte index would cut a multi-byte character in half
and hand the shell an invalid sequence. An unmeasurable tag is returned
untouched rather than raising -- timew still matches on it, and the clip is
cosmetic.

The model keeps the whole tag, because that is what timew filters on; only the
chip's own text is clipped, in the builder, which is where this file already
keeps width knowledge. The trend caption above takes the same tag unclipped on
purpose: it owns a row with nothing to its right, so it has no total to push.

State 5b covers it with the reported tag, and the budget is exported so
compare()'s expectation cannot drift from the constant while a literal in that
one state pins the constant itself.
yuris left a comment

Verdict: request changes. One blocking defect (F1): the fix does not fix the reported bug for CJK, Hangul or fullwidth tags, and the new bound assert reports those as fitting. Everything else here is advisory, including a settled answer to both of your own doubts.

All work below ran on a cp -r copy at /tmp/pr20. The checkout was never touched.

Verified

Claim What proves it
The chip overflows because the row is fixed and nothing shrinks flex.cpp:532 grows only (remaining = std::max(0.0F, containerMain - fixedMain)), there is no flexShrink in the tree, flex.cpp:741 arranges cursor += item.main with no clamp, and node.h:210 defaults m_clipChildren = false. The chip pushes the total past the frame and nothing cuts it.
ui.button takes neither maxWidth nor maxLines; ui.label takes both ui_tree_reconciler.cpp:471-475 and :457-460. Your citation is exact.
A capped width would not have worked True, but not for the reason given — see F4. width on a button is honoured (ui_tree_reconciler.cpp:1404-1407, min+max) and does arm m_label->setEllipsize(TextEllipsize::End) (button.cpp:707). It still never ellipsizes: Button never calls setMaxLines, Label::m_userMaxLines defaults to 0 (label.h:143), so effectiveMaxLines is 0 (label.cpp:503) and Pango gets lineBudget = kHardMaxLines = 500 under PANGO_WRAP_WORD_CHAR (cairo_text_renderer.cpp:449-454). A width-capped button wraps; the ellipsis arrives after 500 lines. Inside a min/max-pinned height = CHIP_H, that is a two-line label in a 22 px box. Clipping the string is the right layer.
The exported budget is not circular Both directions. Export 12 while the builder clips at 99 dies at :1962; export 99 while the builder clips at 12 dies at :1962. Budgets 11 and 13 both die at :2298.
The new bound assert is not vacuous Comment out :171-185 and a no-op Fmt.clip walks straight through :1962 — both sides compute the same wrong string — then dies at :1973. Comment :1973-1977 out as well and it falls through to :2298. The bound is exactly what catches the circular line above it. It earns its place.
The model keeps the whole tag :2291, :2292, :2301.

Mutations

All 24 re-run from scratch. Your eight reproduce.

Mutation Result
clip removed from the chip killed :1962
budget raised to 99 killed :2298
budget lowered to 8 killed :2298
Fmt.clip made a no-op killed :173
clip drops the ellipsis killed :173
clip uses a byte index (string.sub(text, 1, max - 1)) killed :180
clip off by one (utf8.offset without the - 1) killed :173
clip also applied to the trend caption killed :1944
length guard in bytes (utf8.len(text) -> #text) killed :185 — but only incidentally; see F3
n <= max -> n < max killed :172
n <= max -> n <= max + 1 killed :173
invalid-UTF-8 guard dropped killed :185
invalid UTF-8 returns "" killed :185
ellipsis U+2026 -> three ASCII dots killed :173
string.sub start index 1 -> 2 killed :173
export 12, builder clips at 99 killed :1962
export 99, builder clips at 12 killed :1962
budget 13 killed :2298
budget 11 killed :2298
no-op clip, unit asserts commented out killed :1973
DUR_W 56 -> 120 SURVIVED
chip fontSize 11 -> 22 SURVIVED
scope row gap 4 -> 8 SURVIVED
plugin.toml panel width 578 -> 320 SURVIVED

One correction to your table: the byte-index kill lands at :180 only for the faithful mutation, the one that keeps the character budget. Writing string.sub(text, 1, max) shifts the count as well and dies earlier, at :173, on plain ASCII.

Four survivors, all of them the constants the new constant was measured against.

The budget

The measured literal is right. Leaving it unpinned is not.

A derived expression would be worse, and I tried to write one before concluding that. To derive 12 you need pixels per character in the chip's font and the natural widths of two content-sized sibling buttons. Lua can measure none of the three at this API, so the derivation would trade one honestly measured number for three guessed ones and then hide the error inside a math.floor. Keep the 12. Your instinct there is correct.

The real problem is that nothing notices when the measurement goes stale. DUR_W and the 4 px gap are both named in the derivation comment six lines above the constant, and both can move with the suite still green. So can the font size the number was measured at, which is the case you were most worried about.

The geometry is already on the tree the suite walks, so a guard needs no new export and no new file-scope local — the 200-register ceiling is safe:

	assert(
		rightColumn().children[5].props.width == 287
			and rightColumn().children[5].props.gap == 4
			and scopeKids()[#scopeKids()].props.width == 56
			and scopeKids()[3].props.fontSize == 11,
		"CHIP_TAG_MAX = 12 was measured at CHROME_W 287 / gap 4 / DUR_W 56 / chip fontSize 11; one of those moved -- re-measure the budget"
	)

Dropped into state 5b's do block it is green on this branch and converts three of the four survivors into kills at that line, with a message that names the remedy rather than the symptom. The fourth, plugin.toml width, is a different hole: nothing ties the manifest width to LEFT_W + RIGHT_W. That predates this PR and belongs in its own change.

So: pin the fit, not just the clip. You asked for a way to measure text from Lua; there is none, and this is the next best thing — fail when the thing you measured against moves.

Findings

Blocking

F1 — timew/fmt.luau:33-46 and test.luau:1973-1977: a CJK tag still pushes the total off, and the new assert says it fits.

The budget counts code points. The row spends columns, and a CJK, Hangul or fullwidth glyph is two of them. Against the shipped CHIP_TAG_MAX = 12:

tag after Fmt.clip(tag, 12) code points columns
Coding Test Practice Coding Test… 12 12
日本語のタグはとても長い unchanged 12 24
프로젝트관리시스템개발용 unchanged 12 24

Neither non-Latin tag is clipped at all, and each renders about twice the width the constant was measured for, so the bug in the title reproduces exactly. utf8.len(chip.props.text) <= chrome + panel.CHIP_TAG_MAX is true for both, so the suite certifies a fit that is not there — the bound measures code points while the invariant that matters is pixels. The header comment's "never wider than max glyphs in a monospaced face" is a claim about glyph width, and the implementation does not make it true. The Japanese case is cited as proof of the width claim, but it runs at max = 6, a budget the product never uses, and what it actually pins is the code-point cut.

Budget columns instead:

local function cols(c)
	return if (c >= 0x1100 and c <= 0x115F)
		or (c >= 0x2E80 and c <= 0xA4CF)
		or (c >= 0xAC00 and c <= 0xD7A3)
		or (c >= 0xF900 and c <= 0xFAFF)
		or (c >= 0xFE30 and c <= 0xFE6F)
		or (c >= 0xFF00 and c <= 0xFF60)
		or (c >= 0xFFE0 and c <= 0xFFE6) then 2 else 1
end

function Fmt.clip(text, max)
	if utf8.len(text) == nil then
		return text
	end
	local total = 0
	for _, c in utf8.codes(text) do
		total += cols(c)
	end
	if total <= max then
		return text
	end
	-- The ellipsis costs one cell, so the kept prefix has to fit in max - 1.
	local kept = 0
	for i, c in utf8.codes(text) do
		if kept + cols(c) > max - 1 then
			return string.sub(text, 1, i - 1) .. "…"
		end
		kept += cols(c)
	end
	return text
end

I ran this on your branch. The whole suite passes with exactly one assert moved: :182 becomes

assert(Fmt.clip("日本語のタグはとても長いです", 6) == "日本…", "a script with no ASCII at all clips the same way")

since 6 now means six columns. Latin output is byte-identical — Coding Test Practice at 12 is still Coding Test… — so state 5b's literal and every compare() expectation stand unchanged. viewModel: 13 states, 1870 field comparisons / ok, and lint stays 0 errors, 0 warnings.

Advisory

F2 — timew/panel.luau:107-119: the budget is unpinned to its own derivation. Covered above, with the guard and its demonstrated kills.

F3 — test.luau:177-181: the German case earns its line, but not the one its comment describes. Your doubt, settled: against the faithful byte-index mutation, :180 is the first assert to die, and it still dies there with the Japanese case removed. Keep it.

What is missing is the case the comment at :177-178 already spells out. Swap utf8.len(text) for #text in the length guard and the suite still passes once :185 is out of the way — the only thing pinning characters against bytes in that guard is a side effect of the invalid-UTF-8 string, which is a different test doing this one's job by accident. "Präsentationsentwurf" cannot catch it, because at 20 characters it is over budget either way. The string in your comment can:

assert(Fmt.clip("Präsentation", 12) == "Präsentation", "12 characters in 13 bytes is inside the budget: the guard counts characters, not bytes")

F4 — timew/panel.luau:110-114 and the PR body: three claims in the justification are wrong.

  • "it is the only child with no width of its own" — both scope buttons (scopeButton sets no width) and the ui.spacer have none either. The chip is the only child with unbounded content, which is the argument you actually want.
  • "nothing in the UI tree elides" — ui.label with maxWidth and maxLines >= 1 elides through Pango (cairo_text_renderer.cpp:449-454). True of buttons, false as written.
  • "a capped width has undefined overflow behaviour" — it is well defined: PANGO_WRAP_WORD_CHAR onto a 500-line budget. It wraps. That is the stronger argument, because a wrapped label inside a min/max-pinned height = CHIP_H is visibly broken rather than merely unspecified.

Non-issues, checked and clear. Fmt.clip(nil, …) raises, but state.filter starts "" (panel.luau:150) and is only ever Parse.trim(tag) (:488), so nil is unreachable. max <= 0 returns a bare … and a negative max clips from the end; both are unreachable from a module constant. A string that is exactly … is one code point and passes through untouched. Combining marks and ZWJ emoji under-spend the budget rather than over-spend it, so they cannot overflow the row.

Gates

viewModel: 13 states, 1870 field comparisons
ok
0 errors, 0 warnings
**Verdict: request changes.** One blocking defect (F1): the fix does not fix the reported bug for CJK, Hangul or fullwidth tags, and the new bound assert reports those as fitting. Everything else here is advisory, including a settled answer to both of your own doubts. All work below ran on a `cp -r` copy at `/tmp/pr20`. The checkout was never touched. ## Verified | Claim | What proves it | | --- | --- | | The chip overflows because the row is fixed and nothing shrinks | `flex.cpp:532` grows only (`remaining = std::max(0.0F, containerMain - fixedMain)`), there is no `flexShrink` in the tree, `flex.cpp:741` arranges `cursor += item.main` with no clamp, and `node.h:210` defaults `m_clipChildren = false`. The chip pushes the total past the frame and nothing cuts it. | | `ui.button` takes neither `maxWidth` nor `maxLines`; `ui.label` takes both | `ui_tree_reconciler.cpp:471-475` and `:457-460`. Your citation is exact. | | A capped `width` would not have worked | True, but not for the reason given — see F4. `width` on a button *is* honoured (`ui_tree_reconciler.cpp:1404-1407`, min+max) and *does* arm `m_label->setEllipsize(TextEllipsize::End)` (`button.cpp:707`). It still never ellipsizes: Button never calls `setMaxLines`, `Label::m_userMaxLines` defaults to 0 (`label.h:143`), so `effectiveMaxLines` is 0 (`label.cpp:503`) and Pango gets `lineBudget = kHardMaxLines = 500` under `PANGO_WRAP_WORD_CHAR` (`cairo_text_renderer.cpp:449-454`). A width-capped button **wraps**; the ellipsis arrives after 500 lines. Inside a min/max-pinned `height = CHIP_H`, that is a two-line label in a 22 px box. Clipping the string is the right layer. | | The exported budget is not circular | Both directions. Export 12 while the builder clips at 99 dies at `:1962`; export 99 while the builder clips at 12 dies at `:1962`. Budgets 11 and 13 both die at `:2298`. | | The new bound assert is not vacuous | Comment out `:171-185` and a no-op `Fmt.clip` walks straight through `:1962` — both sides compute the same wrong string — then dies at `:1973`. Comment `:1973-1977` out as well and it falls through to `:2298`. The bound is exactly what catches the circular line above it. It earns its place. | | The model keeps the whole tag | `:2291`, `:2292`, `:2301`. | ## Mutations All 24 re-run from scratch. Your eight reproduce. | Mutation | Result | | --- | --- | | clip removed from the chip | killed `:1962` | | budget raised to 99 | killed `:2298` | | budget lowered to 8 | killed `:2298` | | `Fmt.clip` made a no-op | killed `:173` | | clip drops the ellipsis | killed `:173` | | clip uses a byte index (`string.sub(text, 1, max - 1)`) | killed `:180` | | clip off by one (`utf8.offset` without the `- 1`) | killed `:173` | | clip also applied to the trend caption | killed `:1944` | | length guard in bytes (`utf8.len(text)` -> `#text`) | killed `:185` — but only incidentally; see F3 | | `n <= max` -> `n < max` | killed `:172` | | `n <= max` -> `n <= max + 1` | killed `:173` | | invalid-UTF-8 guard dropped | killed `:185` | | invalid UTF-8 returns `""` | killed `:185` | | ellipsis U+2026 -> three ASCII dots | killed `:173` | | `string.sub` start index 1 -> 2 | killed `:173` | | export 12, builder clips at 99 | killed `:1962` | | export 99, builder clips at 12 | killed `:1962` | | budget 13 | killed `:2298` | | budget 11 | killed `:2298` | | no-op clip, unit asserts commented out | killed `:1973` | | `DUR_W` 56 -> 120 | **SURVIVED** | | chip `fontSize` 11 -> 22 | **SURVIVED** | | scope row `gap` 4 -> 8 | **SURVIVED** | | `plugin.toml` panel `width` 578 -> 320 | **SURVIVED** | One correction to your table: the byte-index kill lands at `:180` only for the faithful mutation, the one that keeps the character budget. Writing `string.sub(text, 1, max)` shifts the count as well and dies earlier, at `:173`, on plain ASCII. Four survivors, all of them the constants the new constant was measured against. ## The budget **The measured literal is right. Leaving it unpinned is not.** A derived expression would be worse, and I tried to write one before concluding that. To derive 12 you need pixels per character in the chip's font and the natural widths of two content-sized sibling buttons. Lua can measure none of the three at this API, so the derivation would trade one honestly measured number for three guessed ones and then hide the error inside a `math.floor`. Keep the 12. Your instinct there is correct. The real problem is that nothing notices when the measurement goes stale. `DUR_W` and the 4 px gap are both named in the derivation comment six lines above the constant, and both can move with the suite still green. So can the font size the number was measured at, which is the case you were most worried about. The geometry is already on the tree the suite walks, so a guard needs no new export and no new file-scope local — the 200-register ceiling is safe: ```lua assert( rightColumn().children[5].props.width == 287 and rightColumn().children[5].props.gap == 4 and scopeKids()[#scopeKids()].props.width == 56 and scopeKids()[3].props.fontSize == 11, "CHIP_TAG_MAX = 12 was measured at CHROME_W 287 / gap 4 / DUR_W 56 / chip fontSize 11; one of those moved -- re-measure the budget" ) ``` Dropped into state 5b's `do` block it is green on this branch and converts three of the four survivors into kills at that line, with a message that names the remedy rather than the symptom. The fourth, `plugin.toml` `width`, is a different hole: nothing ties the manifest width to `LEFT_W + RIGHT_W`. That predates this PR and belongs in its own change. So: pin the fit, not just the clip. You asked for a way to measure text from Lua; there is none, and this is the next best thing — fail when the thing you measured against moves. ## Findings ### Blocking **F1 — `timew/fmt.luau:33-46` and `test.luau:1973-1977`: a CJK tag still pushes the total off, and the new assert says it fits.** The budget counts code points. The row spends columns, and a CJK, Hangul or fullwidth glyph is two of them. Against the shipped `CHIP_TAG_MAX = 12`: | tag | after `Fmt.clip(tag, 12)` | code points | columns | | --- | --- | --- | --- | | `Coding Test Practice` | `Coding Test…` | 12 | 12 | | `日本語のタグはとても長い` | unchanged | 12 | 24 | | `프로젝트관리시스템개발용` | unchanged | 12 | 24 | Neither non-Latin tag is clipped at all, and each renders about twice the width the constant was measured for, so the bug in the title reproduces exactly. `utf8.len(chip.props.text) <= chrome + panel.CHIP_TAG_MAX` is true for both, so the suite certifies a fit that is not there — the bound measures code points while the invariant that matters is pixels. The header comment's "never wider than `max` glyphs in a monospaced face" is a claim about glyph width, and the implementation does not make it true. The Japanese case is cited as proof of the width claim, but it runs at `max = 6`, a budget the product never uses, and what it actually pins is the code-point cut. Budget columns instead: ```lua local function cols(c) return if (c >= 0x1100 and c <= 0x115F) or (c >= 0x2E80 and c <= 0xA4CF) or (c >= 0xAC00 and c <= 0xD7A3) or (c >= 0xF900 and c <= 0xFAFF) or (c >= 0xFE30 and c <= 0xFE6F) or (c >= 0xFF00 and c <= 0xFF60) or (c >= 0xFFE0 and c <= 0xFFE6) then 2 else 1 end function Fmt.clip(text, max) if utf8.len(text) == nil then return text end local total = 0 for _, c in utf8.codes(text) do total += cols(c) end if total <= max then return text end -- The ellipsis costs one cell, so the kept prefix has to fit in max - 1. local kept = 0 for i, c in utf8.codes(text) do if kept + cols(c) > max - 1 then return string.sub(text, 1, i - 1) .. "…" end kept += cols(c) end return text end ``` I ran this on your branch. The whole suite passes with exactly one assert moved: `:182` becomes ```lua assert(Fmt.clip("日本語のタグはとても長いです", 6) == "日本…", "a script with no ASCII at all clips the same way") ``` since 6 now means six columns. Latin output is byte-identical — `Coding Test Practice` at 12 is still `Coding Test…` — so state 5b's literal and every `compare()` expectation stand unchanged. `viewModel: 13 states, 1870 field comparisons / ok`, and lint stays `0 errors, 0 warnings`. ### Advisory **F2 — `timew/panel.luau:107-119`: the budget is unpinned to its own derivation.** Covered above, with the guard and its demonstrated kills. **F3 — `test.luau:177-181`: the German case earns its line, but not the one its comment describes.** Your doubt, settled: against the faithful byte-index mutation, `:180` is the first assert to die, and it still dies there with the Japanese case removed. Keep it. What is missing is the case the comment at `:177-178` already spells out. Swap `utf8.len(text)` for `#text` in the length guard and the suite still passes once `:185` is out of the way — the only thing pinning characters against bytes in that guard is a side effect of the invalid-UTF-8 string, which is a different test doing this one's job by accident. `"Präsentationsentwurf"` cannot catch it, because at 20 characters it is over budget either way. The string in your comment can: ```lua assert(Fmt.clip("Präsentation", 12) == "Präsentation", "12 characters in 13 bytes is inside the budget: the guard counts characters, not bytes") ``` **F4 — `timew/panel.luau:110-114` and the PR body: three claims in the justification are wrong.** - "it is the only child with no width of its own" — both scope buttons (`scopeButton` sets no `width`) and the `ui.spacer` have none either. The chip is the only child with *unbounded content*, which is the argument you actually want. - "nothing in the UI tree elides" — `ui.label` with `maxWidth` and `maxLines >= 1` elides through Pango (`cairo_text_renderer.cpp:449-454`). True of buttons, false as written. - "a capped `width` has undefined overflow behaviour" — it is well defined: `PANGO_WRAP_WORD_CHAR` onto a 500-line budget. It wraps. That is the stronger argument, because a wrapped label inside a min/max-pinned `height = CHIP_H` is visibly broken rather than merely unspecified. **Non-issues, checked and clear.** `Fmt.clip(nil, …)` raises, but `state.filter` starts `""` (`panel.luau:150`) and is only ever `Parse.trim(tag)` (`:488`), so nil is unreachable. `max <= 0` returns a bare `…` and a negative `max` clips from the end; both are unreachable from a module constant. A string that is exactly `…` is one code point and passes through untouched. Combining marks and ZWJ emoji under-spend the budget rather than over-spend it, so they cannot overflow the row. ## Gates ``` viewModel: 13 states, 1870 field comparisons ok 0 errors, 0 warnings ```
yuris force-pushed fix/clip-filter-chip from b2e0581dd6 to f3f64522c9 2026-09-02 08:46:26 +00:00 Compare
Author
Owner

Actioned. All four findings applied, none declined — plus one correction to the review's own
premise and two false claims neither of us had caught. Amended to a12cf5b, force-pushed.
Description rewritten.

Accepted

F1 (blocking) — the budget now counts display cells. Fmt.clip walks code points and
charges 2 for East Asian Wide and Fullwidth, 1 otherwise, reserving one cell for the
ellipsis. Your cols helper is in, promoted to an exported Fmt.cols (see below for why) and
with one range added: the supplementary ideographic planes 0x20000-0x3FFFD, which are W
across their whole range and were the same hole in miniature.

Your :182 prediction holds exactly — it is the only assert whose expectation moved, now
Fmt.clip("日本語のタグはとても長いです", 6) == "日本…". Latin output is byte-identical, and I
pushed that past a spot check: 200,000 random narrow-only strings across budgets 1–20, zero
differences against the old implementation. State 5b's literal and every compare()
expectation stand.

tag before cols after cols
日本語のタグはとても長い unchanged 24 日本語のタ… 11
프로젝트관리시스템개발용 unchanged 24 프로젝트관… 11
Coding Test Practice Coding Test… 12 Coding Test… 12

One correction to your evidence, and it strengthens the finding. You and the PR body both
assume something monospaced. It is not. ui.button has no fontFamily prop, so the chip
inherits shell.font_family, which defaults to "sans-serif" (config_types.h:1070,
cairo_text_renderer.h:210) — and this host's config.toml sets no font at all, so the live
panel is DejaVu Sans, proportional, with CJK falling to Noto Sans CJK KR. Measurement is
Pango's logical extents from real per-glyph advances (cairo_text_renderer.cpp:411,
:472-474); there is no cell model anywhere in the renderer.

So "columns" do not exist here, and I stopped claiming they do. What does hold is the ratio,
measured off the font files:

face Latin Wide ratio
DejaVu Sans + Noto Sans CJK (live default) 0.51–0.59 em for real tag text 1.00 em ideograph / 0.92 em Hangul 1.56x–1.96x
JetBrainsMono + Noto Sans Mono CJK flat 0.60 em 1.00 / 0.92 em 1.53x–1.67x

Both under 2, so charging 2 over-spends and never under-spends — one-sided error, on the side
that fits. In em against the 6.948 em Coding Test… occupies (the string the 12 was
calibrated on): the CJK tag was 12.000 em unclipped, 1.73x the budget, and is now 6.000 em.
The header comment says exactly this instead of "never wider than max glyphs in a
monospaced face", and names what it still does not bound: MMMMMMMMMMMM is twelve cells and
still too wide, and an emoji spends one while rendering 1.245 em.

The bound assert. Fmt.cols(chip.props.text) <= chrome + panel.CHIP_TAG_MAX. It needed an
export rather than a local because it lives in compare() and the file has no register budget
for another file-scope helper. I tried the export-free form
Fmt.clip(chipText, chrome + max) == chipText first and threw it away: it is a fixed point,
so a no-op Fmt.clip satisfies it trivially and your vacuousness test stops working. The
Fmt.cols form keeps it — no-op clip with the unit asserts stripped still dies there,
at :1997.

F2 — the staleness guard is in, verbatim. Three of the four survivors become kills at
:2336, as you predicted: DUR_W, chip fontSize, row gap. It also kills CHROME_W
moving. The fourth, plugin.toml width 578 -> 320, still survives; I left it alone —
nothing ties the manifest width to LEFT_W + RIGHT_W, that predates this PR, and it is its
own change.

F3 — "Präsentation" added, German case kept. Your reading is right and my doubt was
wrong. The byte-vs-character case now dies on its own merits rather than borrowing the
invalid-UTF-8 assert's kill: Fmt.cols counting #text dies at :171, and the faithful
byte-index cut dies at :194 on "Präsentationsentwurf".

F4 — all three claims corrected, in panel.luau, the Fmt.clip header, the commit message
and the PR body.
"Only child with no width of its own" is now "only child whose content is
unbounded". "Nothing in the UI tree elides" is gone; a ui.label with maxWidth and
maxLines >= 1 does elide, and the comment says so and points at the branch. "Undefined
overflow behaviour" is replaced by the wrap mechanism, with the chain you traced — and I
opened every one of those files rather than copying your line numbers, which turned up three
that were off: cursor += item.main is flex.cpp:737 not :741, m_clipChildren is
node.h:211 not :210, and the Pango block wants :436-451 (kHardMaxLines at 436,
lineBudget at 447) rather than :449-454. Everything else was exact.

More false claims, outside the findings

"Only box, image and button take onClick" is also wrong: kFlex carries onClick
(ui_tree_reconciler.cpp:449) and row/column get the InputArea wrapper when clickable
(:371). So a clickable wrapper was available, and a row wrapping a ui.label with
maxWidth + maxLines = 1 would have elided without any clipping at all. The chip is still a
button, but for the reason already written six lines above buildScope — the theme owns a
button's radius and no plugin prop reaches it, so a rounder wrapper could only disagree with
the hover rectangle drawn inside it — not for lack of an alternative. The body says that now.

And a fifth, in the comment I was rewriting: it derived the budget from "a dated day button"
and then named "Yesterday" as the worst case. Mon 31/08 measures wider (5.308 em against
5.005 em), it is what the derivation already subtracts, and it is the chrome you verified
live. Fixed.

And a sixth, which the sweep for the second one turned up: panel.luau:1060 repeated
"only box, image and button accept onClick" over the history row's own tag button, outside
the region this PR touched. Same falsehood, same fix — the load-bearing reason there is that
ui.label takes no onClick and selected is what tints the filtered row, so a clickable
row wrapper would have to hand-roll that tint.

Beyond the findings

Your vacuousness technique found one more hole once F1 was fixed. With the unit asserts
stripped, reverting to a code-point budget survived the whole suite: state 5b's tag is
Latin, so the panel-level bound never saw a Wide tag, and the only thing pinning the fix was
the unit asserts in the same file as the code. State 5b now drives a second open() with
日本語のタグはとても長い and reads the chip off the tree — no check(), so no fourteenth
fixture and no change to the field-comparison count. That mutation now dies at :2357.

I also pinned the ranges themselves: one code point out of each of the six otherwise-untested
ranges in a single assert at :175, because dropping Hangul Jamo, the fullwidth forms or the
supplementary planes was silent.

31 mutations, one survivor, and it is the pre-existing manifest-width one. Verified live in
three scripts on the running shell, with the trend caption carrying the unclipped tag in the
same frame as the clipped chip — before and after in one screenshot.

viewModel: 13 states, 1870 field comparisons
ok
0 errors, 0 warnings

What I would challenge next

The 2. It is safe and it is slack: a CJK chip now renders ~6.0 em where the Latin string the
budget was calibrated against renders 6.9 em, so wide tags clip a little earlier than they
must. 2 is the terminal convention and the only integer that is an upper bound in both font
stacks, but it is a convention borrowed from a grid this renderer does not have. And the
guard still cannot see the font: shell.font_family is not on the tree the suite walks, so
the one input that would most obviously invalidate the 12 is the one input nothing pins.

Actioned. All four findings applied, none declined — plus one correction to the review's own premise and two false claims neither of us had caught. Amended to `a12cf5b`, force-pushed. Description rewritten. ## Accepted **F1 (blocking) — the budget now counts display cells.** `Fmt.clip` walks code points and charges 2 for East Asian Wide and Fullwidth, 1 otherwise, reserving one cell for the ellipsis. Your `cols` helper is in, promoted to an exported `Fmt.cols` (see below for why) and with one range added: the supplementary ideographic planes `0x20000-0x3FFFD`, which are `W` across their whole range and were the same hole in miniature. Your `:182` prediction holds exactly — it is the only assert whose expectation moved, now `Fmt.clip("日本語のタグはとても長いです", 6) == "日本…"`. Latin output is byte-identical, and I pushed that past a spot check: 200,000 random narrow-only strings across budgets 1–20, zero differences against the old implementation. State 5b's literal and every `compare()` expectation stand. | tag | before | cols | after | cols | | --- | --- | --- | --- | --- | | `日本語のタグはとても長い` | *unchanged* | 24 | `日本語のタ…` | 11 | | `프로젝트관리시스템개발용` | *unchanged* | 24 | `프로젝트관…` | 11 | | `Coding Test Practice` | `Coding Test…` | 12 | `Coding Test…` | 12 | **One correction to your evidence, and it strengthens the finding.** You and the PR body both assume something monospaced. It is not. `ui.button` has no `fontFamily` prop, so the chip inherits `shell.font_family`, which defaults to `"sans-serif"` (`config_types.h:1070`, `cairo_text_renderer.h:210`) — and this host's `config.toml` sets no font at all, so the live panel is **DejaVu Sans**, proportional, with CJK falling to Noto Sans CJK KR. Measurement is Pango's logical extents from real per-glyph advances (`cairo_text_renderer.cpp:411`, `:472-474`); there is no cell model anywhere in the renderer. So "columns" do not exist here, and I stopped claiming they do. What does hold is the ratio, measured off the font files: | face | Latin | Wide | ratio | | --- | --- | --- | --- | | DejaVu Sans + Noto Sans CJK (live default) | 0.51–0.59 em for real tag text | 1.00 em ideograph / 0.92 em Hangul | 1.56x–1.96x | | JetBrainsMono + Noto Sans Mono CJK | flat 0.60 em | 1.00 / 0.92 em | 1.53x–1.67x | Both under 2, so charging 2 over-spends and never under-spends — one-sided error, on the side that fits. In em against the 6.948 em `Coding Test…` occupies (the string the 12 was calibrated on): the CJK tag was 12.000 em unclipped, 1.73x the budget, and is now 6.000 em. The header comment says exactly this instead of "never wider than `max` glyphs in a monospaced face", and names what it still does not bound: `MMMMMMMMMMMM` is twelve cells and still too wide, and an emoji spends one while rendering 1.245 em. **The bound assert.** `Fmt.cols(chip.props.text) <= chrome + panel.CHIP_TAG_MAX`. It needed an export rather than a local because it lives in `compare()` and the file has no register budget for another file-scope helper. I tried the export-free form `Fmt.clip(chipText, chrome + max) == chipText` first and threw it away: it is a fixed point, so a no-op `Fmt.clip` satisfies it trivially and your vacuousness test stops working. The `Fmt.cols` form keeps it — no-op clip with the unit asserts stripped still dies there, at `:1997`. **F2 — the staleness guard is in, verbatim.** Three of the four survivors become kills at `:2336`, as you predicted: `DUR_W`, chip `fontSize`, row `gap`. It also kills `CHROME_W` moving. The fourth, `plugin.toml` `width` 578 -> 320, still survives; I left it alone — nothing ties the manifest width to `LEFT_W + RIGHT_W`, that predates this PR, and it is its own change. **F3 — `"Präsentation"` added, German case kept.** Your reading is right and my doubt was wrong. The byte-vs-character case now dies on its own merits rather than borrowing the invalid-UTF-8 assert's kill: `Fmt.cols` counting `#text` dies at `:171`, and the faithful byte-index cut dies at `:194` on `"Präsentationsentwurf"`. **F4 — all three claims corrected, in `panel.luau`, the `Fmt.clip` header, the commit message and the PR body.** "Only child with no width of its own" is now "only child whose content is unbounded". "Nothing in the UI tree elides" is gone; a `ui.label` with `maxWidth` and `maxLines >= 1` does elide, and the comment says so and points at the branch. "Undefined overflow behaviour" is replaced by the wrap mechanism, with the chain you traced — and I opened every one of those files rather than copying your line numbers, which turned up three that were off: `cursor += item.main` is `flex.cpp:737` not `:741`, `m_clipChildren` is `node.h:211` not `:210`, and the Pango block wants `:436-451` (`kHardMaxLines` at `436`, `lineBudget` at `447`) rather than `:449-454`. Everything else was exact. ## More false claims, outside the findings "Only `box`, `image` and `button` take `onClick`" is also wrong: `kFlex` carries `onClick` (`ui_tree_reconciler.cpp:449`) and row/column get the InputArea wrapper when clickable (`:371`). So a clickable wrapper *was* available, and a row wrapping a `ui.label` with `maxWidth` + `maxLines = 1` would have elided without any clipping at all. The chip is still a button, but for the reason already written six lines above `buildScope` — the theme owns a button's radius and no plugin prop reaches it, so a rounder wrapper could only disagree with the hover rectangle drawn inside it — not for lack of an alternative. The body says that now. And a fifth, in the comment I was rewriting: it derived the budget from "a dated day button" and then named `"Yesterday"` as the worst case. `Mon 31/08` measures wider (5.308 em against 5.005 em), it is what the derivation already subtracts, and it is the chrome you verified live. Fixed. And a sixth, which the sweep for the second one turned up: `panel.luau:1060` repeated "only box, image and button accept onClick" over the history row's own tag button, outside the region this PR touched. Same falsehood, same fix — the load-bearing reason there is that `ui.label` takes no `onClick` and `selected` is what tints the filtered row, so a clickable row wrapper would have to hand-roll that tint. ## Beyond the findings Your vacuousness technique found one more hole once F1 was fixed. With the unit asserts stripped, reverting to a code-point budget **survived** the whole suite: state 5b's tag is Latin, so the panel-level bound never saw a Wide tag, and the only thing pinning the fix was the unit asserts in the same file as the code. State 5b now drives a second `open()` with `日本語のタグはとても長い` and reads the chip off the tree — no `check()`, so no fourteenth fixture and no change to the field-comparison count. That mutation now dies at `:2357`. I also pinned the ranges themselves: one code point out of each of the six otherwise-untested ranges in a single assert at `:175`, because dropping Hangul Jamo, the fullwidth forms or the supplementary planes was silent. 31 mutations, one survivor, and it is the pre-existing manifest-width one. Verified live in three scripts on the running shell, with the trend caption carrying the unclipped tag in the same frame as the clipped chip — before and after in one screenshot. ``` viewModel: 13 states, 1870 field comparisons ok 0 errors, 0 warnings ``` ## What I would challenge next The 2. It is safe and it is slack: a CJK chip now renders ~6.0 em where the Latin string the budget was calibrated against renders 6.9 em, so wide tags clip a little earlier than they must. 2 is the terminal convention and the only integer that is an upper bound in both font stacks, but it is a convention borrowed from a grid this renderer does not have. And the guard still cannot see the font: `shell.font_family` is not on the tree the suite walks, so the one input that would most obviously invalidate the 12 is the one input nothing pins.
yuris force-pushed fix/clip-filter-chip from f3f64522c9 to a12cf5ba8e 2026-09-02 08:49:25 +00:00 Compare
yuris merged commit 2029e99d0d into main 2026-09-02 08:54:40 +00:00
yuris deleted branch fix/clip-filter-chip 2026-09-02 08:54:40 +00:00
Sign in to join this conversation.
No reviewers
No labels
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
yuris/noctalia-timew!20
No description provided.