fix: an empty group name is a name; and stop reserving the group rows
Copilot's seventh pass. One applied, one applied by *withdrawing* the previous fix rather than patching it again. **`RenderColGroup::label` is an `Option<String>`.** `""` was the ungrouped-run sentinel, but an empty string is a legal group *name* — `Category::add_item_in_group` accepts one and the TUI draws its indicator like any other — so a real group serialized identically to no group at all and the page dropped its indicator on a truthiness test. `None` is now the run in no group, and `app.js` checks for null. **Inline group rows are no longer reserved in the viewport, and the reason is written down.** Copilot was right twice in a row here, about two opposite mistakes. Counting the current group rows is a feedback loop (a smaller window crosses fewer groups and asks for a larger one). Keeping the maximum is a ratchet: one group header per data row stores an oversized cost on the first oversized projection and clamps the grid to a single row *permanently*. Both were on this branch. The third option — invent a density heuristic — is the one this repo warns against, in a file no test here can execute. So the reservation keeps only what depends on the axes and not on the window: the header lines, the group line, the row-header width. The residue is an over-request bounded by the groups in view, and the off-screen-selection gap it leaves is neither new nor specific to groups — nothing in this page calls `scrollIntoView` at all. Filed rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+25
-28
@@ -116,9 +116,9 @@ function setShellStatus(text) {
|
||||
//
|
||||
// It is measured off the painted model rather than predicted, and defaults to
|
||||
// the old one-and-one before the first paint, which is the only moment there
|
||||
// is nothing to measure. `groups` is kept as its own high-water mark — see
|
||||
// the tail of `repaint`.
|
||||
let frameCost = { view: null, rows: 1, cols: 1, groups: 0 };
|
||||
// is nothing to measure. What it counts, and the one thing it deliberately
|
||||
// does not, is at the tail of `repaint`.
|
||||
let frameCost = { rows: 1, cols: 1 };
|
||||
|
||||
/** The extent this window can show, in whole DATA cells. */
|
||||
function extent() {
|
||||
@@ -262,33 +262,26 @@ function repaint() {
|
||||
// header lines, a group line if there is one, the row header's width in
|
||||
// columns, and every inline group row.
|
||||
//
|
||||
// The group rows are the awkward one, because they depend on how many
|
||||
// rows the window asked for: counted directly they are a feedback loop —
|
||||
// a smaller window crosses fewer groups and asks for a larger one, which
|
||||
// crosses more. So the reservation is a HIGH-WATER MARK, which cannot
|
||||
// oscillate because it only ever rises, and it rises in the safe
|
||||
// direction: over-reserving asks for fewer rows than fit and leaves
|
||||
// blank space, where under-reserving asks for more rows than the table
|
||||
// can show and is the off-screen-selection bug itself. It resets when
|
||||
// the view changes, so a dense document does not make every later view
|
||||
// pessimistic.
|
||||
if (model.view_name !== frameCost.view) {
|
||||
frameCost = { view: model.view_name, rows: 1, cols: 1, groups: 0 };
|
||||
}
|
||||
const groups = Math.max(
|
||||
frameCost.groups,
|
||||
model.rows.reduce((n, row) => n + (row.kind === "group" ? 1 : 0), 0),
|
||||
);
|
||||
// Inline group ROWS are NOT reserved, and that is a decision rather than
|
||||
// an oversight. They take real height, but how many are in view depends on
|
||||
// how many rows the window asked for, so every way of counting them is
|
||||
// either a feedback loop (count the current ones: a smaller window crosses
|
||||
// fewer groups and asks for a larger one) or a ratchet (keep the maximum:
|
||||
// a group-dense grid stores an oversized cost on its first oversized
|
||||
// projection and never lets it fall — one group header per row clamps the
|
||||
// grid to a single row, permanently). Both were tried on this PR.
|
||||
//
|
||||
// What is left is an over-request bounded by the groups in view. The cost
|
||||
// is that a grouped grid asks for a few rows more than the table shows,
|
||||
// which matters only for a selection near the bottom edge — and that gap
|
||||
// is not new and not specific to groups: nothing in this page calls
|
||||
// `scrollIntoView` at all, so a selection the client has not yet scrolled
|
||||
// to can already sit below the fold. Filed as its own ticket rather than
|
||||
// answered with a heuristic in a file no test in this repo can execute.
|
||||
const next = {
|
||||
view: model.view_name,
|
||||
groups,
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0) + groups,
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
||||
cols: model.row_levels,
|
||||
};
|
||||
// The high-water mark is committed unconditionally; only the ANNOUNCEMENT
|
||||
// is gated on the extent changing. Committing it alongside would discard a
|
||||
// risen group count whenever a falling header line cancelled it out, and a
|
||||
// later repaint would then under-reserve — the bug this is here to stop.
|
||||
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
||||
frameCost = next;
|
||||
if (changed) sendViewport();
|
||||
@@ -316,7 +309,11 @@ function gridTable(model) {
|
||||
for (const group of model.col_groups) {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = group.span;
|
||||
th.textContent = group.label ? indicator(group.collapsed) + " " + group.label : "";
|
||||
// `label` is null for a run in no group. An empty STRING is a legal
|
||||
// group name and still gets its indicator, which is why this is a null
|
||||
// check and not a truthiness test.
|
||||
th.textContent =
|
||||
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
|
||||
th.classList.add("group");
|
||||
groups.appendChild(th);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user