Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ function buildMenu(): void {
// than the native picker — the user adds/reorders there, then confirms.
click: () => win?.webContents.send('spyde:open_stack_dialog'),
},
{
label: 'Load In-Situ Data…',
// A potentiostat / holder log recorded alongside a movie. Auto-
// discovery already scans the movie's own folder on open; this is
// for a record that lives elsewhere, or one the duration scorer
// passed over.
click: () => pickInsituData(),
},
{ type: 'separator' },
{
label: 'Save Signal…',
Expand Down Expand Up @@ -618,6 +626,26 @@ ipcMain.handle('spyde:open-file', async () => {
}
})

/** Pick an instrument record (potentiostat / holder log) and attach it.
* `.txt` is offered because EC-Lab's ASCII export is routinely saved that way;
* Python dispatches on the file's own magic, not on the extension. */
async function pickInsituData(): Promise<void> {
const result = await dialog.showOpenDialog(win!, {
title: 'Load in-situ instrument data',
properties: ['openFile'],
filters: [
{ name: 'In-Situ Data', extensions: ['mpr', 'mpt', 'txt', 'mps'] },
{ name: 'BioLogic EC-Lab', extensions: ['mpr', 'mpt', 'mps'] },
{ name: 'All Files', extensions: ['*'] },
],
})
if (!result.canceled) {
for (const p of result.filePaths) sendAction('load_insitu_data', { path: p })
}
}

ipcMain.handle('spyde:load-insitu-data', () => pickInsituData())

/** Open a .zspy/.zarr DIRECTORY store (folder picker → load). */
ipcMain.handle('spyde:open-zarr-folder', async () => {
const result = await dialog.showOpenDialog(win!, {
Expand Down
3 changes: 3 additions & 0 deletions electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('electron', {
/** Open a .zspy/.zarr DIRECTORY store (folder picker → load). */
openZarrFolder: (): Promise<void> => ipcRenderer.invoke('spyde:open-zarr-folder'),

/** Pick an in-situ instrument record (.mpr/.mpt/.txt/.mps) and attach it. */
loadInsituData: (): Promise<void> => ipcRenderer.invoke('spyde:load-insitu-data'),

/** Quit the app (custom title-bar menu replaces native File→Quit). */
quit: (): Promise<void> => ipcRenderer.invoke('spyde:quit'),

Expand Down
21 changes: 15 additions & 6 deletions electron/src/renderer/src/components/FloatingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,12 @@ export function FloatingToolbar({
// clock runs and un-lights when playback auto-stops at the movie end.
const pb = state.playback
const playbackActive = a.name === 'Play' && pb.playing
const ffSpeed = a.name === 'Fast Forward' && pb.playing && pb.speed > 1
? pb.speed : 0
// The "×N" badge shows whenever a speed above 1× is SELECTED, not only
// while the clock happens to be running: Fast Forward is a cycle, so
// pausing at ×8 and pressing Play must still read as ×8. Gating it on
// `pb.playing` made the speed vanish the moment you paused, which
// looked like the speed change had not been applied at all.
const ffSpeed = a.name === 'Fast Forward' && pb.speed > 1 ? pb.speed : 0
const active = openName === a.name || live.has(a.name)
|| (state.subItems.get(windowId)?.get(a.name)?.length ?? 0) > 0
|| playbackActive
Expand Down Expand Up @@ -714,12 +718,17 @@ const styles: Record<string, React.CSSProperties> = {
border: 'none', cursor: 'pointer', width: 30, height: 30, borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
},
// Small "×N" corner badge on the Fast Forward button while speed > 1.
// "×N" badge on the Fast Forward button while a speed above 1× is selected.
// Sized to stay legible at "32x" — the cycle reaches ×32, and the previous
// 8px corner chip was too small to read the speed off at a glance, which is
// the badge's entire job.
speedBadge: {
position: 'absolute', bottom: -3, right: -3,
position: 'absolute', bottom: -5, right: -7,
background: '#f38ba8', color: '#11111b',
fontSize: 8, fontWeight: 700, lineHeight: 1,
padding: '1px 3px', borderRadius: 6, pointerEvents: 'none',
fontSize: 10, fontWeight: 800, lineHeight: 1,
padding: '2px 4px', borderRadius: 7, pointerEvents: 'none',
letterSpacing: '-0.2px',
boxShadow: '0 0 0 1.5px #11111b',
},
subBar: { ...subBase, top: '100%', marginTop: 10, animation: 'spyde-pop 130ms ease-out' },
subBarUp: { ...subBase, bottom: '100%', marginBottom: 10, animation: 'spyde-pop-up 130ms ease-out' },
Expand Down
1 change: 1 addition & 0 deletions electron/src/renderer/src/components/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function MenuBar({ onStartGuide, onShowInfo }: {
{ label: 'Open…', onClick: () => window.electron.openFile() },
{ label: 'Open Zarr Folder (.zspy)…', onClick: () => window.electron.openZarrFolder() },
{ label: 'Load Stack…', onClick: () => openStackDialog() },
{ label: 'Load In-Situ Data…', onClick: () => window.electron.loadInsituData() },
{ separator: true },
{ label: 'Save Signal…', onClick: () => window.electron.saveDialog() },
{ separator: true },
Expand Down
94 changes: 70 additions & 24 deletions electron/src/renderer/src/components/MovieEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import React from 'react'
import { useSpyDE } from '../kernel/SpyDEContext'
import { SeamlessFigureFrame } from './ReportFigureCell'
import { WINDOW_DRAG_MIME } from '../kernel/dnd'
import type { MovieStateMessage, MovieParams, MovieAnnotation, MovieSpeedSegment } from '../kernel/protocol'
import type { MovieStateMessage, MovieParams, MovieAnnotation, MovieSpeedSegment, MovieTextOverlay } from '../kernel/protocol'

interface Props {
cellId: string
Expand All @@ -35,7 +35,17 @@ interface Props {
const CMAPS = ['gray', 'viridis', 'magma', 'inferno', 'plasma', 'cividis', 'hot', 'jet']
const DOWNSAMPLES = [1, 2, 4, 8]
// Speed presets (a segment's multiplier). 0 = hold (freeze); <1 slow-mo; >1 ff.
const SPEEDS = [0, 0.25, 0.5, 1, 2, 4, 8]
// Segment speed multipliers. Reaches 32x for the same reason the playback
// cycle does: an in-situ acquisition is thousands of frames of slow change,
// and a long uneventful stretch wants skipping hard, not merely fast.
const SPEEDS = [0, 0.25, 0.5, 1, 2, 4, 8, 16, 32]

/** A burn-in's clip label: its literal text, or the quantity it tracks. */
function burninLabel(o: MovieTextOverlay): string {
if (o.builtin === 'label') return o.text || 'Text'
if (o.builtin === 'time') return 'Timestamp'
return o.label || o.insitu_channel || 'value'
}
const SPEED_LABEL = (s: number) => (s === 0 ? 'hold' : `${s}×`)

// A selected timeline clip (which lane + index), so the inspector edits it.
Expand Down Expand Up @@ -185,6 +195,11 @@ export function MovieEditor({ cellId, sendAction, onClose }: Props) {
// ── overlay + speed lists ─────────────────────────────────────────────────
const anns = st?.annotations ?? []
const textOverlays = st?.text_overlays ?? []
// Everything addable as burnt-in text (static label, clock, instrument
// channels) — the backend owns the list so the rail has one source of truth.
const burninSources = st?.burnin_sources
?? [{ source: 'label', label: 'Text', units: '' },
{ source: 'time', label: 'Timestamp', units: 's' }]
const speedSegs = st?.speed_segments ?? []
const setAnnotations = (list: MovieAnnotation[]) => {
setSt(s => (s ? { ...s, annotations: list } : s)); tune({ annotations: list })
Expand Down Expand Up @@ -254,11 +269,28 @@ export function MovieEditor({ cellId, sendAction, onClose }: Props) {
<div style={styles.body}>
{/* Left rail — just the ADD buttons (compact). */}
<div style={styles.rail}>
<div style={styles.railHead}>Add overlay</div>
<button style={styles.toolBtn} data-testid="movie-add-text" onClick={addText}>+ Text</button>
{/* Burnt-in TEXT — one section, one button per source. Static text,
the clock and each attached instrument channel are the same kind
of object (they differ only in where the string comes from), so
they share this list, the Burn-in timeline lane and one
inspector. */}
<div style={styles.railHead}>Burn-in text</div>
{burninSources.map(s => (
<button key={s.source} style={styles.toolBtn}
data-testid={`movie-add-burnin-${s.source}`}
title={`Burn ${s.label} into every frame`}
onClick={() => sendAction('movie_add_burnin',
{ cell_id: cellId, source: s.source })}>
+ {s.label}{s.units ? ` (${s.units})` : ''}
</button>
))}
<div style={styles.railRule} />
<div style={styles.railHead}>Shapes</div>
<button style={styles.toolBtn} data-testid="movie-add-roi" onClick={addRoi}>+ ROI box</button>
<div style={styles.railRule} />
<div style={styles.railHead}>Timing</div>
<button style={styles.toolBtn} data-testid="movie-add-speed" onClick={addSpeedSeg}>+ Speed segment</button>
<div style={{ height: 8 }} />
<div style={styles.railRule} />
<div style={styles.railHead}>Crop</div>
<button data-testid="movie-crop-toggle"
style={cropMode ? { ...styles.toolBtn, ...styles.toolBtnActive } : styles.toolBtn}
Expand Down Expand Up @@ -312,23 +344,16 @@ export function MovieEditor({ cellId, sendAction, onClose }: Props) {

{/* Timeline dock: overlay lanes + speed track. */}
<div style={styles.timeline} data-testid="movie-timeline">
<TimelineLane label="Text" testid="movie-lane-text">
<Playhead frac={frac(t * scaleS)} />
{anns.map((a, i) => a.kind === 'text' ? (
<Clip key={i} testid={`movie-clip-text-${i}`} color="#89b4fa"
t0={frac(a.time_range?.[0] ?? 0)} t1={frac(a.time_range?.[1] ?? duration)}
label={a.text || 'text'} selected={sel?.lane === 'text' && sel.index === i}
onSelect={() => setSel({ lane: 'text', index: i })}
onMove={(n0, n1) => setAnnotations(anns.map((x, j) => j === i ? { ...x, time_range: [n0 * duration, n1 * duration] } : x))}
onRemove={() => { setAnnotations(anns.filter((_, j) => j !== i)); setSel(null) }} />
) : null)}
</TimelineLane>
<TimelineLane label="Signal" testid="movie-lane-signal">
{/* ONE lane for every burnt-in text — static labels, the clock and
instrument channels are the same object, so they belong on the
same track rather than being split across "Text" and "Signal"
by where their string happens to come from. */}
<TimelineLane label="Burn-in" testid="movie-lane-signal">
<Playhead frac={frac(t * scaleS)} />
{textOverlays.map((o, i) => (
<Clip key={i} testid={`movie-clip-signal-${i}`} color="#a6e3a1"
t0={frac(o.time_range?.[0] ?? 0)} t1={frac(o.time_range?.[1] ?? duration)}
label={o.label || 'signal'} selected={sel?.lane === 'signal' && sel.index === i}
label={burninLabel(o)} selected={sel?.lane === 'signal' && sel.index === i}
onSelect={() => setSel({ lane: 'signal', index: i })}
onMove={(n0, n1) => setTextOverlays(textOverlays.map((x, j) => j === i ? { ...x, time_range: [n0 * duration, n1 * duration] as [number, number] } : x))}
onRemove={() => { setTextOverlays(textOverlays.filter((_, j) => j !== i)); setSel(null) }} />
Expand Down Expand Up @@ -442,15 +467,30 @@ function Inspector({ sel, st, anns, textOverlays, speedSegs, speeds,
if (!o) return null
const upd = (patch: Partial<typeof o>) =>
setTextOverlays(textOverlays.map((x, j) => j === sel.index ? { ...x, ...patch } : x))
const isLabel = o.builtin === 'label'
return (
<div style={styles.inspSection} data-testid="movie-inspector-signal">
<div style={styles.inspHead}>Signal-as-text</div>
<Field label="Label"><input type="text" data-testid="movie-insp-siglabel" value={o.label ?? ''} style={styles.inp}
onChange={(e) => upd({ label: e.target.value })} /></Field>
<Field label="Format"><input type="text" value={o.fmt ?? ''} placeholder="{label} = {value:.1f} {units}" style={styles.inp}
onChange={(e) => upd({ fmt: e.target.value })} /></Field>
<Field label="Colour"><input type="color" value={o.color || '#ffffff'} style={styles.color}
<div style={styles.inspHead}>Burn-in — {burninLabel(o)}</div>
{isLabel ? (
<Field label="Text"><input type="text" data-testid="movie-insp-sigtext" value={o.text ?? ''} style={styles.inp}
onChange={(e) => upd({ text: e.target.value })} /></Field>
) : (
<>
<Field label="Label"><input type="text" data-testid="movie-insp-siglabel" value={o.label ?? ''} style={styles.inp}
onChange={(e) => upd({ label: e.target.value })} /></Field>
<Field label="Format"><input type="text" value={o.fmt ?? ''} placeholder="{label} = {value:.1f} {units}" style={styles.inp}
onChange={(e) => upd({ fmt: e.target.value })} /></Field>
</>
)}
<Field label="Colour"><input type="color" data-testid="movie-insp-sigcolor"
value={o.color || '#ffffff'} style={styles.color}
onChange={(e) => upd({ color: e.target.value })} /></Field>
<Field label="Size"><input type="number" data-testid="movie-insp-sigsize" min={6} max={96}
value={o.size ?? 18} style={styles.num}
onChange={(e) => upd({ size: Number(e.target.value) })} /></Field>
<div style={styles.hint}>
Drag it on the frame to move it; clip it on the Burn-in track to time-gate it.
</div>
</div>
)
}
Expand Down Expand Up @@ -509,6 +549,10 @@ function RenderControls({ params, patchParams }: {
onChange={(b) => patchParams({ axes: b })} />
<Check testid="movie-scalebar" checked={params.scalebar !== false} label="Scale bar"
onChange={(b) => patchParams({ scalebar: b })} />
{/* Timestamp is an ordinary burn-in overlay (`builtin: "time"`), so the
checkbox only adds/removes it — position, size and colour are edited
on the Signal lane like a voltage's, not with a second set of
controls here. */}
<Check testid="movie-timestamp" checked={params.timestamp !== false} label="Timestamp"
onChange={(b) => patchParams({ timestamp: b })} />
</div>
Expand Down Expand Up @@ -644,6 +688,8 @@ const styles: Record<string, React.CSSProperties> = {
body: { flex: 1, display: 'flex', minHeight: 0 },
rail: { display: 'flex', flexDirection: 'column', gap: 5, padding: 12, borderRight: '1px solid #313244', flexShrink: 0, width: 132, overflowY: 'auto' },
railHead: { fontSize: 10, fontWeight: 700, color: '#89b4fa', textTransform: 'uppercase', letterSpacing: 0.4 },
// Hairline between the rail's groups (burn-in text / shapes / timing).
railRule: { height: 1, background: 'rgba(255,255,255,0.10)', margin: '10px 0 8px' },
toolBtn: { background: '#1e1e2e', color: '#cdd6f4', border: '1px solid #313244', borderRadius: 5, padding: '5px 8px', fontSize: 11.5, cursor: 'pointer', textAlign: 'left' },
toolBtnActive: { background: '#89b4fa', color: '#11111b', borderColor: '#89b4fa', fontWeight: 700 },
center: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, padding: 14, gap: 10 },
Expand Down
1 change: 1 addition & 0 deletions electron/src/renderer/src/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ declare global {
action: (action: string, payload?: Record<string, unknown>, windowId?: number) => void
openFile: () => Promise<void>
openZarrFolder: () => Promise<void>
loadInsituData: () => Promise<void>
quit: () => Promise<void>
saveDialog: () => Promise<void>
pickFile: (opts: { name?: string; extensions?: string[] }) => Promise<string | null>
Expand Down
32 changes: 31 additions & 1 deletion electron/src/renderer/src/kernel/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export interface PlaybackStateMessage extends MsgBase {
type: 'playback_state'
/** True while the movie clock is running. */
playing: boolean
/** Current speed multiplier (1/2/4/8) — drives the Fast Forward "×N" badge. */
/** Current speed multiplier (1/2/4/8/16/32) — drives the Fast Forward "×N" badge. */
speed?: number
loop?: boolean
}
Expand Down Expand Up @@ -561,6 +561,14 @@ export interface ReportCell {
* at render time; `fmt` is a Python-style format string over {label,value,units}. */
export interface MovieTextOverlay {
source?: Record<string, unknown> // a SignalRef dict (opaque to the renderer)
/** An attached instrument channel (spyde/insitu), e.g. "Ewe/V". */
insitu_channel?: string
/** A built-in value needing no source: "time" is the elapsed-time burn-in,
* which is an ordinary overlay so it gets the same widget, timeline clip
* and inspector controls as any other. */
builtin?: 'time' | 'label'
/** Literal string for a builtin:'label' overlay. */
text?: string
label?: string
units?: string
fmt?: string
Expand Down Expand Up @@ -623,6 +631,8 @@ export interface MovieParams {
cmap?: string
clim?: [number, number] | null
timestamp?: boolean
/** Burnt-in timestamp colour ("#rrggbb"); white when unset. */
timestamp_color?: string
scalebar?: boolean
axes?: boolean // draw calibrated axis ticks (default true)
t_start?: number
Expand Down Expand Up @@ -659,6 +669,26 @@ export interface MovieStateMessage extends MsgBase {
signal_window_id: number | null // the MDI window holding the signal figure
nav_fig_id: string | null // the 1-D navigator figure (shown beside, opt)
current_index: number // the navigator's current time index
/** Instrument channels aligned to this movie (electrochemistry potential /
* current, holder temperature…). Already per-frame on the tree, so the
* editor offers each as a one-click burn-in overlay; empty when none is
* attached. See spyde/insitu and movie_add_insitu_overlay. */
insitu_channels?: InsituChannelOption[]
/** Everything addable as burnt-in text: static label, clock, channels. */
burnin_sources?: BurninSource[]
}

/** One attached instrument channel offered as a burn-in overlay. */
export interface BurninSource {
source: string // "label" | "time" | an instrument channel key
label: string
units: string
}

export interface InsituChannelOption {
channel: string // the backend key, e.g. "Ewe/V"
label: string // display name, e.g. "Ewe"
units: string // e.g. "V"
}

/** Export finished (spyde:movie_done). */
Expand Down
Loading