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:
parent
d233880a86
commit
a9d77df627
2 changed files with 103 additions and 7 deletions
|
|
@ -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
66
tests/tui/Viewer.test.tsx
Normal 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:');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue