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. applyTheme also sets the window’s own theme (setTheme, light for Light and dark otherwise; capability core:window:allow-set-theme), which on Linux tao turns into GTK’s gtk-application-prefer-dark-theme, so the native menu bar matches.
  • 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). tokens.css holds Studio’s values for the first paint (a test keeps it equal to studioCss), then the things no theme changes: the six font sizes (--fs-xs to --fs-xxl) and the focus ring. tests/styles.test.ts fails on a hex colour or a font size outside the tokens, and tests/contrast.test.ts on --text, --dim or --faint under 4.5:1 (WCAG) on any surface text sits on, in any theme (it reads themes.ts as text and builds each theme’s map with its spread), or on the three out of order.
  • Text that doesn’t fit follows shared rules in text-fit.css rather than one-off fixes: .truncate (one line ending in “…”), .path-text with PathText.tsx and textFit.ts (a path shortened from its start at a separator, measured with a canvas in the element’s font, the whole path in the tooltip), captions and buttons that never wrap, and a checkbox kept beside its label.
  • Keyboard focus. :focus-visible draws --focus-ring (base.css). Browsers also count a key pressed after a click as keyboard focus, which here would ring the last button clicked as soon as a shortcut is pressed, so focusRing.ts marks <html> with data-keyboard-focus on Tab and clears it on a mouse press, and the ring outside text fields waits for it. Knobs, faders and sliders are role="slider" with their value as text (sliderAria in sliderKeys.ts); sliderKeyDown (Controls.tsx) maps the arrow, Page, Home and End keys to a new value and sends it through the control’s onChange between its touch start and end, as a drag would, so automation writing and undo coalescing treat it as one.
  • .app is a grid of one column, minmax(0, 1fr): exactly the window’s width. (An implicit column is as wide as its widest content, so a Mixer with more strips than fit widened the whole layout, control bar included.) Wide content scrolls in its own pane: the Mixer’s .mixer-strips, the editors’ bodies. .center has min-width: 0 for the same reason.
  • The centre column is the Tracks area, the divider and the bottom pane. The Tracks area’s min-height is tracksAreaMinOf (its menu bar, ruler, global tracks, overview and 48 px of track rows); the bottom panes (.mixer, .editor, .fx-dock) are their remembered height with flex: 0 1 auto; min-height: 0, so when the window has no room for that height the pane shrinks and the divider stays on screen. The remembered size isn’t changed by this: a larger window shows it again. A drag of the divider starts from the height shown and stays within bottomMax (src/paneSizes.ts), so a size remembered too tall never needs a long drag back.
  • Narrow bars. The editors’ local bars (.editor-bar, editor-bars.css) and the control bar are CSS size containers (container-type: inline-size). Their @container rules drop the captions and tighten sliders and gaps below widths measured for each bar, so the rules hold at any interface size (125 % at 1280 px is a 1024 px wide page). An editor bar wraps as a last resort: its tools (.local-right) stay beside the tabs and menus while they have 360 px there, wrapping within it, and otherwise take a row of their own; every row is 24 px.
  • The Mixer’s height. useStripFit (Panels.tsx) measures a strip with no slot rows and its fader at its least, and fitStripRows (stripRows.ts) takes slot rows away, sends first, while the fader would get less than 120 px. Below one row each the strips scroll, pinned to the bottom until scrolled up, so faders and names show first. The Mixer’s default height is 560 px, the Editors’ 360 px.
  • 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 described as data in src-tauri/src/menu.rs (spec) and built from it. Each item emits a menu event with its id; handleMenu in App.tsx dispatches it. Marbble › Quit Marbble is an ordinary item (Tauri’s predefined Quit draws disabled on GTK and skips the page on macOS): menu.rs closes the main window with it, which raises the same close request as the window’s close button, so onCloseRequested asks about unsaved changes.
  • The menu bar follows the app through set_menu_state, fed by src/menuBar.ts from the main window: ticks, enabled states, the Undo/Redo names and the keys of the key commands. The states come from the Tracks area’s own menus (editMenu, functionsMenu, viewMenu in TracksArea.tsx; their items carry the menu bar’s ids) plus a few conditions only the menu bar needs (Record’s modes, Track On, Freeze, the theme…). It recomputes after any store or settings change and on focus changes (an editor with focus takes the Edit menu, so its items stay enabled then), sends only the fields that changed (menuBarModel.ts changedStates), and runs on a zero timeout so a key pressed right after a click finds the item enabled.
  • Two kinds of key. Accelerators in spec (Ctrl+S, Ctrl+Z…) belong to the menu: the toolkit runs the item before the page sees the key. Key commands (keymap.ts) are handled by the page and only shown in the menu bar: on GTK menu.rs finds each item’s widget by its position (Entry::path, separators counted) under the window’s default_vbox and sets the key on its GtkAccelLabel with set_accel, which draws a key without binding it (muda does the same for its Cut/Copy/Paste); on Windows the key goes into the label after a tab, which Windows right-aligns. A registered single-letter key would be taken from text fields and Musical Typing and skip the page’s key handling; a key is never both, so nothing runs twice (tests/menuLabels.test.ts). Panel windows get the keys in their own menu bar when they first come to the front. macOS doesn’t show the key commands’ keys yet (AppKit has no draw-only key).
  • A check item ticks itself when chosen; menu.rs puts the tick back at once and the page sends the new state if the command changed it, so a command that changes nothing leaves the tick right.
  • A submenu of spec (Repeat, Trim, Track Alternatives…) is greyed out while every item in it is (set_menu_state keeps each item’s enabled state and checks the submenus after each change), as the in-app menus’ submenus are.
  • One-of-several items (radio in spec: View › Theme; and File › Project Alternatives) are check items, as muda has nothing else; on GTK menu.rs sets draw_as_radio on their widgets, found by path like the keys, whenever a window comes to the front and after the alternatives are rebuilt.
  • Window and Help are handled in menu.rs and never reach the page, except Help › Key Commands (sent as settings-keys) and Help › Check for Updates… (help-updates, below). Window › Minimize minimizes the window in front (muda’s predefined Minimize doesn’t exist on GTK); the end of Window lists the open windows (window:<label>, the main window first, then the panel windows by title), rebuilt on the main thread when a window comes to the front or closes and when the project menus change, and only when the list differs. Help’s pages (HELP_PAGES) open in the browser through GIO on Linux, open on macOS and explorer on Windows.
  • The update check (src/updates.ts, rules in src/updateCheck.ts) runs in the main window: once at launch when Settings › General’s ui.check_updates is on, and from Help › Check for Updates…. It fetches api.github.com/repos/JackMechem/marbble/releases from the page (the CSP is off and the API allows CORS, so no HTTP crate), takes the newest non-draft release on the channel (ui.update_channel; unset follows the build: Unstable for a prerelease) and compares it with app_version by semver precedence. A newer one goes to components/UpdateNotice.tsx, a non-modal notice above the status line that never takes focus and waits out a recording; View Release calls open_release_page, which opens only pages under the repository’s releases with menu.rs’s open_url. Nothing is downloaded. A debug build’s app_version returns MARBBLE_FAKE_VERSION when set, to try the check against the real releases.
  • About Marbble passes the 128 px icon as the dialog’s logo: GTK otherwise looks it up in the icon theme, which a build that isn’t installed doesn’t have.
  • The window’s background is the theme’s --bg (themes.ts windowTheme, beside setTheme). GTK’s menu bar has no background of its own in Adwaita and shows the window’s, which Tauri sets from backgroundColor; left at the dark default, Light’s dark menu text sat on a near-black bar.
  • Names: src/menuLabels.ts holds one name per command (MENU_LABELS) and the Edit menu’s layout (EDIT_MENU), used by the Tracks area’s menus and checked against menu.rs by tests/menuLabels.test.ts; a region’s right-click menu drops “Regions” (SHORT_LABELS).
  • Keys are written as the platform’s menu bar draws them (keyFormat.ts): GTK’s order and names on Linux (“Shift+Ctrl+Z”, “Ctrl+Right”), since GTK draws every key in the menu bar itself; tests/gtkKeyLabels.json pins the pairs, checked against gtk_accelerator_get_label by menu.rs’s tests and against formatKeyFor by tests/keyFormat.test.ts. GTK translates its key names (“BackSpace” untranslated, “Backspace” in English), so before the first render main.tsx asks gtk_key_names (menu.rs) for the names in the app’s language and setGtkKeyNames uses them; the pinned pairs are the untranslated names the tests see. Tooltips add a command’s current key with tip(label, id) (keymap.ts), so a reassigned key shows there too. 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); the file dialogs go through src/fileDialog.ts (below).

On Linux the dialog plug-in opens GTK’s file chooser, which reads the GSettings schema org.gtk.Settings.FileChooser, and GLib aborts the process when a schema isn’t installed (GLib-GIO-ERROR … is not installed): a debug binary started outside the dev shell lost the session on File › Save As. Two guards, in src-tauri/src/schemas.rs:

  • At startup, before GTK or any thread starts, ensure_file_chooser_schema looks for the schema in the directories GLib will read (XDG_DATA_DIRS, the user’s data directory, GSETTINGS_SCHEMA_DIR), each read on its own with SettingsSchemaSource::from_directory so GLib’s own list, fixed the first time it is read, is left alone. If it isn’t there but is in a data directory of the environment Marbble was built in (option_env!("XDG_DATA_DIRS"), the dev shell’s), that directory is added to XDG_DATA_DIRS.
  • Before each file dialog, src/fileDialog.ts asks file_dialog_problem, which looks the schema up in GLib’s default list, the one GTK will use. When it is missing the user gets an error message saying what to install, the dialog doesn’t open and the call returns null, as a cancel does. tests/fileDialog.test.ts fails if a file takes open or save from the plug-in directly. Message dialogs (ask, message) don’t use the schema.

The packages carry the schema: the Nix package through wrapGAppsHook3 (GTK’s schemas on XDG_DATA_DIRS), the AppImage through linuxdeploy’s GTK plug-in (GSETTINGS_SCHEMA_DIR in the image), the .deb and .rpm through their GTK dependency (libgtk-3-0 brings libgtk-3-common). Other platforms have no GSettings; there the check always passes.

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.

Marbble is released under the MIT licence.Source on GitHub.