Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
# Long-lived INTEGRATION branches too, not just main. `feat/0.3.0` collects
# a whole release, and with `[main]` alone a PR into it ran no tests at all
# — #82 through #88 each landed a wave that way, and the matrix first saw
# them only when 0.3.0 itself went at main. A PR whose checks are green
# because nothing ran is worse than a red one.
branches: [main, "feat/[0-9]*"]

concurrency:
group: build-${{ github.ref }}
Expand Down
5 changes: 3 additions & 2 deletions electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,10 +476,11 @@ function buildMenu(): void {
const result = await dialog.showOpenDialog(win!, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'EM Data', extensions: ['hspy', 'zspy', 'mrc', 'tif', 'tiff', 'de5'] },
{ name: 'EM Data', extensions: ['hspy', 'zspy', 'mrc', 'tif', 'tiff', 'de5', 'csb'] },
{ name: 'HyperSpy', extensions: ['hspy', 'zspy'] },
{ name: 'MRC', extensions: ['mrc'] },
{ name: 'TIFF', extensions: ['tif', 'tiff'] },
{ name: 'DE CSB (.csb)', extensions: ['csb'] },
],
})
if (!result.canceled) {
Expand Down Expand Up @@ -607,7 +608,7 @@ ipcMain.handle('spyde:open-file', async () => {
const result = await dialog.showOpenDialog(win!, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'EM Data', extensions: ['hspy', 'zspy', 'mrc', 'tif', 'tiff', 'de5'] },
{ name: 'EM Data', extensions: ['hspy', 'zspy', 'mrc', 'tif', 'tiff', 'de5', 'csb'] },
],
})
if (!result.canceled) {
Expand Down
36 changes: 32 additions & 4 deletions electron/src/renderer/src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,26 @@
*/
import React from 'react'

export function Dropdown<T extends string>({ value, options, onChange, testid, width }: {
export function Dropdown<T extends string>({
value, options, onChange, testid, width, triggerText, bare, caretColor,
}: {
value: T
options: readonly { value: T; label: string }[]
onChange: (v: T) => void
testid: string
width?: number | string
/** Override what the TRIGGER shows, while the menu keeps the full labels.
* For a dropdown attached to another control, where the selection is
* already displayed beside it and repeating the whole label would just be
* noise — pass '' for a bare caret. */
triggerText?: string
/** Drop the trigger's own border and background so it can sit INSIDE
* another control as a caret. Buttons cannot nest, so a split control has
* to be a styled wrapper around two siblings — this makes the second one
* disappear into it. `caretColor` keeps the caret legible on whatever the
* wrapper's background is. */
bare?: boolean
caretColor?: string
}) {
const [open, setOpen] = React.useState(false)
// Auto drop-UP when the menu would clip the bottom of the window (e.g. the
Expand Down Expand Up @@ -60,11 +74,21 @@ export function Dropdown<T extends string>({ value, options, onChange, testid, w
<button
type="button" data-testid={testid} data-value={value}
aria-haspopup="listbox" aria-expanded={open}
style={{ ...S.trigger, ...(open ? S.triggerOpen : {}) }}
style={{
...S.trigger,
...(open && !bare ? S.triggerOpen : {}),
...(bare ? S.triggerBare : {}),
}}
onClick={toggle}
>
<span style={S.triggerLabel}>{current?.label ?? String(value)}</span>
<span style={S.caret}>▾</span>
{triggerText !== '' && (
<span style={S.triggerLabel}>
{triggerText ?? current?.label ?? String(value)}
</span>
)}
<span style={{ ...S.caret, ...(caretColor ? { color: caretColor } : {}) }}>
</span>
</button>
{open && (
<div role="listbox"
Expand Down Expand Up @@ -102,6 +126,10 @@ const S: Record<string, React.CSSProperties> = {
textAlign: 'left',
},
triggerOpen: { borderColor: '#45475a', background: '#181825' },
triggerBare: {
background: 'transparent', border: 'none', padding: '0 5px 0 2px',
width: 'auto',
},
triggerLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
caret: { fontSize: 9, color: '#6c7086', flex: '0 0 auto' },
// The panel is a copy of MenuBar's `styles.dropdown` / `styles.item`.
Expand Down
102 changes: 94 additions & 8 deletions electron/src/renderer/src/components/PlotControlDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -629,14 +629,54 @@ export function PlotControlDock() {
style={{ ...styles.selectorDot, background: s.color ?? '#00e676' }}
title={s.title ?? 'Navigator'}
/>
<button
data-testid="selector-crosshair"
style={s.mode === 'crosshair' ? styles.toggleActive : styles.toggle}
onClick={() => sendAction('set_selector_mode',
{ integrate: false, selector_id: s.selectorId }, s.windowId)}
>
✛ Point
</button>
{/* Point is a SPLIT control: the button picks the mode, and a
caret at its right edge opens the frame-width menu. It has to
be a styled wrapper around two siblings rather than a caret
nested in the button, because buttons cannot contain buttons
— so the wrapper carries the active/inactive look and both
children are transparent inside it. */}
<div style={{
...(s.mode === 'crosshair' ? styles.toggleActive : styles.toggle),
...styles.splitWrap,
}}>
<button
data-testid="selector-crosshair"
style={{
...styles.splitMain,
// Explicit, not inherited: a <button> does not take the
// parent's colour from the UA stylesheet, and the `font`
// shorthand in an inline style wiped the label entirely.
color: s.mode === 'crosshair' ? '#11111b' : '#a6adc8',
fontWeight: s.mode === 'crosshair' ? 600 : 400,
}}
onClick={() => sendAction('set_selector_mode',
{ integrate: false, selector_id: s.selectorId }, s.windowId)}
>
✛ Point
{/* The width, subtly, so you can see what the pointer is
reading without opening the menu. */}
{/* 0 is raw — a single camera frame, below one position. */}
{s.sumFrames === 0 && (
<span style={styles.sumBadge}>raw</span>
)}
{s.sumFrames != null && s.sumFrames > 1 && (
<span style={styles.sumBadge}>{s.sumFrames}f</span>
)}
</button>
{s.sumFrames != null && s.mode === 'crosshair' && (
<Dropdown
testid="selector-sum-frames"
value={String(s.sumFrames ?? 1)}
triggerText=""
bare
caretColor={s.mode === 'crosshair' ? '#11111b' : '#6c7086'}
options={sumFrameOptions(s.navSize ?? 0, s.navScale ?? 0,
s.rawPerPlane ?? 0)}
onChange={(v) => sendAction('set_selector_sum',
{ frames: Number(v), selector_id: s.selectorId }, s.windowId)}
/>
)}
</div>
<button
data-testid="selector-integrate"
style={s.mode === 'integrate' ? styles.toggleActive : styles.toggle}
Expand All @@ -653,7 +693,53 @@ export function PlotControlDock() {
)
}

/** The Sum-frames ladder: powers of two up to the navigation length.
*
* Powers of two rather than round frame rates because a rate cannot generally
* divide the acquisition cadence — asking for "60 fps" on a 2564 fps camera
* silently rounds to 43 frames, so the number you picked is not the number
* you got. A frame count is always exact, and the rate it produces is shown
* beside it when the axis is time. */
function sumFrameOptions(navSize: number, navScale: number, rawPerPlane = 0) {
const opts: { value: string; label: string }[] = []
// Below one position, when the source streams finer than it was loaded at.
// A .csb arrives as integrated planes because one raw 390 us frame of an
// 8192^2 detector is ~0.5% occupied and mostly noise — the right default to
// look at, but it hides what the format is actually streaming. "0" is the
// wire value for raw (every real width is >= 1 position).
if (rawPerPlane > 1) {
const raw = navScale > 0 ? (rawPerPlane / navScale) : 0
const hz = raw >= 1000 ? `${(raw / 1000).toFixed(1)} kfps`
: raw >= 1 ? `${raw.toFixed(0)} fps` : ''
opts.push({ value: '0', label: hz ? `1 raw frame — ${hz}` : '1 raw frame' })
}
for (let n = 1; n <= Math.max(1, navSize); n *= 2) {
const rate = navScale > 0 ? 1 / (n * navScale) : 0
const hz = rate >= 1000 ? `${(rate / 1000).toFixed(1)} kfps`
: rate >= 1 ? `${rate.toFixed(0)} fps`
: rate > 0 ? `${rate.toFixed(2)} fps` : ''
const frames = n === 1 ? '1 frame' : `${n} frames`
opts.push({ value: String(n), label: hz ? `${frames} — ${hz}` : frames })
if (n >= 64) break
}
return opts
}

const styles: Record<string, React.CSSProperties> = {
// The wrapper owns the button's look; the children sit transparently in it.
// NO overflow:hidden — the caret's menu is absolutely positioned inside this
// wrapper, so clipping it renders the options in the DOM but never visible.
splitWrap: {
display: 'flex', alignItems: 'center', padding: 0,
},
splitMain: {
flex: 1, background: 'transparent', border: 'none', fontSize: 11,
padding: '4px 2px 4px 6px', cursor: 'pointer', textAlign: 'center',
},
sumBadge: {
marginLeft: 6, fontSize: 10, fontWeight: 600, opacity: 0.62,
fontVariantNumeric: 'tabular-nums',
},
dock: {
width: 300, flexShrink: 0,
height: '100%',
Expand Down
21 changes: 21 additions & 0 deletions electron/src/renderer/src/kernel/SpyDEContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ export interface SelectorInfo {
selectorId?: number
/** Widget colour — the dock row's dot. */
color?: string
/** How many navigation positions a POINT selector sums (1 = plain
* crosshair). Only sent for a 1-D (movie/time) navigator; its absence is
* what hides the control on a 2-D one, where "n frames" has no direction. */
sumFrames?: number
/** Length of that navigation axis, so the dock can cap the ladder. */
navSize?: number
/** Seconds per navigation position (0 when the axis isn't time), so a
* summed window can be labelled with the rate it works out to. */
navScale?: number
/** Raw camera frames integrated into ONE navigation position, when the
* source streams finer than it was loaded at (a CSB event stream). Its
* presence is what lets the width ladder go BELOW one position to a single
* raw frame; absent for an ordinary movie, where nothing lies underneath. */
rawPerPlane?: number
}
/** The named navigators a navigator window offers (its top chip strip). */
export interface NavigatorOptions { names: string[]; current?: string | null }
Expand Down Expand Up @@ -1111,6 +1125,13 @@ export function SpyDEProvider({ children }: { children: React.ReactNode }) {
// creation-time title/colour on a mode-only re-emit.
...(msg.title != null ? { title: msg.title } : {}),
...(msg.color != null ? { color: msg.color } : {}),
...(msg.sum_frames != null
? { sumFrames: Number(msg.sum_frames) } : {}),
...(msg.nav_size != null ? { navSize: Number(msg.nav_size) } : {}),
...(msg.nav_scale != null
? { navScale: Number(msg.nav_scale) } : {}),
...(msg.raw_per_plane != null
? { rawPerPlane: Number(msg.raw_per_plane) } : {}),
},
})
break
Expand Down
Loading
Loading