| … | … | @@ -38,6 +38,7 @@ export function buildFileLevel (model) { |
| 38 | 38 | code: sym.kind === 'module-body' |
| 39 | 39 | ? { sparse: true, container: sym.fn } |
| 40 | 40 | : { from: sym.range.startLine, to: sym.range.endLine }, |
| 41 | span: sym.range, | |
| 41 | 42 | openTo: sym.fn ? sym.name : null |
| 42 | 43 | })) |
| 43 | 44 | return { cards, links: fileLinks(model), containers: [] } |
| … | … | @@ -81,6 +82,7 @@ export function buildSymbolLevel (model, sym) { |
| 81 | 82 | sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''), |
| 82 | 83 | container: zoneIds.has(op.zone) ? op.zone : null, |
| 83 | 84 | code: { from: op.range.startLine, to: op.range.endLine }, |
| 85 | span: op.range, | |
| 84 | 86 | openTo: null |
| 85 | 87 | }) |
| 86 | 88 | } |
| … | … | @@ -99,6 +101,7 @@ export function buildSymbolLevel (model, sym) { |
| 99 | 101 | fn.range.startLine + '-' + fn.range.endLine, |
| 100 | 102 | container: innermostZoneOf(zones, fn.range), |
| 101 | 103 | code: { from: fn.range.startLine, to: fn.range.endLine }, |
| 104 | span: fn.range, | |
| 102 | 105 | openTo: null |
| 103 | 106 | }) |
| 104 | 107 | } |
| … | … | @@ -116,6 +119,7 @@ export function buildSymbolLevel (model, sym) { |
| 116 | 119 | sub: 'L' + ex.range.startLine, |
| 117 | 120 | container: zoneIds.has(ex.zone) ? ex.zone : null, |
| 118 | 121 | code: { from: ex.range.startLine, to: ex.range.endLine }, |
| 122 | span: ex.range, | |
| 119 | 123 | openTo: null |
| 120 | 124 | }) |
| 121 | 125 | } |
| … | … | @@ -235,6 +239,64 @@ export function zoneLabel (zone) { |
| 235 | 239 | return out |
| 236 | 240 | } |
| 237 | 241 | |
| 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 | */ | |
| 258 | export 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 | */ | |
| 285 | export 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. */ | |
| 290 | export 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 | ||
| 238 | 300 | function describeInput (i) { |
| 239 | 301 | if (i.literal) return i.name |
| 240 | 302 | return i.name + (i.source === 'node' ? '' : ' [' + i.source + ']') |
| … | … | @@ -27,6 +27,8 @@ |
| 27 | 27 | .n-sig { fill: #8b949e; font-size: 9px; font-family: ui-monospace, 'SF Mono', monospace; } |
| 28 | 28 | .n-code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 8.5px; fill: #9aa0a6; |
| 29 | 29 | white-space: pre; } |
| 30 | .code-dim { fill: #5b626b; } | |
| 31 | .code-own { fill: #e8e8ea; font-weight: 600; } | |
| 30 | 32 | .n-lineno { font-family: ui-monospace, monospace; font-size: 7px; fill: #484f58; } |
| 31 | 33 | .n-chev { fill: #8b949e; font-size: 10px; cursor: pointer; } |
| 32 | 34 | .n-hit { fill: transparent; cursor: pointer; } |
| … | … | @@ -14,7 +14,7 @@ |
| 14 | 14 | |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | 16 | import { |
| 17 | buildFileLevel, buildSymbolLevel, resolveCode, lineDepths | |
| 17 | buildFileLevel, buildSymbolLevel, resolveCode, lineDepths, spanOnLine, marksOwnSpan | |
| 18 | 18 | } from '/floor/cards.js' |
| 19 | 19 | |
| 20 | 20 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -288,9 +288,7 @@ function drawCard (world, card, pos) { |
| 288 | 288 | const num = el('text', { class: 'n-lineno', x: 18, y, 'text-anchor': 'end' }) |
| 289 | 289 | num.textContent = lineNo |
| 290 | 290 | 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)) | |
| 294 | 292 | y += CODE_LINE_H |
| 295 | 293 | } |
| 296 | 294 | if (code.clipped) { |
| … | … | @@ -319,6 +317,44 @@ function drawCard (world, card, pos) { |
| 319 | 317 | world.appendChild(g) |
| 320 | 318 | } |
| 321 | 319 | |
| 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 | */ | |
| 331 | function 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 | ||
| 322 | 358 | function clip (text, n) { |
| 323 | 359 | const max = Math.max(4, n) |
| 324 | 360 | return text.length > max ? text.slice(0, max - 1) + '…' : text |
| … | … | @@ -1,5 +1,8 @@ |
| 1 | 1 | import { test } from 'brittle' |
| 2 | import { buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks } from '../lib/floor/cards.js' | |
| 2 | import { | |
| 3 | buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks, | |
| 4 | spanOnLine, displayColumn, marksOwnSpan | |
| 5 | } from '../lib/floor/cards.js' | |
| 3 | 6 | import { deriveFile } from '../lib/derive.js' |
| 4 | 7 | |
| 5 | 8 | // 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) => { |
| 163 | 166 | t.alike(buildSymbolLevel(model, tally), buildSymbolLevel(model, tally), 'symbol level') |
| 164 | 167 | t.alike(ownLines(model, tally.fn), ownLines(model, tally.fn), 'own lines') |
| 165 | 168 | }) |
| 169 | ||
| 170 | test('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 | ||
| 181 | test('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 | ||
| 202 | test('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 | ||
| 224 | test('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 | }) |