mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
fix(ruview-live): fit the inline pane; actions on top; show focus state
Outside fullscreen the pane sits inline above the prompt with a few rows,
so the bordered cards, rule and footer were cut off (Refresh/Close never
visible). Inline (or under 18 rows) the pane is now compact: views and
actions share the top row, no borders/rule/gaps, pictures sized to the
rows left, radar readings beside a small fan. The top row always says
whether the pane holds the keyboard ("keys active · Esc returns them" or
"click the pane or press ctrl+x tab to use the keys").
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1 parent
78a1474057
commit
629e1b9c6c
3 files changed
+89
-44
No files matched your search
@@ -15,7 +15,7 @@ import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
export * from './anim.mjs';
|
||||
export * from './model.mjs';
|
||||
export * from './raster.mjs';
|
||||
export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
export { ANIMATED, compactOf, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
|
||||
export const PANE_ID = 'ruview-live';
|
||||
export const COMMAND = 'ruview';
|
||||
@@ -98,7 +98,7 @@ export function register(on, options = {}) {
|
||||
}
|
||||
|
||||
/** Options shared by the full drawing and each animation frame. */
|
||||
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
|
||||
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows, placement: size.placement });
|
||||
const shownModel = () => (model ? { ...model, spectrum } : null);
|
||||
|
||||
/** One animation frame: repaint each mounted animated Raster in place. */
|
||||
@@ -199,12 +199,13 @@ export function register(on, options = {}) {
|
||||
size = {
|
||||
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
|
||||
rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
|
||||
placement: e.props?.placement,
|
||||
};
|
||||
const ui = await $.ui.resolve(e);
|
||||
const now = await $.clock.now();
|
||||
return viewOf(ui, shownModel(), {
|
||||
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
|
||||
focused: e.props?.isFocused !== false,
|
||||
focused: e.props?.isFocused === true, placement: e.props?.placement,
|
||||
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
|
||||
onRefresh: () => { void refresh(); },
|
||||
onClose: () => { void close(); },
|
||||
|
||||
@@ -13,20 +13,26 @@ const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall']
|
||||
/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
|
||||
export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
|
||||
|
||||
/** Picture sizes that fit the pane body. */
|
||||
export function sizesOf(columns = 80, rows = 30) {
|
||||
const width = Math.max(24, Math.min(128, columns - 6));
|
||||
const height = Math.max(6, Math.min(26, rows - 12));
|
||||
/**
|
||||
* Compact when the pane sits inline above the prompt (a few rows) rather than
|
||||
* docked beside the transcript: no borders, rule or gaps, actions on top.
|
||||
*/
|
||||
export const compactOf = (opts) => opts.placement === 'inline' || (opts.rows ?? 30) < 18;
|
||||
|
||||
/** Picture sizes that fit the pane body (compact: the rows left after the chrome). */
|
||||
export function sizesOf(columns = 80, rows = 30, compact = false) {
|
||||
const width = Math.max(24, Math.min(128, columns - (compact ? 2 : 6)));
|
||||
const height = compact ? Math.max(3, Math.min(26, rows - 6)) : Math.max(6, Math.min(26, rows - 12));
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const wrapIndex = (i, n) => ((i % n) + n) % n;
|
||||
const radarLayout = (columns, rows) => {
|
||||
const { width, height } = sizesOf(columns, rows);
|
||||
const wide = (columns ?? 80) >= 110;
|
||||
const radarLayout = (columns, rows, compact = false) => {
|
||||
const { width, height } = sizesOf(columns, rows, compact);
|
||||
const wide = compact || (columns ?? 80) >= 110;
|
||||
const fanW = wide ? Math.floor(width * 0.55) : width;
|
||||
// A 120° wedge is about width / (4·sin 60°) rows tall.
|
||||
const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
|
||||
const fanH = Math.max(compact ? 3 : 6, Math.min(height, Math.round(fanW / 3.4) + 1));
|
||||
return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
|
||||
};
|
||||
|
||||
@@ -39,17 +45,18 @@ export function picturesOf(model, opts) {
|
||||
const mode = opts.mode || 'overview';
|
||||
const t = Number.isFinite(opts.t) ? opts.t : 0;
|
||||
const columns = opts.columns ?? 80;
|
||||
const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
|
||||
const compact = compactOf(opts);
|
||||
const out = { shimmer: compact ? null : { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
|
||||
if (!model) return out;
|
||||
if (mode === 'waterfall' && model.spectrum?.length) {
|
||||
const nodes = model.spectrum;
|
||||
const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
|
||||
const { width, height } = sizesOf(columns, opts.rows);
|
||||
const { width, height } = sizesOf(columns, opts.rows, compact);
|
||||
out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
|
||||
}
|
||||
if (mode === 'radar' && model.radar) {
|
||||
const history = opts.history || { distance: [] };
|
||||
const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
|
||||
const { fanW, fanH, chartW } = radarLayout(columns, opts.rows, compact);
|
||||
out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
|
||||
out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
|
||||
}
|
||||
@@ -68,6 +75,8 @@ export function viewOf(ui, model, opts) {
|
||||
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
|
||||
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
|
||||
const pics = ui.Raster ? picturesOf(model, opts) : {};
|
||||
const compact = compactOf(opts);
|
||||
opts = { ...opts, compact };
|
||||
|
||||
// Header: freshness badge and age, then the signal rule.
|
||||
const finiteAt = model && Number.isFinite(model.at);
|
||||
@@ -84,13 +93,19 @@ export function viewOf(ui, model, opts) {
|
||||
]);
|
||||
const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
|
||||
|
||||
// Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
|
||||
const tabs = opts.onMode ? row([
|
||||
...TABS.map(([m, key, label]) => Button({
|
||||
// Views and actions share the top row, so they stay visible however short
|
||||
// the pane is. Hotkeys reach a Pane only while it holds the keyboard; say so.
|
||||
const actions = row([
|
||||
...(opts.onMode ? TABS.map(([m, key, label]) => Button({
|
||||
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
|
||||
})),
|
||||
opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
|
||||
], { gap: 2 }) : null;
|
||||
})) : []),
|
||||
Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
|
||||
mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
|
||||
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
|
||||
opts.focused
|
||||
? t('keys active · Esc returns them', { color: 'green', dimColor: true })
|
||||
: t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }),
|
||||
], { gap: compact ? 1 : 2 });
|
||||
|
||||
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
|
||||
@@ -99,19 +114,17 @@ export function viewOf(ui, model, opts) {
|
||||
else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
|
||||
else body = overview(ui, model, opts, t, row);
|
||||
|
||||
const footer = row([
|
||||
Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
|
||||
mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
|
||||
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
|
||||
], { gap: 2 });
|
||||
|
||||
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
|
||||
return Box({
|
||||
flexDirection: 'column', gap: compact ? 0 : 1, paddingX: compact ? 0 : 1,
|
||||
children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), actions, ...alerts, body].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
function card(ui, title, subtitle, borderColor, children, t, row) {
|
||||
function card(ui, title, subtitle, borderColor, children, t, row, compact = false) {
|
||||
return ui.Box({
|
||||
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
|
||||
children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
|
||||
flexDirection: 'column', flexGrow: 1,
|
||||
...(compact ? {} : { borderStyle: 'round', borderColor, paddingX: 1 }),
|
||||
children: [row([t(title, { bold: true, color: compact ? borderColor : undefined }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,18 +150,18 @@ function overview(ui, model, opts, t, row) {
|
||||
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
|
||||
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
: t('○ no presence', { dimColor: true });
|
||||
radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
|
||||
radarCard = card(ui, '60 GHz RADAR', opts.compact ? `${r.name} · device-reported, not validated` : r.name, r.present ? 'green' : 'gray', [
|
||||
presence,
|
||||
vital('distance', r.distance, r.distanceCm, null, history.distance),
|
||||
vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
|
||||
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
|
||||
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
opts.compact ? null : t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
|
||||
], t, row, opts.compact);
|
||||
} else if (!opts.radarConfigured) {
|
||||
radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
|
||||
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
|
||||
t('claude plugin configure ruview-live', { color: 'cyan' }),
|
||||
], t, row);
|
||||
], t, row, opts.compact);
|
||||
}
|
||||
const lines = model.nodes.length
|
||||
? model.nodes.map((n) => row([
|
||||
@@ -166,8 +179,8 @@ function overview(ui, model, opts, t, row) {
|
||||
];
|
||||
const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
|
||||
...lines,
|
||||
model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
|
||||
], t, row);
|
||||
model.nodes.length && !opts.compact ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
|
||||
], t, row, opts.compact);
|
||||
const cards = [radarCard, nodesCard].filter(Boolean);
|
||||
return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
|
||||
}
|
||||
@@ -180,16 +193,16 @@ function waterfallView(ui, model, opts, pics, t, row) {
|
||||
return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
|
||||
t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
|
||||
t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
|
||||
], t, row);
|
||||
], t, row, opts.compact);
|
||||
}
|
||||
const { grid, lo, hi, node: n, nodes } = pics.waterfall;
|
||||
const legend = colourbar(Math.min(32, grid.columns - 20));
|
||||
return card(ui, 'CSI WATERFALL', `${n.label} · ${n.subcarriers} subcarriers → ${n.bins} bins · ${n.rateHz ?? '—'} Hz${nodes.length > 1 ? ` · node ${nodes.indexOf(n) + 1}/${nodes.length}` : ''}`, n.synthetic ? 'yellow' : 'cyan', [
|
||||
n.synthetic ? t('SYNTHETIC — simulator frames, not a live measurement', { color: 'yellow', bold: true }) : t('MEASURED — live CSI amplitude received on this host', { color: 'green' }),
|
||||
ui.Raster(grid.toRaster('waterfall')),
|
||||
row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
|
||||
opts.compact ? null : row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
|
||||
row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
|
||||
], t, row);
|
||||
], t, row, opts.compact);
|
||||
}
|
||||
|
||||
function radarView(ui, model, opts, pics, t, row) {
|
||||
@@ -197,10 +210,10 @@ function radarView(ui, model, opts, pics, t, row) {
|
||||
if (note) return note;
|
||||
if (!model) return t('waiting for the first capture…', { dimColor: true });
|
||||
if (!opts.radarConfigured) {
|
||||
return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
|
||||
return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row, opts.compact);
|
||||
}
|
||||
const r = model.radar;
|
||||
if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row);
|
||||
if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row, opts.compact);
|
||||
const history = opts.history || { heart: [], breathing: [], distance: [] };
|
||||
const { wide, chartW } = radarLayout(opts.columns, opts.rows);
|
||||
const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
@@ -209,7 +222,7 @@ function radarView(ui, model, opts, pics, t, row) {
|
||||
presence,
|
||||
ui.Raster(pics.fan.grid.toRaster('fan')),
|
||||
t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
], t, row, opts.compact);
|
||||
const chart = (label, series, kind, unit, colour, minSpan) => {
|
||||
const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
|
||||
const last = [...series].reverse().find((v) => Number.isFinite(v));
|
||||
@@ -219,6 +232,20 @@ function radarView(ui, model, opts, pics, t, row) {
|
||||
ui.Raster(g.toRaster(`chart-${label}`)),
|
||||
];
|
||||
};
|
||||
if (opts.compact) {
|
||||
// Inline: the fan beside the readings and the pulse; charts need the dock.
|
||||
const line = (label, value, raw, kind) => {
|
||||
const warn = kind ? plausibilityOf(kind, raw) : null;
|
||||
return row([t(label.padEnd(10), { dimColor: true }), t(value, { bold: true, color: warn ? 'yellow' : undefined }), warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null]);
|
||||
};
|
||||
const readings = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
|
||||
pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
|
||||
line('distance', r.distance, r.distanceCm, null),
|
||||
line('heart', r.heart, r.heartBpm, 'heart'),
|
||||
line('breathing', r.breathing, r.breathingBpm, 'breathing'),
|
||||
], t, row, true);
|
||||
return ui.Box({ flexDirection: 'row', gap: 2, children: [fan, readings] });
|
||||
}
|
||||
const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
|
||||
pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
|
||||
pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
|
||||
@@ -226,6 +253,6 @@ function radarView(ui, model, opts, pics, t, row) {
|
||||
...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
|
||||
...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
|
||||
t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
], t, row, opts.compact);
|
||||
return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ANIMATED, Braille, colormap, commandsOf, DEFAULT, drain, Grid, lineChart, modelOf, phaseOf, picturesOf, pingOf, pulse,
|
||||
ANIMATED, Braille, colormap, compactOf, commandsOf, DEFAULT, drain, Grid, lineChart, modelOf, phaseOf, picturesOf, pingOf, pulse,
|
||||
radarFan, rgb, settingsOf, shimmer, shownFrames, spectrumOf, spectrumWith, toBase64, viewOf, waterfall,
|
||||
} from '../mod/hooks/register.mjs';
|
||||
|
||||
@@ -167,3 +167,20 @@ test('an unfocused pane says how to give it the keys; a focused one does not', (
|
||||
assert.match(JSON.stringify(viewOf(UI, model, { ...base, focused: false })), /ctrl\+x tab to use the keys/);
|
||||
assert.doesNotMatch(JSON.stringify(viewOf(UI, model, { ...base, focused: true })), /ctrl\+x tab/);
|
||||
});
|
||||
|
||||
test('inline panes are compact: actions on top, no borders, pictures sized to the rows', () => {
|
||||
assert.equal(compactOf({ placement: 'inline', rows: 30 }), true);
|
||||
assert.equal(compactOf({ placement: 'dock', rows: 34 }), false);
|
||||
assert.equal(compactOf({ rows: 8 }), true, 'a short dock is compact too');
|
||||
const base = { refreshMs: 15000, liveRefreshMs: 4000, columns: 135, rows: 8, placement: 'inline', radarConfigured: true,
|
||||
history: { heart: [70, 72], breathing: [14, 15], distance: [110, 120], rates: {} }, onMode() {}, onRefresh() {}, onClose() {} };
|
||||
for (const mode of ['overview', 'waterfall', 'radar']) {
|
||||
const tree = viewOf(UI, modelOf(spectrumCapture(false), radar, 0), { ...base, mode });
|
||||
const nodes = flat(tree);
|
||||
assert.ok(!nodes.some((n) => n.props?.borderStyle), `${mode}: no borders inline`);
|
||||
assert.ok(!nodes.some((n) => n.type === 'Raster' && n.props.key === 'shimmer'), `${mode}: no decorative rule inline`);
|
||||
const actions = tree.props.children[1];
|
||||
assert.deepEqual(flat(actions).filter((n) => n.type === 'Button').map((b) => b.props.hotkey).slice(-2), ['r', 'c'], `${mode}: refresh and close on the top row`);
|
||||
for (const r of nodes.filter((n) => n.type === 'Raster')) assert.ok(r.props.rows <= 3, `${mode}/${r.props.key}: ${r.props.rows} rows fits an 8-row pane`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user