feat(panel): clip the history list in a real scroll viewport, delete the pager (takes the row family's closures) #12
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "upgrade/u2-scroll"
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?
Deletes the pager and replaces it with a real clipped scroll viewport. The row
list is no longer capped: every row that matches the current filter is on the
tree and reachable by wheel or scrollbar.
This PR takes the row family's closures as a prerequisite of uncapping, not as
scope creep. Unbounded rows and named handlers are incompatible —
onRow1..8existed because the list was eight rows, so "delete the cap" and "rows carry
closures" are one change. Writing out
onRow1..onRow200in order to delete themin the next PR would be work created to be destroyed. Only the row family
converts here;
onDay1..14,onTag1..4,onClearFilter,onScopeDay,onScopeAll, the four input handlers,onStop,onStartDraft,onOpenandonIpcare untouched and still named. The next task inherits a smallerregistry than planned — 22 named handlers instead of 30, and no
onRowfamily.
Closures need no version bump.
kUiCallbackClosure(9) has zero call sitesin the shell;
luau_ui_renderroutes anylua_isfunctionprop toregisterUiHandlerwith no version check at all (plugin_bindings.cpp:568-586).Why the viewport clips: the chain, read out of the shell
heighton aui.scrolldoes not clip, and that is the whole reason the READMEconcluded scrolling was impossible. It maps to
setMinHeight/setMaxHeight, andScrollViewmeasures throughFlex::measureByLayout, which honours an exactconstraint or
m_explicitHeightand nothing else — min/max never bind, so theviewport falls through to
naturalHandm_maxScrollOffsetstays 0(
flex.cpp:770-777,scroll_view.cpp:417-418). The README's conclusion wasright; its implied cause was not.
flexGrowbinds, but only down an unbroken chain of known heights. The shellhands one over and the plugin has to claim it:
PanelManagersizes the panelpanel_manager.cpp:2644-2653Style::panelPadding= 422PluginPanel::createnests the tree in aflexGrow = 1column inside a root the panelsetSizes outrightplugin_panel.cpp:147-166,289flexGrow = 1on our root rowm_contentFlexis vertical, so grow is along its main axis → the row is arranged at 422ui.row/ui.columndefault toalign = "stretch"ui_tree_reconciler.cpp:646, 655flexGrowof its own, which is what letswidth = RIGHT_Wstay authoritativeflex.cpp:751-757+node.cpp:113-120(m_arranging)heightKnown→ grow distribution runsflex.cpp:525-539constraints.hasExactHeight()→measureHeight→setSizeFromLayout→height() > 0→viewportHbounded →m_maxScrollOffset > 0Drop the
flexGrowfrom the root and every node below it measures at contentheight again, and the list silently stops clipping. That is why the suite
asserts all three of
root.flexGrow == 1,scroll.flexGrow == 1andscroll.height == nil— any one of them alone is not the property.onScrollis not wired. It fires once per animation frame during the fling,not per detent, and both args arrive as strings; the panel needs neither the
offset nor a repaint from it, so the simplest correct answer was to leave it out.
No keys on rows. A generated handler name is the node's path, positional for
an unkeyed node (
plugin_bindings.cpp:450-458), so the once-a-second repaintproduces byte-identical names and rewires nothing. Keys buy node identity —
input focus, caret, scroll offset, hover slot — and a row holds none of those:
the caret is in the search field above the scroll and the offset belongs to the
ScrollView, which is one positionally stable child.ALL_WALK_MAX = 200isalso comfortably under
kUiTreeMaxChildren = 256, above which the shell dropsthe tail with a warning (
plugin_bindings.cpp:378, 544-549); the suite assertsthat pair.
Rulings on the caps that were not automatically obsolete
RECENT_MAX = 4— kept. The chip row is in the left column, a plain flexbeside the viewport rather than inside it, so it still cannot clip. The cap is
a genuine display cap and the comment on
fetchTagsnow says which column itis about instead of "the panel". Wrapping the left column in a second viewport
to delete this would put two scrolls in competition for one vertical budget for
four buttons' worth of gain.
DAY_WALK_MAX = 60/ALL_WALK_MAX = 200— kept, and reclassified. Theseare cost controls: each id is one
timew getinside a shell loop, so they boundhow many subprocesses a fetch runs, and a clipping viewport changes nothing
about that. What did change is that they are now the only cap between timew
and the screen, so they are also the row count the tree has to survive — which
newly couples them to
kUiTreeMaxChildren = 256. That coupling is stated on theconstants and asserted in the suite beside
T.cmd.allIntervals(), which readsthe bound back out of the walk script the constant builds. (It was not so
asserted when this PR was first opened:
test.luau:130wasassert(200 < 256, …), two literals, and it could not fail. Caught in review, replaced, andmutation-proved.) Lowering them would lose data; raising
ALL_WALK_MAXpast 256would silently drop the tail.
The
listOffsetbug fromREFACTOR.md: unreachable, not merely unlikelyThe bug:
listOffsetwas module state that outlived the list it indexed.gotoPageclamped it against thematchingof the last render, so abackground refetch that shrank the day under a pager nobody rewound left the
offset where it was —
firstShown = 17,lastShown = 16, an empty window under"17-16 / 12". The suite reproduced it deliberately.
It is unreachable by construction now, and the argument is structural rather
than a clamp:
listOffsetis deleted, and so are its fourrewind sites (
loadDay,setFilter, both scope handlers,onOpen). Astale value needs a value.
pageCount,pageOf,pageStrip,state.matchingand the wholem.pagertable are gone. Thebackwards range was two derived numbers disagreeing; there is one list now
and its own length is the count.
viewModel()call that paints.Query.matchingreadsstate.dayRows/state.allRowsand returns;commitpublishes that very table into
state.shownand hands the same model torender. So the only thing that can disagree with the rows on screen is therows on screen.
publication. A row's closure captures an index, and
filterFromRowreadsstate.shown[i]— but the handler table is replaced wholesale each render(
ui_handler_table.h:5-19), so the only closures reachable are the newestrender's, and that render's
commitpublished the list they index. A shortlist cannot be indexed by a long list's closure.
same(m.pager, nil, ...)runs in every one of the 12 model states, so apagertable coming back fails 12 times rather than needing a new test.
Deleted tests → the assert that carries the invariant
Query.window(rows, 0, 3)/(rows, 8, 3)— page slicingcaprows fromoffsetQuery.matching(wideSource, …) == 200— "the row cap is gone, not merely raised"Query.window(rows, 10, 3)/(rows, 99, 3)— offset at/past the endsame(m.pager, nil)in all 12 statesQuery.window(…, predicate)narrows window and count#matched == 5+matched[1] == rows[2] and matched[5] == rows[10]— same claim, plus order and by-reference identity the old assert did not makeQuery.window({}, 0, 3)empty source#Query.matching({}, …) == 0, "an empty source is empty, not nil"— verbatimQuery.pageCount(0/8/9, 8)(3 asserts)Query.pageOf(0/8/15, 8)(3 asserts)Query.pageStripcentring, 5-wide and 3-wide (9 asserts)#clickedTexts(…) == 8"the window caps the page at ROWS_MAX"ROWS_MAX, not what timew sent#clickedTexts(panelTree, ROW) == 20"every matching row is on the tree, not a page of them" +#scrollNode().children == 20#clickedTexts(…) == 4"the last page is the remainder"#m.rows, asserted per state incomparenot onScreen("1-8 / 20")+onScreen("panel.page_range")not onScreen("1-8 / 20")("the range label the panel used to build is gone"); thepage_rangehalf died with the label.panel.page_range,panel.newerandpanel.olderhad no consumer left oncebuildPagerwent, so all three are deleted fromen.jsonand from the suite'sENcopy of itm.pager.pages/current/pageBase/stripvsQuery.*(5 asserts incompare)same(m.pager, nil)in every state#kids == strip + 4,‹/›handlers andenabled, per-slot text/selected/handler, range label (11 asserts incompare)#rightKids == 6, search at 4, scope at 5,rightKids[6].kind == "scroll", and every scroll child is arowpage1.pager.pages == 6,#strip == PAGE_BUTTONS,pageBase == 2,#rows == 5(states 9-11)#wideModel.rows == 45,#scrollNode().children == 45,#clickedTexts(…, ROW) == 45, andpanel.state.shown == panel.state.allRows(the by-reference publication)onPage1..5readm.pager.pageBase(state 13, 3 asserts per slot)clickRow(45)→filter == "tag45"on a fixture with a distinct tag per row. Same invariant — a click resolves to the thing its node showed — on the mechanism that replaced the strip. The fixture's tags are distinct because a repeated tag would let every closure capture the same index and passpast.pager.firstShown == 17 and lastShown == 16(state 13, the reproduced bug)#scrollNode().children == 12,children[13] == nil, andclickRow(12)still resolvingpaintsFor("a page click", 1, …)onScrollis unwiredstate.matching == m.pager.totalinpaintsForand at the tail#panel.state.shown == #m.matchedRowsplus per-row identitypanelEnv.onRow1()/onRow2()clickRow(1)/clickRow(2), which invoke the tree's ownonClickprop. The suite can no longer name a handler the panel did not put on a nodeNew coverage with no deleted counterpart: the three layout props
(
root.flexGrow,scroll.flexGrow,scroll.height == nil); the shared rightedge (
chromeW - rowW == INSET,INSET + rowW == chromeW,scroll.width == rowW + INSET*2 + GUTTER);r.handler == nilandtype(onClick) == "function"and dot-shares-tag's-closure, per row per state;ALL_WALK_MAX < kUiTreeMaxChildren.Geometry: unchanged, deliberately
plugin.tomlkeepswidth = 578, height = 450. The width is untouched, and theheight is now a real budget instead of dead space: deleting the pager row
(
CHIP_H 22+gap 6) hands 28 px back to the list, so the viewport showsroughly 9-10 rows where the pager showed 8 — and the rest are a wheel away
rather than four clicks.
One thing did have to move, and it is the change most worth challenging: the
history block's right edge is
RIGHT_W - 18rather thanRIGHT_W. The scrolltakes
Style::spaceXs(4) on each side asm_viewportPaddingH— unreachablefrom a plugin, since
kScroll'spaddingHconfigures the inner content flex andnot the viewport (
ui_tree_reconciler.cpp:1693-1701) — and reservesscrollbarWidth 6 + scrollbarGap 8when the content overflows(
scroll_view.cpp:429-431). Both are reserved unconditionally here, becausea gutter that appeared and vanished with the row count would slide the duration
column 14 px sideways the moment the list outgrew the viewport. So rows are
LIST_Wand the chrome isCHROME_W = LIST_W + 4, which puts the durationcolumn and the scope total on the same pixel — the pair the README insists must
line up. The cost is that the history block sits 18 px inside the chart's right
edge, one block down past a separator, and the list reads as indented 4 px under
its own header. The alternative — chrome at
RIGHT_W, rows atLIST_W— putsthe 18 px step between the scope total and the durations directly beneath it,
which is worse.
Gates
Line delta:
panel.luau1782 → 1700 (−82).test.luau1939 → 2010(+71).
en.json34 → 31 (−3). Net −14 across the three.What a reviewer should challenge hardest
Every link is cited above and the shape differs from the probe's verified one
in three ways: the root is a
rowcarryingflexGrowrather than a column;the scroll's parent gets its height purely from the root row's default
align = "stretch", never from aflexGrowof its own; and nothing sitsbelow the scroll, so "a footer stays pinned" is not what is being claimed —
the chrome above it staying put is. A live screenshot of this exact tree is
requested and pending; if the real
viewportPaddingHor gutter differs from4/14 the two constants move and the asserts move with them, but the clipping
argument does not depend on those numbers.
Query.matchingreturns the source table by reference when there is nopredicate. That is deliberate — all-time scope filters in timew, so a copy
would duplicate up to 200 rows once a second — and it means
state.shown,state.allRowsandm.matchedRowsare one table in that scope. Nothingmutates it today; a builder that ever did would move state without writing
state.<field>, which is the shape the note on thestatetable alreadywarns about.
remove
state.shownfrom the click path entirely. It captures the indexbecause
onIpc("row", n)arrives with nothing but a number, and onefilterFromRow(i)for both paths beats two spellings of "row i" — but itdoes mean the publication in
commitstays load-bearing, and the argumentthat a stale index is unreachable rests on the handler table being replaced
wholesale each render rather than on anything in this file.
the tag button;
compareasserts they are the same value. If they everdiverge, a click on the dot and a click on the tag would filter by different
rows and only that assert would say so.
RECENT_MAXstaying is a judgement, not a proof. The left column couldhost its own scroll. I ruled it not worth a second viewport for four buttons;
disagree and it is a one-line change plus a chain like the one above.
Live verification: the viewport clips, and the right edges are exact
The PR body derived the layout chain from the shell source and flagged that a
screenshot of this exact tree was pending. It has now been rendered in a
578x450 panel, screenshotted, wheel-scrolled, and measured off the raw PNG. All
three claims that differ from the previously-probed shape hold.
1. Right-edge alignment: exact, zero offset.
LIST_W = 283/CHROME_W = 287are correct as written.
Decomposing from the column origin:
4 left pad + 283 content + 8 gap + 6 bar + 4 right pad = 305. Scale is 1 on the test box, so there is noscaled()/raw-Stylemismatch hiding in it. Constants confirmed instyle.h:spaceXs = 4(:23),spaceSm = 8(:24),scrollbarWidth = 6(:48),scrollbarGap = spaceSm(:49).2. Clipping: yes. Thumb at the top of the track on open, rows 1-10 fully
visible, row 11 cut mid-height at the panel's inner edge. Measured during the
fling:
lastOffset=224 lastMax=635. With 40 rows atheight 20 + gap 1thecontent is 839, so the viewport is
839 - 635 = 204 px— 9.7 rows against thepager's 8, with the remaining 30 a wheel away instead of four clicks.
3. Chrome above stays put. After a four-notch wheel over the scroll body the
rows showed 11:00..21:00 while the trend label, all 14 bars, the horizontal
separator, the search row and the Day/All row were pixel-identical and fully
visible. The viewport clips at the top too — row 11 renders as a partial row.
4. The vertical separator now spans the panel. Stretch does give it the root
row's height (~424 px of the 448 px interior) rather than content height, which
it had before. Sampled down
x=917it peaks mid-panel and fades to thebackground within ~14 px of each end, so it reads as a soft vertical fade rather
than a crisp rule. That is
ui.separator's own gradient, not a layout artifact;a hard rule would need a
ui.box({ width = 1 }). Left alone as a cosmeticquestion outside this PR.
One finding that strengthened a decision, and is now in the code
The scrollbar is painted whether the list scrolls or not:
m_scrollbar->setVisiblereads the always-truem_showScrollbar(
scroll_view.cpp:447), only the gutter reservation keys offm_scrollbarShown(:429-430), andsetScrollbarVisibleexists in C++ but isabsent from the
kScrollwhitelist — so a plugin cannot turn it off.This makes the unconditional gutter reservation the only correct answer rather
than merely the steadier one, and it retires the alternative the PR body
considered and rejected. Measured at 6 rows: content 125 px against a 204 px
viewport, no scrolling, thumb still painted at
x=1221..1226. Rows stay 283wide, so both right edges still land on 1212 and the bar sits in the 14 px of
slack. Had the rows taken
flexGrowinstead ofwidth = LIST_Wthey would havejumped 14 px wider at exactly the row count where the list stopped
scrolling — the worst possible moment for a duration column to move.
3dc78b4records that reason onLIST_W. Comment only, no code change; bothgates re-run green on it.
Non-finding, checked because it is a silent failure mode
textAlignaccepts onlystart | center | endand warns-and-drops anythingelse (
ui_tree_reconciler.cpp:253-259), so a"right"would have shipped aragged duration column with nothing but a log line. Both call sites here —
buildList's duration label andbuildScope's total — already useend.What this changes in the PR's "challenge hardest" list
Item 1 (the layout chain read from source rather than a screenshot of this tree)
is now closed by measurement: the shape works, the constants are right, and the
one number worth knowing is that the viewport is 204 px. Items 2-5 stand as
written.
Verdict: REQUEST_CHANGES
The layout chain is right, the pager is gone without residue, the closures are correct
under the hazard
UPGRADE.mdnames, and the deleted-test table is honest in 17 of its 18rows. One row is not, and it is the row that matters: the assert this PR advertises as the
guard between
ALL_WALK_MAXandkUiTreeMaxChildrenis a comparison of two literals andcannot fail. Two shipped source comments state that it holds the pair together. It does
not, and I proved it does not.
Everything below was derived independently: the shell chain re-read at
/nix/store/7pgw3p2dhkn8sfpnli7qiyzs9zyhr01d-source, the numbers recomputed, 26 mutationsrun against lines this PR wrote in a throwaway copy at
/tmp/mut(the worktree was neverwritten to; final
md5summatches the pristine tree).Blocking
1.
test.luau:130— theALL_WALK_MAX/kUiTreeMaxChildrenassert cannot failBoth sides are literals.
200is not read fromALL_WALK_MAX(a module local, no export),and
256is not read from anything. The assert is true at parse time and stays true forevery possible value of the constant it claims to bound.
Two shipped comments claim otherwise:
timew/panel.luau:70-71— "soALL_WALK_MAXhas to stay under that; the 200-row assertin test.luau is what holds the pair together."
"the suite asserts that pair."
Proof it is not held together. Two runs,
ALL_WALK_MAX = 200 -> 300:So the only thing that fires is the argv-script literal at
:349, whose message is aboutwalk defaults and says nothing about the tree; the natural repair walks straight past
:130, which still reads200 < 256. At that point the scroll node is handed up to 300children and the shell clamps it:
(
kUiTreeMaxChildren = 256atplugin_bindings.cpp:378.) The node is kept, the tree stillrenders, 44 rows are gone, and the only trace is one shell log line. That is exactly the
silent failure the assert is named for.
The cap is observable from the suite —
T.cmd.allIntervals()already carries it — sothe real check costs two lines. Placed at
test.luau:355(after the existing walk asserts,where
Tis in scope; line 130 is above the module load and has noT):Verified both ways: green on
HEAD, and withALL_WALK_MAX = 300plus the:351/:354literals updated it fails with
test.luau:356: ALL_WALK_MAX 300 is at or over the shell per-node child cap, so the tail of the list is dropped unrendered.Delete
test.luau:130when you add it, and the sentence attimew/panel.luau:70-71becomestrue as written.
Advisory
2.
test.luau:1472-1473— only row 45's captured index is pinned; interior rows are notThe fixture was built with a distinct tag per row precisely so a closure's index is
observable — "a repeated tag would let every closure capture the same index and pass" —
and then only the last row is clicked. A stale index on any interior row survives the whole
suite:
clickRowis called at 1, 2, 12 and 45; rows 3-11 and 13-44 are unverified. The fixturealready pays for the coverage, so collect it. Replacing the two lines at 1472-1473:
Verified: green on
HEAD, and it kills the row-5 mutation attest.luau:1474. Each clickrepaints, and all-time scope passes no predicate, so the 45 rows survive every iteration.
3.
test.luau:966-975— the shared right edge is pinned on the scope row onlychromeWis read fromrightKids[5](the scope row).rightKids[4], the search row thisPR moved from
RIGHT_WtoCHROME_W, is never checked, so half the chrome can drift back:That leaves the search field 18 px wider than the scope row directly under it and the rows
under that — the exact invariant the PR lists as new coverage. One line, after
:968:Verified: green on
HEAD, fails attest.luau:969under the mutation.4.
timew/panel.luau:26-46— "the scrollbar is painted whether the list scrolls or not" is not what the source doesCommit
3dc78b4rests its whole reason on this::447makes the Scrollbar container node visible. Its parts are gated one layer down,and
ScrollView::doLayoutfeeds them the live extents on every pass(
scroll_view.cpp:449,m_scrollbar->update(viewportH, contentHeight, m_scrollOffset)):At the 6-row case the comment cites (content 125 px, viewport 204 px),
m_shownis falseand track, thumb and both hit areas are hidden — nothing is painted in the gutter. The
reported thumb at
x=1221..1226under 6 rows does not follow from this source.No code change:
LIST_W/CHROME_Ware correct either way, because rows carry a fixedwidth = LIST_Wand cannot grow into the freed 14 px. The paragraph's second argument isthe true one and is worth keeping on its own —
m_scrollbarShown = m_showScrollbar && m_content->height() > viewportH + 0.5F(
scroll_view.cpp:429) does mean the gutter appears and vanishes at the overflow boundary,which is what would move a flexible row 14 px. Drop the "painted whether the list scrolls or
not" claim and keep the jump argument.
5.
timew/translations/en.json:28,29,33— three dead keys survive the pagerpanel.newer,panel.olderandpanel.page_rangehad exactly one consumer between them:buildPager, deleted here. Nothing intimew/reads any of the three now; the only otheroccurrences are the suite's
ENmirror (test.luau:1048,1049,1053). The PR body accountsfor
page_rangeonly, and does so backwards — it stays "because that table is a verbatimmirror of
en.json", buten.jsonis the source and the mirror is the copy.newerandolderare not mentioned at all.noctalia plugins lintreports0 errors, 0 warnings, sonothing catches this. Delete all three from
en.jsonand from theENmirror.6. Small stale references
test.luau:685-688— the comment aboveclickedPropstill reads "onTagN is the chiprow, onRowN the activity list", four lines above the
ROW = "^function"selector thatreplaced
^onRow%d+$precisely becauseonRowNno longer exists.timew/panel.luau:975citesplugin_bindings.cpp:568-586for "luau_ui_render registersany function-valued prop".
568-586isluau_ui_render, which creates the fresh handlertable and swaps it in; the registration is
plugin_bindings.cpp:523-524(
if (lua_isfunction(L, -1)) { out.props.emplace(propName, registerUiHandler(...)); })plus
registerUiHandlerat463-471. Same forpanel.luau:1496citing450-458for thenode path —
uiNodePathis459-463. The substance is right in both cases (there is noversion gate anywhere on that path;
kUiCallbackClosurePluginApiVersion = 9is defined atplugin_api.h:11and referenced nowhere in the tree), only the line spans are off.panel.luauis 1695 at3dc78b4, not 1687,so -87 not -95, and the net across the two files is -34 not -42.
Verified
The scroll clips, and every ancestor passes an exact height down. Re-derived from
source, independent of the screenshot:
m_flex(root column, Stretch)m_flex->setSize(width, height)outside layout pinsm_explicitHeightplugin_panel.cpp:289;flex.cpp:123-127m_contentFlex(column, Stretch,flexGrow = 1.0F)plugin_panel.cpp:153-156;flex.cpp:537-538ui.row({ ..., flexGrow = 1 })m_contentFlex's single child (reconcilepushes one root, no wrapper); non-grow children are measured loose on the main axis (measureItem(item, false, 0.0F)), so withoutflexGrowhere the chain breaks at this nodeui_tree_reconciler.cpp:620-623;flex.cpp:510,528-539ui.column({ width = RIGHT_W })— noflexGrowui.row/ui.columndefault toFlexAlign::Stretchui_tree_reconciler.cpp:645-646, 654-655;flex.cpp:493-494Node::arrangesetsm_arranging;Flex::doArrange->runLayout(LayoutConstraints::exact(...))node.cpp:113-118;flex.cpp:734-736, 765-767ui.scroll({ flexGrow = 1 })hasExactHeight()->setSizeFromLayout->height() > 0-> boundedviewportH->m_maxScrollOffset > 0flex.cpp:770-777;scroll_view.cpp:417-419, 441-442No non-growing ancestor anywhere on the path. The negative half holds too:
heighton ascroll maps to
setMinHeight/setMaxHeight(ui_tree_reconciler.cpp:1706-1709), andScrollView::doLayoutreads onlyheight()(:418) — min/max are clamped inFlex::runLayout, whichScrollViewoverrides — so a sized scroll falls tonaturalHandm_maxScrollOffsetis 0. One correction for the record:ScrollView::doMeasureisscroll_view.cpp:486-488, a three-line delegation toFlex::measureByLayout; the heightchoice cited as
scroll_view.cpp:417-418is indoLayout.flexGrowis on thekScrollwhitelist (
ui_tree_reconciler.cpp:496) and is applied generically before type dispatch(
:960-962).Geometry, recomputed.
RIGHT_W = 19*14 + 3*13 = 305.m_viewportPaddingH = Style::spaceXs = 4(
scroll_view.h:94,style.h:23), gutter= scrollbarWidth 6 + scrollbarGap 8 = 14(
style.h:48-49,24).availableW = 305 - 8 = 297;contentWidth = 297 - 14 = 283 = LIST_W;CHROME_W = 287.4 + 283 + 8 + 6 + 4 = 305. Both constants correct as written.Row closures are correct under the real hazard. Every conditional path in
buildListwas walked:
onRowis built unconditionally per iteration and assigned unconditionally toboth click targets; the only conditionals are
tooltip(not a handler) and the#out == 0empty-state label.
iis fresh peripairsiteration.m.rowsandm.matchedRowsarebuilt one-to-one in the same loop, so the captured index addresses the published list.
renderhas exactly one caller —commitatpanel.luau:402— andstate.shownispublished at
:401, before the paint, from the same model object. The cross-render argumentholds at the shell too: handlers register into a fresh table per render and the table is
swapped wholesale only on a successful root read (
plugin_bindings.cpp:576, 581-582), andpushCallbackresolves@ui:-prefixed names against the live table only(
luau_host.cpp:3085-3091) — a miss returns before any result machinery(
script_runtime.cpp:702-705), silently, asUPGRADE.mdsays.Cross-boundary, producer to consumer. A function-valued
onClickis registeredunconditionally by
readUiTreeNode(plugin_bindings.cpp:523-524), regardless of nodetype, and the prop the tree carries is the generated name string.
onClickis on bothconsuming whitelists —
kBox(ui_tree_reconciler.cpp:456) andkButton(:474) — soneither of the row's two click targets is dropped. No version gate exists on that path.
Caps.
ALL_WALK_MAX = 200-> at most 200 scroll children, underkUiTreeMaxChildren = 256;tree depth is 5 against
kUiTreeMaxDepth = 32(plugin_bindings.cpp:377).RECENT_MAXstaying is sound: the chip row is
panelTree.children[1], the left column, not inside thescroll.
The deleted-test table. All 18 rows checked against the file. 17 are honest, including
the four I expected to be soft: the
Query.windowpredicate row is genuinely strengthened(order and by-reference identity,
test.luau:101-106); the pager-row-shape row is reallyreplaced by the chrome-outside-the-viewport asserts (
:936-942); theonPage1..5row isreally replaced by
clickRow(45)on a distinct-tag fixture (:1472-1473, see finding 2 forits one gap); and the reproduced-bug row is really replaced by 12 rows,
children[13] == niland a resolvingclickRow(12)(:1494-1503).same(m.pager, nil)does run in all 12 states. The 18th is finding 1.
Gates, re-run at
3dc78b4in the worktree.The grep claim holds, and a wider sweep (
pageCount|pageOf|gotoPage|onPage|buildPager|m\.pager|page_range)returns nothing in
panel.luaueither — no pager concept survives under a new name. Theonly pager residue anywhere is the three translation keys in finding 5.
Not checked
derivation, and finding 4 is where it disagrees with the reported measurement. Everything
else in the live comment is consistent with the source.
tracking — roughly 1000 tree tables and 200 registered closures per paint, against the
25 ms per-handler budget the file cites. The suite's
paintsForcounts paints, not theircost. I have no measurement either way, so I am not raising it; it is the one thing
uncapping bought that nothing in the diff bounds.
filterFromRow's nil guard (panel.luau:1030) is untested — removingrow andleaves the suite green — but both the guard and the gap predate this PR (
4deaaf2panel.luau:998; base suite only exercisedonIpc("row", 1)and("row", 2)), so it isout of scope here.
noctalia msg plugin yuris/timew:panel all row 99is the reachablepath if you want it covered.
1606field-comparison count and the 12-state count were taken from the run, notaudited assert by assert.
Mutations
26 mutations against lines this PR wrote, in a copy at
/tmp/mut; every one restored andthe tree re-verified byte-identical (
md5sum 1c45dd1e…,diff -rclean). The worktree wasnever modified. 4 survivors.
flexGrow = 1:954flexGrow = 1:952height = 200:953ui.scroll->ui.column:940:1284(compare)1:1473:101Query.matchingre-caps at 8:107commitstops publishingstate.shown:915RIGHT_W:967RIGHT_W:967RIGHT_Wwidth = CHROME_W:974ALL_WALK_MAX200 -> 300:349(argv literal, not:130):351/:354literals updatedfilterFromRowdrops its nil guard:929gap = 1->gap = 6buildList:996commitpublishes a copy, not the model's table:1469:928:851:938rowsbuilt fromstate.dayRows, notmatchedRows:851Query.matchingreverses order:106commitpublishes a secondviewModel()call:1599The kill set is genuinely strong — 22 of 26, including every layout prop, both closure
targets, the publication link and the chrome/viewport split. The three actionable survivors
are all in new coverage this PR added, and two of them (2 and 3) are one edit each against
fixtures that already exist.
Review actioned —
be4208eThe blocking finding is right and I reproduced it independently before fixing it.
Everything below is quoted from runs, not restated.
Finding 1 (blocking) — accepted, and re-proved from scratch
Reproduced on a pristine copy of
3dc78b4,ALL_WALK_MAX = 200 -> 300with theone-line argv repair any author would make when
:349fires:test.luau:130is deleted. Your replacement is in, at:364-365, whereTis inscope. Both source comments that claimed
:130held the pair are corrected:panel.luau:79-81now says the suite reads the bound back out of the walk script,and the
test.luau:118-125comment says the check is not in that scope and pointsat where it is. The PR body's two sentences are corrected too, and the correction
says plainly that the coupling was not asserted when the PR was opened.
One deliberate keep: your
< 256rather than<= 256. The shell keepsstd::min(objlen, 256)children and only warns above 256, so exactly 256 is legaland the assert is one value stricter than the shell. The margin is wanted — the
scroll's children are the rows today, and a sibling added inside the viewport
later would eat it — and the message ("at or over") matches the condition exactly.
Finding 2 — accepted
All 45 rows clicked.
test.luau:1484-1491.Finding 3 — accepted
rightKids[4]pinned tochromeW.test.luau:981-984.Finding 4 — accepted, and the commit message it rests on
Confirmed at source:
scrollbar.cpp:117-121gatesm_track,m_thumb,m_trackAreaandm_thumbAreaoncontentExtent > viewportExtent + 0.5F, so ashort list paints an empty gutter, not a bar over a row. The
"painted whether the list scrolls or not" claim is gone from
panel.luau:36-47;the gutter-jump argument (
scroll_view.cpp:429) is kept, since it is the one thatactually pins
LIST_W/CHROME_W. The unverifiablex=1221..1226thumbmeasurement is dropped; the
x=1212edge measurement and the arithmetic stay.3dc78b4's message carries the same wrong claim. I did not rewrite it: this reposquash-merges with an empty body (
4deaaf2onmainis title-only), so the claimnever enters
main's history, and force-pushing would dangle the hash this reviewcites. The durable artifact — the source comment — is fixed.
Finding 5 — accepted, all three keys
Not just
page_range. Deleted fromen.jsonand from the suite'sENcopy.Confirmed no consumer survives:
The PR body's backwards accounting is rewritten.
Finding 6 — accepted
clickedProp's comment no longer claimsonRowNexists.plugin_bindings.cpp:568-586→523-524(registerUiHandlerat463-471).plugin_bindings.cpp:450-458→454-459. Note your own correction is off bythe same shape:
uiNodePathis454-459, not459-463.panel.luau−82,test.luau+71,
en.json−3, net −14.The sweep you asked for: are there other asserts of that shape?
Literal-vs-literal: one, and it was yours. Every
assert(in the file wasextracted with a balanced-paren scan (245 of them) and each condition classified
by whether any identifier it reads is live rather than a local bound to a literal:
A second pass over every literal comparison anywhere in the file (not just inside
assert) returns the same one line. So:130was alone in that exact shape."Cannot observe what its message names": one more, now fixed. Every assert
whose message names an identifier or a multi-digit number the condition does not
mention was listed and each checked. All but one are legitimate — the message
names the mechanism and the condition observes its effect — and I mutation-proved
the three that name a module local, since that is the failure mode
:130had:RECENT_MAX4 → 6:830fetchTags caps the chip row at RECENT_MAX: got 6 chips for six tagsVIEWPORT_INSET4 → 6:980the chrome is one viewport inset wider than a row: 285 vs 279SCROLLBAR_GUTTER14 → 20:991the scroll is the chart's full width with the inset and gutter inside it: 305So
:236's deferral ("RECENT_MAXis the caller's job") is honest, and theINSET/GUTTERliterals at:976are real mirrors, not restatements.The one real instance is
tr,test.luau:1072. Its message read"en.json has no key " .. key— buttrreads theENcopy above it, and theluau CLI has no
io, so nothing in this file can seeen.jsonat all. A keypresent in the copy and absent from the file passes. That is exactly what let
finding 5 sit there: three keys were dead in both, and both gates were green. The
copy is the only thing it can name, so it names it, and the drift possibility is
now stated in the message rather than papered over by it.
Vacuous-loop shape, checked and clear. Every
forwrapping an assert (17 ofthem) was listed; each iterates a literal table or a collection whose length is
pinned by an assert immediately above it, so none can pass by iterating zero
times.
Mutations — every assert added or changed
Each mutation is applied in place, the suite run, the tree restored from a
pre-mutation copy and the restore verified by
md5sumbefore the next one.ALL_WALK_MAX200 → 300 + the:351/:354argv literals repaired (your 14b):365ALL_WALK_MAX 300 is at or over the shell per-node child cap, so the tail of the list is dropped unrendered%-le (%d+) %]capture stops matching:365ALL_WALK_MAX nil is at or over the shell per-node child cap, …buildSearch→width = RIGHT_W(your 12b):984the search row shares the scope row's width: 305 vs 287buildList→filterFromRow(if i == 5 then 1 else i)(your 7):1490row 5 closure filters by its own tag, got tag01i == 23— an interior row your mutation did not name:1490row 23 closure filters by its own tag, got tag01EN["panel.untagged"]renamed:1072the EN copy above has no key panel.untagged -- render asked for one no locale defines, or the copy has drifted from en.jsonMutation 2 matters because the new assert is two lines: without it the
tonumberline could rot into a permanent
niland thewalkCap ~= nilhalf would be theonly thing standing. It fires, and the message reads sensibly with
nilin it.Gates
Worktree clean; the mutation harness was a throwaway and is not in the tree.
Not actioned, on purpose
filterFromRow's nil guard. Agreed out of scope — it predates this branch(
4deaaf2), and covering it needs theonIpc("row", 99)path, which is adifferent change than the one under review.
diff makes it worse than the merge did. Left as your "not checked".
gap = 1→gap = 6surviving. Cosmetic, no assert warranted, agreed.