Files
Media.JoshHeaps.Net/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js
T
2026-08-20 09:54:18 -06:00

132 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Top toolbar: project identity, save state, simulation transport, zoom.
import { el, button, setText, createListenerBag } from './dom.js';
/** Simulation speed presets, in engine events per second. */
const SPEED_STEPS = Object.freeze([1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]);
const DEFAULT_SPEED_INDEX = 5;
function formatSpeed(eventsPerSecond) {
if (eventsPerSecond >= 1000000) return `${eventsPerSecond / 1000000} M events/s`;
if (eventsPerSecond >= 1000) return `${eventsPerSecond / 1000} k events/s`;
return `${eventsPerSecond} events/s`;
}
export function createToolbar(root, handlers) {
const bag = createListenerBag();
const title = el('h1', { className: 'bb-title', text: handlers.projectName || 'Breadboard' });
const saveState = el('span', { className: 'bb-save-state', text: 'Loading…' });
const saveButton = button('Save', { className: 'bb-btn bb-btn-primary', attrs: { 'aria-keyshortcuts': 'Control+S' } });
const runButton = button('Run', { className: 'bb-btn bb-btn-run', title: 'Start the simulation' });
const pauseButton = button('Pause', { className: 'bb-btn', title: 'Pause the simulation' });
const stepButton = button('Step', { className: 'bb-btn', title: 'Advance one event' });
const resetButton = button('Reset', { className: 'bb-btn', title: 'Reload the circuit and clear burned-out parts' });
const simState = el('span', { className: 'bb-sim-state', text: 'idle' });
const speedInput = el('input', {
className: 'bb-speed',
attrs: {
type: 'range', min: '0', max: String(SPEED_STEPS.length - 1),
step: '1', value: String(DEFAULT_SPEED_INDEX),
'aria-label': 'Simulation speed'
}
});
const speedLabel = el('span', { className: 'bb-speed-label', text: formatSpeed(SPEED_STEPS[DEFAULT_SPEED_INDEX]) });
const zoomOut = button('', { className: 'bb-btn bb-btn-icon', title: 'Zoom out' });
const zoomIn = button('+', { className: 'bb-btn bb-btn-icon', title: 'Zoom in' });
const zoomFit = button('Fit', { className: 'bb-btn', title: 'Fit all boards in view' });
const zoomLabel = el('span', { className: 'bb-zoom-label', text: '100%' });
const addBoardButton = button('Add board', { className: 'bb-btn' });
// Last state written to the DOM. setSimState is called on every simulation frame,
// and writing unchanged text/disabled flags still invalidates layout on a
// wrapping toolbar, so identical calls stop here.
let lastSimSignature = null;
const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
const bar = el('div', {
className: 'bb-toolbar',
children: [
group('bb-group-project', [title, saveState, saveButton]),
group('bb-group-sim', [runButton, pauseButton, stepButton, resetButton, simState]),
group('bb-group-speed', [speedLabel, speedInput]),
group('bb-group-view', [addBoardButton, zoomOut, zoomLabel, zoomIn, zoomFit])
]
});
root.appendChild(bar);
bag.on(saveButton, 'click', () => handlers.onSave());
bag.on(runButton, 'click', () => handlers.onRun());
bag.on(pauseButton, 'click', () => handlers.onPause());
bag.on(stepButton, 'click', () => handlers.onStep());
bag.on(resetButton, 'click', () => handlers.onReset());
bag.on(addBoardButton, 'click', () => handlers.onAddBoard());
bag.on(zoomIn, 'click', () => handlers.onZoom(1.25));
bag.on(zoomOut, 'click', () => handlers.onZoom(1 / 1.25));
bag.on(zoomFit, 'click', () => handlers.onZoomFit());
bag.on(speedInput, 'input', () => {
const speed = SPEED_STEPS[Number(speedInput.value)] || SPEED_STEPS[DEFAULT_SPEED_INDEX];
setText(speedLabel, formatSpeed(speed));
handlers.onSpeed(speed);
});
return {
get initialSpeed() { return SPEED_STEPS[DEFAULT_SPEED_INDEX]; },
setProjectName(name) {
setText(title, name || 'Breadboard');
},
/**
* Reflect save state.
* @param {'clean'|'dirty'|'saving'|'error'} kind
*/
setSaveState(kind, text) {
saveState.className = `bb-save-state bb-save-${kind}`;
setText(saveState, text);
saveButton.disabled = kind === 'saving' || kind === 'clean';
},
/**
* Reflect simulation state. `halted` means the engine refused to run - a
* rail-to-rail short - so the run button must visibly not take.
*/
setSimState({ available, running, settled, halted, loaded }) {
const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
if (signature === lastSimSignature) return;
lastSimSignature = signature;
runButton.disabled = !available || running || halted;
pauseButton.disabled = !available || !running;
stepButton.disabled = !available || running;
resetButton.disabled = !available;
speedInput.disabled = !available;
let label = 'idle';
if (!available) label = 'engine unavailable';
else if (halted) label = 'halted — fix the fault, then reset';
else if (running) label = 'running';
else if (settled) label = 'settled';
else if (loaded) label = 'paused';
simState.className = `bb-sim-state${halted ? ' bb-sim-halted' : ''}${running ? ' bb-sim-running' : ''}`;
setText(simState, label);
},
setZoom(zoom) {
setText(zoomLabel, `${Math.round(zoom * 100)}%`);
},
destroy() {
bag.removeAll();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
export { SPEED_STEPS };