0
0
Fork 0

tui(v0.3): Viewer overlays inline live values when liveValues prop set

Task 3 of v0.3 plan.

<Viewer liveValues={...}/> walks each visible line for an identifier
that's a key in the map. First match wins; appends '  ◀ live: <val>'
in green at end of line. No prop -> no overlay (default).

Matches against the original line text, not against highlighter
tokens: the highlighter splits mixed-case names like bRunning into
'b' + 'R' + 'unning' (uppercase-only ident regex) which would never
match a 'bRunning' key. Operating on the line preserves identifiers
intact.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-29 09:45:49 +02:00
parent d233880a86
commit a9d77df627
2 changed files with 103 additions and 7 deletions

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Box, Text } from 'ink';
import { POU } from '../shared/types.js';
import { Token, tokenizeLines, TokenKind } from './highlight.js';
import { LiveValueSnapshot } from '../shared/live-values.js';
const COLORS: Record<TokenKind, string | undefined> = {
keyword: 'cyan',
@ -23,14 +24,38 @@ function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement
);
}
/**
* Find the first var name on the line that has a live value.
*
* Match rule: scan the raw line for identifiers, return the value of the
* first one that's a key in liveValues. Operates on the original line text
* (not on highlighter tokens) because the highlighter's uppercase-only
* identifier splitter shreds mixed-case names like `bRunning` into
* `b` + `R` + `unning`, which would never match a `bRunning` key.
*/
function findOverlayValue(
line: string,
liveValues: Record<string, LiveValueSnapshot>
): string | null {
const re = /[A-Za-z_][A-Za-z0-9_]*/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
const v = liveValues[m[0]];
if (v) return v.value;
}
return null;
}
export interface ViewerProps {
pou: POU | null;
text: string | null;
scrollTop: number;
visibleRows: number;
/** Optional bare-name → live-value map. When set, lines with a matching var get an inline overlay. */
liveValues?: Record<string, LiveValueSnapshot>;
}
export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement {
export function Viewer({ pou, text, scrollTop, visibleRows, liveValues }: ViewerProps): React.ReactElement {
if (!pou || text == null) {
return (
<Box flexDirection="column">
@ -48,12 +73,17 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
<Text bold>
{pou.name}.st ({pou.kind}, {pou.loc} L)
</Text>
{sliceTokens.map((tokens, i) => (
<Text key={scrollTop + i}>
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
<HighlightedTokens tokens={tokens} />
</Text>
))}
{sliceTokens.map((tokens, i) => {
const lineNo = scrollTop + i;
const overlay = liveValues ? findOverlayValue(lines[lineNo] ?? '', liveValues) : null;
return (
<Text key={lineNo}>
<Text dimColor>{String(lineNo + 1).padStart(4, ' ')} </Text>
<HighlightedTokens tokens={tokens} />
{overlay !== null && <Text color="green"> live: {overlay}</Text>}
</Text>
);
})}
</Box>
);
}

66
tests/tui/Viewer.test.tsx Normal file
View file

@ -0,0 +1,66 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { Viewer } from '../../src/tui/browser/Viewer.tsx';
import { POU } from '../../src/tui/shared/types.ts';
const pou: POU = {
name: 'PLC_PRG',
kind: 'PRG',
relPath: 'PLC_PRG.st',
absPath: '/abs/PLC_PRG.st',
loc: 4,
mtimeMs: 0,
};
const text = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
' bRunning : BOOL;',
'END_VAR',
].join('\n');
describe('<Viewer>', () => {
it('renders without overlay when liveValues is absent', () => {
const { lastFrame } = render(
<Viewer pou={pou} text={text} scrollTop={0} visibleRows={10} />
);
expect(lastFrame()).not.toContain('live:');
});
it('overlays "◀ live: <val>" on the line whose token matches a key', () => {
const { lastFrame } = render(
<Viewer
pou={pou}
text={text}
scrollTop={0}
visibleRows={10}
liveValues={{
counter: { value: '47', type: 'INT', ts: Date.now() },
bRunning: { value: 'TRUE', type: 'BOOL', ts: Date.now() },
}}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/counter\b.*◀ live: 47/);
expect(out).toMatch(/bRunning\b.*◀ live: TRUE/);
});
it('does not overlay vars that are not in liveValues', () => {
const { lastFrame } = render(
<Viewer
pou={pou}
text={text}
scrollTop={0}
visibleRows={10}
liveValues={{ counter: { value: '47', ts: Date.now() } }}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/counter\b.*◀ live: 47/);
// bRunning line must not gain an overlay
const bRunningLine = out.split('\n').find((l) => l.includes('bRunning'))!;
expect(bRunningLine).not.toContain('live:');
});
});