OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 57a3c7b

Highlight each node's own span within a shared line
Several operations routinely share one line - `!`, `?:` and `return` all live on line 21 of entryKeyFor - so expanding each of them showed the SAME line three times with nothing to say which part belonged to which node. Now each card draws its own span bright and the rest of the line dim. ## Mechanics, in cards.js, tested `spanOnLine(span, lineNumber, rawLine, tabWidth)` returns the columns a node occupies on one displayed line, and `displayColumn` maps a source column through tab expansion to the column actually drawn. That mapping is the part that would silently break: the view expands tabs before drawing, so a tab-indented line shifts by (tabWidth - 1) per tab ahead of the span, and ignoring it marks the wrong text on exactly the files that need it most - GDScript is tab-indented throughout. The test asserts both the right answer AND that raw source columns would have given the wrong one. ## Policy, named separately `marksOwnSpan(card)` decides whether marking is worth doing: only for a span that lives on a single line. This exists because the mechanics are happy to mark a SYMBOL card's span, which on `export function tally (rows) {` is everything after `export ` - accurate and useless. Keeping the mechanical function honest and putting the judgement in its own named predicate beats teaching spanOnLine to have opinions. ## Rendering Three tspans - dim, bright, dim - rather than a positioned highlight rectangle. A tspan flows from the one before it, so the split lands exactly on the character boundary whatever the font's metrics turn out to be, with no advance-width arithmetic to get wrong. Verified by screenshot on entryKeyFor with every node expanded: four cards, all showing line 21, each highlighting a different part of it. Two of my own test expectations were wrong before the code was: a span covering all of a line's ink correctly returns null (so my tab test had to use a real sub-span), and a symbol card's span legitimately yields columns (so the suppression belongs in policy, not in the mechanics). Suite: 185 tests, 1081 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57a3c7bd4d499cda74187a6e144e2f9b701b90e3
by plankatron <fleeky@gmail.com> · 2026-09-20T23:13:35.000Z

Changed files (4) +172 -5

lib/floor/cards.js +62 -0
……@@ -38,6 +38,7 @@ export function buildFileLevel (model) {
3838 code: sym.kind === 'module-body'
3939 ? { sparse: true, container: sym.fn }
4040 : { from: sym.range.startLine, to: sym.range.endLine },
41 span: sym.range,
4142 openTo: sym.fn ? sym.name : null
4243 }))
4344 return { cards, links: fileLinks(model), containers: [] }
……@@ -81,6 +82,7 @@ export function buildSymbolLevel (model, sym) {
8182 sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''),
8283 container: zoneIds.has(op.zone) ? op.zone : null,
8384 code: { from: op.range.startLine, to: op.range.endLine },
85 span: op.range,
8486 openTo: null
8587 })
8688 }
……@@ -99,6 +101,7 @@ export function buildSymbolLevel (model, sym) {
99101 fn.range.startLine + '-' + fn.range.endLine,
100102 container: innermostZoneOf(zones, fn.range),
101103 code: { from: fn.range.startLine, to: fn.range.endLine },
104 span: fn.range,
102105 openTo: null
103106 })
104107 }
……@@ -116,6 +119,7 @@ export function buildSymbolLevel (model, sym) {
116119 sub: 'L' + ex.range.startLine,
117120 container: zoneIds.has(ex.zone) ? ex.zone : null,
118121 code: { from: ex.range.startLine, to: ex.range.endLine },
122 span: ex.range,
119123 openTo: null
120124 })
121125 }
……@@ -235,6 +239,64 @@ export function zoneLabel (zone) {
235239 return out
236240}
237241
242/**
243 * Which columns of one displayed line the node ITSELF occupies.
244 *
245 * Several operations routinely share a line — `!`, `?:` and `return` all live on
246 * line 21 of entryKeyFor — so expanding each of them showed the same line three
247 * times with nothing to say which part was which. This is that part.
248 *
249 * The columns must be DISPLAYED columns, not source columns: the view expands
250 * tabs before drawing, so a tab-indented line shifts by (tabWidth - 1) per tab
251 * ahead of the span. Getting this wrong puts the highlight in the wrong place on
252 * exactly the files that need it most — GDScript is tab-indented throughout.
253 *
254 * Returns null when there is nothing useful to mark: a line outside the span, or
255 * a span that covers the whole line anyway (a function card's span is its whole
256 * body, so every line is "its own" and highlighting says nothing).
257 */
258export function spanOnLine (span, lineNumber, rawLine, tabWidth = 2) {
259 if (!span || typeof rawLine !== 'string') return null
260 if (lineNumber < span.startLine || lineNumber > span.endLine) return null
261
262 const srcFrom = lineNumber === span.startLine ? span.startCol : 0
263 const srcTo = lineNumber === span.endLine ? span.endCol : rawLine.length
264 if (srcTo <= srcFrom) return null
265
266 const from = displayColumn(rawLine, srcFrom, tabWidth)
267 const to = displayColumn(rawLine, srcTo, tabWidth)
268
269 // Nothing to say if it is the entire line's content.
270 const firstInk = displayColumn(rawLine, rawLine.length - rawLine.replace(/^\s+/, '').length, tabWidth)
271 const lastInk = displayColumn(rawLine, rawLine.replace(/\s+$/, '').length, tabWidth)
272 if (from <= firstInk && to >= lastInk) return null
273
274 return { from, to }
275}
276
277/**
278 * Whether marking this card's own span inside its code is worth doing.
279 *
280 * spanOnLine is mechanical and will happily mark a symbol card's span — which
281 * on `export function tally (rows) {` is everything after `export `, accurate
282 * and useless. The feature exists because several operations SHARE a line, so
283 * the policy is: mark only a span that lives on one line.
284 */
285export function marksOwnSpan (card) {
286 return !!(card && card.span && card.span.startLine === card.span.endLine)
287}
288
289/** A source column mapped through tab expansion to the column actually drawn. */
290export function displayColumn (rawLine, sourceCol, tabWidth = 2) {
291 let out = 0
292 const limit = Math.min(sourceCol, rawLine.length)
293 for (let i = 0; i < limit; i++) {
294 if (rawLine[i] === '\t') out = Math.floor(out / tabWidth) * tabWidth + tabWidth
295 else out++
296 }
297 return out + Math.max(0, sourceCol - rawLine.length)
298}
299
238300function describeInput (i) {
239301 if (i.literal) return i.name
240302 return i.name + (i.source === 'node' ? '' : ' [' + i.source + ']')
lib/floor/level.html +2 -0
……@@ -27,6 +27,8 @@
2727 .n-sig { fill: #8b949e; font-size: 9px; font-family: ui-monospace, 'SF Mono', monospace; }
2828 .n-code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 8.5px; fill: #9aa0a6;
2929 white-space: pre; }
30 .code-dim { fill: #5b626b; }
31 .code-own { fill: #e8e8ea; font-weight: 600; }
3032 .n-lineno { font-family: ui-monospace, monospace; font-size: 7px; fill: #484f58; }
3133 .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; }
3234 .n-hit { fill: transparent; cursor: pointer; }
lib/floor/level.js +40 -4
……@@ -14,7 +14,7 @@
1414
1515import { layout } from '/floor/layout.js'
1616import {
17 buildFileLevel, buildSymbolLevel, resolveCode, lineDepths
17 buildFileLevel, buildSymbolLevel, resolveCode, lineDepths, spanOnLine, marksOwnSpan
1818} from '/floor/cards.js'
1919
2020const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -288,9 +288,7 @@ function drawCard (world, card, pos) {
288288 const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' })
289289 num.textContent = lineNo
290290 g.appendChild(num)
291 const t = el('text', { class: 'n-code', x: 24, y })
292 t.textContent = clip(code.lines[i].replace(/\t/g, ' '), Math.floor((pos.w - 30) / 4.9))
293 g.appendChild(t)
291 g.appendChild(codeLine(card, code.lines[i], lineNo, Math.floor((pos.w - 30) / 4.9), y))
294292 y += CODE_LINE_H
295293 }
296294 if (code.clipped) {
……@@ -319,6 +317,44 @@ function drawCard (world, card, pos) {
319317 world.appendChild(g)
320318}
321319
320/**
321 * One line of code, with the card's OWN span drawn bright and the rest dim.
322 *
323 * Several operations share a line all the time - `!`, `?:` and `return` all sit
324 * on line 21 of entryKeyFor - so without this, expanding each of them showed the
325 * same line three times with nothing to distinguish them.
326 *
327 * Three tspans rather than a positioned highlight rect: a tspan flows from the
328 * one before it, so the split lands exactly on the character boundary whatever
329 * the font's metrics turn out to be.
330 */
331function codeLine (card, rawLine, lineNo, maxChars, y) {
332 const text = el('text', { class: 'n-code', x: 24, y })
333 const displayed = clip(rawLine.replace(/\t/g, ' '), maxChars)
334
335 const mark = marksOwnSpan(card) ? spanOnLine(card.span, lineNo, rawLine, 2) : null
336 if (!mark) {
337 text.textContent = displayed
338 return text
339 }
340
341 const from = Math.min(mark.from, displayed.length)
342 const to = Math.min(mark.to, displayed.length)
343 const parts = [
344 { t: displayed.slice(0, from), cls: 'code-dim' },
345 { t: displayed.slice(from, to), cls: 'code-own' },
346 { t: displayed.slice(to), cls: 'code-dim' }
347 ]
348 for (const part of parts) {
349 if (!part.t) continue
350 const span = document.createElementNS(SVG_NS, 'tspan')
351 span.setAttribute('class', part.cls)
352 span.textContent = part.t
353 text.appendChild(span)
354 }
355 return text
356}
357
322358function clip (text, n) {
323359 const max = Math.max(4, n)
324360 return text.length > max ? text.slice(0, max - 1) + '…' : text
test/floor-cards.test.js +68 -1
……@@ -1,5 +1,8 @@
11import { test } from 'brittle'
2import { buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks } from '../lib/floor/cards.js'
2import {
3 buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks,
4 spanOnLine, displayColumn, marksOwnSpan
5} from '../lib/floor/cards.js'
36import { deriveFile } from '../lib/derive.js'
47
58// These were inside level.js and therefore untestable — the view imported the
……@@ -163,3 +166,67 @@ test('cards: pure — same model in, same cards out', (t) => {
163166 t.alike(buildSymbolLevel(model, tally), buildSymbolLevel(model, tally), 'symbol level')
164167 t.alike(ownLines(model, tally.fn), ownLines(model, tally.fn), 'own lines')
165168})
169
170test('cards: spanOnLine marks the node its own columns, in DISPLAYED space', (t) => {
171 // ` const stepped = bump(total)` — the call `bump(total)` is a sub-span.
172 const { cards } = buildSymbolLevel(model, tally)
173 const call = cards.find(c => c.badge === '-> stepped')
174 const raw = LINES[call.span.startLine - 1]
175
176 const marked = spanOnLine(call.span, call.span.startLine, raw)
177 t.ok(marked, 'the call gets a span on its own line')
178 t.is(raw.slice(marked.from, marked.to), 'bump(total)', 'which is exactly the call')
179})
180
181test('cards: spanOnLine shifts for expanded tabs', (t) => {
182 // A tab-indented line with a SUB-span: the call inside the return. In source
183 // columns it starts at 8; displayed, after a width-4 tab, it starts at 11. A
184 // view ignoring this marks the wrong text on every tab-indented file, which is
185 // all of GDScript.
186 const raw = '\treturn foo(x)'
187 const span = { startLine: 1, endLine: 1, startCol: 8, endCol: 14 }
188
189 t.is(displayColumn(raw, 1, 4), 4, 'one tab occupies four display columns')
190 t.is(displayColumn(raw, 8, 4), 11, 'and shifts everything after it')
191
192 const marked = spanOnLine(span, 1, raw, 4)
193 t.ok(marked, 'a sub-span on a tab-indented line is marked')
194 const displayed = raw.replace(/\t/g, ' ')
195 t.is(displayed.slice(marked.from, marked.to), 'foo(x)', 'and marks exactly the call')
196
197 // The same span WITHOUT accounting for tabs would land here — wrong text.
198 t.not(displayed.slice(span.startCol, span.endCol), 'foo(x)',
199 'which is not what raw source columns would have given')
200})
201
202test('cards: spanOnLine says nothing when there is nothing to say', (t) => {
203 const raw = ' const a = 1'
204 // A span covering the whole line's content: highlighting it adds no information.
205 t.is(spanOnLine({ startLine: 1, endLine: 1, startCol: 2, endCol: raw.length }, 1, raw), null,
206 'whole-line span is not marked')
207 // A line outside the span entirely.
208 t.is(spanOnLine({ startLine: 5, endLine: 6, startCol: 0, endCol: 3 }, 1, raw), null,
209 'a line outside the span is not marked')
210 // A symbol card's span DOES yield columns — on `export function tally (rows) {`
211 // it is everything after `export `, which is accurate and useless. That is a
212 // policy question, not a mechanical one, so it lives in marksOwnSpan.
213 const { cards } = buildFileLevel(model)
214 const fn = cards.find(c => c.title === 'tally')
215 t.ok(spanOnLine(fn.span, fn.span.startLine, LINES[fn.span.startLine - 1]),
216 'mechanically it still has columns')
217 t.absent(marksOwnSpan(fn), 'but a multi-line span is not worth marking')
218
219 const sym = buildSymbolLevel(model, tally)
220 const op = sym.cards.find(c => c.kind === 'call')
221 t.ok(marksOwnSpan(op), 'a single-line operation is')
222})
223
224test('cards: a multi-line span marks to the end of the first line and from the start of the last', (t) => {
225 const lines = ['foo(bar,', ' baz)']
226 const span = { startLine: 1, endLine: 2, startCol: 0, endCol: 8 }
227 const first = spanOnLine(span, 1, lines[0])
228 t.is(first, null, 'the first line is entirely the span, so nothing is marked')
229
230 const inner = spanOnLine({ startLine: 1, endLine: 2, startCol: 4, endCol: 8 }, 1, lines[0])
231 t.is(lines[0].slice(inner.from, inner.to), 'bar,', 'a partial first line marks from its column to the end')
232})