The frontend
src/store.tsis a tiny external store (getState,setState,useStore(selector)onuseSyncExternalStore). It holds the latestSnapshot(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 theprojectevent. 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.Apponly 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.tscatches it). Use a shared empty constant, or select the parts separately.
Ticks and the frame loop
Section titled “Ticks and the frame loop”- The
tickevent (~30 Hz) is kept outside React state:pushTickstores it, rebuilds a meter map (track id, -1 for Stereo Out, -100-n for input n) and callsonTicklisteners. The clip indicators’ holds (peakHold) and gain reductions (gainReduction) use the same keys. Recording previews go intoliveTakes. src/frame.tsruns onerequestAnimationFrameloop for everything that animates (playhead, meters, LCD).framePosextrapolates 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.
Canvas views
Section titled “Canvas views”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 fromsrc/theme.ts(a mutable object thatapplyThemeoverwrites), and subscribe tothemeIdso 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_waveformreturns raw bytes: a signed min/max pair per 32 frames) and cached.
Layout and viewport
Section titled “Layout and viewport”src/layout.ts:trackLayoutcomputes every track’s top, height (0 for tracks hidden or inside a collapsed stack, plus open take-folder rows) and stack depth.indexAtmaps a y coordinate to a track,stepVisiblesteps over hidden ones. Heights are stored per project inlocalStorage.src/view.tsshares 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.tsconverts between samples and bars/beats (1-based, like Logic’s LCD).
Themes
Section titled “Themes”src/themes.tsdefines each theme as a map of CSS custom properties (applied to:root) plus aCanvasThemefor 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.applyThemealso sets the window’s own theme (setTheme, light for Light and dark otherwise; capabilitycore:window:allow-set-theme), which on Linux tao turns into GTK’sgtk-application-prefer-dark-theme, so the native menu bar matches.src/styles/uses only the variables, never literal theme colours.src/styles/index.cssimports one stylesheet per area, and its order is the cascade:tokens.cssfirst, the rest in the order they were written, so an area can come in more than one file (see the comment there).tokens.cssholds Studio’s values for the first paint (a test keeps it equal tostudioCss), then the things no theme changes: the six font sizes (--fs-xsto--fs-xxl) and the focus ring.tests/styles.test.tsfails on a hex colour or a font size outside the tokens, andtests/contrast.test.tson--text,--dimor--faintunder 4.5:1 (WCAG) on any surface text sits on, in any theme (it readsthemes.tsas 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.cssrather than one-off fixes:.truncate(one line ending in “…”),.path-textwithPathText.tsxandtextFit.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-visibledraws--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, sofocusRing.tsmarks<html>withdata-keyboard-focuson Tab and clears it on a mouse press, and the ring outside text fields waits for it. Knobs, faders and sliders arerole="slider"with their value as text (sliderAriainsliderKeys.ts);sliderKeyDown(Controls.tsx) maps the arrow, Page, Home and End keys to a new value and sends it through the control’sonChangebetween its touch start and end, as a drag would, so automation writing and undo coalescing treat it as one.
The window’s layout
Section titled “The window’s layout”.appis 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..centerhasmin-width: 0for the same reason.- The centre column is the Tracks area, the divider and the bottom pane. The Tracks area’s
min-heightistracksAreaMinOf(its menu bar, ruler, global tracks, overview and 48 px of track rows); the bottom panes (.mixer,.editor,.fx-dock) are their remembered height withflex: 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 withinbottomMax(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@containerrules 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, andfitStripRows(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.
Key commands
Section titled “Key commands”src/keymap.tslists 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:
Modis ⌘ on macOS and Ctrl elsewhere,Ctrlis the Control key on macOS (folded intoModelsewhere bynormalizeKey), thenAlt,Shift.eventKeynames the physical letter or punctuation for Alt/⌘ chords, since macOS Option changes the character. App.tsxinstalls onekeydownlistener (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 withcommandForEvent(which honours the overrides insettings.json›ui.keymap) and runs the matching entry inKEY_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.tsreadskeymap.ts,App.tsxandmenu.rsand fails when a key command or menu id has no handler, or two commands share a default key.
Menus and dialogs
Section titled “Menus and dialogs”- The native menu bar is described as data in
src-tauri/src/menu.rs(spec) and built from it. Each item emits amenuevent with its id;handleMenuinApp.tsxdispatches it. Marbble › Quit Marbble is an ordinary item (Tauri’s predefined Quit draws disabled on GTK and skips the page on macOS):menu.rscloses the main window with it, which raises the same close request as the window’s close button, soonCloseRequestedasks about unsaved changes. - The menu bar follows the app through
set_menu_state, fed bysrc/menuBar.tsfrom 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,viewMenuinTracksArea.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.tschangedStates), 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 GTKmenu.rsfinds each item’s widget by its position (Entry::path, separators counted) under the window’sdefault_vboxand sets the key on itsGtkAccelLabelwithset_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.rsputs 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_statekeeps each item’s enabled state and checks the submenus after each change), as the in-app menus’ submenus are. - One-of-several items (
radioinspec: View › Theme; and File › Project Alternatives) are check items, as muda has nothing else; on GTKmenu.rssetsdraw_as_radioon 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.rsand never reach the page, except Help › Key Commands (sent assettings-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,openon macOS andexploreron Windows. - The update check (
src/updates.ts, rules insrc/updateCheck.ts) runs in the main window: once at launch when Settings › General’sui.check_updatesis on, and from Help › Check for Updates…. Itfetchesapi.github.com/repos/JackMechem/marbble/releasesfrom 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 withapp_versionby semver precedence. A newer one goes tocomponents/UpdateNotice.tsx, a non-modal notice above the status line that never takes focus and waits out a recording; View Release callsopen_release_page, which opens only pages under the repository’s releases withmenu.rs’sopen_url. Nothing is downloaded. A debug build’sapp_versionreturnsMARBBLE_FAKE_VERSIONwhen 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.tswindowTheme, besidesetTheme). GTK’s menu bar has no background of its own in Adwaita and shows the window’s, which Tauri sets frombackgroundColor; left at the dark default, Light’s dark menu text sat on a near-black bar. - Names:
src/menuLabels.tsholds one name per command (MENU_LABELS) and the Edit menu’s layout (EDIT_MENU), used by the Tracks area’s menus and checked againstmenu.rsbytests/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.jsonpins the pairs, checked againstgtk_accelerator_get_labelbymenu.rs’s tests and againstformatKeyForbytests/keyFormat.test.ts. GTK translates its key names (“BackSpace” untranslated, “Backspace” in English), so before the first rendermain.tsxasksgtk_key_names(menu.rs) for the names in the app’s language andsetGtkKeyNamesuses them; the pinned pairs are the untranslated names the tests see. Tooltips add a command’s current key withtip(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.tsbuilds 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 usewindow.prompt,confirmoralert: macOS’s web view doesn’t implement them (prompt returns null), andtests/browserDialogs.test.tsfails if they appear insrc/. Detached windows show the name sheet too.- Native file dialogs and confirmations use
@tauri-apps/plugin-dialog(src/files.ts); the file dialogs go throughsrc/fileDialog.ts(below).
File dialogs
Section titled “File dialogs”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_schemalooks 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 withSettingsSchemaSource::from_directoryso 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 toXDG_DATA_DIRS. - Before each file dialog,
src/fileDialog.tsasksfile_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.tsfails if a file takesopenorsavefrom 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.
Detached windows
Section titled “Detached windows”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 (localStoragemarbble.detachedPanels), and opens, focuses and closes the windows (windows.rs:open_panel_window,focus_panel_window,close_panel_window). Panel windows ask it withpanel-msgevents (hello,attach,detach,close,key,geometry), and key commands pressed in them run in the main window’sKEY_ACTIONS. - Kinds.
src/panelKinds.tsxregisters each kind withsrc/detach.ts: its title, what its window renders, whether a window’s state shows it, and the state changes that hide and show it (bottomfor the Mixer, Editors, Event List and Step Sequencer,fxDockfor 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.tsSYNCED_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 asui-syncpatches (syncPatchcompares by identity), settings asui-settings. Snapshots don’t travel through the frontend: after a window’sact()changes itssnap, it callsbroadcast_snapshot(throttled to one every 30 ms), and Rust sends the session’s snapshot to every other window asproject-sync. The engine’stickandprojectevents 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 withgetCurrentWebviewWindow().listen. - Menu bar.
windows.rsroute_menusends 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.rsdestroys 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.