fix(panel): clip the filter chip's tag so it stops pushing the scope total off #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/clip-filter-chip"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Reported with the tag
Coding Test Practice: the filter chip's width grew with the taguntil the scope total beside it was pushed past the panel's right edge.
Today All time [Coding Test Practice ✕] 0:15— total clipped offToday All time [Coding Test… ✕] 0:15Why the chip cannot shrink, and cannot elide
The row is fixed and nothing in it gives: free space is grow-only
(
flex.cpp:532clamps it at zero), arrangement is an unclampedcursor += item.main(
flex.cpp:737), and a node does not clip its children (node.h:211).ui.buttonaccepts neithermaxWidthnormaxLines(ui_tree_reconciler.cpp:471-475,against
kLabelat:457-460which accepts both). A barewidthwould not produce anellipsis either, and the reason is not that the behaviour is undefined — it is well
defined, and it is worse. A
widthon a button pins min and max width(
:1404-1407) and does armsetEllipsize(TextEllipsize::End)on the inner label(
button.cpp:707), butButtonnever callssetMaxLines, soLabel::m_userMaxLinesstays 0 (
label.h:143),effectiveMaxLinesis 0 (label.cpp:503), and Pango receiveslineBudget = kHardMaxLines = 500underPANGO_WRAP_WORD_CHAR(
cairo_text_renderer.cpp:436-451). A width-capped button wraps — the ellipsisarrives past 500 lines — and inside a min/max-pinned
height = CHIP_Hthat is atwo-line label in a 22px box.
A
ui.labeldoes elide, withmaxWidthandmaxLines >= 1: the same block, thebranch where the line budget is the caller's. But a label takes no
onClick, andclicking the chip is what clears the filter. A
rowdoes take one(
ui_tree_reconciler.cpp:371,:449) — so a clickable wrapper is available — andbuildScope's own comment says why the chip is one button rather than a tinted rowwrapping 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
maxis a budget in display cells: a Wide or Fullwidth code point (Unicode EastAsian Width classes
WandF, UAX #11) spends two, everything else spends one, and theellipsis costs one of its own. The first version of this counted code points, which is
the same bug in another script:
Fmt.clip(tag, 12), code-point budgetFmt.clip(tag, 12), cell budgetCoding Test PracticeCoding Test…Coding Test…日本語のタグはとても長い日本語のタ…프로젝트관리시스템개발용프로젝트관…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_familyresolves to (cairo_text_renderer.cpp:411sets the font,:472-474takes the extents), which defaults to"sans-serif"(
config_types.h:1070), andui.buttonhas nofontFamilyprop to pin a monospaced one.Measured off the font files on both stacks a user can land in:
sans-serif)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
MMMMMMMMMMMMistwelve cells and still too wide, and an emoji spends one while rendering wider than two
(Noto Color Emoji is 1.245 em);
Emoji_Presentationis a scattered table thisdeliberately 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.subon a byte index would split amulti-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.colsanswersnil, the same answerutf8.lengives) and is returned untouched —timew still matches on it, and clipping is cosmetic.
Where the clip lives, and where it deliberately does not
panel.luaualready 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_TAGand the filter stillselects one row by the full tag.
to its right, so it has no total to push off — and it is a
ui.label, which could takemaxWidthandmaxLinesif it ever needs them. Verified on screen:Last 14 days · 日本語のタグはとても長いfits
RIGHT_Wwith room to spare, in the same frame as the clipped chip. A mutation thatclips it too is killed at
test.luau:1964.The budget, and the guard under it
CHIP_TAG_MAX = 12, measured rather than derived:CHROME_W287, less four 4px gaps, lessDUR_W56, lessAll timeand the widest day button — which is a dated one,Mon 31/08measuring wider than
Yesterday, and the chrome the fix was verified against live. Nothingat 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:
Exported for one assert.
compare()computes its expectation asFmt.clip(m.filter, panel.CHIP_TAG_MAX), so raising the budget cannot leave that linebehind — and state 5b pins the constant itself with a literal (
"Coding Test… ✕"), so theexported constant is not a tautology in both directions at once. The bound assert in
compare()measuresFmt.cols, notutf8.len: in code points it reported both CJK tagsabove 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日本語のタ… ✕,0sfully inside the panel프로젝트관리시스템개발용→ chip shows프로젝트관… ✕,0sfully inside the panelCoding Test Practice→ chip showsCoding Test… ✕,0:15fully inside the panel, byte-identical to the shipped behaviourMon 31/08) → chip clipped, total fully inside the panelLatin 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 -rcopy per mutation, never the live checkout::1982:2322:2322:2322:2322Fmt.clipmade a no-op:182:182:194cut, notcut - 1):182string.substart index 1 -> 2:182:182max - 1->max):182:1964Fmt.colsvalidity guard in bytes (utf8.len->#text):179Fmt.colscounts bytes (the cell walk ->#text):171:171:181:201:171:175:175:175:1982:1982:1997:2357DUR_W56 -> 120:2336fontSize11 -> 22:2336gap4 -> 8:2336CHROME_Wmoved (SCROLLBAR_GUTTER14 -> 20):1685plugin.tomlpanelwidth578 -> 320One survivor, and it is not this change's: nothing ties the manifest's panel
widthtoLEFT_W + RIGHT_W. That hole predates this PR and belongs in its own change.Gates
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 onesame()that changed swappedutf8.lenforFmt.colsinplace.
For a reviewer
above, and the budget charges 2. That is safe but slack: at
CHIP_TAG_MAX = 12a CJKchip 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.
move, which is strictly better than nothing, but it cannot notice the shell font changing
—
shell.font_familyis not on the tree the suite walks, andui.buttonhas nofontFamilyprop to read one off.doblock 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.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 -rcopy at/tmp/pr20. The checkout was never touched.Verified
flex.cpp:532grows only (remaining = std::max(0.0F, containerMain - fixedMain)), there is noflexShrinkin the tree,flex.cpp:741arrangescursor += item.mainwith no clamp, andnode.h:210defaultsm_clipChildren = false. The chip pushes the total past the frame and nothing cuts it.ui.buttontakes neithermaxWidthnormaxLines;ui.labeltakes bothui_tree_reconciler.cpp:471-475and:457-460. Your citation is exact.widthwould not have workedwidthon a button is honoured (ui_tree_reconciler.cpp:1404-1407, min+max) and does armm_label->setEllipsize(TextEllipsize::End)(button.cpp:707). It still never ellipsizes: Button never callssetMaxLines,Label::m_userMaxLinesdefaults to 0 (label.h:143), soeffectiveMaxLinesis 0 (label.cpp:503) and Pango getslineBudget = kHardMaxLines = 500underPANGO_WRAP_WORD_CHAR(cairo_text_renderer.cpp:449-454). A width-capped button wraps; the ellipsis arrives after 500 lines. Inside a min/max-pinnedheight = CHIP_H, that is a two-line label in a 22 px box. Clipping the string is the right layer.:1962; export 99 while the builder clips at 12 dies at:1962. Budgets 11 and 13 both die at:2298.:171-185and a no-opFmt.clipwalks straight through:1962— both sides compute the same wrong string — then dies at:1973. Comment:1973-1977out as well and it falls through to:2298. The bound is exactly what catches the circular line above it. It earns its place.:2291,:2292,:2301.Mutations
All 24 re-run from scratch. Your eight reproduce.
:1962:2298:2298Fmt.clipmade a no-op:173:173string.sub(text, 1, max - 1)):180utf8.offsetwithout the- 1):173:1944utf8.len(text)->#text):185— but only incidentally; see F3n <= max->n < max:172n <= max->n <= max + 1:173:185"":185:173string.substart index 1 -> 2:173:1962:1962:2298:2298:1973DUR_W56 -> 120fontSize11 -> 22gap4 -> 8plugin.tomlpanelwidth578 -> 320One correction to your table: the byte-index kill lands at
:180only for the faithful mutation, the one that keeps the character budget. Writingstring.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_Wand 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:
Dropped into state 5b's
doblock 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.tomlwidth, is a different hole: nothing ties the manifest width toLEFT_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-46andtest.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:Fmt.clip(tag, 12)Coding Test PracticeCoding Test…日本語のタグはとても長い프로젝트관리시스템개발용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_MAXis 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 thanmaxglyphs 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 atmax = 6, a budget the product never uses, and what it actually pins is the code-point cut.Budget columns instead:
I ran this on your branch. The whole suite passes with exactly one assert moved:
:182becomessince 6 now means six columns. Latin output is byte-identical —
Coding Test Practiceat 12 is stillCoding Test…— so state 5b's literal and everycompare()expectation stand unchanged.viewModel: 13 states, 1870 field comparisons / ok, and lint stays0 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,:180is 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-178already spells out. Swaputf8.len(text)for#textin the length guard and the suite still passes once:185is 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:F4 —
timew/panel.luau:110-114and the PR body: three claims in the justification are wrong.scopeButtonsets nowidth) and theui.spacerhave none either. The chip is the only child with unbounded content, which is the argument you actually want.ui.labelwithmaxWidthandmaxLines >= 1elides through Pango (cairo_text_renderer.cpp:449-454). True of buttons, false as written.widthhas undefined overflow behaviour" — it is well defined:PANGO_WRAP_WORD_CHARonto a 500-line budget. It wraps. That is the stronger argument, because a wrapped label inside a min/max-pinnedheight = CHIP_His visibly broken rather than merely unspecified.Non-issues, checked and clear.
Fmt.clip(nil, …)raises, butstate.filterstarts""(panel.luau:150) and is only everParse.trim(tag)(:488), so nil is unreachable.max <= 0returns a bare…and a negativemaxclips 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
b2e0581dd6tof3f64522c9Actioned. 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.clipwalks code points andcharges 2 for East Asian Wide and Fullwidth, 1 otherwise, reserving one cell for the
ellipsis. Your
colshelper is in, promoted to an exportedFmt.cols(see below for why) andwith one range added: the supplementary ideographic planes
0x20000-0x3FFFD, which areWacross their whole range and were the same hole in miniature.
Your
:182prediction holds exactly — it is the only assert whose expectation moved, nowFmt.clip("日本語のタグはとても長いです", 6) == "日本…". Latin output is byte-identical, and Ipushed 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.
日本語のタグはとても長い日本語のタ…프로젝트관리시스템개발용프로젝트관…Coding Test PracticeCoding Test…Coding Test…One correction to your evidence, and it strengthens the finding. You and the PR body both
assume something monospaced. It is not.
ui.buttonhas nofontFamilyprop, so the chipinherits
shell.font_family, which defaults to"sans-serif"(config_types.h:1070,cairo_text_renderer.h:210) — and this host'sconfig.tomlsets no font at all, so the livepanel 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:
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 wascalibrated 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
maxglyphs in amonospaced face", and names what it still does not bound:
MMMMMMMMMMMMis twelve cells andstill 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 anexport rather than a local because it lives in
compare()and the file has no register budgetfor another file-scope helper. I tried the export-free form
Fmt.clip(chipText, chrome + max) == chipTextfirst and threw it away: it is a fixed point,so a no-op
Fmt.clipsatisfies it trivially and your vacuousness test stops working. TheFmt.colsform 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, chipfontSize, rowgap. It also killsCHROME_Wmoving. The fourth,
plugin.tomlwidth578 -> 320, still survives; I left it alone —nothing ties the manifest width to
LEFT_W + RIGHT_W, that predates this PR, and it is itsown change.
F3 —
"Präsentation"added, German case kept. Your reading is right and my doubt waswrong. The byte-vs-character case now dies on its own merits rather than borrowing the
invalid-UTF-8 assert's kill:
Fmt.colscounting#textdies at:171, and the faithfulbyte-index cut dies at
:194on"Präsentationsentwurf".F4 — all three claims corrected, in
panel.luau, theFmt.clipheader, the commit messageand 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.labelwithmaxWidthandmaxLines >= 1does elide, and the comment says so and points at the branch. "Undefinedoverflow 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.mainisflex.cpp:737not:741,m_clipChildrenisnode.h:211not:210, and the Pango block wants:436-451(kHardMaxLinesat436,lineBudgetat447) rather than:449-454. Everything else was exact.More false claims, outside the findings
"Only
box,imageandbuttontakeonClick" is also wrong:kFlexcarriesonClick(
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 aui.labelwithmaxWidth+maxLines = 1would have elided without any clipping at all. The chip is still abutton, but for the reason already written six lines above
buildScope— the theme owns abutton'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/08measures wider (5.308 em against5.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:1060repeated"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.labeltakes noonClickandselectedis what tints the filtered row, so a clickablerow 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 — nocheck(), so no fourteenthfixture 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 thesupplementary 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.
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_familyis not on the tree the suite walks, sothe one input that would most obviously invalidate the 12 is the one input nothing pins.
f3f64522c9toa12cf5ba8e