Skip to content

The frontend

  • src/store.ts is a tiny external store (getState, setState, useStore(selector) on useSyncExternalStore). It holds the latest Snapshot (snap), plus UI state: which panes are shown, zoom (pxPerBeat), track heights, snap mode, selection (selectedRegions, selectedTracks, focus), the clipboard, the open dialog, theme, UI scale, the automation and take-folder view state, and toasts.
  • The snapshot is replaced wholesale on every edit (act) and on the project event. Treat it as read-only; never mutate it in place.
  • Components select narrowly (useStore((s) => s.snap?.project.tracks)) so they re-render only when what they show changes. App only subscribes to layout-level flags. A selector must return the same value while nothing changed: one that builds a new array or object on each call re-renders forever and blanks the window (tests/storeSelectors.test.ts catches it). Use a shared empty constant, or select the parts separately.
  • The tick event (~30 Hz) is kept outside React state: pushTick stores it, rebuilds a meter map (track id, -1 for Stereo Out, -100-n for input n) and calls onTick listeners. The clip indicators’ holds (peakHold) and gain reductions (gainReduction) use the same keys. Recording previews go into liveTakes.
  • src/frame.ts runs one requestAnimationFrame loop for everything that animates (playhead, meters, LCD). framePos extrapolates the playhead from the last tick by the time since it arrived (capped at 100 ms) and wraps at the cycle end, so motion is smooth at display rate. coalesced(f) schedules a redraw once per frame however often it is requested.

The tracks area (Arrange.tsx, drawing in components/tracksCanvas/draw.ts), ruler, Piano Roll, Audio Track Editor and meters are canvas drawings. The common pattern:

  • Draw functions read the store with getState() and the canvas theme from src/theme.ts (a mutable object that applyTheme overwrites), and subscribe to themeId so they redraw on a theme switch.
  • Backing stores are sized with pixelRatio() (uiScale.ts), which accounts for both the display density and the interface zoom.
  • Redraws are triggered by store changes, ticks and viewport changes, coalesced to one per frame.
  • Waveforms (waveform.ts) are fetched once per clip as binary (clip_waveform returns raw bytes: a signed min/max pair per 32 frames) and cached.
  • src/layout.ts: trackLayout computes every track’s top, height (0 for tracks hidden or inside a collapsed stack, plus open take-folder rows) and stack depth. indexAt maps a y coordinate to a track, stepVisible steps over hidden ones. Heights are stored per project in localStorage.
  • src/view.ts shares the tracks area’s scroll position (viewport, onViewport) between the ruler, the headers and the canvas without React state, and holds layout constants (RULER_H, HEADER_W, the track colour palette).
  • src/time.ts converts between samples and bars/beats (1-based, like Logic’s LCD).
  • src/themes.ts defines each theme as a map of CSS custom properties (applied to :root) plus a CanvasTheme for canvas drawing. Flat, Midnight, Light and High Contrast spread the previous theme’s maps and override what differs, so a new variable only needs a value in Studio to exist everywhere.
  • src/styles/ uses only the variables, never literal theme colours. src/styles/index.css imports one stylesheet per area, and its order is the cascade: tokens.css first, the rest in the order they were written, so an area can come in more than one file (see the comment there).
  • src/keymap.ts lists every rebindable command (KEY_COMMANDS: id, label, group, default keys) and, read-only, the native menu’s accelerators (MENU_SHORTCUTS).
  • A key string is modifiers then key: Mod is ⌘ on macOS and Ctrl elsewhere, Ctrl is the Control key on macOS (folded into Mod elsewhere by normalizeKey), then Alt, Shift. eventKey names the physical letter or punctuation for Alt/⌘ chords, since macOS Option changes the character.
  • App.tsx installs one keydown listener (onKey). It ignores events in inputs, while a dialog is open and while an open sheet holds the command back (sheetBlocks, see Sheets and key commands), looks the key up with commandForEvent (which honours the overrides in settings.json › ui.keymap) and runs the matching entry in KEY_ACTIONS.
  • Editors with their own keys (Piano Roll, Event List, Step Sequencer, Audio Track Editor, Musical Typing, Step Input, automation selection) handle them in their own listeners when they have focus. Edit › Select’s key commands act on notes and events while the Piano Roll or the Event List has focus (src/noteSelect.ts).
  • tests/commands.test.ts reads keymap.ts, App.tsx and menu.rs and fails when a key command or menu id has no handler, or two commands share a default key.
  • The native menu bar is built in src-tauri/src/menu.rs. Each item emits a menu event with its id; handleMenu in App.tsx dispatches it. Editors get the first look: the Audio Track Editor (editorMenuCommand) and the Piano Roll (pianoRollMenu) take Split, Cut, Copy, Delete and similar when they have focus or a selection. Menu commands are ignored while a render is running (isBouncing).
  • In-window menus (right-click menus, local menus, slot menus) are rendered by components/Menu.tsx (MenuHost); src/ops.ts builds the track, region and lane menus.
  • Dialogs are chosen by state.dialog (new-tracks, settings, chooser, bounce, bounce-in-place, project-settings, recovery, alternatives), plus separately flagged sheets (group settings, Marker List, Channel EQ, Musical Typing, Controller Assignments, Strip Silence).
  • askName (components/NameSheet.tsx) asks for a name or a value in a small sheet above everything else, the Project Chooser included, and resolves with it or null. Never use window.prompt, confirm or alert: macOS’s web view doesn’t implement them (prompt returns null), and tests/browserDialogs.test.ts fails if they appear in src/. Detached windows show the name sheet too.
  • Native file dialogs and confirmations use @tauri-apps/plugin-dialog (src/files.ts).

A detachable panel (the Mixer, the Editors, the Event List, the Step Sequencer, a Channel EQ, a built-in effect panel) can be shown in an OS window of its own, labelled panel-<key> (keys: mixer, editor, list, steps, eq:<track>, fx:<uid>, the last two shared with fxDock.ts). Every window loads the same frontend; main.tsx renders DetachedWindow instead of App when the label is a panel’s.

  • Authority. The main window decides what is detached (state.detached), remembers it with each kind’s window geometry (localStorage marbble.detachedPanels), and opens, focuses and closes the windows (windows.rs: open_panel_window, focus_panel_window, close_panel_window). Panel windows ask it with panel-msg events (hello, attach, detach, close, key, geometry), and key commands pressed in them run in the main window’s KEY_ACTIONS.
  • Kinds. src/panelKinds.tsx registers each kind with src/detach.ts: its title, what its window renders, whether a window’s state shows it, and the state changes that hide and show it (bottom for the Mixer, Editors, Event List and Step Sequencer, fxDock for effect panels). Each window hides a detached panel its own state tries to show (X, a slot click) and brings that panel’s window to the front instead, so a panel is never shown twice. The main window closes the windows of panels whose track or effect has gone.
  • Sync. Each window keeps its own store. The fields in detachModel.ts SYNCED_FIELDS (the region, track and note selection, focus, the region, channel strip, plug-in and Channel EQ clipboards, snap, Catch, hide mode, open take folders, the automation lane shown, theme, interface size, waveform style, the plug-in lists, detached) go to the other windows as ui-sync patches (syncPatch compares by identity), settings as ui-settings. Snapshots don’t travel through the frontend: after a window’s act() changes its snap, it calls broadcast_snapshot (throttled to one every 30 ms), and Rust sends the session’s snapshot to every other window as project-sync. The engine’s tick and project events reach every window anyway. Applying another window’s change is marked remote, so it isn’t sent back. With nothing detached, the main window sends nothing.
  • Event targets. Tauri delivers an event to every listener registered for any target, whatever target it was sent to, and a plain listen() registers for any target. So the events meant for one window (menu, panel-msg, ui-sync, ui-settings, project-sync) are listened for with getCurrentWebviewWindow().listen.
  • Menu bar. windows.rs route_menu sends a native menu item to the focused panel window, else to the main window. On macOS the menu bar is the app’s, so this is what decides which window an item acts on. A panel window handles the editor’s Edit items itself and passes the rest to the main window.
  • Geometry. Kept per kind, as the outer position and inner size, in physical pixels; on macOS in points, since the screens share one coordinate space in points there and a physical position would be converted with the scale of the screen the window is on now. It is fitted to the monitors’ work areas when a window opens (fitToMonitors).
  • Closing. A panel window’s close button attaches the panel. When the main window is destroyed, windows.rs destroys the panel windows without asking them, so the detached Mixer and Editors reopen at the next launch.