Compare commits

...

44 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 0eff5f7f33 Release 1.0.8-alpha 2026-07-09 18:26:37 -04:00
Skyler Lehmkuhl b6f43d2e72 Bump version to 1.0.8-alpha 2026-07-09 18:26:37 -04:00
Skyler Lehmkuhl 8fbb6d65c0 Prompt to save on quit too; unify with the switch prompt
Closing the window with unsaved changes was a silent data-loss path — and worse,
a clean exit deletes the recovery file, removing the safety net too.

Fold quitting into the same unsaved-changes flow as file switches: one
PendingAction enum (NewFile / Open / Quit), one modal, one do_action, one
after_save handler. render_unsaved_prompt now also intercepts the window-close
request (CancelClose + queue a Quit prompt); Save & Quit saves then closes,
Discard & Quit closes now, Cancel keeps the window open. confirmed_close lets the
final programmatic close through instead of re-intercepting it.

Replaces the near-duplicate close-prompt/switch-prompt code with a single path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:17:37 -04:00
Skyler Lehmkuhl 6aece26a8b Prompt to save unsaved changes before switching files
New File / Open / Open Recent previously discarded unsaved work with no warning
(three // TODO: Prompt to save markers). They now route through request_switch,
which shows a Save / Don't Save / Cancel modal when the document is modified:
  • Save  — saves first (Save As for untitled/recovered docs), then switches once
    the background save completes (switch_after_save).
  • Don't Save — switches and discards.
  • Cancel — stays put.

"Modified" is tracked without hooking every edit site: current epoch vs a
saved_epoch baseline (rebased on manual save / new / load), plus a media_modified
flag for imports/recordings that don't bump the action epoch. Recovered documents
count as modified until given a real home, so switching away from unrecovered work
also prompts.
2026-07-09 18:17:27 -04:00
Skyler Lehmkuhl ef2b0822bd Autosave: keep recovery files out of Recent Files
Recovering a file loaded it through the normal load path, which added it to
Recent Files — so an internal recovered-*.beam in the app data dir showed up in
the Recent list (and could even be auto-reopened as "last session").

Skip add_recent_file for recovery paths on load, and clean any that older builds
already leaked into Recent at startup (before the auto-reopen check reads it).
2026-07-09 18:05:46 -04:00
Skyler Lehmkuhl 5f09222f3f Autosave: configurable interval + startup logging for testing
- LB_AUTOSAVE_SECS overrides the 45s autosave interval (e.g. =5) so the recovery
  flow can be exercised without a long wait.
- Log the session's recovery file path at startup so it's easy to find.
2026-07-09 17:43:27 -04:00
Skyler Lehmkuhl b0e965b1c2 Crash recovery: restore prompt for leftover recovery files
Second half of autosave/recovery — the read/restore side.

At startup, scan the recovery dir for session-*.beam files. Since a clean exit
deletes this session's file (on_exit), any leftover means a previous session
crashed or was killed with unsaved work. Offer the newest via a modal:
  • Recover — rename it out of the session-* namespace (to recovered-*) and open
    it as the current file. It's renamed, not deleted, because recovered raster
    keyframes page in from it on demand; renaming also stops it being re-offered.
  • Discard — delete it.
  • Later — keep it for next launch, stop prompting this session.

Because a recovered file lives in the app data dir, Save behaves as Save As for
it (both the menu action and the Save-As default directory skip recovery paths),
so the work lands where the user wants rather than back in the data dir.

recovered-* files older than a week are garbage-collected at startup.
2026-07-09 17:29:30 -04:00
Skyler Lehmkuhl 6596acb3db Crash recovery: background autosave to a per-session recovery file
First half of the autosave/recovery feature — the write side.

Every ~45s while the document is dirty, write the full current state into a
per-session recovery .beam in the app data dir (directories::ProjectDirs data
dir + /recovery/session-<uuid>.beam). Fully background: reuses the existing file
worker, so the pool serialization / encode / DB write all happen off the UI
thread. The only UI-thread cost is one document clone — and build_save_command
now stamps the UI layout onto the *snapshot clone* rather than the live document
(the old prepare_document_for_save mutated live state via Arc::make_mut, which
could deep-clone the whole document mid-frame). Removed that dead helper.

Dirtiness is tracked centrally via ActionExecutor::epoch() (now also bumped on
undo/redo, not just execute) plus a pending_event flag set by non-action changes
(imports, finished recordings). The baseline is rebased on new/load/manual-save
so a freshly-loaded or just-saved project stays quiet. Idle-after-edit still gets
one snapshot via a single request_repaint_after wakeup; completion is polled
lazily (no forced repaints during the write).

on_exit deletes the session's recovery file, so a leftover file on next launch
means an unclean shutdown — the hook the recovery prompt (next commit) keys on.
2026-07-09 17:20:54 -04:00
Skyler Lehmkuhl d6b86a14b1 Save: skip re-encoding unchanged raster keyframes
save_beam re-encoded every resident raster keyframe to PNG (+ proxy) on every
save, even untouched frames — the dominant per-save cost for painting/animation
projects (the code noted this as deferred "Phase 3").

The infrastructure to do it incrementally already exists: kf.dirty means "current
pixels not yet in the container" (set on any edit, cleared on a successful save,
per main.rs), and it's preserved in the document clone the save worker receives.
Gate the encode on it: a clean keyframe already stored keeps its full + proxy rows
untouched; only dirty (or not-yet-stored) frames are re-encoded. Media blobs were
already incremental; this closes the raster gap.

No new data-loss risk: the mid-save-edit race (edit between the document clone and
save completion) is pre-existing and identical to the old full-write path.
2026-07-09 13:58:34 -04:00
Skyler Lehmkuhl a2839f80b1 Clean up build warnings 2026-07-09 13:24:32 -04:00
Skyler Lehmkuhl 6e6feaddf5 Fix FLAC export end-to-end (real backend path) + smart tag defaults
The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".

Move the work to where export actually happens:
- daw-backend/src/audio/export.rs: real ffmpeg FLAC (16-bit S16 / 24-bit S32,
  skipping the trailing empty flush packet the FLAC muxer rejects); apply_metadata
  on MP3/AAC/FLAC output; RIFF LIST/INFO chunk appended to WAV. New metadata field
  on the backend ExportSettings, threaded from the UI in run_audio_export. Tests
  assert real fLaC magic + round-tripped tags, and a valid WAV INFO chunk.
- Delete the dead export/audio_exporter.rs (removes the duplicate FLAC impl).

Smart tag defaults (filled only when empty, never clobbering edits):
- Year → current civil year, computed from the system clock with i64 math (no
  date crate; correct past 2038/2106 — tests cover post-i32/u32 timestamps).
- Artist → last-used value, else the OS username ($USER/%USERNAME%).
- Album → last-used value.
Last-used Artist/Album persist in AppConfig and prefill next export.
2026-07-09 13:09:54 -04:00
Skyler Lehmkuhl 15bdf80ec1 Audio export: real FLAC + tag metadata for all formats
- FLAC is now real FLAC via ffmpeg, not WAV bytes in a .flac file. 16-bit uses
  S16, 24-bit uses S32 (ffmpeg's flac encoder emits bits_per_raw_sample=24).
  The flush emits a trailing empty packet that the FLAC muxer rejects as
  "invalid data" — it's skipped.

- Tag metadata (title/artist/album/genre/year/track/comment) written into every
  format via each container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis
  comments (FLAC) set through ffmpeg's output metadata; RIFF LIST/INFO appended
  to the hound-written WAV (with a fixed-up RIFF size). New AudioMetadata type
  on AudioExportSettings; dialog gains a Tags section and defaults Title to the
  project name.

Tests: FLAC is a real fLaC container with round-tripped tags; WAV keeps a valid
RIFF with a working INFO chunk.
2026-07-09 12:52:37 -04:00
Skyler Lehmkuhl 6b8a1f1386 SVG export: emit text layers as real glyph outlines
SVG export silently dropped Text layers (they fell through the layer_to_svg
catch-all) while the dialog implied only raster/video/effect were excluded, so
title/caption text vanished from a "lossless" export with a success message.

Emit text as actual glyph-outline <path>s: lay the text out with the same
parley path the renderer uses, then extract each positioned glyph's outline
with skrifa (an OutlinePen that maps points into document space — Y flip,
synthetic-italic skew, variable-font normalized coords). Result is
font-independent and needs no <text>/@font-face. Vello rasterizes glyphs on the
GPU and doesn't expose the path, but the skrifa outline API it uses is directly
callable and parley's glyph IDs are real font GIDs, so the outlines match.

Synthetic bold is not applied (rare). Adds a skrifa dep pinned to parley's 0.43.
2026-07-09 07:10:59 -04:00
Skyler Lehmkuhl 53ffb7d528 Export honesty: real lossy WebP, working ProRes, VP8+audio container
Three cases where an export produced something that didn't match what the UI
offered:

- WebP quality slider was a no-op: image 0.25's WebP encoder is lossless-only,
  so the slider did nothing and files were needlessly large. Encode lossy WebP
  via ffmpeg's libwebp instead (already linked); the quality knob is now real
  and alpha is preserved as YUVA420P. Test asserts a lossy VP8 chunk + that
  quality changes file size.

- ProRes 422 always failed to open: the SDR path fed prores_ks 8-bit YUV420P,
  but it requires 10-bit 4:2:2. Add a CpuYuv422P10Converter (RGBA→YUV422P10LE,
  BT.709) and route ProRes through the existing async pipeline in CPU mode;
  setup_video_encoder now emits YUV422P10LE + prores_ks HQ profile and
  encode_frame handles 4:2:2 chroma. Test guards that the encoder opens.

- VP8+audio failed at mux: the parallel path wrote the temp video to a
  hardcoded .mp4, which VP8 can't live in. Derive the temp container from the
  codec (VP8/VP9 → .webm).
2026-07-09 06:58:04 -04:00
Skyler Lehmkuhl c373af461e Add animated GIF export
New export format alongside audio/image/video/SVG. GIF is multi-frame like
video but palette-quantized with no audio, so it reuses the per-frame RGBA
render/readback path (render_frame_to_gpu_rgba) and streams frames to a
background encoder.

- core: GifExportSettings (resolution, framerate, loop, transparency, fit,
  time range) with centisecond-quantized frame delay + tests.
- gif_exporter: encoder pipeline. Per-frame NeuQuant quantization is the
  dominant cost and is per-frame independent, so it's fanned out across a
  worker pool (cores-1, capped 8); a writer thread reorders and LZW-encodes
  sequentially. Uses the `gif` crate directly (already resolved via `image`).
- orchestrator: start_gif_export + render_next_gif_frame (one frame per egui
  update), wired into is_exporting/has_pending_progress/cancel.
- dialog: GIF tab + settings; main.rs: handle ExportResult::Gif and pump frames.
- Cargo: opt-level=3 for gif/color_quant/weezl in the dev profile so debug
  builds aren't crippled by unoptimized NeuQuant loops.

Together these cut a 10s GIF export from ~1:43 to ~3s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:02:47 -04:00
Skyler Lehmkuhl 6e361aa30c Reset audio backend on new file / project load
The backend's Command::Reset (full teardown: rebuilds Project, audio/buffer
pools, ID counters) was never invoked from the UI. New File and project loads
only cleared app-side maps, so the previous file's tracks and loaded
instruments stayed resident in the backend and kept getting mixed on every
audio callback.

Add a reset_audio_backend() helper (controller.reset() + clear app-side track
maps and backend-derived caches) and call it in the three teardown paths:
NewFile, create_new_project_with_focus, and apply_loaded_project. Also drops a
duplicate layer_to_track_map.clear() in the NewFile handler.

Race-free: the audio thread drains all command_tx commands before any query_tx
queries each callback, so Reset (a command) runs before the track/pool rebuild
(queries), even though they travel on separate channels.
2026-07-09 06:01:34 -04:00
Skyler Lehmkuhl bb6b6fa9e3 Mobile: address code-review findings
Fixes from a review of the mobile-UI branch.

- Guard the inspector size clamps so min<=max (f32::clamp panics otherwise) and set a
  window min_inner_size; prevents a crash / degenerate layout at small sizes. Same
  small-size guards for the piano-roll landscape toggle bar and the intent grid.
- Landscape 2->1 edge reveal: snap to fullscreen at frac>=0.5 (the preview's phase
  boundary) instead of COLLAPSE_HI, so releasing mid-drag no longer jumps backward.
  Both edges.
- Gate the double-tap-drag marquee to mobile so it can't hijack a desktop Brush/Draw
  drag.
- Timeline mobile long-press: request a repaint while counting down so it fires even
  when the finger is held perfectly still.
- Landscape folded top bar: lay the filename+search+overflow cluster out within a
  reserved center span (gutters for the pane's own label/buttons) and elide the
  filename, so it can't overlap or overflow the header.
- Node editor connect/disconnect: assert + document the top-level-graph assumption.
- Palette search field focuses only when unfocused (no per-frame focus stomp); reset
  the inspector tap-anchor on orientation change; remove stale main.rs.backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:55:40 -04:00
Skyler Lehmkuhl 9646eef487 Mobile: hide the desktop menu bar
Gate the top menu bar on !mobile_active(). Its commands are all reachable from the
mobile shell (⌕ command palette, ⋯ overflow, and the stage/timeline context menus),
so the desktop menu bar is redundant on mobile and just wastes a strip of vertical
space. Desktop unchanged.
2026-07-03 01:08:56 -04:00
Skyler Lehmkuhl 6f67ddb709 Mobile P7: landscape / orientation support
Make the phone shell orientation-aware (portrait vs landscape). Orientation is
aspect-based (rotation = a resize the shell re-lays-out each frame); LB_MOBILE_UI=2
opens a landscape dev window. `SharedPaneState.is_portrait` is published to panes.

- Stack caps at 2 panes in landscape (max_panes through the R1–R6 drag ops), with a
  per-frame clamp so rotating while 3 are open drops to 2.
- Edge drags are a continuous full-height reveal instead of stalling at the capped
  trigger: 1-pane → split → next-pane-fullscreen, and (landscape 2-pane) a two-phase
  slide-then-collapse to 1 pane. Both top and bottom edges, mirrored. Release snaps to
  stay / split / fullscreen. Dividers resize and collapse-to-1 at the extremes.
- Inspector becomes a right-side vertical column in landscape (inspector_width_frac,
  left-edge horizontal resize); portrait keeps the bottom sheet.
- Piano roll: vertical "Synthesia" view in portrait; landscape shows a Keys/Notes
  segmented toggle over the conventional horizontal roll / full keyboard.
- Start screen reflows to 2 rows × 3 with the recent list beside it in landscape.
- Landscape headers are shorter (52→34px) and the top bar folds into the middle of the
  top pane header (no separate band), reclaiming vertical space.

All gated to mobile; desktop unchanged (is_portrait defaults true).
2026-07-03 01:07:58 -04:00
Skyler Lehmkuhl 2cbc35f181 Mobile context-menu coverage + z-order
Hook up menu-bar actions that lacked a mobile affordance, and implement z-order.

- Stage/object long-press menu (stage.rs): add Paste (on clip, geometry, and
  empty stage) and Send to Back / Bring to Front on the selection.
- Timeline long-press menu (timeline.rs): the timeline had no mobile context
  menu — add one via manual long-press detection. Clip actions (Split,
  Duplicate, Cut, Copy, Paste, Delete) on a clip; animation actions (New/Blank
  keyframe, Add keyframe at playhead, Duplicate keyframe, Delete frame, Add
  motion/shape tween) on an empty lane. Gate the desktop right-click menu to
  !is_mobile so mobile shows only the long-press menu.
- Implement Send to Back / Bring to Front (were // TODO no-ops): new undoable
  ReorderClipInstancesAction in lightningbeam-core reorders the selected
  instances within their layer's clip_instances Vec (stacking order; last =
  on top; geometry stays flattened underneath). Wired via handle_menu_action.
2026-07-02 10:07:53 -04:00
Skyler Lehmkuhl 4f66ddb515 P6b: mobile node editor — Focus & Patch views
Replace the desktop node-graph canvas with touch-native views on mobile
(wireframe Plate 07), gated on is_mobile in NodeGraphPane::render_content; the
desktop draw_graph_editor path is untouched. New submodule
panes/node_graph/mobile.rs.

Focus view:
- The focused module's parameters as touch controls (slider / dropdown / field
  by the desktop widget rule, with a visible slider rail); editing mutates the
  graph ValueType so the existing check_parameter_changes dispatches
  SetParameterAction.
- A full-width minimap strip (tap the nearest node to focus it), travel chips
  naming connected endpoints (tap to jump), and an Add-node picker that creates
  the frontend node + AddNodeAction and focuses it.
- Bespoke nodes (Sampler/Script/Sequencer/AmpSim/Oscilloscope) render their
  existing desktop bottom_ui; sampler/script-sample loads are wired (other
  custom interactions are a case-by-case follow-up).

Patch view:
- Per-section egui::Grid so input/output port arrows align in a column. Each
  port's Lucide direction arrow (from-line/to-line, tinted by DataType) is
  clickable to arm a cable; a compatibility-filtered picker completes it
  (ConnectAction). Cable chips (remote-node · remote-port) tap to disconnect
  (DisconnectAction). Parallel cables allowed.

Also: add ARROW_RIGHT_FROM_LINE / ARROW_RIGHT_TO_LINE to mobile/icons.rs
(codepoints extracted from lucide.ttf via ttx).
2026-07-02 09:26:11 -04:00
Skyler Lehmkuhl b14972f657 P6a: mobile music surface (keyboard-primary instrument pane)
The mobile Piano Roll becomes a unified, keyboard-primary instrument surface
(wireframe Plate 04/08): a playable keyboard as the base, revealing a
Synthesia-style falling-notes roll above it when the pane is tall enough.

- Shared keyboard geometry (panes/keyboard_layout.rs): width-driven, pan-aware
  pitch->x, so the roll's note columns stay aligned with the Virtual Piano keys.
  Shared keyboard_octave + keyboard_pan_x in SharedPaneState.
- Piano Roll vertical mode (is_mobile): notes as columns falling toward an amber
  now-line by the keys, tempo-map converted (beats->seconds) so onsets cross the
  line exactly when they sound. Vertical drag scrubs the timeline; horizontal
  drag smoothly scrolls the keys (snaps to nearest key on release). Long-press
  creates a note (drag to size) or resizes an existing one; pan suppresses it.
  The keyboard is embedded (reuses Virtual Piano render + MIDI); the standalone
  VirtualPiano stack slot is removed. show_roll is driven by the snapped pane
  size-class so the keyboard<->roll reveal lands on a stack snap point.
- Virtual Piano: renders via the shared layout on mobile, colors playback_notes
  like pressed keys, gates note-on/glissando to presses that start on the keys
  (never gating release), and hides QWERTY hints on mobile.
- Transport formats by document.timeline_mode (Measures->bar.beat.tick,
  Frames->MM:SS:FF, Seconds->MM:SS.mmm) — per project type, like desktop.
- In-pane instrument header (name + Presets + REC). Recording is driven from the
  app each frame (not the Timeline pane's render) so REC works regardless of
  visible panes, and stopping playback stops recording.
- Compose/Record intent opens Timeline + instrument pane; mobile central panel is
  full-bleed (no inner margin) so panes sit flush.
- phone-ui-sketches.html: inst-bar moved to the top of the music surface.
2026-07-02 07:50:37 -04:00
Skyler Lehmkuhl 77eb2c2d33 Fix oscillator/synth phase wraparound drift
Replace conditional-subtraction phase wrapping with a single stable wrap:
- OscillatorNode: `(phase + freq_mod/sr).rem_euclid(1.0)` — also wraps correctly
  when FM drives freq_mod negative (the old `if >= 1.0` wouldn't).
- SynthVoice: `(phase + frequency/sr).fract()`.
Repeated `if phase >= 1.0 { phase -= 1.0 }` accumulates f32 rounding error over
long-held notes, drifting the timbre.
2026-07-02 01:15:45 -04:00
Skyler Lehmkuhl 4f58edf436 P5 follow-ups: double-tap priority, inspector dismiss, breadcrumb path
- Mobile double-tap is now a single tool-independent priority chain: object →
  enter (movie clip or group); empty inside a clip → exit one level; empty at
  root → zoom-to-fit. Desktop keeps its own select-tool double-click (gated).
- Inspector "tap outside to dismiss" now only HIDES the sheet (with a dismissed
  flag reset on selection change) instead of clearing the selection — the earlier
  clear clobbered the selection before menu/omnibutton actions ran, breaking
  Group/Convert/Cut/Copy. Delete also clears focus so it dismisses the inspector.
- Breadcrumb shows the full editing path (Scene 1 › … › current) via a new
  SharedPaneState.editing_clip_path / EditingContext::clip_path(); each ancestor
  crumb jumps to that level (pending_exit_to_depth → EditingContext::exit_to_depth).
- Remove the "Vello Stage" debug overlay; move the breadcrumb up to the top-left.
2026-07-02 01:14:38 -04:00
Skyler Lehmkuhl 01b20165cc P5 mobile gestures + long-press context menus
Gestures (Stage + Timeline):
- Pinch-zoom via zoom_delta() (touch pinch + Ctrl+wheel), unified with the raw-wheel
  path so Ctrl+wheel zooms exactly once; plain wheel zoom + trackpad pan preserved.
- Double-tap empty → zoom-to-fit (Stage: artboard via zoom_to_fit; Timeline: new
  fit_to_project over the full duration).
- Double-tap-drag on empty → transient marquee regardless of the active tool.

Long-press context menu: a shared SharedPaneState.mobile_context_menu that panes
populate with (label, MenuAction) items on secondary_clicked(); the mobile shell
renders one persistent popup (styled like the timeline menu) and dispatches via
pending_menu_actions. Stage offers Cut/Copy/Duplicate/Delete for clip instances and
Cut/Copy/Convert-to-movie-clip/Delete for geometry.

Fixes surfaced along the way:
- Geometry delete now frees selected fills (free_fill) and GCs isolated vertices
  (new VectorGraph::gc_isolated_vertices) — no more orphaned fills / phantom snap
  points; applies to the Delete key too.
- clipboard_delete_selection clears focus so deleting dismisses the mobile inspector.
- Mobile inspector: appears on pointer release (not press) so drags aren't
  interrupted; reflows only when the tapped point is actually behind the sheet; taps
  outside the sheet dismiss it; rounded-corner border no longer detaches.
2026-07-02 00:11:49 -04:00
Skyler Lehmkuhl 4b2802076b Touch-friendly egui widgets on mobile; egui-based inspector header
Add mobile::apply_touch_style(ctx) — applied every frame when mobile is active —
enlarging egui's global spacing/sizing (interact_size, button padding, slider
width/rail, combo width/height, icon sizes, scrollbar, body/button text) so the
standard widgets in panes and dialogs are finger-sized. Desktop is untouched.

Rework the mobile inspector header into a single row using real egui widgets:
title (left, truncating) + Timeline/Nodes jump buttons + ✕ close, laid out with
egui layouts so they inherit the touch sizing and theme visuals instead of being
hand-painted.
2026-07-01 07:28:13 -04:00
Skyler Lehmkuhl 943ff7c15f Unify colors via theme CSS variables; theme egui visuals
Add Theme::var(name, ctx) (CSS custom-property getter) and Theme::apply_to_egui(ctx)
which maps the palette onto egui's global Visuals so standard widgets share the
theme colors. Add a mobile::palette::Palette built from the theme each frame and
replace the duplicated per-file C_* color constants across all mobile modules
(omni, topbar, stack, inspector, transport, intent, shell) with it. Add --scrim
and --accent-* category vars to styles.css. Apply the theme visuals every frame in
update(). Mobile and the main UI now share one CSS-variable-driven palette that
responds to light/dark/user themes.
2026-07-01 07:08:00 -04:00
Skyler Lehmkuhl f13a127c9d Fit modal dialogs to the phone screen on mobile
Add mobile::dialog_width(ctx, desired) — a min()-clamp that's a no-op on wide
desktop screens. Clamp the Export and Export Progress modals and the Sample
Import window to it. Render Preferences as an egui::Modal (dim backdrop, centered,
no title bar) with a screen-fit scroll height when mobile_active(); desktop keeps
its window. Extracted the preferences body into render_body() shared by both paths.
2026-07-01 06:28:04 -04:00
Skyler Lehmkuhl a0ac4f2d35 Add mobile top bar: filename + command palette (⌕) + overflow (⋯)
Reserve a slim top bar in the mobile shell showing the project filename (or
"Lightningbeam" when unsaved) on the left, and ⌕/⋯ on the right. ⋯ opens a
curated overflow sheet (Save/Save As/Open/New/Import/Export/Undo/Redo/
Preferences); ⌕ opens a command palette that searches the whole menu tree
(flattened from MenuItemDef::menu_structure()). Both dispatch MenuActions.
Completes the P4 omnibutton + global menu.
2026-07-01 06:09:26 -04:00
Skyler Lehmkuhl b8419778a5 Add omnibutton FAB (P4); fix Stage playback freeze when timeline hidden
Omnibutton (mobile/omni.rs): a context FAB whose radial holds the active layer's
drawing tools laid out in a staggered inner/outer arc, plus a "+New" petal
(create Vector/Audio/MIDI/Raster/Video layer, Group, Import, and Split/Duplicate
when a clip is selected — dispatched as MenuActions) and a "more" petal that
opens a full-tools grid when the layer has extra tools. Contextual: the tool ring
only appears when the Stage is in the visible window; off-Stage the FAB is a
"+New" create button. Tools/creates use Lucide icons. Pane-internal adds
(nodes/notes/instruments) deferred to P6.

Playback fix: document.current_time (read by the renderer for animation eval and
video-frame decode) was only synced from the audio playback position inside
TimelinePane::render_content. In the mobile shell, panes render on demand, so with
the timeline off-screen current_time went stale and the Stage froze during
playback. Move the sync into the per-frame block in update() so it runs regardless
of which panes are visible.
2026-07-01 05:42:53 -04:00
Skyler Lehmkuhl 08b6aa0139 Compress mobile layer headers to a swatch; conditional inspector reflow
- Add `is_mobile` to SharedPaneState (set in build_frame). On mobile the timeline
  layer-header column collapses to a minimal type-colored swatch (1:2 w:h, ~30px),
  brighter/outlined when active/selected; tapping it selects the layer (handled by
  the existing header-column input path, which now uses the narrower width).
  Desktop headers unchanged.
- Inspector reflow: the sheet overlaps the stack by default, but when the selected
  pane would be covered by the sheet, the stack reflows (renders above the sheet)
  so it stays visible. Restore on dismiss is automatic since reflow only changes
  the render rect; manual divider moves while the sheet is up persist. Adds
  stack::pane_bottom_in and inspector::target_slot.
2026-07-01 01:14:37 -04:00
Skyler Lehmkuhl 87263489c0 Add mobile selection inspector sheet (P3); editable layer properties
- New mobile/inspector.rs: when something is selected/focused, a bottom sheet
  overlays the lower stack (no reflow) above the transport, showing the object's
  properties by reusing InfopanelPane full-bleed. Header shows a title + ✕ to
  deselect; jump-to chips (Timeline, Nodes) reframe the stack to that surface
  with the selection kept. Grab handle resizes the sheet.
- Make the Infopanel Layer section editable (benefits desktop too): Name (text,
  commit on Enter/blur), Opacity and Volume sliders (live preview, single-undo
  on release), and Mute/Solo/Lock toggles. Backs the plan to reduce mobile layer
  headers to a minimal swatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:16:14 -04:00
Skyler Lehmkuhl f2cb516e8a Add mobile new-file intent picker (P2)
When LB_MOBILE_UI is set, the desktop start screen is replaced by a phone
intent picker: a 2x3 grid of icon cards (Draw / Animate / Compose / Record /
Edit video / Blank) taking the top ~2/3, and a scrollable Recent-projects list
in the bottom ~1/3.

Each intent reuses EditorApp::create_new_project_with_focus and then sets the
initial stack window (e.g. Draw -> fullscreen Stage, Compose -> Timeline +
Piano Roll + Keys). Recent rows load the project and enter the editor. Cards are
icon-only (Lucide glyph + label) for legibility on a phone. Adds the intent
icons to the Lucide set.
2026-06-30 22:16:09 -04:00
Skyler Lehmkuhl b25e639a1c Linked-divider preset snapping + animated transitions for the stack
Rework 3-pane divider behavior and animate every layout change:

- All transitions now ease over ~100ms via a LayoutAnim between two full stack
  configs (resize, slide, collapse, fullscreen) instead of jumping; edge
  transitions continue from the drag's progress.
- 3-pane dividers are linked along a single path parameter through the three
  presets ([.5,.25,.25] / even / [.25,.25,.5]) so both dividers move together
  and reach their snaps simultaneously. Over-grow past the extreme preset is
  anchored to the path endpoint so the non-dragged divider stays continuous.
- Growing a pane past ~66% collapses the window to 2 panes (dropping the
  opposite-end pane); the surviving divider snaps to the nearest 2-pane preset
  (1/4, 1/2, 3/4) based on the drop position. Dragging to the slide extreme
  still slides the window.
- 2-pane snaps to 1/4, 1/2, 3/4.
2026-06-30 02:07:31 -04:00
Skyler Lehmkuhl 01fe14f197 Ease divider snap into place over ~100ms
After releasing a divider, the boundary now eases (cubic ease-out, ~100ms) from
where the finger lifted to the snapped fraction instead of jumping. Starting a
new drag cancels an in-flight ease; membership changes still apply instantly.
2026-06-30 01:39:53 -04:00
Skyler Lehmkuhl 3b39e80e40 Add intermediate divider snapping (uneven pane sizes)
Dragging a divider now resizes its two adjacent panes live and, on release,
snaps the boundary to the nearest of 1/4, 1/3, 1/2, 2/3, 3/4 — so one pane can
be made dominant within a 2- or 3-pane window. Dragging far enough to squeeze a
pane past the collapse threshold still triggers the membership transition
(slide/shrink per R1-R6), after which sizing resets to even.

MobileState gains a `weights: [f32;3]` (normalized on use); config_rects is
weight-aware; edge drags keep animating toward the even-split target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:18:13 -04:00
Skyler Lehmkuhl 0e3c437d50 Round the top corners of stack pane headers
Each header now has rounded top corners (square at the bottom where it meets
the pane content) so it reads as a tab atop the pane.
2026-06-30 01:02:36 -04:00
Skyler Lehmkuhl 76e7963f03 Add Lucide icon font for the mobile UI
Bundle Lucide (ISC license) at assets/fonts/lucide.ttf and register it as the
egui font family "lucide" via mobile/icons.rs::install (called in EditorApp::new).
Icons are drawn as text with icons::font(size) + PUA codepoint constants.

Switch the stack headers (grip, maximize/minimize, node⇄instrument), the footer
chevron, and the transport play/pause to Lucide glyphs instead of relying on
egui's limited built-in emoji-icon-font.
2026-06-30 01:02:03 -04:00
Skyler Lehmkuhl 597697d0a6 Taller stack headers, fullscreen toggle, icon-font glyphs
- Double header height (52px) so the drag targets are easy to grab; footer
  stays compact (28px).
- Add a fullscreen button on the right of each header that collapses the
  window to that single pane; dragging the sole pane's header down or the
  footer up splits it with the adjacent pane (count can now be 1). The button
  toggles back to a 2-pane split.
- Use egui's bundled emoji-icon-font glyphs (⛶ fullscreen / ▣ restore) instead
  of hand-drawn primitives.
- Cap the drag-to-commit distance (TRIGGER_MAX) so transitions don't require
  dragging half the screen.
2026-06-30 00:33:47 -04:00
Skyler Lehmkuhl 0c59ed8b4b Fix stack overflow at list ends; full-height drag headers
- Fix panic "attempt to subtract with overflow" when sliding to the end:
  the R-ops used then_some(), which eagerly evaluates top-1 even when the
  guard is false. Switch to lazy .then(|| ...).
- Replace the thin divider/edge grab bars with full-width header bars: each
  band has a labeled header (top of band) that is its drag handle, plus a
  reserved bottom footer for the BottomEdge "pull up for more" handle. Band 0's
  header = TopEdge, band i's header = the divider above it, footer = BottomEdge.
  The Node/Instrument ⇄ toggle lives in that band's header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:13:08 -04:00
Skyler Lehmkuhl 79f063d00c Replace mobile tabbed shell with vertical sliding-window stack
Per design feedback, drop the top-tabs + single-ribbon model. The mobile UI
is now one vertical stack of all panes in a fixed order (Outliner, Asset
Library, Stage, Timeline, Piano Roll, Virtual Piano, Node/Instrument, Script
editor) with a window of 2-3 consecutive panes visible at a time.

Dragging the dividers between panes and the top/bottom screen edges grows,
shrinks, or slides the window via a small state machine (R1-R6 in stack.rs),
with continuous interpolation during the drag and snap-on-release. The
Node/Instrument band toggles between the node editor and the preset browser.
The transport stays pinned as the bottom floor.

Each visible band reuses PaneInstance::render_content full-bleed (surface.rs).
Removes topbar.rs and ribbon.rs; rewrites mod.rs state (window_top,
window_count, show_instruments, drag); adds stack.rs.
2026-06-30 00:12:57 -04:00
Skyler Lehmkuhl 5ee2df5db7 Add mobile UI shell scaffold behind LB_MOBILE_UI
New src/mobile/ module renders a phone-style shell: top surface tabs
(Stage/Time/Nodes/Mixer/Tree), a single full-bleed hero pane reusing the
existing PaneInstance renderers, a resizable timeline ribbon (peek/half/full
snap tiers riding above the floor), and a fixed transport bar wired to the
audio controller (play/pause, timecode, project scrub).

Gated entirely on the LB_MOBILE_UI env var: when set, main() opens a
phone-aspect window and update() routes the central panel through
render_mobile_shell instead of render_layout_node, sharing the same
build_frame context and post-render drain. Desktop is unchanged when unset.

Tabs mirror the pane list; per the wireframe the Toolbar and Infopanel do
not become tabs (they become the omnibutton and inspector in later phases),
and the Nodes surface is a placeholder pending the focus/patch rework.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:40:54 -04:00
Skyler Lehmkuhl da0b39fef0 Refactor RenderContext construction into EditorApp::build_frame
Extract the ~80-field SharedPaneState/RenderContext literal from update()
into EditorApp::build_frame, returning a FrameBundle that also carries the
layout-editing borrows (current_layout, drag_state, etc.). Per-frame locals
move into a FrameScratch struct so the bundle can borrow both self and the
scratch under one lifetime.

This is a behavior-preserving refactor that gives the upcoming mobile UI
path a single shared construction site for SharedPaneState instead of
duplicating the literal.
2026-06-29 21:42:40 -04:00
Skyler Lehmkuhl fd582828c2 Add text layers
Introduce editable text layers: a resizable text box with editable text,
font size, color, font family, and alignment.

Core:
- New TextLayer/TextContent (text_layer.rs), wired into AnyLayer/LayerType
  and all the exhaustive match sites; structured so content can be keyframed
  later via content_at().
- fonts.rs: thread-local parley FontContext with three bundled fonts
  (Liberation Sans/Serif/Mono, SIL OFL), system-font enumeration consolidated
  to base families, document-embedded fonts, glyph/caret/selection geometry,
  and a background preloader for the picker fonts.
- Rendering via parley layout + Scene::draw_glyphs (renderer.rs); text
  composites through the vector path.
- Actions: CreateTextClipAction (vector-layer branch, undoable),
  SetTextContentAction, ResizeTextBoxAction.
- .beam font embedding: MediaKind::Font rows (content-hash dedupe) written on
  save and registered on load, with bundled-default fallback.
- VectorClip content bounds include text boxes so text-only clips are
  selectable/draggable.

Editor:
- Text tool: click empty/raster/video to create a top-level text layer, or a
  vector layer to create+enter a clip containing the text; click an existing
  box to edit it.
- Hybrid in-place editing: a hidden egui TextEdit drives input/IME/caret while
  the text and caret/selection render in Vello; empty just-created layers are
  removed on commit.
- Selection outline + 8 resize handles (re-wrap text) with hover cursors;
  factored the corner/edge resize-cursor mapping shared with the Transform tool.
- Info panel: edit text, size, color, alignment, box size, and a font-family
  picker that previews each entry in its own font (fonts preloaded in the
  background to avoid hitches).

Deps: add parley (git, pinned to match vello's peniko); bundle Liberation
fonts under lightningbeam-core/assets/fonts. Gitignore the local
.cargo/config.toml used to select a machine's ffmpeg.
2026-06-27 18:14:47 -04:00
80 changed files with 10582 additions and 2840 deletions

View File

@ -1,3 +1,23 @@
# 1.0.8-alpha:
Changes:
- Mobile/touch UI (experimental, testing only — not built or packaged for mobile yet; enabled on desktop with the LB_MOBILE_UI environment variable): work-in-progress phone-friendly interface with a vertical sliding-window pane stack you drag to reveal panes, a new-file intent picker, a selection inspector sheet, a keyboard-primary music surface, a Focus/Patch node editor, long-press context menus, a command palette, and landscape/orientation support
- Text layers: add and edit text with a chosen font; non-bundled fonts are embedded in the project so it renders on machines that lack them
- Animated GIF export (parallel palette encoding)
- Audio tag metadata (title, artist, album, genre, year, track, comment) is written into exports — ID3v2 for MP3, iTunes/MP4 atoms for AAC, Vorbis comments for FLAC, RIFF INFO for WAV — with sensible defaults (year, artist/album remembered between exports)
- Lossy WebP image export, so the quality control now actually applies
- SVG export now includes text layers, as real font-independent glyph outlines
- Crash recovery: the editor autosaves your work to a recovery file in the background and offers to restore it after an unclean shutdown
- Prompt to save unsaved changes before starting a new file, opening another, or quitting
- Faster saves on painting projects: unchanged raster frames are no longer re-encoded every save
Bugfixes:
- FLAC export previously wrote a WAV file with a .flac extension; it now encodes real (compressed) FLAC
- ProRes 422 export always failed; it now encodes 10-bit 4:2:2 correctly
- Fix VP8 video export with audio (muxed into WebM instead of an incompatible container)
- The WebP "Quality" slider had no effect
- Starting a new file now fully resets the audio engine, so instruments and voices from the previous project no longer linger
- Fix oscillator/synth phase drift over long playback
# 1.0.7-alpha: # 1.0.7-alpha:
Changes: Changes:
- HDR video support: PQ/HLG/BT.2020 video is now read correctly (decoded to scene-linear), with a per-document output mode (clip vs highlight rolloff) and 10-bit HDR export (HEVC Main10, PQ or HLG) - HDR video support: PQ/HLG/BT.2020 video is now read correctly (decoded to scene-linear), with a per-document output mode (clip vs highlight rolloff) and 10-bit HDR export (HEVC Main10, PQ or HLG)

View File

@ -50,6 +50,10 @@ pub struct ExportSettings {
pub end_time: Seconds, pub end_time: Seconds,
/// Tempo map for beat-position scheduling /// Tempo map for beat-position scheduling
pub tempo_map: TempoMap, pub tempo_map: TempoMap,
/// Tag metadata as (ffmpeg-key, value) pairs (e.g. ("title", "…"), ("artist", "…")). Written to
/// the container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC), RIFF INFO
/// (WAV). Empty = no tags.
pub metadata: Vec<(String, String)>,
} }
impl Default for ExportSettings { impl Default for ExportSettings {
@ -63,10 +67,26 @@ impl Default for ExportSettings {
start_time: Seconds::ZERO, start_time: Seconds::ZERO,
end_time: Seconds(60.0), end_time: Seconds(60.0),
tempo_map: TempoMap::constant(120.0), tempo_map: TempoMap::constant(120.0),
metadata: Vec::new(),
} }
} }
} }
/// Set tag metadata on an ffmpeg output context (before `write_header`). FFmpeg maps the standard
/// keys to each container's native tags.
fn apply_metadata(output: &mut ffmpeg_next::format::context::Output, metadata: &[(String, String)]) {
if metadata.is_empty() {
return;
}
let mut dict = ffmpeg_next::Dictionary::new();
for (k, v) in metadata {
if !v.is_empty() {
dict.set(k, v);
}
}
output.set_metadata(dict);
}
/// Export the project to an audio file /// Export the project to an audio file
/// ///
/// This performs offline rendering, processing the entire timeline /// This performs offline rendering, processing the entire timeline
@ -108,17 +128,21 @@ pub fn export_audio<P: AsRef<Path>>(
// Route to appropriate export implementation based on format. // Route to appropriate export implementation based on format.
// Ensure export mode is disabled even if an error occurs. // Ensure export mode is disabled even if an error occurs.
let result = match settings.format { let result = match settings.format {
ExportFormat::Wav | ExportFormat::Flac => { ExportFormat::Wav => {
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
// Signal that rendering is done and we're now writing the file
if let Some(ref mut tx) = event_tx { if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing); let _ = tx.push(AudioEvent::ExportFinalizing);
} }
match settings.format { write_wav(&samples, settings, &output_path)
ExportFormat::Wav => write_wav(&samples, settings, &output_path), // hound writes no metadata; append a RIFF INFO chunk for tags.
ExportFormat::Flac => write_flac(&samples, settings, &output_path), .and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata))
_ => unreachable!(), }
ExportFormat::Flac => {
let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
} }
export_flac(&samples, settings, &output_path)
} }
ExportFormat::Mp3 => { ExportFormat::Mp3 => {
export_mp3(project, pool, settings, output_path, event_tx) export_mp3(project, pool, settings, output_path, event_tx)
@ -273,48 +297,174 @@ fn write_wav<P: AsRef<Path>>(
Ok(()) Ok(())
} }
/// Write FLAC file using hound (FLAC is essentially lossless WAV) /// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment
fn write_flac<P: AsRef<Path>>( /// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit
/// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32,
/// taking the top 24 bits).
fn export_flac<P: AsRef<Path>>(
samples: &[f32], samples: &[f32],
settings: &ExportSettings, settings: &ExportSettings,
output_path: P, output_path: P,
) -> Result<(), String> { ) -> Result<(), String> {
// For now, we'll use hound to write a WAV-like FLAC file use ffmpeg_next as ffmpeg;
// In the future, we could use a dedicated FLAC encoder
let spec = hound::WavSpec { ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
channels: settings.channels as u16,
sample_rate: settings.sample_rate, let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC)
bits_per_sample: settings.bit_depth, .ok_or("FLAC encoder not found in this ffmpeg build")?;
sample_format: hound::SampleFormat::Int, let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
}; };
let mut writer = hound::WavWriter::create(output_path, spec) // FLAC accepts packed S16 or S32; S32 → 24-bit output.
.map_err(|e| format!("Failed to create FLAC file: {}", e))?; let use_24 = settings.bit_depth >= 24;
let sample_fmt = if use_24 {
ffmpeg::format::Sample::I32(ffmpeg::format::sample::Type::Packed)
} else {
ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed)
};
// Write samples (same as WAV for now) let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
match settings.bit_depth { .encoder()
16 => { .audio()
for &sample in samples { .map_err(|e| format!("Failed to create FLAC encoder: {}", e))?;
let clamped = sample.max(-1.0).min(1.0); encoder.set_rate(settings.sample_rate as i32);
let pcm_value = (clamped * 32767.0) as i16; encoder.set_channel_layout(channel_layout);
writer.write_sample(pcm_value) encoder.set_format(sample_fmt);
.map_err(|e| format!("Failed to write sample: {}", e))?; encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
let mut encoder = encoder.open_as(codec)
.map_err(|e| format!("Failed to open FLAC encoder: {}", e))?;
{
let mut stream = output.add_stream(codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
}
apply_metadata(&mut output, &settings.metadata);
output.write_header()
.map_err(|e| format!("Failed to write FLAC header: {}", e))?;
let channels = settings.channels as usize;
let num_frames = samples.len() / channels;
let frame_size = if encoder.frame_size() > 0 { encoder.frame_size() as usize } else { 4096 };
let mut done = 0usize;
while done < num_frames {
let n = (num_frames - done).min(frame_size);
let mut frame = ffmpeg::frame::Audio::new(sample_fmt, n, channel_layout);
frame.set_rate(settings.sample_rate);
frame.set_pts(Some(done as i64)); // samples; the FLAC muxer requires PTS
let buf = frame.data_mut(0); // packed interleaved → plane 0
let base = done * channels;
if use_24 {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s as f64 * 2_147_483_647.0) as i32; // full-scale S32; encoder takes top 24
buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
} else {
for i in 0..n * channels {
let s = samples[base + i].clamp(-1.0, 1.0);
let v = (s * 32767.0) as i16;
buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes());
} }
} }
24 => {
for &sample in samples { encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?;
let clamped = sample.max(-1.0).min(1.0); flac_write_packets(&mut encoder, &mut output)?;
let pcm_value = (clamped * 8388607.0) as i32; done += n;
writer.write_sample(pcm_value) }
.map_err(|e| format!("Failed to write sample: {}", e))?; encoder.send_eof().map_err(|e| format!("Failed to flush FLAC encoder: {}", e))?;
} flac_write_packets(&mut encoder, &mut output)?;
output.write_trailer().map_err(|e| format!("Failed to finalize FLAC: {}", e))?;
Ok(())
}
/// Drain encoded FLAC packets and write them (non-interleaved). Skips the trailing empty flush
/// packet, which the FLAC muxer otherwise rejects as "Invalid data". Rescales packet ts from the
/// encoder time base to the stream's.
fn flac_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut pkt = ffmpeg_next::Packet::empty();
let enc_tb = encoder.time_base();
let stream_tb = output.stream(0).map(|s| s.time_base()).unwrap_or(enc_tb);
while encoder.receive_packet(&mut pkt).is_ok() {
if pkt.size() == 0 {
continue;
} }
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), pkt.set_stream(0);
pkt.rescale_ts(enc_tb, stream_tb);
pkt.write(output).map_err(|e| format!("Failed to write FLAC packet: {}", e))?;
}
Ok(())
}
/// Append a RIFF `LIST`/`INFO` metadata chunk to a finished WAV file (hound writes no tags), then
/// fix up the top-level RIFF size. Maps ffmpeg-style keys to RIFF INFO sub-chunk IDs. Trailing INFO
/// chunks are ignored by players that don't read them.
fn append_wav_info_chunk(path: &Path, metadata: &[(String, String)]) -> Result<(), String> {
use std::io::{Seek, SeekFrom, Write};
let riff_id = |key: &str| -> Option<&'static [u8; 4]> {
match key {
"title" => Some(b"INAM"),
"artist" => Some(b"IART"),
"album" => Some(b"IPRD"),
"genre" => Some(b"IGNR"),
"comment" => Some(b"ICMT"),
"date" => Some(b"ICRD"),
"track" => Some(b"ITRK"),
_ => None,
}
};
let mut info: Vec<u8> = Vec::new();
info.extend_from_slice(b"INFO");
for (key, val) in metadata {
if val.is_empty() {
continue;
}
let Some(id) = riff_id(key) else { continue };
let mut bytes = val.as_bytes().to_vec();
bytes.push(0); // NUL-terminate
if bytes.len() % 2 == 1 {
bytes.push(0); // pad to even
}
info.extend_from_slice(id);
info.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
info.extend_from_slice(&bytes);
}
if info.len() <= 4 {
return Ok(()); // nothing but the "INFO" tag
} }
writer.finalize() let mut list: Vec<u8> = Vec::with_capacity(info.len() + 8);
.map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; list.extend_from_slice(b"LIST");
list.extend_from_slice(&(info.len() as u32).to_le_bytes());
list.extend_from_slice(&info);
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("Failed to open WAV for tagging: {}", e))?;
let end = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
if end % 2 == 1 {
f.write_all(&[0]).map_err(|e| e.to_string())?;
}
f.write_all(&list).map_err(|e| format!("Failed to write WAV tags: {}", e))?;
let new_len = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
f.seek(SeekFrom::Start(4)).map_err(|e| e.to_string())?;
f.write_all(&((new_len - 8) as u32).to_le_bytes())
.map_err(|e| format!("Failed to update RIFF size: {}", e))?;
Ok(()) Ok(())
} }
@ -362,6 +512,7 @@ fn export_mp3<P: AsRef<Path>>(
stream.set_parameters(&encoder); stream.set_parameters(&encoder);
} }
apply_metadata(&mut output, &settings.metadata);
output.write_header() output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?; .map_err(|e| format!("Failed to write header: {}", e))?;
@ -531,6 +682,7 @@ fn export_aac<P: AsRef<Path>>(
stream.set_parameters(&encoder); stream.set_parameters(&encoder);
} }
apply_metadata(&mut output, &settings.metadata);
output.write_header() output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?; .map_err(|e| format!("Failed to write header: {}", e))?;
@ -838,4 +990,61 @@ mod tests {
assert_eq!(ExportFormat::Wav.extension(), "wav"); assert_eq!(ExportFormat::Wav.extension(), "wav");
assert_eq!(ExportFormat::Flac.extension(), "flac"); assert_eq!(ExportFormat::Flac.extension(), "flac");
} }
fn tagged_settings(format: ExportFormat) -> ExportSettings {
ExportSettings {
format,
sample_rate: 48000,
channels: 2,
bit_depth: 24,
mp3_bitrate: 192,
start_time: Seconds::ZERO,
end_time: Seconds(0.2), // tiny render
tempo_map: TempoMap::constant(120.0),
metadata: vec![
("title".to_string(), "Test Title".to_string()),
("artist".to_string(), "Test Artist".to_string()),
],
}
}
/// FLAC export must be a real FLAC container (not WAV bytes) carrying Vorbis-comment tags.
#[test]
fn flac_export_is_real_flac_with_tags() {
let settings = tagged_settings(ExportFormat::Flac);
let mut project = Project::new(48000);
let pool = AudioPool::new();
let path = std::env::temp_dir().join("lb_be_flac_test.flac");
export_audio(&mut project, &pool, &settings, &path, None).expect("FLAC export failed");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"fLaC", "not real FLAC (got {:?})", &bytes[0..4]);
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("Test Title"), "title tag missing from FLAC");
assert!(s.contains("Test Artist"), "artist tag missing from FLAC");
std::fs::remove_file(&path).ok();
}
/// WAV export keeps a valid RIFF container and gains a LIST/INFO tag chunk with a fixed-up size.
#[test]
fn wav_export_has_info_chunk() {
let settings = tagged_settings(ExportFormat::Wav);
let mut project = Project::new(48000);
let pool = AudioPool::new();
let path = std::env::temp_dir().join("lb_be_wav_test.wav");
export_audio(&mut project, &pool, &settings, &path, None).expect("WAV export failed");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
assert_eq!(riff_size, bytes.len() - 8, "RIFF size not fixed up after tagging");
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("LIST") && s.contains("INFO") && s.contains("INAM"),
"no RIFF INFO chunk");
assert!(s.contains("Test Title"), "title not in WAV INFO chunk");
std::fs::remove_file(&path).ok();
}
} }

View File

@ -164,11 +164,11 @@ impl AudioNode for OscillatorNode {
output[frame * 2] = sample; // Left output[frame * 2] = sample; // Left
output[frame * 2 + 1] = sample; // Right output[frame * 2 + 1] = sample; // Right
// Update phase once per frame // Update phase once per frame. `rem_euclid(1.0)` gives a numerically stable
self.phase += freq_mod / sample_rate_f32; // wraparound into [0, 1): repeated conditional subtraction accumulates f32
if self.phase >= 1.0 { // rounding error over long-held notes (timbre drift), and FM can drive
self.phase -= 1.0; // `freq_mod` negative, which a one-sided `if >= 1.0` wouldn't wrap at all.
} self.phase = (self.phase + freq_mod / sample_rate_f32).rem_euclid(1.0);
} }
} }

View File

@ -113,11 +113,10 @@ impl SynthVoice {
// Simple sine wave // Simple sine wave
let sample = (self.phase * 2.0 * PI).sin() * (self.velocity as f32 / 127.0) * 0.3; let sample = (self.phase * 2.0 * PI).sin() * (self.velocity as f32 / 127.0) * 0.3;
// Update phase // Update phase. Use `.fract()` for a numerically stable wraparound: repeated
self.phase += self.frequency / sample_rate; // conditional subtraction accumulates f32 rounding error over long-held notes,
if self.phase >= 1.0 { // which drifts the timbre.
self.phase -= 1.0; self.phase = (self.phase + self.frequency / sample_rate).fract();
}
self.age += 1; self.age += 1;

View File

@ -15,3 +15,8 @@
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local, machine-specific cargo config (e.g. PKG_CONFIG_PATH for a local ffmpeg).
# CI/packaging build ffmpeg from source (see .github/workflows/build.yml), so this
# must never leak into a commit.
.cargo/config.toml

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,11 @@ vello = { git = "https://github.com/linebender/vello", branch = "main" }
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] } wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
kurbo = { version = "0.12", features = ["serde"] } kurbo = { version = "0.12", features = ["serde"] }
peniko = "0.5" peniko = "0.5"
# Text layout/shaping for text layers. Pinned to git main to match the peniko 0.5
# / skrifa 0.37 that vello's git-main resolves to — a released parley still pins
# peniko 0.4, which would split `peniko::Font` into two incompatible types at the
# vello `draw_glyphs` boundary.
parley = { git = "https://github.com/linebender/parley", branch = "main" }
# Windowing # Windowing
winit = "0.30" winit = "0.30"
@ -78,6 +83,15 @@ opt-level = 2
[profile.dev.package.cpal] [profile.dev.package.cpal]
opt-level = 2 opt-level = 2
# GIF export: NeuQuant palette quantization is tight numeric loops that are punishingly slow
# unoptimized (1030× in debug). Optimize the encoder crates even in dev builds.
[profile.dev.package.gif]
opt-level = 3
[profile.dev.package.color_quant]
opt-level = 3
[profile.dev.package.weezl]
opt-level = 3
# Use local egui fork with ibus/Wayland text input fix # Use local egui fork with ibus/Wayland text input fix
[patch.crates-io] [patch.crates-io]
egui = { path = "../../egui-fork/crates/egui" } egui = { path = "../../egui-fork/crates/egui" }

View File

@ -18,6 +18,12 @@ bytemuck = { version = "1.14", features = ["derive"] }
kurbo = { workspace = true } kurbo = { workspace = true }
vello = { workspace = true } vello = { workspace = true }
# Text layout/shaping (text layers)
parley = { workspace = true }
# Glyph outline extraction for lossless text→SVG export. Pinned to the version parley resolves
# (0.43) so glyph IDs / normalized coords from parley layouts match this outliner.
skrifa = "0.43"
# Image decoding for image fills # Image decoding for image fills
image = { workspace = true } image = { workspace = true }

View File

@ -0,0 +1,14 @@
Bundled fonts for Lightningbeam text layers
===========================================
These are the Liberation fonts, used as the built-in serif / sans-serif / monospaced
families so text renders deterministically without depending on system fonts:
LiberationSans-Regular.ttf
LiberationSerif-Regular.ttf
LiberationMono-Regular.ttf
License: SIL Open Font License, Version 1.1.
Copyright (c) Red Hat, Inc., with Reserved Font Name "Liberation".
Full license text: https://openfontlicense.org/ (also distributed with the Liberation
fonts package as its OFL.txt / LICENSE).

View File

@ -247,6 +247,7 @@ impl ActionExecutor {
Ok(()) => { Ok(()) => {
// Move to redo stack // Move to redo stack
self.redo_stack.push(action); self.redo_stack.push(action);
self.epoch = self.epoch.wrapping_add(1);
Ok(true) Ok(true)
} }
Err(e) => { Err(e) => {
@ -271,6 +272,7 @@ impl ActionExecutor {
Ok(()) => { Ok(()) => {
// Move back to undo stack // Move back to undo stack
self.undo_stack.push(action); self.undo_stack.push(action);
self.epoch = self.epoch.wrapping_add(1);
Ok(true) Ok(true)
} }
Err(e) => { Err(e) => {
@ -444,6 +446,7 @@ impl ActionExecutor {
// Move to redo stack // Move to redo stack
self.redo_stack.push(action); self.redo_stack.push(action);
self.epoch = self.epoch.wrapping_add(1);
Ok(true) Ok(true)
} else { } else {
@ -481,6 +484,7 @@ impl ActionExecutor {
// Move back to undo stack // Move back to undo stack
self.undo_stack.push(action); self.undo_stack.push(action);
self.epoch = self.epoch.wrapping_add(1);
Ok(true) Ok(true)
} else { } else {

View File

@ -104,7 +104,7 @@ impl Action for AddClipInstanceAction {
AnyLayer::Group(_) => { AnyLayer::Group(_) => {
return Err("Cannot add clip instances directly to group layers".to_string()); return Err("Cannot add clip instances directly to group layers".to_string());
} }
AnyLayer::Raster(_) => { AnyLayer::Raster(_) | AnyLayer::Text(_) => {
return Err("Cannot add clip instances directly to group layers".to_string()); return Err("Cannot add clip instances directly to group layers".to_string());
} }
} }
@ -145,8 +145,8 @@ impl Action for AddClipInstanceAction {
AnyLayer::Group(_) => { AnyLayer::Group(_) => {
// Group layers don't have clip instances, nothing to rollback // Group layers don't have clip instances, nothing to rollback
} }
AnyLayer::Raster(_) => { AnyLayer::Raster(_) | AnyLayer::Text(_) => {
// Raster layers don't have clip instances, nothing to rollback // Raster/text layers don't have clip instances, nothing to rollback
} }
} }
self.executed = false; self.executed = false;

View File

@ -138,6 +138,7 @@ impl Action for AddLayerAction {
AnyLayer::Effect(_) => "Add effect layer", AnyLayer::Effect(_) => "Add effect layer",
AnyLayer::Group(_) => "Add group layer", AnyLayer::Group(_) => "Add group layer",
AnyLayer::Raster(_) => "Add raster layer", AnyLayer::Raster(_) => "Add raster layer",
AnyLayer::Text(_) => "Add text layer",
} }
.to_string() .to_string()
} }

View File

@ -0,0 +1,133 @@
//! Create-text-clip action
//!
//! Used by the text tool when the active layer is a **vector** layer: it creates a
//! VectorClip containing a single text layer, registers it, and places a clip
//! instance in the parent vector layer. The editor then enters that clip so the
//! text is directly editable. All of this is one undoable step so undo never
//! leaves an orphan clip.
use crate::action::Action;
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::text_layer::TextLayer;
use uuid::Uuid;
/// Minimum clip duration (seconds) when the document has no duration set yet.
const FALLBACK_DURATION: f64 = 10.0;
pub struct CreateTextClipAction {
/// The vector layer to place the clip instance in.
parent_layer_id: Uuid,
/// The text layer to embed (its id is stable across redo).
text_layer: TextLayer,
/// Instance position in the parent layer's space.
position: (f64, f64),
// Assigned on first execute; reused on redo so ids are stable.
clip_id: Option<Uuid>,
instance_id: Option<Uuid>,
executed: bool,
}
impl CreateTextClipAction {
pub fn new(parent_layer_id: Uuid, text_layer: TextLayer, position: (f64, f64)) -> Self {
Self {
parent_layer_id,
text_layer,
position,
clip_id: None,
instance_id: None,
executed: false,
}
}
/// Preset the clip + instance ids (so the caller knows them up-front for
/// entering the clip immediately after execute). Ids stay stable across redo.
pub fn with_ids(mut self, clip_id: Uuid, instance_id: Uuid) -> Self {
self.clip_id = Some(clip_id);
self.instance_id = Some(instance_id);
self
}
/// The vector clip id (after execute).
pub fn clip_id(&self) -> Option<Uuid> {
self.clip_id
}
/// The clip instance id placed in the parent layer (after execute).
pub fn instance_id(&self) -> Option<Uuid> {
self.instance_id
}
/// The embedded text layer's id.
pub fn text_layer_id(&self) -> Uuid {
self.text_layer.layer.id
}
}
impl Action for CreateTextClipAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let clip_id = self.clip_id.unwrap_or_else(Uuid::new_v4);
self.clip_id = Some(clip_id);
let instance_id = self.instance_id.unwrap_or_else(Uuid::new_v4);
self.instance_id = Some(instance_id);
let duration = if document.duration > 0.0 { document.duration } else { FALLBACK_DURATION };
// Build the clip with the text layer as its single root layer.
let mut clip = VectorClip::with_id(
clip_id,
"Text",
self.text_layer.box_width.max(1.0),
self.text_layer.box_height.max(1.0),
duration,
);
// A movie clip (not keyframe-gated) so the text persists across the timeline.
clip.is_group = false;
clip.layers.add_root(AnyLayer::Text(self.text_layer.clone()));
// Registers the clip and its layers in layer_to_clip_map for O(1) lookup.
document.add_vector_clip(clip);
// Place an instance of the clip in the parent vector layer.
let layer = document
.get_layer_mut(&self.parent_layer_id)
.ok_or_else(|| format!("Parent layer {} not found", self.parent_layer_id))?;
let AnyLayer::Vector(vector_layer) = layer else {
return Err("Text clip can only be created inside a vector layer".to_string());
};
let mut instance = ClipInstance::with_id(instance_id, clip_id);
instance.transform.x = self.position.0;
instance.transform.y = self.position.1;
vector_layer.clip_instances.push(instance);
self.executed = true;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if !self.executed {
return Ok(());
}
// Remove the clip instance from the parent layer.
if let (Some(instance_id), Some(AnyLayer::Vector(vector_layer))) =
(self.instance_id, document.get_layer_mut(&self.parent_layer_id))
{
vector_layer.clip_instances.retain(|ci| ci.id != instance_id);
}
// Remove the clip and its layer_to_clip_map registrations.
if let Some(clip_id) = self.clip_id {
if let Some(clip) = document.vector_clips.remove(&clip_id) {
for node in &clip.layers.roots {
document.layer_to_clip_map.remove(&node.data.id());
}
}
}
self.executed = false;
Ok(())
}
fn description(&self) -> String {
"Add text".to_string()
}
}

View File

@ -37,6 +37,7 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops { for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
@ -61,6 +62,7 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops { for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {

View File

@ -11,6 +11,7 @@ pub mod add_layer;
pub mod add_shape; pub mod add_shape;
pub mod modify_shape_path; pub mod modify_shape_path;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_document_properties; pub mod set_document_properties;
@ -44,6 +45,9 @@ pub mod resize_raster_layer;
pub mod move_layer; pub mod move_layer;
pub mod set_fill_paint; pub mod set_fill_paint;
pub mod set_image_fill; pub mod set_image_fill;
pub mod create_text_clip;
pub mod set_text_content;
pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction; pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction; pub use add_effect::AddEffectAction;
@ -51,6 +55,7 @@ pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyGraphAction; pub use modify_shape_path::ModifyGraphAction;
pub use move_clip_instances::MoveClipInstancesAction; pub use move_clip_instances::MoveClipInstancesAction;
pub use reorder_clip_instances::ReorderClipInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction; pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction; pub use set_document_properties::SetDocumentPropertiesAction;
@ -84,3 +89,6 @@ pub use set_fill_paint::SetFillPaintAction;
pub use set_image_fill::SetImageFillAction; pub use set_image_fill::SetImageFillAction;
pub use change_bpm::ChangeBpmAction; pub use change_bpm::ChangeBpmAction;
pub use change_fps::ChangeFpsAction; pub use change_fps::ChangeFpsAction;
pub use create_text_clip::CreateTextClipAction;
pub use set_text_content::SetTextContentAction;
pub use resize_text_box::ResizeTextBoxAction;

View File

@ -58,6 +58,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -97,6 +98,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
@ -132,6 +134,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
// Update timeline_start for each clip instance // Update timeline_start for each clip instance
@ -159,6 +162,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
// Restore original timeline_start for each clip instance // Restore original timeline_start for each clip instance

View File

@ -46,6 +46,7 @@ impl Action for RemoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
// Find and remove the instance, saving it for rollback // Find and remove the instance, saving it for rollback
@ -72,6 +73,7 @@ impl Action for RemoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
clip_instances.push(instance); clip_instances.push(instance);

View File

@ -0,0 +1,78 @@
//! Reorder clip instances within a layer's stacking order (Send to Back / Bring to Front).
//!
//! A layer's `clip_instances` Vec order *is* the stacking order — the last element renders on top
//! (hit-testing walks it in reverse). Geometry renders underneath and is unaffected. This action
//! moves the selected instances to the front (end) or back (start) of that Vec.
use crate::action::Action;
use crate::clip::ClipInstance;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
pub struct ReorderClipInstancesAction {
layer_id: Uuid,
instance_ids: Vec<Uuid>,
/// `true` = bring to front (top), `false` = send to back (bottom).
to_front: bool,
/// Full instance order captured on `execute`, for `rollback`.
old_order: Option<Vec<Uuid>>,
}
impl ReorderClipInstancesAction {
pub fn new(layer_id: Uuid, instance_ids: Vec<Uuid>, to_front: bool) -> Self {
Self { layer_id, instance_ids, to_front, old_order: None }
}
}
/// The clip-instance stack for a layer, if it has one (Group/Raster/Text don't).
fn clip_instances_mut<'a>(document: &'a mut Document, layer_id: &Uuid) -> Option<&'a mut Vec<ClipInstance>> {
match document.get_layer_mut(layer_id)? {
AnyLayer::Vector(l) => Some(&mut l.clip_instances),
AnyLayer::Audio(l) => Some(&mut l.clip_instances),
AnyLayer::Video(l) => Some(&mut l.clip_instances),
AnyLayer::Effect(l) => Some(&mut l.clip_instances),
_ => None,
}
}
impl Action for ReorderClipInstancesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instances = clip_instances_mut(document, &self.layer_id)
.ok_or_else(|| "Layer has no clip-instance stack".to_string())?;
self.old_order = Some(instances.iter().map(|c| c.id).collect());
let mut selected = Vec::new();
let mut rest = Vec::new();
for ci in instances.drain(..) {
if self.instance_ids.contains(&ci.id) {
selected.push(ci);
} else {
rest.push(ci);
}
}
if self.to_front {
rest.extend(selected); // selected last → rendered on top
*instances = rest;
} else {
selected.extend(rest); // selected first → rendered at the bottom (of the stack)
*instances = selected;
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(order) = self.old_order.clone() else {
return Ok(());
};
let instances = clip_instances_mut(document, &self.layer_id)
.ok_or_else(|| "Layer has no clip-instance stack".to_string())?;
let rank = |id: &Uuid| order.iter().position(|o| o == id).unwrap_or(usize::MAX);
instances.sort_by(|a, b| rank(&a.id).cmp(&rank(&b.id)));
Ok(())
}
fn description(&self) -> String {
if self.to_front { "Bring to Front".to_string() } else { "Send to Back".to_string() }
}
}

View File

@ -0,0 +1,59 @@
//! Resize-text-box action
//!
//! Updates a text layer's box origin and dimensions (which changes the wrap
//! width, so parley re-wraps the text). One undoable step.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use kurbo::Point;
use uuid::Uuid;
pub struct ResizeTextBoxAction {
layer_id: Uuid,
new_origin: Point,
new_width: f64,
new_height: f64,
old: Option<(Point, f64, f64)>,
}
impl ResizeTextBoxAction {
pub fn new(layer_id: Uuid, new_origin: Point, new_width: f64, new_height: f64) -> Self {
Self { layer_id, new_origin, new_width, new_height, old: None }
}
}
impl Action for ResizeTextBoxAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Text(text_layer) = layer else {
return Err("ResizeTextBoxAction target is not a text layer".to_string());
};
if self.old.is_none() {
self.old = Some((text_layer.box_origin, text_layer.box_width, text_layer.box_height));
}
text_layer.box_origin = self.new_origin;
text_layer.box_width = self.new_width.max(1.0);
text_layer.box_height = self.new_height.max(1.0);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some((origin, w, h)) = self.old else { return Ok(()) };
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Text(text_layer) = layer {
text_layer.box_origin = origin;
text_layer.box_width = w;
text_layer.box_height = h;
}
Ok(())
}
fn description(&self) -> String {
"Resize text box".to_string()
}
}

View File

@ -0,0 +1,61 @@
//! Set-text-content action
//!
//! Replaces a text layer's [`TextContent`] (text string, font size, color, family,
//! alignment) as one undoable step. Used both by in-place editing (text changes)
//! and the info panel (style changes).
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::text_layer::TextContent;
use uuid::Uuid;
pub struct SetTextContentAction {
layer_id: Uuid,
new: TextContent,
old: Option<TextContent>,
}
impl SetTextContentAction {
pub fn new(layer_id: Uuid, new: TextContent) -> Self {
Self { layer_id, new, old: None }
}
/// Construct with an explicit `old` value. Used by in-place editing, which mutates
/// the document live for preview and then records one undoable step capturing the
/// content as it was *before* editing began.
pub fn with_old(layer_id: Uuid, old: TextContent, new: TextContent) -> Self {
Self { layer_id, new, old: Some(old) }
}
}
impl Action for SetTextContentAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Text(text_layer) = layer else {
return Err("SetTextContentAction target is not a text layer".to_string());
};
if self.old.is_none() {
self.old = Some(text_layer.content.clone());
}
text_layer.content = self.new.clone();
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(old) = self.old.clone() else { return Ok(()) };
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Text(text_layer) = layer {
text_layer.content = old;
}
Ok(())
}
fn description(&self) -> String {
"Edit text".to_string()
}
}

View File

@ -114,6 +114,7 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()), AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()),
AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()), AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()),
AnyLayer::Text(_) => return Err("Cannot split clip instances on group layers".to_string()),
}; };
let instance = clip_instances let instance = clip_instances
@ -233,7 +234,7 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Group(_) => { AnyLayer::Group(_) => {
return Err("Cannot split clip instances on group layers".to_string()); return Err("Cannot split clip instances on group layers".to_string());
} }
AnyLayer::Raster(_) => { AnyLayer::Raster(_) | AnyLayer::Text(_) => {
return Err("Cannot split clip instances on group layers".to_string()); return Err("Cannot split clip instances on group layers".to_string());
} }
} }
@ -294,8 +295,8 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Group(_) => { AnyLayer::Group(_) => {
// Group layers don't have clip instances, nothing to rollback // Group layers don't have clip instances, nothing to rollback
} }
AnyLayer::Raster(_) => { AnyLayer::Raster(_) | AnyLayer::Text(_) => {
// Raster layers don't have clip instances, nothing to rollback // Raster/text layers don't have clip instances, nothing to rollback
} }
} }

View File

@ -101,6 +101,7 @@ impl Action for TransformClipInstancesAction {
AnyLayer::Effect(_) => {} AnyLayer::Effect(_) => {}
AnyLayer::Group(_) => {} AnyLayer::Group(_) => {}
AnyLayer::Raster(_) => {} AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
} }
Ok(()) Ok(())
} }
@ -140,6 +141,7 @@ impl Action for TransformClipInstancesAction {
AnyLayer::Effect(_) => {} AnyLayer::Effect(_) => {}
AnyLayer::Group(_) => {} AnyLayer::Group(_) => {}
AnyLayer::Raster(_) => {} AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
} }
Ok(()) Ok(())
} }

View File

@ -101,6 +101,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -138,6 +139,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -182,6 +184,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
let instance = clip_instances.iter() let instance = clip_instances.iter()
@ -275,6 +278,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
// Apply trims // Apply trims
@ -315,6 +319,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue, AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue, AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
}; };
// Restore original trim values // Restore original trim values

View File

@ -62,6 +62,10 @@ pub enum MediaKind {
/// the keyframe id). Decoded eagerly on load and shown while the full-res pixels /// the keyframe id). Decoded eagerly on load and shown while the full-res pixels
/// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`. /// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`.
RasterProxy = 6, RasterProxy = 6,
/// An embedded font file (TTF/OTF) used by a text layer (keyed by a content hash
/// of the bytes so identical fonts dedupe). Registered into the font collection
/// on load so documents render faithfully on machines lacking the font.
Font = 7,
} }
impl MediaKind { impl MediaKind {
@ -74,6 +78,7 @@ impl MediaKind {
4 => Some(Self::Waveform), 4 => Some(Self::Waveform),
5 => Some(Self::Thumbnail), 5 => Some(Self::Thumbnail),
6 => Some(Self::RasterProxy), 6 => Some(Self::RasterProxy),
7 => Some(Self::Font),
_ => None, _ => None,
} }
} }

View File

@ -122,6 +122,7 @@ impl VectorClip {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
for ci in clip_instances { for ci in clip_instances {
// Compute end position of this clip instance in beats // Compute end position of this clip instance in beats
@ -223,6 +224,17 @@ impl VectorClip {
Some(existing) => existing.union(transformed_bounds), Some(existing) => existing.union(transformed_bounds),
}); });
} }
} else if let AnyLayer::Text(text_layer) = &layer_node.data {
// Text layers contribute their box bounds (so a text-only clip is
// selectable/draggable, not a degenerate point).
let r = Rect::from_origin_size(
text_layer.box_origin,
(text_layer.box_width, text_layer.box_height),
);
combined_bounds = Some(match combined_bounds {
None => r,
Some(existing) => existing.union(r),
});
} }
} }

View File

@ -62,6 +62,7 @@ impl ClipboardLayerType {
AnyLayer::Effect(_) => ClipboardLayerType::Effect, AnyLayer::Effect(_) => ClipboardLayerType::Effect,
AnyLayer::Group(_) => ClipboardLayerType::Vector, AnyLayer::Group(_) => ClipboardLayerType::Vector,
AnyLayer::Raster(_) => ClipboardLayerType::Vector, AnyLayer::Raster(_) => ClipboardLayerType::Vector,
AnyLayer::Text(_) => ClipboardLayerType::Vector,
} }
} }
@ -449,6 +450,13 @@ fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLay
nl.layer.id = new_layer_id; nl.layer.id = new_layer_id;
AnyLayer::Raster(nl) AnyLayer::Raster(nl)
} }
AnyLayer::Text(tl) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(tl.layer.id, new_layer_id);
let mut nl = tl.clone();
nl.layer.id = new_layer_id;
AnyLayer::Text(nl)
}
AnyLayer::Group(gl) => { AnyLayer::Group(gl) => {
let new_layer_id = Uuid::new_v4(); let new_layer_id = Uuid::new_v4();
id_map.insert(gl.layer.id, new_layer_id); id_map.insert(gl.layer.id, new_layer_id);

View File

@ -489,8 +489,8 @@ impl Document {
} }
} }
} }
crate::layer::AnyLayer::Raster(_) => { crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
// Raster layers don't have clip instances // Raster and text layers don't have clip instances
} }
crate::layer::AnyLayer::Group(group) => { crate::layer::AnyLayer::Group(group) => {
// Recurse into group children to find their clip instance endpoints // Recurse into group children to find their clip instance endpoints
@ -530,8 +530,8 @@ impl Document {
} }
} }
} }
crate::layer::AnyLayer::Raster(_) => { crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
// Raster layers don't have clip instances // Raster and text layers don't have clip instances
} }
crate::layer::AnyLayer::Group(g) => { crate::layer::AnyLayer::Group(g) => {
process_group_children(&g.children, doc, max_end, calc_end); process_group_children(&g.children, doc, max_end, calc_end);
@ -937,6 +937,7 @@ impl Document {
AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Effect(effect) => &effect.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
@ -977,6 +978,7 @@ impl Document {
AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Effect(effect) => &effect.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
for instance in instances { for instance in instances {
@ -1039,6 +1041,7 @@ impl Document {
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips
AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips
AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips
}; };
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
@ -1134,6 +1137,7 @@ impl Document {
AnyLayer::Vector(v) => &v.clip_instances, AnyLayer::Vector(v) => &v.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
// Collect non-group clip ranges // Collect non-group clip ranges
@ -1205,6 +1209,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
for other in instances { for other in instances {
@ -1253,6 +1258,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
let mut nearest_start = f64::MAX; let mut nearest_start = f64::MAX;
@ -1300,6 +1306,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
let mut nearest_end = 0.0; let mut nearest_end = 0.0;

View File

@ -51,6 +51,54 @@ impl AudioFormat {
} }
} }
/// Optional tag metadata written into the exported audio file. Empty fields are omitted. FFmpeg
/// maps these standard keys to each container's native tags: ID3v2 (MP3), iTunes/MP4 atoms (M4A),
/// Vorbis comments (FLAC), and RIFF INFO (WAV).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AudioMetadata {
pub title: String,
pub artist: String,
pub album: String,
pub genre: String,
pub comment: String,
/// Year or full date (written to the `date` tag).
pub year: String,
/// Track number (written to the `track` tag).
pub track: String,
}
impl AudioMetadata {
/// True when no field is set (so no metadata need be written).
pub fn is_empty(&self) -> bool {
self.title.is_empty()
&& self.artist.is_empty()
&& self.album.is_empty()
&& self.genre.is_empty()
&& self.comment.is_empty()
&& self.year.is_empty()
&& self.track.is_empty()
}
/// The set (ffmpeg-key, value) pairs for non-empty fields, in a stable order.
pub fn pairs(&self) -> Vec<(&'static str, &str)> {
let mut v = Vec::new();
for (key, val) in [
("title", &self.title),
("artist", &self.artist),
("album", &self.album),
("genre", &self.genre),
("comment", &self.comment),
("date", &self.year),
("track", &self.track),
] {
if !val.is_empty() {
v.push((key, val.as_str()));
}
}
v
}
}
/// Audio export settings /// Audio export settings
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioExportSettings { pub struct AudioExportSettings {
@ -79,6 +127,10 @@ pub struct AudioExportSettings {
/// Project BPM (for beat-position scheduling during export) /// Project BPM (for beat-position scheduling during export)
pub bpm: f64, pub bpm: f64,
/// Tag metadata (title/artist/…) written into the file. Empty = none.
#[serde(default)]
pub metadata: AudioMetadata,
} }
impl Default for AudioExportSettings { impl Default for AudioExportSettings {
@ -92,6 +144,7 @@ impl Default for AudioExportSettings {
start_time: 0.0, start_time: 0.0,
end_time: 60.0, end_time: 60.0,
bpm: 120.0, bpm: 120.0,
metadata: AudioMetadata::default(),
} }
} }
} }
@ -543,6 +596,82 @@ impl ImageExportSettings {
} }
} }
// ── Animated GIF export ──────────────────────────────────────────────────────
/// Settings for exporting an animated GIF (multi-frame, palette-quantized, no audio).
///
/// GIF stores a per-frame delay in centiseconds (1/100 s), so effective frame rate is quantized to
/// whole centiseconds — [`Self::frame_delay_ms`] rounds accordingly and the dialog offers sensible
/// GIF rates. Each frame is quantized to a 256-color palette by the encoder.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GifExportSettings {
/// Output width in pixels (None = use document width).
pub width: Option<u32>,
/// Output height in pixels (None = use document height).
pub height: Option<u32>,
/// Frame rate (fps). Snapped to whole-centisecond delays at encode time.
pub framerate: f64,
/// Loop the animation forever (GIF `NETSCAPE2.0` infinite loop). False = play once.
pub loop_forever: bool,
/// Preserve full alpha as GIF 1-bit transparency (pixels below the alpha threshold become the
/// transparent index). When false, frames are composited onto an opaque background first.
pub transparency: bool,
/// How the document is fit into the output frame when aspect ratios differ (default Letterbox).
#[serde(default)]
pub fit: ExportFitMode,
/// Start time in seconds.
pub start_time: f64,
/// End time in seconds.
pub end_time: f64,
}
impl Default for GifExportSettings {
fn default() -> Self {
Self {
width: None,
height: None,
framerate: 15.0,
loop_forever: true,
transparency: false,
fit: ExportFitMode::Letterbox,
start_time: 0.0,
end_time: 5.0,
}
}
}
impl GifExportSettings {
pub fn validate(&self) -> Result<(), String> {
if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } }
if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } }
if self.framerate <= 0.0 {
return Err("Framerate must be greater than 0".into());
}
if self.start_time < 0.0 {
return Err("Start time cannot be negative".into());
}
if self.end_time <= self.start_time {
return Err("End time must be greater than start time".into());
}
Ok(())
}
/// Duration in seconds.
pub fn duration(&self) -> f64 { self.end_time - self.start_time }
/// Total number of frames to render.
pub fn total_frames(&self) -> usize {
(self.duration() * self.framerate).ceil().max(1.0) as usize
}
/// Per-frame delay in milliseconds, from the framerate (GIF stores this at centisecond
/// resolution, so the effective rate is snapped to the nearest 10 ms, min 10 ms).
pub fn frame_delay_ms(&self) -> u32 {
let ms = 1000.0 / self.framerate;
((ms / 10.0).round().max(1.0) * 10.0) as u32
}
}
/// Progress updates during export /// Progress updates during export
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ExportProgress { pub enum ExportProgress {
@ -728,6 +857,33 @@ mod tests {
assert_eq!(settings.total_frames(), 300); assert_eq!(settings.total_frames(), 300);
} }
#[test]
fn test_gif_frame_delay_and_frames() {
// Frame rates that map to clean centisecond delays.
let mk = |fps: f64| GifExportSettings { framerate: fps, ..Default::default() };
assert_eq!(mk(10.0).frame_delay_ms(), 100); // 100 ms
assert_eq!(mk(20.0).frame_delay_ms(), 50); // 50 ms
assert_eq!(mk(50.0).frame_delay_ms(), 20); // 20 ms
// 15 fps = 66.6 ms rounds to 70 ms (7 cs); 25 fps = 40 ms.
assert_eq!(mk(15.0).frame_delay_ms(), 70);
assert_eq!(mk(25.0).frame_delay_ms(), 40);
// Very high fps clamps to the 10 ms minimum (1 cs).
assert_eq!(mk(1000.0).frame_delay_ms(), 10);
let settings = GifExportSettings { framerate: 20.0, start_time: 0.0, end_time: 3.0, ..Default::default() };
assert_eq!(settings.total_frames(), 60);
}
#[test]
fn test_gif_validate() {
let mut s = GifExportSettings::default();
assert!(s.validate().is_ok());
s.framerate = 0.0;
assert!(s.validate().is_err());
s = GifExportSettings { start_time: 5.0, end_time: 2.0, ..Default::default() };
assert!(s.validate().is_err());
}
#[test] #[test]
fn test_export_progress_percentage() { fn test_export_progress_percentage() {
let progress = ExportProgress::FrameRendered { frame: 50, total: 100 }; let progress = ExportProgress::FrameRendered { frame: 50, total: 100 };

View File

@ -242,6 +242,21 @@ fn thumbnail_media_id(clip_id: Uuid) -> Uuid {
Uuid::from_u128(clip_id.as_u128() ^ SENTINEL) Uuid::from_u128(clip_id.as_u128() ^ SENTINEL)
} }
/// Content-hash id for an embedded font row, so identical font files dedupe to one
/// row regardless of which / how many text layers use them. A 128-bit id is built
/// from two salted 64-bit hashes of the bytes.
fn font_media_id(bytes: &[u8]) -> Uuid {
use std::hash::{Hash, Hasher};
let mut h1 = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h1);
let mut h2 = std::collections::hash_map::DefaultHasher::new();
0xF0E1_D2C3_B4A5_9687u64.hash(&mut h2);
bytes.hash(&mut h2);
let hi = h1.finish() as u128;
let lo = h2.finish() as u128;
Uuid::from_u128((hi << 64) | lo)
}
/// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's /// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's
/// own full-res `Raster` row, which is keyed by the raw keyframe id). /// own full-res `Raster` row, which is keyed by the raw keyframe id).
fn raster_proxy_media_id(kf_id: Uuid) -> Uuid { fn raster_proxy_media_id(kf_id: Uuid) -> Uuid {
@ -384,16 +399,29 @@ pub fn save_beam(
} }
// --- raster keyframes -> media rows (PNG), keyed by keyframe id --- // --- raster keyframes -> media rows (PNG), keyed by keyframe id ---
// (Phase 0 writes all resident frames each save; a disk-dirty flag to skip // Incremental: only (re)encode a keyframe whose pixels changed since the last save.
// unchanged frames in place is deferred to Phase 3.) // `kf.dirty` means "current pixels are not yet in the container" (set on any edit,
// cleared on a successful save — see main.rs); a clean frame already stored is kept
// in place, skipping the PNG re-encode of every resident frame on every save.
// Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes // Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes
// are persisted too, and so `live_media` covers them — matching the load path, // are persisted too, and so `live_media` covers them — matching the load path,
// which arms `needs_fault_in` recursively. Top-level-only projects are unaffected. // which arms `needs_fault_in` recursively. Top-level-only projects are unaffected.
let mut raster_count = 0usize; let mut raster_count = 0usize;
let mut raster_skipped = 0usize;
for layer in document.all_layers() { for layer in document.all_layers() {
if let crate::layer::AnyLayer::Raster(rl) = layer { if let crate::layer::AnyLayer::Raster(rl) = layer {
for kf in &rl.keyframes { for kf in &rl.keyframes {
if !kf.raw_pixels.is_empty() { if !kf.raw_pixels.is_empty() {
// Clean + already stored → keep the existing full + proxy rows untouched.
if !kf.dirty && txn.media_exists(kf.id)? {
live_media.insert(kf.id);
let proxy_id = raster_proxy_media_id(kf.id);
if txn.media_exists(proxy_id)? {
live_media.insert(proxy_id);
}
raster_skipped += 1;
continue;
}
let img = let img =
crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height); crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height);
match crate::brush_engine::encode_png(&img) { match crate::brush_engine::encode_png(&img) {
@ -535,6 +563,32 @@ pub fn save_beam(
} }
let _ = image_count; let _ = image_count;
// --- embedded fonts -> media rows (Font), keyed by content hash so identical
// fonts dedupe. Embed every non-bundled family used by a text layer so the
// document renders faithfully on machines lacking the font. The bundled
// three ship with the app and are never embedded. ---
{
use crate::layer::AnyLayer;
let mut families: HashSet<String> = HashSet::new();
for layer in document.all_layers() {
if let AnyLayer::Text(tl) = layer {
let fam = &tl.content.font_family;
if !fam.is_empty() && !crate::fonts::is_bundled(fam) {
families.insert(fam.clone());
}
}
}
for fam in families {
if let Some(bytes) = crate::fonts::family_font_bytes(&fam) {
let id = font_media_id(&bytes);
if !txn.media_exists(id)? {
txn.put_media_packed(id, MediaKind::Font, &fam, &bytes, MediaMeta::default())?;
}
live_media.insert(id);
}
}
}
// --- orphan cleanup: drop media for removed clips/keyframes --- // --- orphan cleanup: drop media for removed clips/keyframes ---
let removed = txn.retain_media(&live_media)?; let removed = txn.retain_media(&live_media)?;
@ -567,9 +621,10 @@ pub fn save_beam(
} }
eprintln!( eprintln!(
"📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media, {} orphans removed, in {:.2}ms", "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media ({} unchanged frames skipped), {} orphans removed, in {:.2}ms",
audio_pool_entries.len(), audio_pool_entries.len(),
raster_count, raster_count,
raster_skipped,
removed, removed,
fn_start.elapsed().as_secs_f64() * 1000.0 fn_start.elapsed().as_secs_f64() * 1000.0
); );
@ -598,6 +653,17 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)..."); eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)...");
let archive = BeamArchive::open(path)?; let archive = BeamArchive::open(path)?;
// Register document-embedded fonts into the runtime font collection so text
// layers referencing them render faithfully even if the host lacks the font.
if let Ok(font_ids) = archive.media_ids_of_kind(MediaKind::Font) {
for id in font_ids {
if let Ok(bytes) = archive.read_media_full(id) {
crate::fonts::register_embedded(bytes);
}
}
}
let json = archive.get_project_json()?; let json = archive.get_project_json()?;
let beam_project: BeamProject = serde_json::from_str(&json) let beam_project: BeamProject = serde_json::from_str(&json)
.map_err(|e| format!("Failed to deserialize project.json: {}", e))?; .map_err(|e| format!("Failed to deserialize project.json: {}", e))?;

View File

@ -0,0 +1,296 @@
//! Font registry + text layout for text layers.
//!
//! Wraps a thread-local parley [`FontContext`]/[`LayoutContext`]. Three fonts are
//! bundled (serif, sans-serif, monospaced) so text renders deterministically even
//! offline; system fonts are also enumerated (parley's fontique source) and
//! document-embedded fonts can be registered at load time (see `file_io`).
//!
//! The same `linebender_resource_handle` crate (0.1.1) backs both vello's
//! `peniko::FontData` and parley's `FontData`, so a glyph run's `run.font()` can be
//! handed straight to `vello::Scene::draw_glyphs` with no conversion.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, Layout, LayoutContext,
PositionedLayoutItem, StyleProperty,
};
use vello::peniko::Blob;
use crate::text_layer::{TextAlign, TextContent};
// ── Bundled fonts (SIL OFL; vendored under assets/fonts) ─────────────────────
static BUNDLED_SANS: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf");
static BUNDLED_SERIF: &[u8] = include_bytes!("../assets/fonts/LiberationSerif-Regular.ttf");
static BUNDLED_MONO: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf");
/// Parley requires a brush type, but glyph color is applied by vello at draw time,
/// so we use a zero-sized placeholder.
#[derive(Clone, Copy, PartialEq, Default, Debug)]
pub struct NoBrush;
struct FontStore {
fcx: FontContext,
lcx: LayoutContext<NoBrush>,
/// Registered family names of the three bundled fonts (sans, serif, mono).
bundled: Vec<String>,
/// The default family (bundled sans-serif), used when `font_family` is empty.
default_family: String,
}
impl FontStore {
fn new() -> Self {
let mut fcx = FontContext::new();
let mut bundled = Vec::new();
for bytes in [BUNDLED_SANS, BUNDLED_SERIF, BUNDLED_MONO] {
let blob = Blob::new(Arc::new(bytes) as Arc<dyn AsRef<[u8]> + Send + Sync>);
for (family_id, _) in fcx.collection.register_fonts(blob, None) {
if let Some(name) = fcx.collection.family_name(family_id) {
let name = name.to_string();
if !bundled.contains(&name) {
bundled.push(name);
}
}
}
}
let default_family = bundled.first().cloned().unwrap_or_else(|| "sans-serif".to_string());
Self { fcx, lcx: LayoutContext::new(), bundled, default_family }
}
}
thread_local! {
static STORE: RefCell<FontStore> = RefCell::new(FontStore::new());
}
/// The default family name (bundled sans-serif), used when a text layer's
/// `font_family` is empty.
pub fn default_family() -> String {
STORE.with(|s| s.borrow().default_family.clone())
}
/// Whether `family` is one of the three bundled families (which therefore must
/// never be embedded into a `.beam`).
pub fn is_bundled(family: &str) -> bool {
STORE.with(|s| s.borrow().bundled.iter().any(|f| f == family))
}
/// True if a shorter word-prefix of `name` is itself a family in `set` — i.e. `name`
/// is a variant of a base family (e.g. "Noto Sans Arabic" when "Noto Sans" exists).
/// Used to collapse the many script/region-specific fonts (mostly Noto) into their
/// base family in the picker; parley's fallback still resolves the right script font
/// automatically when rendering non-Latin text.
fn is_variant_of_listed_base(name: &str, set: &std::collections::HashSet<&str>) -> bool {
let words: Vec<&str> = name.split(' ').collect();
for k in 1..words.len() {
if set.contains(words[..k].join(" ").as_str()) {
return true;
}
}
false
}
/// Available family names for the info-panel picker: the three bundled families first,
/// then a consolidated, alphabetical list of system families — script/region variants
/// whose base family is present are dropped (monospace variants are kept).
pub fn families() -> Vec<String> {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let mut system: Vec<String> =
s.fcx.collection.family_names().map(|n| n.to_string()).collect();
system.sort();
system.dedup();
let set: std::collections::HashSet<&str> = system.iter().map(|x| x.as_str()).collect();
let mut out = s.bundled.clone();
for name in &system {
if out.iter().any(|b| b == name) {
continue;
}
// Keep base families and monospace variants; drop script/region variants
// whose base family is already in the list.
if name.contains("Mono") || !is_variant_of_listed_base(name, &set) {
out.push(name.clone());
}
}
out
})
}
/// Register a document-embedded font (raw TTF/OTF bytes) into the runtime font
/// collection so text layers referencing its family resolve to it. Returns the
/// registered family names.
pub fn register_embedded(bytes: Vec<u8>) -> Vec<String> {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let blob = Blob::new(Arc::new(bytes) as Arc<dyn AsRef<[u8]> + Send + Sync>);
let mut names = Vec::new();
for (family_id, _) in s.fcx.collection.register_fonts(blob, None) {
if let Some(name) = s.fcx.collection.family_name(family_id) {
names.push(name.to_string());
}
}
names
})
}
/// Whether `family` currently resolves to a registered family (bundled, system,
/// or embedded). Used to flag "missing font" on load.
pub fn family_available(family: &str) -> bool {
if family.is_empty() {
return true;
}
STORE.with(|s| {
let s = &mut *s.borrow_mut();
s.fcx.collection.family_id(family).is_some()
})
}
/// The raw bytes of the font file that `family` resolves to (for embedding into a
/// `.beam`). Returns `None` if the family resolves to no glyphs. Lays out a single
/// glyph and reads the resolved run's font blob (the same `linebender_resource_handle`
/// blob vello uses).
pub fn family_font_bytes(family: &str) -> Option<Vec<u8>> {
let content = TextContent {
text: "A".to_string(),
font_family: family.to_string(),
..TextContent::default()
};
with_layout(&content, 10_000.0, |layout| {
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(run) = item {
return Some(run.run().font().data.data().to_vec());
}
}
}
None
})
}
// ── Background font preloading ───────────────────────────────────────────────
//
// Loading a family's bytes (`family_font_bytes`) is the expensive part (font file IO +
// shaping). Doing it for the whole picker on the UI thread causes hitches, so a
// background thread (started at app launch) loads every picker family's bytes ahead of
// time into a shared map. The UI then only has to *register* them with its renderer
// (cheap), and synchronously loads the few stragglers only if needed before they're ready.
struct PreloadState {
loaded: HashMap<String, Vec<u8>>,
started: bool,
done: bool,
}
fn preload() -> &'static Mutex<PreloadState> {
static P: OnceLock<Mutex<PreloadState>> = OnceLock::new();
P.get_or_init(|| Mutex::new(PreloadState {
loaded: HashMap::new(),
started: false,
done: false,
}))
}
/// Start (once) a background thread that loads every picker font's bytes. Idempotent;
/// safe to call every frame or from app startup.
pub fn start_preload() {
{
let mut p = preload().lock().unwrap();
if p.started {
return;
}
p.started = true;
}
let _ = std::thread::Builder::new()
.name("font-preload".into())
.spawn(|| {
// This thread gets its own thread-local FontContext (enumerates system fonts
// off the UI thread). Bytes are plain `Vec<u8>` (Send) handed back via the map.
for fam in families() {
if let Some(bytes) = family_font_bytes(&fam) {
preload().lock().unwrap().loaded.insert(fam, bytes);
}
}
preload().lock().unwrap().done = true;
});
}
/// Remove and return a preloaded font's bytes if the background thread has them ready.
pub fn take_preloaded(family: &str) -> Option<Vec<u8>> {
preload().lock().unwrap().loaded.remove(family)
}
/// Whether the background preloader has finished loading every family.
pub fn preload_done() -> bool {
preload().lock().unwrap().done
}
/// Caret rectangle (x0, y0, x1, y1, in layout space relative to the box origin) for
/// `byte_index` into `content.text`, wrapped to `max_width`.
pub fn caret_geometry(content: &TextContent, max_width: f32, byte_index: usize) -> Option<(f64, f64, f64, f64)> {
let caret_w = (content.font_size as f32 * 0.06).max(1.0);
with_layout(content, max_width, |layout| {
let cur = parley::Cursor::from_byte_index(layout, byte_index, parley::Affinity::Downstream);
let bb = cur.geometry(layout, caret_w);
Some((bb.x0, bb.y0, bb.x1, bb.y1))
})
}
/// Selection highlight rectangles (each x0, y0, x1, y1 in layout space) for the byte
/// range `[start, end)` into `content.text`, wrapped to `max_width`.
pub fn selection_geometry(content: &TextContent, max_width: f32, start: usize, end: usize) -> Vec<(f64, f64, f64, f64)> {
if start == end {
return Vec::new();
}
with_layout(content, max_width, |layout| {
let anchor = parley::Cursor::from_byte_index(layout, start, parley::Affinity::Downstream);
let focus = parley::Cursor::from_byte_index(layout, end, parley::Affinity::Downstream);
let sel = parley::Selection::new(anchor, focus);
sel.geometry(layout)
.into_iter()
.map(|(bb, _)| (bb.x0, bb.y0, bb.x1, bb.y1))
.collect()
})
}
fn alignment_of(a: TextAlign) -> Alignment {
match a {
TextAlign::Left => Alignment::Left,
TextAlign::Center => Alignment::Center,
TextAlign::Right => Alignment::Right,
TextAlign::Justify => Alignment::Justify,
}
}
/// Build a parley layout for `content` wrapped to `max_width` (document units),
/// then invoke `f` with it. The layout is rebuilt on each call (v1: no cache).
pub fn with_layout<R>(
content: &TextContent,
max_width: f32,
f: impl FnOnce(&Layout<NoBrush>) -> R,
) -> R {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let default_family = s.default_family.clone();
let FontStore { fcx, lcx, .. } = s;
let mut builder = lcx.ranged_builder(fcx, &content.text, 1.0, true);
builder.push_default(StyleProperty::FontSize(content.font_size as f32));
let family_name = if content.font_family.is_empty() {
default_family
} else {
content.font_family.clone()
};
let family = FontFamily::Single(FontFamilyName::Named(Cow::Owned(family_name)));
builder.push_default(StyleProperty::FontFamily(family));
let mut layout = builder.build(&content.text);
layout.break_all_lines(Some(max_width));
layout.align(alignment_of(content.align), AlignmentOptions::default());
f(&layout)
})
}

View File

@ -9,6 +9,7 @@ use crate::vector_graph::VectorGraph;
use crate::effect_layer::EffectLayer; use crate::effect_layer::EffectLayer;
use crate::object::ShapeInstance; use crate::object::ShapeInstance;
use crate::raster_layer::RasterLayer; use crate::raster_layer::RasterLayer;
use crate::text_layer::TextLayer;
use crate::shape::Shape; use crate::shape::Shape;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@ -31,6 +32,8 @@ pub enum LayerType {
Group, Group,
/// Raster pixel-buffer painting layer /// Raster pixel-buffer painting layer
Raster, Raster,
/// Text layer (a single editable text box)
Text,
} }
/// Common trait for all layer types /// Common trait for all layer types
@ -866,6 +869,7 @@ impl GroupLayer {
AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], // no nested groups AnyLayer::Group(_) => &[], // no nested groups
AnyLayer::Raster(_) => &[], // raster layers have no clip instances AnyLayer::Raster(_) => &[], // raster layers have no clip instances
AnyLayer::Text(_) => &[], // raster layers have no clip instances
}; };
for ci in instances { for ci in instances {
result.push((child_id, ci)); result.push((child_id, ci));
@ -884,6 +888,7 @@ pub enum AnyLayer {
Effect(EffectLayer), Effect(EffectLayer),
Group(GroupLayer), Group(GroupLayer),
Raster(RasterLayer), Raster(RasterLayer),
Text(TextLayer),
} }
impl LayerTrait for AnyLayer { impl LayerTrait for AnyLayer {
@ -895,6 +900,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.id(), AnyLayer::Effect(l) => l.id(),
AnyLayer::Group(l) => l.id(), AnyLayer::Group(l) => l.id(),
AnyLayer::Raster(l) => l.id(), AnyLayer::Raster(l) => l.id(),
AnyLayer::Text(l) => l.id(),
} }
} }
@ -906,6 +912,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.name(), AnyLayer::Effect(l) => l.name(),
AnyLayer::Group(l) => l.name(), AnyLayer::Group(l) => l.name(),
AnyLayer::Raster(l) => l.name(), AnyLayer::Raster(l) => l.name(),
AnyLayer::Text(l) => l.name(),
} }
} }
@ -917,6 +924,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_name(name), AnyLayer::Effect(l) => l.set_name(name),
AnyLayer::Group(l) => l.set_name(name), AnyLayer::Group(l) => l.set_name(name),
AnyLayer::Raster(l) => l.set_name(name), AnyLayer::Raster(l) => l.set_name(name),
AnyLayer::Text(l) => l.set_name(name),
} }
} }
@ -928,6 +936,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.has_custom_name(), AnyLayer::Effect(l) => l.has_custom_name(),
AnyLayer::Group(l) => l.has_custom_name(), AnyLayer::Group(l) => l.has_custom_name(),
AnyLayer::Raster(l) => l.has_custom_name(), AnyLayer::Raster(l) => l.has_custom_name(),
AnyLayer::Text(l) => l.has_custom_name(),
} }
} }
@ -939,6 +948,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_has_custom_name(custom), AnyLayer::Effect(l) => l.set_has_custom_name(custom),
AnyLayer::Group(l) => l.set_has_custom_name(custom), AnyLayer::Group(l) => l.set_has_custom_name(custom),
AnyLayer::Raster(l) => l.set_has_custom_name(custom), AnyLayer::Raster(l) => l.set_has_custom_name(custom),
AnyLayer::Text(l) => l.set_has_custom_name(custom),
} }
} }
@ -950,6 +960,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.visible(), AnyLayer::Effect(l) => l.visible(),
AnyLayer::Group(l) => l.visible(), AnyLayer::Group(l) => l.visible(),
AnyLayer::Raster(l) => l.visible(), AnyLayer::Raster(l) => l.visible(),
AnyLayer::Text(l) => l.visible(),
} }
} }
@ -961,6 +972,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_visible(visible), AnyLayer::Effect(l) => l.set_visible(visible),
AnyLayer::Group(l) => l.set_visible(visible), AnyLayer::Group(l) => l.set_visible(visible),
AnyLayer::Raster(l) => l.set_visible(visible), AnyLayer::Raster(l) => l.set_visible(visible),
AnyLayer::Text(l) => l.set_visible(visible),
} }
} }
@ -972,6 +984,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.opacity(), AnyLayer::Effect(l) => l.opacity(),
AnyLayer::Group(l) => l.opacity(), AnyLayer::Group(l) => l.opacity(),
AnyLayer::Raster(l) => l.opacity(), AnyLayer::Raster(l) => l.opacity(),
AnyLayer::Text(l) => l.opacity(),
} }
} }
@ -983,6 +996,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_opacity(opacity), AnyLayer::Effect(l) => l.set_opacity(opacity),
AnyLayer::Group(l) => l.set_opacity(opacity), AnyLayer::Group(l) => l.set_opacity(opacity),
AnyLayer::Raster(l) => l.set_opacity(opacity), AnyLayer::Raster(l) => l.set_opacity(opacity),
AnyLayer::Text(l) => l.set_opacity(opacity),
} }
} }
@ -994,6 +1008,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.volume(), AnyLayer::Effect(l) => l.volume(),
AnyLayer::Group(l) => l.volume(), AnyLayer::Group(l) => l.volume(),
AnyLayer::Raster(l) => l.volume(), AnyLayer::Raster(l) => l.volume(),
AnyLayer::Text(l) => l.volume(),
} }
} }
@ -1005,6 +1020,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_volume(volume), AnyLayer::Effect(l) => l.set_volume(volume),
AnyLayer::Group(l) => l.set_volume(volume), AnyLayer::Group(l) => l.set_volume(volume),
AnyLayer::Raster(l) => l.set_volume(volume), AnyLayer::Raster(l) => l.set_volume(volume),
AnyLayer::Text(l) => l.set_volume(volume),
} }
} }
@ -1016,6 +1032,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.muted(), AnyLayer::Effect(l) => l.muted(),
AnyLayer::Group(l) => l.muted(), AnyLayer::Group(l) => l.muted(),
AnyLayer::Raster(l) => l.muted(), AnyLayer::Raster(l) => l.muted(),
AnyLayer::Text(l) => l.muted(),
} }
} }
@ -1027,6 +1044,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_muted(muted), AnyLayer::Effect(l) => l.set_muted(muted),
AnyLayer::Group(l) => l.set_muted(muted), AnyLayer::Group(l) => l.set_muted(muted),
AnyLayer::Raster(l) => l.set_muted(muted), AnyLayer::Raster(l) => l.set_muted(muted),
AnyLayer::Text(l) => l.set_muted(muted),
} }
} }
@ -1038,6 +1056,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.soloed(), AnyLayer::Effect(l) => l.soloed(),
AnyLayer::Group(l) => l.soloed(), AnyLayer::Group(l) => l.soloed(),
AnyLayer::Raster(l) => l.soloed(), AnyLayer::Raster(l) => l.soloed(),
AnyLayer::Text(l) => l.soloed(),
} }
} }
@ -1049,6 +1068,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_soloed(soloed), AnyLayer::Effect(l) => l.set_soloed(soloed),
AnyLayer::Group(l) => l.set_soloed(soloed), AnyLayer::Group(l) => l.set_soloed(soloed),
AnyLayer::Raster(l) => l.set_soloed(soloed), AnyLayer::Raster(l) => l.set_soloed(soloed),
AnyLayer::Text(l) => l.set_soloed(soloed),
} }
} }
@ -1060,6 +1080,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.locked(), AnyLayer::Effect(l) => l.locked(),
AnyLayer::Group(l) => l.locked(), AnyLayer::Group(l) => l.locked(),
AnyLayer::Raster(l) => l.locked(), AnyLayer::Raster(l) => l.locked(),
AnyLayer::Text(l) => l.locked(),
} }
} }
@ -1071,6 +1092,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_locked(locked), AnyLayer::Effect(l) => l.set_locked(locked),
AnyLayer::Group(l) => l.set_locked(locked), AnyLayer::Group(l) => l.set_locked(locked),
AnyLayer::Raster(l) => l.set_locked(locked), AnyLayer::Raster(l) => l.set_locked(locked),
AnyLayer::Text(l) => l.set_locked(locked),
} }
} }
} }
@ -1085,6 +1107,7 @@ impl AnyLayer {
AnyLayer::Effect(l) => &l.layer, AnyLayer::Effect(l) => &l.layer,
AnyLayer::Group(l) => &l.layer, AnyLayer::Group(l) => &l.layer,
AnyLayer::Raster(l) => &l.layer, AnyLayer::Raster(l) => &l.layer,
AnyLayer::Text(l) => &l.layer,
} }
} }
@ -1097,6 +1120,7 @@ impl AnyLayer {
AnyLayer::Effect(l) => &mut l.layer, AnyLayer::Effect(l) => &mut l.layer,
AnyLayer::Group(l) => &mut l.layer, AnyLayer::Group(l) => &mut l.layer,
AnyLayer::Raster(l) => &mut l.layer, AnyLayer::Raster(l) => &mut l.layer,
AnyLayer::Text(l) => &mut l.layer,
} }
} }

View File

@ -54,6 +54,8 @@ pub mod svg_export;
pub mod snap; pub mod snap;
pub mod webcam; pub mod webcam;
pub mod raster_layer; pub mod raster_layer;
pub mod text_layer;
pub mod fonts;
pub mod raster_store; pub mod raster_store;
pub mod brush_settings; pub mod brush_settings;
pub mod brush_engine; pub mod brush_engine;

View File

@ -698,6 +698,11 @@ pub fn render_layer_isolated(
}; };
} }
} }
AnyLayer::Text(text_layer) => {
// Text composites as vector geometry (glyphs in the Vello scene).
rendered.has_content =
render_text_layer_to_scene(text_layer, time, &mut rendered.scene, base_transform);
}
} }
rendered rendered
@ -757,6 +762,57 @@ fn render_raster_layer_to_scene(
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
} }
/// Render a text layer's glyphs into a Vello scene.
///
/// Text is laid out with parley (wrapped to the box width) and drawn via
/// `Scene::draw_glyphs`. The box origin offsets the whole layout; `base_transform`
/// carries the layer/clip-instance transform and camera. Returns whether anything
/// was drawn.
fn render_text_layer_to_scene(
layer: &crate::text_layer::TextLayer,
time: f64,
scene: &mut Scene,
base_transform: Affine,
) -> bool {
let content = layer.content_at(time);
if content.text.is_empty() {
return false;
}
let color = vello::peniko::Color::new(content.color);
let origin = Affine::translate((layer.box_origin.x, layer.box_origin.y));
let mut drew = false;
crate::fonts::with_layout(content, layer.box_width as f32, |layout| {
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let synthesis = run.synthesis();
let glyph_xform = synthesis
.skew()
.map(|angle| Affine::skew((angle as f64).to_radians().tan(), 0.0));
drew = true;
scene
.draw_glyphs(font)
.font_size(font_size)
.brush(color)
.transform(base_transform * origin)
.glyph_transform(glyph_xform)
.draw(
Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
});
drew
}
// ============================================================================ // ============================================================================
// Legacy Single-Scene Rendering (kept for backwards compatibility) // Legacy Single-Scene Rendering (kept for backwards compatibility)
// ============================================================================ // ============================================================================
@ -884,6 +940,11 @@ fn render_layer(
if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; } if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; }
render_raster_layer_to_scene(raster_layer, time, scene, base_transform); render_raster_layer_to_scene(raster_layer, time, scene, base_transform);
} }
AnyLayer::Text(text_layer) => {
// Text is non-video content — force the Vello fallback if extracting.
if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; }
render_text_layer_to_scene(text_layer, time, scene, base_transform);
}
} }
} }
@ -1900,7 +1961,9 @@ fn render_vector_content_cpu(
render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache); render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache);
} }
} }
AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) => {} // Text is not rendered in the tiny-skia CPU fallback (GPU path only) for v1.
AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_)
| AnyLayer::Text(_) => {}
} }
} }

View File

@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph};
use kurbo::{BezPath, PathEl, Rect, Shape}; use kurbo::{BezPath, PathEl, Rect, Shape};
/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`. /// Serialize the document's **vector** content to a standalone SVG string, at document time `time`.
/// Vector layers (and groups of them) only — raster/video/audio/effect layers are skipped (a later /// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/
/// pass can rasterize them to `<image>`). Animation is a single static frame at `time`. /// effect layers are skipped (a later pass can rasterize them to `<image>`). Animation is a single
/// static frame at `time`.
pub fn document_to_svg(document: &Document, time: f64) -> String { pub fn document_to_svg(document: &Document, time: f64) -> String {
let (w, h) = (document.width, document.height); let (w, h) = (document.width, document.height);
let mut defs = String::new(); let mut defs = String::new();
@ -300,11 +301,136 @@ fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut Str
body.push_str("</g>"); body.push_str("</g>");
} }
} }
AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body),
// Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass.
_ => {} _ => {}
} }
} }
/// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string.
///
/// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each
/// point into document space: `x = gx + px + skew·py`, `y = gy py` (Y flips, `skew` applies any
/// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position.
struct SvgOutlinePen<'a> {
gx: f64,
gy: f64,
skew: f64,
d: &'a mut String,
}
impl<'a> SvgOutlinePen<'a> {
fn map(&self, px: f32, py: f32) -> (f64, f64) {
let (px, py) = (px as f64, py as f64);
(self.gx + px + self.skew * py, self.gy - py)
}
}
impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("M{x:.2} {y:.2}"));
}
fn line_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("L{x:.2} {y:.2}"));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let (cx, cy) = self.map(cx, cy);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}"));
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
let (c0x, c0y) = self.map(c0x, c0y);
let (c1x, c1y) = self.map(c1x, c1y);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}"));
}
fn close(&mut self) {
self.d.push('Z');
}
}
/// Append a text layer's glyphs to `body` as a single filled `<path>` of real glyph outlines
/// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the
/// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and
/// synthetic-italic skew are honored; synthetic bold is not (rare).
fn text_layer_to_svg(
tl: &crate::text_layer::TextLayer,
time: f64,
parent_opacity: f64,
body: &mut String,
) {
use skrifa::MetadataProvider;
if !tl.layer.visible {
return;
}
let content = tl.content_at(time);
if content.text.is_empty() {
return;
}
let (ox, oy) = (tl.box_origin.x, tl.box_origin.y);
let mut d = String::new();
crate::fonts::with_layout(content, tl.box_width as f32, |layout| {
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let skew = run
.synthesis()
.skew()
.map(|angle| (angle as f64).to_radians().tan())
.unwrap_or(0.0);
let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else {
continue;
};
let outlines = font_ref.outline_glyphs();
// Variable-font axis position for this run (empty for static fonts).
let coords: Vec<skrifa::instance::NormalizedCoord> = run
.normalized_coords()
.iter()
.map(|&c| skrifa::instance::NormalizedCoord::from_bits(c))
.collect();
let location = skrifa::instance::LocationRef::new(&coords);
let size = skrifa::instance::Size::new(font_size);
for g in glyph_run.positioned_glyphs() {
let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else {
continue;
};
let mut pen = SvgOutlinePen {
gx: ox + g.x as f64,
gy: oy + g.y as f64,
skew,
d: &mut d,
};
let settings = skrifa::outline::DrawSettings::unhinted(size, location);
let _ = glyph.draw(settings, &mut pen);
}
}
}
});
if d.is_empty() {
return;
}
let [r, g, b, a] = content.color;
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0);
body.push_str(&format!(
r#"<path fill="rgb({},{},{})" fill-opacity="{:.4}" fill-rule="nonzero" d="{}"/>"#,
to_u8(r), to_u8(g), to_u8(b), fill_opacity, d
));
}
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`, /// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`,
/// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform). /// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform).
fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) { fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) {
@ -488,4 +614,58 @@ mod export_tests {
// 1 fill path + 3 stroked edges = 4 <path> elements. // 1 fill path + 3 stroked edges = 4 <path> elements.
assert_eq!(body.matches("<path").count(), 4, "{body}"); assert_eq!(body.matches("<path").count(), 4, "{body}");
} }
#[test]
fn outline_pen_maps_yflip_and_skew() {
use skrifa::outline::OutlinePen;
let mut d = String::new();
{
let mut pen = SvgOutlinePen { gx: 10.0, gy: 100.0, skew: 0.0, d: &mut d };
pen.move_to(0.0, 0.0); // baseline origin → (10, 100)
pen.line_to(5.0, 20.0); // 20 up → y = 100 20 = 80
pen.close();
}
assert!(d.contains("M10.00 100.00"), "d={d}");
assert!(d.contains("L15.00 80.00"), "d={d}");
assert!(d.ends_with('Z'));
// Synthetic-italic skew shifts x right in proportion to height.
let mut d2 = String::new();
{
let mut pen = SvgOutlinePen { gx: 0.0, gy: 0.0, skew: 0.5, d: &mut d2 };
pen.move_to(0.0, 10.0); // x = 0 + 0.5·10 = 5, y = 10
}
assert!(d2.contains("M5.00 -10.00"), "d={d2}");
}
#[test]
fn text_layer_emits_real_glyph_outlines() {
use crate::text_layer::TextLayer;
let mut tl = TextLayer::new("t", Point::new(20.0, 60.0));
tl.content.text = "Hi".to_string();
tl.content.font_size = 48.0;
tl.content.color = [1.0, 0.0, 0.0, 1.0];
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
// Bundled fonts guarantee glyphs → a filled path with actual outline segments.
assert!(body.contains("<path"), "no path emitted: {body}");
assert!(body.contains(r#"fill="rgb(255,0,0)""#), "wrong fill: {body}");
assert!(
body.contains('C') || body.contains('Q') || body.contains('L'),
"path has no outline segments: {body}"
);
assert!(body.len() > 80, "suspiciously short path: {body}");
}
#[test]
fn empty_text_layer_emits_nothing() {
use crate::text_layer::TextLayer;
let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
assert!(body.is_empty(), "empty text should emit nothing: {body}");
}
} }

View File

@ -0,0 +1,145 @@
//! Text layer for Lightningbeam
//!
//! A text layer holds a single editable text field inside a resizable box,
//! with editable size, color, and font. Text is rendered as vector glyphs
//! (via parley + Vello) so it composites through the same path as vector art.
//!
//! The text/style fields are grouped in [`TextContent`] so they can move to a
//! per-keyframe model later without touching call sites; v1 stores a single
//! static instance and reads it through [`TextLayer::content_at`].
use crate::layer::{Layer, LayerTrait, LayerType};
use kurbo::Point;
use serde::{Deserialize, Serialize};
use std::hash::{Hash, Hasher};
use uuid::Uuid;
/// Horizontal alignment of text within the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextAlign {
Left,
Center,
Right,
Justify,
}
impl Default for TextAlign {
fn default() -> Self {
TextAlign::Left
}
}
/// The text content + styling for a text layer.
///
/// Grouped as its own struct so a future keyframed model can store
/// `Vec<TextKeyframe>` of these without changing the layer's public shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextContent {
/// The text string.
pub text: String,
/// Font size in pixels (document space).
pub font_size: f64,
/// Fill color, linear RGBA in 0..=1.
pub color: [f32; 4],
/// Logical font family name. Empty string = the bundled default font.
pub font_family: String,
/// Horizontal alignment within the box.
pub align: TextAlign,
}
impl Default for TextContent {
fn default() -> Self {
Self {
text: String::new(),
font_size: 48.0,
color: [1.0, 1.0, 1.0, 1.0],
font_family: String::new(),
align: TextAlign::Left,
}
}
}
impl TextContent {
/// A stable hash of everything that affects shaped layout (everything except
/// `color`, which only affects the brush, not glyph positions). Used to key
/// the renderer's external parley-layout cache, including the wrap width.
pub fn layout_hash(&self, box_width: f64) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
self.text.hash(&mut h);
self.font_size.to_bits().hash(&mut h);
self.font_family.hash(&mut h);
self.align.hash(&mut h);
box_width.to_bits().hash(&mut h);
h.finish()
}
}
impl Hash for TextAlign {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
/// A text layer: a single resizable text box.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextLayer {
/// Base layer properties (id, name, opacity, visibility, animation data, …).
pub layer: Layer,
/// Top-left of the text box, in layer/clip-local space.
pub box_origin: Point,
/// Box width in document units (drives text wrapping).
pub box_width: f64,
/// Box height in document units.
pub box_height: f64,
/// The text content + styling (single static instance for v1).
pub content: TextContent,
}
impl TextLayer {
/// Default text-box dimensions for a freshly created layer.
pub const DEFAULT_WIDTH: f64 = 300.0;
pub const DEFAULT_HEIGHT: f64 = 100.0;
/// Create a new, empty text layer with the given name, positioned at `origin`.
pub fn new(name: impl Into<String>, origin: Point) -> Self {
Self {
layer: Layer::new(LayerType::Text, name),
box_origin: origin,
box_width: Self::DEFAULT_WIDTH,
box_height: Self::DEFAULT_HEIGHT,
content: TextContent::default(),
}
}
/// Read the active text content at `time`. v1 is static, so `time` is unused;
/// a future keyframed model will select the active keyframe here.
pub fn content_at(&self, _time: f64) -> &TextContent {
&self.content
}
/// Mutable access to the active text content at `time`.
pub fn content_at_mut(&mut self, _time: f64) -> &mut TextContent {
&mut self.content
}
}
// Delegate all LayerTrait methods to self.layer (mirrors RasterLayer).
impl LayerTrait for TextLayer {
fn id(&self) -> Uuid { self.layer.id }
fn name(&self) -> &str { &self.layer.name }
fn set_name(&mut self, name: String) { self.layer.name = name; }
fn has_custom_name(&self) -> bool { self.layer.has_custom_name }
fn set_has_custom_name(&mut self, custom: bool) { self.layer.has_custom_name = custom; }
fn visible(&self) -> bool { self.layer.visible }
fn set_visible(&mut self, visible: bool) { self.layer.visible = visible; }
fn opacity(&self) -> f64 { self.layer.opacity }
fn set_opacity(&mut self, opacity: f64) { self.layer.opacity = opacity; }
fn volume(&self) -> f64 { self.layer.volume }
fn set_volume(&mut self, volume: f64) { self.layer.volume = volume; }
fn muted(&self) -> bool { self.layer.muted }
fn set_muted(&mut self, muted: bool) { self.layer.muted = muted; }
fn soloed(&self) -> bool { self.layer.soloed }
fn set_soloed(&mut self, soloed: bool) { self.layer.soloed = soloed; }
fn locked(&self) -> bool { self.layer.locked }
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
}

View File

@ -381,7 +381,9 @@ impl Tool {
use crate::layer::LayerType; use crate::layer::LayerType;
match layer_type { match layer_type {
None | Some(LayerType::Vector) => Tool::all(), None | Some(LayerType::Vector) => Tool::all(),
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], // The Text tool is available on audio/video/raster too: clicking the
// stage with it there creates a new top-level text layer.
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split, Tool::Text],
Some(LayerType::Raster) => &[ Some(LayerType::Raster) => &[
// Brush tools // Brush tools
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush, Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
@ -397,9 +399,10 @@ impl Tool {
// Transform // Transform
Tool::Transform, Tool::Warp, Tool::Liquify, Tool::Transform, Tool::Warp, Tool::Liquify,
// Utility // Utility
Tool::Eyedropper, Tool::Eyedropper, Tool::Text,
], ],
_ => &[Tool::Select], Some(LayerType::Text) => &[Tool::Select, Tool::Text, Tool::Transform],
_ => &[Tool::Select, Tool::Text],
} }
} }

View File

@ -351,6 +351,29 @@ impl VectorGraph {
} }
} }
/// Free any vertex no longer referenced by a non-deleted edge (e.g. after deleting a shape's
/// edges), so stale vertices don't linger as snap targets.
pub fn gc_isolated_vertices(&mut self) {
let mut referenced = vec![false; self.vertices.len()];
for e in &self.edges {
if e.deleted {
continue;
}
for v in e.vertices.iter() {
if !v.is_none() {
referenced[v.idx()] = true;
}
}
}
let to_free: Vec<VertexId> = (0..self.vertices.len())
.filter(|&i| !self.vertices[i].deleted && !referenced[i])
.map(|i| VertexId(i as u32))
.collect();
for vid in to_free {
self.free_vertex(vid);
}
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Fill / hit-test queries // Fill / hit-test queries
// ------------------------------------------------------------------- // -------------------------------------------------------------------

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.7-alpha" version = "1.0.8-alpha"
edition = "2021" edition = "2021"
description = "Multimedia editor for audio, video and 2D animation" description = "Multimedia editor for audio, video and 2D animation"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@ -41,6 +41,10 @@ serde_json = { workspace = true }
# Image loading # Image loading
image = { workspace = true } image = { workspace = true }
# Animated GIF encoding — used directly (not just via `image`) so per-frame NeuQuant palette
# quantization can be parallelized across a worker pool. Pinned to the version `image` already
# resolves (0.13), so this adds no new transitive graph.
gif = "0.13"
resvg = { workspace = true } resvg = { workspace = true }
tiny-skia = "0.11" tiny-skia = "0.11"

View File

@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -99,6 +99,14 @@
--font-size-small: 11px; --font-size-small: 11px;
--font-size-default: 13px; --font-size-default: 13px;
--pane-border-width: 1px; --pane-border-width: 1px;
/* Modal backdrop (translucent) + intent/category accents (shared by light & dark) */
--scrim: #10141ab0;
--accent-coral: #e8826b;
--accent-cyan: #54c3e8;
--accent-amber: #f4a340;
--accent-pink: #c75b8a;
--accent-violet: #8a6ec0;
} }
/* ============================================ /* ============================================

View File

@ -70,6 +70,14 @@ pub struct AppConfig {
/// sooner; larger = smaller pyramid, wider re-decode span. Default 256. /// sooner; larger = smaller pyramid, wider re-decode span. Default 256.
#[serde(default = "defaults::waveform_floor_samples_per_texel")] #[serde(default = "defaults::waveform_floor_samples_per_texel")]
pub waveform_floor_samples_per_texel: u32, pub waveform_floor_samples_per_texel: u32,
/// Last-used audio-export "Artist" tag, remembered so it prefills next time.
#[serde(default)]
pub last_audio_artist: String,
/// Last-used audio-export "Album" tag, remembered so it prefills next time.
#[serde(default)]
pub last_audio_album: String,
} }
impl Default for AppConfig { impl Default for AppConfig {
@ -90,6 +98,8 @@ impl Default for AppConfig {
keybindings: KeybindingConfig::default(), keybindings: KeybindingConfig::default(),
large_media_default: LargeMediaMode::default(), large_media_default: LargeMediaMode::default(),
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
last_audio_artist: String::new(),
last_audio_album: String::new(),
} }
} }
} }

View File

@ -1,475 +0,0 @@
#![allow(dead_code)]
//! Audio export functionality
//!
//! Exports audio from the timeline to various formats:
//! - WAV and FLAC: Use existing DAW backend export
//! - MP3 and AAC: Use FFmpeg encoding with rendered samples
use lightningbeam_core::export::{AudioExportSettings, AudioFormat};
use daw_backend::audio::{
export::{ExportFormat, ExportSettings as DawExportSettings, render_to_memory},
midi_pool::MidiClipPool,
pool::AudioPool,
project::Project,
};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Export audio to a file
///
/// This function routes to the appropriate export method based on the format:
/// - WAV/FLAC: Use DAW backend export
/// - MP3/AAC: Use FFmpeg encoding (TODO)
pub fn export_audio<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
// Validate settings
settings.validate()?;
// Check for cancellation before starting
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled by user".to_string());
}
match settings.format {
AudioFormat::Wav | AudioFormat::Flac => {
export_audio_daw_backend(project, pool, midi_pool, settings, output_path)
}
AudioFormat::Mp3 => {
export_audio_ffmpeg_mp3(project, pool, midi_pool, settings, output_path, cancel_flag)
}
AudioFormat::Aac => {
export_audio_ffmpeg_aac(project, pool, midi_pool, settings, output_path, cancel_flag)
}
}
}
/// Export audio using the DAW backend (WAV/FLAC)
fn export_audio_daw_backend<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
) -> Result<(), String> {
// Convert our export settings to DAW backend format
let daw_settings = DawExportSettings {
format: match settings.format {
AudioFormat::Wav => ExportFormat::Wav,
AudioFormat::Flac => ExportFormat::Flac,
_ => unreachable!(), // This function only handles WAV/FLAC
},
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: settings.bit_depth,
mp3_bitrate: 320, // Not used for WAV/FLAC
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Use the existing DAW backend export function
// No progress reporting for this direct export path
daw_backend::audio::export::export_audio(
project,
pool,
&daw_settings,
output_path,
None,
)
}
/// Export audio as MP3 using FFmpeg
fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
// Initialize FFmpeg
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Convert settings to DAW backend format
let daw_settings = DawExportSettings {
format: ExportFormat::Wav, // Unused, but required
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory
let pcm_samples = render_to_memory(
project,
pool,
&daw_settings,
None, // No progress events for now
)?;
// Check for cancellation
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
// Step 2: Set up FFmpeg encoder
let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MP3)
.ok_or("MP3 encoder (libmp3lame) not found")?;
// Create output file
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
// Create encoder
let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
// Configure encoder
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
encoder.set_format(ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar));
encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
// Open encoder
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open MP3 encoder: {}", e))?;
// Add stream and set parameters
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
} // Drop stream here to release the borrow
// Write header
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Step 3: Encode frames and write to output
// Convert interleaved f32 samples to planar i16 format
let num_frames = pcm_samples.len() / settings.channels as usize;
let planar_samples = convert_to_planar_i16(&pcm_samples, settings.channels);
// Get encoder frame size
let frame_size = encoder.frame_size();
let samples_per_frame = if frame_size > 0 {
frame_size as usize
} else {
1152 // Default MP3 frame size
};
// Encode in chunks
let mut samples_encoded = 0;
while samples_encoded < num_frames {
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
let samples_remaining = num_frames - samples_encoded;
let chunk_size = samples_remaining.min(samples_per_frame);
// Create audio frame
let mut frame = ffmpeg::frame::Audio::new(
ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar),
chunk_size,
channel_layout,
);
frame.set_rate(settings.sample_rate);
// Copy planar samples to frame
// Use plane_mut::<i16> instead of data_mut — data_mut(ch) is buggy for planar audio:
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
// plane_mut uses self.samples() for the length, which is correct for all planes.
for ch in 0..settings.channels as usize {
let plane = frame.plane_mut::<i16>(ch);
let offset = samples_encoded;
plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]);
}
// Send frame to encoder
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(&mut encoder, &mut output)?;
samples_encoded += chunk_size;
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
// Write trailer
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
/// Convert interleaved f32 samples to planar i16 format
fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec<Vec<i16>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0i16; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
// Clamp and convert f32 (-1.0 to 1.0) to i16
let clamped = sample.max(-1.0).min(1.0);
planar[ch][i] = (clamped * 32767.0) as i16;
}
}
planar
}
/// Convert interleaved f32 samples to planar f32 format
fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec<Vec<f32>> {
let num_frames = interleaved.len() / channels as usize;
let mut planar = vec![vec![0.0f32; num_frames]; channels as usize];
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
for (ch, &sample) in chunk.iter().enumerate() {
planar[ch][i] = sample;
}
}
planar
}
/// Receive encoded packets and write to output
fn receive_and_write_packets(
encoder: &mut ffmpeg_next::encoder::Audio,
output: &mut ffmpeg_next::format::context::Output,
) -> Result<(), String> {
let mut encoded = ffmpeg_next::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
encoded.set_stream(0);
encoded.write_interleaved(output)
.map_err(|e| format!("Failed to write packet: {}", e))?;
}
Ok(())
}
/// Export audio as AAC using FFmpeg
fn export_audio_ffmpeg_aac<P: AsRef<Path>>(
project: &mut Project,
pool: &AudioPool,
_midi_pool: &MidiClipPool,
settings: &AudioExportSettings,
output_path: P,
cancel_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
// Initialize FFmpeg
ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?;
// Convert settings to DAW backend format
let daw_settings = DawExportSettings {
format: ExportFormat::Wav, // Unused, but required
sample_rate: settings.sample_rate,
channels: settings.channels,
bit_depth: 16, // Unused
mp3_bitrate: settings.bitrate_kbps,
start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm),
};
// Step 1: Render audio to memory
let pcm_samples = render_to_memory(
project,
pool,
&daw_settings,
None, // No progress events for now
)?;
// Check for cancellation
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
// Step 2: Set up FFmpeg encoder
let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::AAC)
.ok_or("AAC encoder not found")?;
// Create output file
let mut output = ffmpeg::format::output(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
// Create encoder
let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec)
.encoder()
.audio()
.map_err(|e| format!("Failed to create encoder: {}", e))?;
// Configure encoder
let channel_layout = match settings.channels {
1 => ffmpeg::channel_layout::ChannelLayout::MONO,
2 => ffmpeg::channel_layout::ChannelLayout::STEREO,
_ => return Err(format!("Unsupported channel count: {}", settings.channels)),
};
encoder.set_rate(settings.sample_rate as i32);
encoder.set_channel_layout(channel_layout);
// AAC encoder supports FLTP (F32 Planar) format
encoder.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar));
encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize);
encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32));
// Open encoder
let mut encoder = encoder.open_as(encoder_codec)
.map_err(|e| format!("Failed to open AAC encoder: {}", e))?;
// Add stream and set parameters
{
let mut stream = output.add_stream(encoder_codec)
.map_err(|e| format!("Failed to add stream: {}", e))?;
stream.set_parameters(&encoder);
} // Drop stream here to release the borrow
// Write header
output.write_header()
.map_err(|e| format!("Failed to write header: {}", e))?;
// Step 3: Encode frames and write to output
// Convert interleaved f32 samples to planar f32 format (no conversion needed, just rearrange)
let num_frames = pcm_samples.len() / settings.channels as usize;
let planar_samples = convert_to_planar_f32(&pcm_samples, settings.channels);
// Get encoder frame size
let frame_size = encoder.frame_size();
let samples_per_frame = if frame_size > 0 {
frame_size as usize
} else {
1024 // Default AAC frame size
};
// Encode in chunks
let mut samples_encoded = 0;
while samples_encoded < num_frames {
if cancel_flag.load(Ordering::Relaxed) {
return Err("Export cancelled".to_string());
}
let samples_remaining = num_frames - samples_encoded;
let chunk_size = samples_remaining.min(samples_per_frame);
// Create audio frame
let mut frame = ffmpeg::frame::Audio::new(
ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar),
chunk_size,
channel_layout,
);
frame.set_rate(settings.sample_rate);
// Copy planar samples to frame
unsafe {
for ch in 0..settings.channels as usize {
let plane = frame.data_mut(ch);
let offset = samples_encoded;
let src = &planar_samples[ch][offset..offset + chunk_size];
std::ptr::copy_nonoverlapping(
src.as_ptr() as *const u8,
plane.as_mut_ptr(),
chunk_size * std::mem::size_of::<f32>(),
);
}
}
// Send frame to encoder
encoder.send_frame(&frame)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(&mut encoder, &mut output)?;
samples_encoded += chunk_size;
}
// Flush encoder
encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?;
receive_and_write_packets(&mut encoder, &mut output)?;
// Write trailer
output.write_trailer()
.map_err(|e| format!("Failed to write trailer: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_export_audio_validation() {
let mut settings = AudioExportSettings::default();
settings.sample_rate = 0; // Invalid
let project = Project::new(44100);
let pool = AudioPool::new();
let midi_pool = MidiClipPool::new();
let cancel_flag = Arc::new(AtomicBool::new(false));
let result = export_audio(
&mut project.clone(),
&pool,
&midi_pool,
&settings,
"/tmp/test.wav",
&cancel_flag,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Sample rate"));
}
#[test]
fn test_export_audio_cancellation() {
let settings = AudioExportSettings::default();
let mut project = Project::new(44100);
let pool = AudioPool::new();
let midi_pool = MidiClipPool::new();
let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled
let result = export_audio(
&mut project,
&pool,
&midi_pool,
&settings,
"/tmp/test.wav",
&cancel_flag,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cancelled"));
}
}

View File

@ -101,6 +101,94 @@ impl CpuYuvConverter {
} }
} }
/// CPU RGBA→YUV422P10LE converter (10-bit, 4:2:2) via swscale, for ProRes 422 export.
///
/// ProRes (`prores_ks`) requires a 10-bit 4:2:2 input; the SDR pipeline otherwise produces 8-bit
/// 4:2:0. Source is still 8-bit RGBA (bit-depth is promoted, not conjured), which is normal for
/// SDR ProRes. BT.709 with the requested range, matching the encoder's color tags.
pub struct CpuYuv422P10Converter {
width: u32,
height: u32,
scaler: ffmpeg::software::scaling::Context,
rgba_frame: ffmpeg::frame::Video,
yuv_frame: ffmpeg::frame::Video,
}
impl CpuYuv422P10Converter {
pub fn new(width: u32, height: u32, full_range: bool) -> Result<Self, String> {
let mut scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA, width, height,
ffmpeg::format::Pixel::YUV422P10LE, width, height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create YUV422P10 swscale context: {}", e))?;
// BT.709, requested output range (matches setup_video_encoder's SDR tags). No safe
// ffmpeg-next wrapper for sws_setColorspaceDetails, so this is the raw call (as in
// CpuYuvConverter::new above).
unsafe {
let coeffs = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709 as i32);
let dst_range = if full_range { 1 } else { 0 };
let one = 1 << 16;
ffmpeg::ffi::sws_setColorspaceDetails(
scaler.as_mut_ptr(),
coeffs, 1,
coeffs, dst_range,
0, one, one,
);
}
let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV422P10LE, width, height);
Ok(Self { width, height, scaler, rgba_frame, yuv_frame })
}
/// Convert packed RGBA (width*height*4) to tight YUV422P10LE planes (little-endian, 2 bytes per
/// sample): Y is width×height, U and V are (width/2)×height. Planes are returned tight (stride
/// padding stripped) to match what `encode_frame` expects.
pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
let expected = (self.width * self.height * 4) as usize;
assert_eq!(rgba_data.len(), expected,
"RGBA data size mismatch: expected {} bytes, got {}", expected, rgba_data.len());
// Copy RGBA into the source frame honoring its stride (may be padded).
let row_bytes = (self.width * 4) as usize;
let src_stride = self.rgba_frame.stride(0);
{
let dst = self.rgba_frame.data_mut(0);
for row in 0..self.height as usize {
let s = row * row_bytes;
let d = row * src_stride;
dst[d..d + row_bytes].copy_from_slice(&rgba_data[s..s + row_bytes]);
}
}
self.scaler
.run(&self.rgba_frame, &mut self.yuv_frame)
.map_err(|e| format!("YUV422P10 swscale conversion failed: {}", e))?;
// Extract each plane tight (2 bytes/sample). Y: width samples/row × height rows.
// Chroma (4:2:2): width/2 samples/row × height rows.
let extract = |frame: &ffmpeg::frame::Video, idx: usize, samples_w: usize, rows: usize| {
let bytes_per_row = samples_w * 2;
let stride = frame.stride(idx);
let data = frame.data(idx);
let mut out = Vec::with_capacity(bytes_per_row * rows);
for row in 0..rows {
let start = row * stride;
out.extend_from_slice(&data[start..start + bytes_per_row]);
}
out
};
let (w, h) = (self.width as usize, self.height as usize);
let y_plane = extract(&self.yuv_frame, 0, w, h);
let u_plane = extract(&self.yuv_frame, 1, w / 2, h);
let v_plane = extract(&self.yuv_frame, 2, w / 2, h);
Ok((y_plane, u_plane, v_plane))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -131,6 +219,20 @@ mod tests {
assert_eq!(v.len(), (1920 / 2) * (1080 / 2)); assert_eq!(v.len(), (1920 / 2) * (1080 / 2));
} }
#[test]
fn test_yuv422p10_output_sizes() {
// Use a width that forces swscale linesize padding (not a multiple of 32/64) to exercise
// the stride-stripping extraction.
let (w, h) = (1000u32, 720u32);
let mut c = CpuYuv422P10Converter::new(w, h, false).unwrap();
let rgba = vec![0u8; (w * h * 4) as usize];
let (y, u, v) = c.convert(&rgba).unwrap();
// 10-bit → 2 bytes/sample. Y full res; U/V half width, full height (4:2:2).
assert_eq!(y.len(), (w * h * 2) as usize);
assert_eq!(u.len(), ((w / 2) * h * 2) as usize);
assert_eq!(v.len(), ((w / 2) * h * 2) as usize);
}
#[test] #[test]
#[should_panic(expected = "RGBA data size mismatch")] #[should_panic(expected = "RGBA data size mismatch")]
fn test_wrong_input_size_panics() { fn test_wrong_input_size_panics() {

View File

@ -5,11 +5,42 @@
use eframe::egui; use eframe::egui;
use lightningbeam_core::export::{ use lightningbeam_core::export::{
AudioExportSettings, AudioFormat, AudioExportSettings, AudioFormat,
GifExportSettings,
ImageExportSettings, ImageFormat, ImageExportSettings, ImageFormat,
VideoExportSettings, VideoCodec, VideoQuality, ColorRange, VideoExportSettings, VideoCodec, VideoQuality, ColorRange,
}; };
use std::path::PathBuf; use std::path::PathBuf;
/// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag.
fn os_username() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default()
}
/// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate.
fn current_year() -> i64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
year_from_unix_secs(secs)
}
/// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm.
fn year_from_unix_secs(secs: i64) -> i64 {
let days = secs.div_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
y + if m <= 2 { 1 } else { 0 }
}
/// Hint about document content, used to pick a smart default export type. /// Hint about document content, used to pick a smart default export type.
pub struct DocumentHint { pub struct DocumentHint {
pub has_video: bool, pub has_video: bool,
@ -25,6 +56,8 @@ pub enum ExportType {
Audio, Audio,
Image, Image,
Video, Video,
/// Animated GIF (multi-frame, palette-quantized, no audio).
Gif,
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped). /// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
Svg, Svg,
} }
@ -36,6 +69,8 @@ pub enum ExportResult {
Image(ImageExportSettings, PathBuf), Image(ImageExportSettings, PathBuf),
VideoOnly(VideoExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf),
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
/// Animated GIF export.
Gif(GifExportSettings, PathBuf),
/// SVG of vector layers at the given document time. /// SVG of vector layers at the given document time.
Svg(f64, PathBuf), Svg(f64, PathBuf),
} }
@ -57,6 +92,9 @@ pub struct ExportDialog {
/// Video export settings /// Video export settings
pub video_settings: VideoExportSettings, pub video_settings: VideoExportSettings,
/// Animated GIF export settings
pub gif_settings: GifExportSettings,
/// Include audio with video? /// Include audio with video?
pub include_audio: bool, pub include_audio: bool,
@ -104,6 +142,7 @@ impl Default for ExportDialog {
audio_settings: AudioExportSettings::standard_mp3(), audio_settings: AudioExportSettings::standard_mp3(),
image_settings: ImageExportSettings::default(), image_settings: ImageExportSettings::default(),
video_settings: VideoExportSettings::default(), video_settings: VideoExportSettings::default(),
gif_settings: GifExportSettings::default(),
include_audio: true, include_audio: true,
output_path: None, output_path: None,
error_message: None, error_message: None,
@ -120,10 +159,18 @@ impl Default for ExportDialog {
impl ExportDialog { impl ExportDialog {
/// Open the dialog with default settings, using `hint` to pick a smart default tab. /// Open the dialog with default settings, using `hint` to pick a smart default tab.
pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { pub fn open(
&mut self,
timeline_duration: f64,
project_name: &str,
hint: &DocumentHint,
last_artist: &str,
last_album: &str,
) {
self.open = true; self.open = true;
self.audio_settings.end_time = timeline_duration; self.audio_settings.end_time = timeline_duration;
self.video_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration;
self.gif_settings.end_time = timeline_duration;
self.image_settings.time = hint.current_time; self.image_settings.time = hint.current_time;
// Propagate document dimensions as defaults (None means "use doc size"). // Propagate document dimensions as defaults (None means "use doc size").
self.image_settings.width = None; self.image_settings.width = None;
@ -143,6 +190,24 @@ impl ExportDialog {
else if only_raster { ExportType::Image } else if only_raster { ExportType::Image }
else { self.export_type } // keep current as fallback else { self.export_type } // keep current as fallback
}; };
// Sensible tag defaults, only filled when empty so a user's edits are never clobbered:
// • Title → project name (on a project switch)
// • Year → current year
// • Artist → last-used artist, else the OS username
// • Album → last-used album
let meta = &mut self.audio_settings.metadata;
if meta.title.is_empty() && !same_project {
meta.title = project_name.to_owned();
}
if meta.year.is_empty() {
meta.year = current_year().to_string();
}
if meta.artist.is_empty() {
meta.artist = if !last_artist.is_empty() { last_artist.to_owned() } else { os_username() };
}
if meta.album.is_empty() && !last_album.is_empty() {
meta.album = last_album.to_owned();
}
self.current_project = project_name.to_owned(); self.current_project = project_name.to_owned();
// Restore the last exported path if available; otherwise default to project name. // Restore the last exported path if available; otherwise default to project name.
@ -160,6 +225,7 @@ impl ExportDialog {
ExportType::Audio => self.audio_settings.format.extension(), ExportType::Audio => self.audio_settings.format.extension(),
ExportType::Image => self.image_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(),
ExportType::Video => self.video_settings.codec.container_format(), ExportType::Video => self.video_settings.codec.container_format(),
ExportType::Gif => "gif",
ExportType::Svg => "svg", ExportType::Svg => "svg",
} }
} }
@ -203,12 +269,13 @@ impl ExportDialog {
ExportType::Audio => "Export Audio", ExportType::Audio => "Export Audio",
ExportType::Image => "Export Image", ExportType::Image => "Export Image",
ExportType::Video => "Export Video", ExportType::Video => "Export Video",
ExportType::Gif => "Export GIF",
ExportType::Svg => "Export SVG", ExportType::Svg => "Export SVG",
}; };
let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal")) let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal"))
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(500.0); ui.set_width(crate::mobile::dialog_width(ctx, 500.0));
ui.heading(window_title); ui.heading(window_title);
ui.add_space(8.0); ui.add_space(8.0);
@ -225,6 +292,7 @@ impl ExportDialog {
(ExportType::Audio, "Audio"), (ExportType::Audio, "Audio"),
(ExportType::Image, "Image"), (ExportType::Image, "Image"),
(ExportType::Video, "Video"), (ExportType::Video, "Video"),
(ExportType::Gif, "GIF"),
(ExportType::Svg, "SVG"), (ExportType::Svg, "SVG"),
] { ] {
if ui.selectable_value(&mut self.export_type, variant, label).clicked() { if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
@ -242,6 +310,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_basic(ui), ExportType::Audio => self.render_audio_basic(ui),
ExportType::Image => self.render_image_settings(ui), ExportType::Image => self.render_image_settings(ui),
ExportType::Video => self.render_video_basic(ui), ExportType::Video => self.render_video_basic(ui),
ExportType::Gif => self.render_gif_basic(ui),
ExportType::Svg => self.render_svg_settings(ui), ExportType::Svg => self.render_svg_settings(ui),
} }
@ -261,6 +330,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_advanced(ui), ExportType::Audio => self.render_audio_advanced(ui),
ExportType::Image => self.render_image_advanced(ui), ExportType::Image => self.render_image_advanced(ui),
ExportType::Video => self.render_video_advanced(ui), ExportType::Video => self.render_video_advanced(ui),
ExportType::Gif => self.render_gif_advanced(ui),
ExportType::Svg => {} // SVG has no advanced settings ExportType::Svg => {} // SVG has no advanced settings
} }
} }
@ -460,10 +530,50 @@ impl ExportDialog {
ui.add_space(8.0); ui.add_space(8.0);
// Tag metadata (ID3 / MP4 / Vorbis / RIFF-INFO depending on format).
self.render_audio_metadata(ui);
ui.add_space(8.0);
// Time range // Time range
self.render_time_range(ui); self.render_time_range(ui);
} }
/// Render the audio tag-metadata fields (title/artist/album/…). Written into the file on export.
fn render_audio_metadata(&mut self, ui: &mut egui::Ui) {
let m = &mut self.audio_settings.metadata;
ui.label(egui::RichText::new("Tags").strong());
// Placeholder styling: italic + a clearly faded color so an empty field's hint never reads
// as a real value (the theme's default weak color is too close to the text color). An
// explicit color overrides egui's weak-color fallback for hint text.
let hint_color = {
let t = ui.visuals().text_color();
egui::Color32::from_rgba_unmultiplied(t.r(), t.g(), t.b(), 100)
};
let year_hint = format!("e.g. {}", current_year());
egui::Grid::new("audio_metadata_grid")
.num_columns(2)
.spacing([8.0, 4.0])
.show(ui, |ui| {
let row = |ui: &mut egui::Ui, label: &str, val: &mut String, hint: &str| {
ui.label(label);
ui.add(
egui::TextEdit::singleline(val)
.hint_text(egui::RichText::new(hint).italics().color(hint_color))
.desired_width(260.0),
);
ui.end_row();
};
row(ui, "Title", &mut m.title, "e.g. My Song");
row(ui, "Artist", &mut m.artist, "e.g. Jane Doe");
row(ui, "Album", &mut m.album, "e.g. Greatest Hits");
row(ui, "Genre", &mut m.genre, "e.g. Electronic");
row(ui, "Year", &mut m.year, &year_hint);
row(ui, "Track", &mut m.track, "e.g. 1 or 1/12");
row(ui, "Comment", &mut m.comment, "Optional notes…");
});
}
/// Video presets: (name, codec, quality, width, height, fps) /// Video presets: (name, codec, quality, width, height, fps)
const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[ const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[
("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0), ("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),
@ -614,12 +724,65 @@ impl ExportDialog {
self.render_time_range(ui); self.render_time_range(ui);
} }
/// GIF frame-rate presets (fps). GIF delays are centisecond-quantized, so these map to clean
/// per-frame delays (10/15/20/25/50 fps → 100/70/50/40/20 ms after rounding).
const GIF_FPS: &'static [f64] = &[10.0, 15.0, 20.0, 25.0, 50.0];
/// Render basic GIF settings (frame rate + loop).
fn render_gif_basic(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Frame rate:");
egui::ComboBox::from_id_salt("gif_fps")
.selected_text(format!("{} fps", self.gif_settings.framerate as u32))
.show_ui(ui, |ui| {
for &fps in Self::GIF_FPS {
ui.selectable_value(&mut self.gif_settings.framerate, fps, format!("{} fps", fps as u32));
}
});
});
ui.checkbox(&mut self.gif_settings.loop_forever, "Loop forever");
ui.add_space(8.0);
self.render_time_range(ui);
}
/// Render advanced GIF settings (resolution, fit, transparency).
fn render_gif_advanced(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Size:");
let mut w = self.gif_settings.width.unwrap_or(0);
let mut h = self.gif_settings.height.unwrap_or(0);
let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed();
let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed();
if changed_w { self.gif_settings.width = if w == 0 { None } else { Some(w) }; }
if changed_h { self.gif_settings.height = if h == 0 { None } else { Some(h) }; }
ui.weak("(0 = document size)");
});
ui.horizontal(|ui| {
use lightningbeam_core::export::ExportFitMode;
ui.label("Fit:");
egui::ComboBox::from_id_salt("gif_fit_mode")
.selected_text(self.gif_settings.fit.name())
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name());
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name());
ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name());
});
});
ui.checkbox(&mut self.gif_settings.transparency, "Preserve transparency (1-bit)");
ui.label(egui::RichText::new("GIF supports only on/off transparency; semi-transparent pixels are keyed out.").weak().small());
}
/// Render time range UI (common to both audio and video) /// Render time range UI (common to both audio and video)
fn render_time_range(&mut self, ui: &mut egui::Ui) { fn render_time_range(&mut self, ui: &mut egui::Ui) {
let (start_time, end_time) = match self.export_type { let (start_time, end_time) = match self.export_type {
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time),
ExportType::Image | ExportType::Svg => return, // single time field, not a range ExportType::Image | ExportType::Svg => return, // single time field, not a range
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
ExportType::Gif => (&mut self.gif_settings.start_time, &mut self.gif_settings.end_time),
}; };
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -693,6 +856,13 @@ impl ExportDialog {
Some(ExportResult::Image(self.image_settings.clone(), output_path)) Some(ExportResult::Image(self.image_settings.clone(), output_path))
} }
ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)), ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)),
ExportType::Gif => {
if let Err(err) = self.gif_settings.validate() {
self.error_message = Some(err);
return None;
}
Some(ExportResult::Gif(self.gif_settings.clone(), output_path))
}
ExportType::Audio => { ExportType::Audio => {
// Validate audio settings // Validate audio settings
if let Err(err) = self.audio_settings.validate() { if let Err(err) = self.audio_settings.validate() {
@ -801,7 +971,7 @@ impl ExportProgressDialog {
egui::Modal::new(egui::Id::new("export_progress_modal")) egui::Modal::new(egui::Id::new("export_progress_modal"))
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(400.0); ui.set_width(crate::mobile::dialog_width(ctx, 400.0));
ui.heading("Exporting..."); ui.heading("Exporting...");
ui.add_space(8.0); ui.add_space(8.0);
@ -861,3 +1031,30 @@ impl ExportProgressDialog {
should_cancel should_cancel
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn year_from_unix_secs_known_values() {
assert_eq!(year_from_unix_secs(0), 1970); // Unix epoch
assert_eq!(year_from_unix_secs(946_684_800), 2000); // 2000-01-01
assert_eq!(year_from_unix_secs(1_735_689_600), 2025); // 2025-01-01
assert_eq!(year_from_unix_secs(1_767_225_599), 2025); // 2025-12-31 23:59:59
assert_eq!(year_from_unix_secs(1_767_225_600), 2026); // 2026-01-01
// Post-2038: these timestamps exceed i32::MAX (2_147_483_647) — and the last exceeds
// u32::MAX — so a 32-bit time_t would wrap here. i64 math handles them correctly.
assert_eq!(year_from_unix_secs(2_148_595_200), 2038); // 2038-02-01 (> i32::MAX)
assert_eq!(year_from_unix_secs(2_223_331_200), 2040); // 2040-06-15
assert_eq!(year_from_unix_secs(4_102_444_800), 2100); // 2100-01-01 (not a leap year)
assert_eq!(year_from_unix_secs(9_214_646_400), 2262); // 2262-01-01 (> u32::MAX)
}
#[test]
fn current_year_is_plausible() {
let y = current_year();
assert!((2020..3000).contains(&y), "implausible year: {y}");
}
}

View File

@ -0,0 +1,196 @@
//! Animated GIF encoding.
//!
//! Palette-quantizes a stream of RGBA8 frames and writes them to a `.gif`. The expensive part —
//! per-frame NeuQuant 256-color quantization — is embarrassingly parallel (each frame gets its own
//! local palette), so it's fanned out across a worker pool. A single writer thread collects the
//! quantized frames, reorders them, and LZW-encodes them to the file in sequence.
//!
//! Pipeline (all off the UI thread):
//! ```text
//! UI render thread ──RGBA──▶ coordinator ──round-robin──▶ N quantizer workers
//! │ (idx, gif::Frame)
//! ▼
//! writer thread ──▶ .gif
//! ```
//! Rendering + readback happen on the UI thread (see `render_next_gif_frame`); this module owns
//! everything after a raw RGBA frame arrives.
use lightningbeam_core::export::ExportProgress;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::Arc;
/// Message from the UI (render) thread to the GIF encoder coordinator.
pub enum GifFrameMessage {
/// One RGBA8 frame (top-left origin, tightly packed `width*height*4` bytes).
Frame { frame_num: usize, pixels: Vec<u8> },
/// All frames have been sent.
Done,
}
/// gif crate quantization speed (1 = slowest/best, 30 = fastest/worst). 10 balances palette quality
/// against per-frame cost; the parallelism below is what actually recovers the wall-clock.
const QUANT_SPEED: i32 = 10;
/// Run the GIF encoder pipeline. Receives RGBA8 frames from `frame_rx`, quantizes them in parallel,
/// and writes the ordered result to `output_path`, reporting progress. `transparency == false`
/// composites each frame onto opaque black first (GIF's 1-bit transparency would otherwise key out
/// semi-transparent pixels).
#[allow(clippy::too_many_arguments)]
pub fn run_gif_encoder(
frame_rx: Receiver<GifFrameMessage>,
output_path: PathBuf,
width: u32,
height: u32,
total_frames: usize,
delay_ms: u32,
loop_forever: bool,
transparency: bool,
progress_tx: Sender<ExportProgress>,
cancel_flag: Arc<AtomicBool>,
) {
let _ = progress_tx.send(ExportProgress::Started { total_frames });
let delay_cs = ((delay_ms / 10).max(1)) as u16;
let expected_len = (width as usize) * (height as usize) * 4;
// One quantizer worker per spare core (leave one for the UI render thread), capped so we don't
// spawn absurdly many for short exports.
let n_workers = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1))
.unwrap_or(1)
.clamp(1, 8);
// Per-worker input channels (coordinator dispatches round-robin) + one shared result channel.
let mut worker_txs: Vec<Sender<(usize, Vec<u8>)>> = Vec::with_capacity(n_workers);
let (result_tx, result_rx) = channel::<(usize, gif::Frame<'static>)>();
let mut worker_handles = Vec::with_capacity(n_workers);
for _ in 0..n_workers {
let (wtx, wrx) = channel::<(usize, Vec<u8>)>();
worker_txs.push(wtx);
let result_tx = result_tx.clone();
let cancel = Arc::clone(&cancel_flag);
worker_handles.push(std::thread::spawn(move || {
while let Ok((idx, mut pixels)) = wrx.recv() {
if cancel.load(Ordering::Relaxed) {
break;
}
// NeuQuant local-palette quantization (the expensive step). `from_rgba_speed` uses
// the RGBA buffer as scratch, so it's fine that we own `pixels` here.
let mut frame =
gif::Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, QUANT_SPEED);
frame.delay = delay_cs;
if result_tx.send((idx, frame)).is_err() {
break; // writer gone
}
}
}));
}
drop(result_tx); // only the workers hold senders now; writer's rx ends when they all finish
// Writer thread: order frames by index and LZW-encode them sequentially.
let writer_progress = progress_tx.clone();
let writer_cancel = Arc::clone(&cancel_flag);
let writer_output = output_path.clone();
let writer = std::thread::spawn(move || -> Result<(), String> {
let file = std::fs::File::create(&writer_output)
.map_err(|e| format!("Failed to create GIF file: {e}"))?;
let mut buf = std::io::BufWriter::new(file);
let mut encoder = gif::Encoder::new(&mut buf, width as u16, height as u16, &[])
.map_err(|e| format!("GIF encoder init failed: {e}"))?;
if loop_forever {
encoder
.set_repeat(gif::Repeat::Infinite)
.map_err(|e| format!("GIF set_repeat failed: {e}"))?;
}
// Frames may arrive out of order; hold stragglers until their turn.
let mut pending: HashMap<usize, gif::Frame<'static>> = HashMap::new();
let mut next = 0usize;
let mut written = 0usize;
while let Ok((idx, frame)) = result_rx.recv() {
if writer_cancel.load(Ordering::Relaxed) {
break;
}
pending.insert(idx, frame);
while let Some(f) = pending.remove(&next) {
encoder
.write_frame(&f)
.map_err(|e| format!("GIF write_frame failed: {e}"))?;
next += 1;
written += 1;
let _ = writer_progress.send(ExportProgress::FrameRendered {
frame: written,
total: total_frames,
});
}
}
// Encoder/BufWriter flush on drop.
Ok(())
});
// Coordinator: pull RGBA frames from the UI thread and dispatch round-robin to the workers.
let mut dispatched = 0usize;
let mut fatal: Option<String> = None;
loop {
match frame_rx.recv() {
Ok(GifFrameMessage::Frame { frame_num, mut pixels }) => {
if cancel_flag.load(Ordering::Relaxed) {
break;
}
if pixels.len() != expected_len {
fatal = Some("GIF frame size mismatch".into());
break;
}
if !transparency {
// Premultiply onto opaque black, then force alpha opaque.
for px in pixels.chunks_exact_mut(4) {
let a = px[3] as u32;
px[0] = (px[0] as u32 * a / 255) as u8;
px[1] = (px[1] as u32 * a / 255) as u8;
px[2] = (px[2] as u32 * a / 255) as u8;
px[3] = 255;
}
}
let w = dispatched % n_workers;
if worker_txs[w].send((frame_num, pixels)).is_err() {
fatal = Some("GIF quantizer worker died".into());
break;
}
dispatched += 1;
}
Ok(GifFrameMessage::Done) => break,
Err(_) => break, // UI thread dropped the sender
}
}
let _ = progress_tx.send(ExportProgress::Finalizing);
// Close worker inputs → workers finish → their result senders drop → writer's loop ends.
drop(worker_txs);
for h in worker_handles {
let _ = h.join();
}
let writer_result = writer.join().unwrap_or_else(|_| Err("GIF writer thread panicked".into()));
if cancel_flag.load(Ordering::Relaxed) {
std::fs::remove_file(&output_path).ok();
// Emit Complete so the UI poll loop clears its state; the dialog was closed on cancel.
let _ = progress_tx.send(ExportProgress::Complete { output_path });
return;
}
match fatal.or_else(|| writer_result.err()) {
Some(message) => {
std::fs::remove_file(&output_path).ok();
let _ = progress_tx.send(ExportProgress::Error { message });
}
None => {
let _ = progress_tx.send(ExportProgress::Complete { output_path });
}
}
}

View File

@ -175,7 +175,9 @@ impl GpuYuv {
} }
/// CPU reference for the exact math/layout the shader produces — used by unit tests so /// CPU reference for the exact math/layout the shader produces — used by unit tests so
/// the packing and BT.709 coefficients stay verifiable without a GPU. /// the packing and BT.709 coefficients stay verifiable without a GPU. Test-only, so it isn't
/// compiled into (and flagged as unused by) release builds.
#[cfg(test)]
fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec<u8> { fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec<u8> {
let w = width as usize; let w = width as usize;
let h = height as usize; let h = height as usize;

View File

@ -45,13 +45,179 @@ pub fn save_rgba_image(
encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}")) encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}"))
} }
ImageFormat::WebP => { ImageFormat::WebP => {
if allow_transparency { // `image` 0.25's WebP encoder is lossless-only, which ignored the quality slider and
img.save(path).map_err(|e| format!("WebP save failed: {e}")) // produced needlessly large files. Encode lossy WebP via ffmpeg's libwebp instead so
} else { // the quality control is real; alpha is preserved (as YUVA420P) when requested.
let flat = flatten_alpha(img); save_webp_ffmpeg(pixels, width, height, quality, allow_transparency, path)
flat.save(path).map_err(|e| format!("WebP save failed: {e}")) }
}
}
/// Encode a single frame as lossy WebP via ffmpeg's `libwebp` encoder.
///
/// `quality` is libwebp's 0100 quality factor. When `allow_transparency` is true the source is
/// converted to YUVA420P so libwebp keeps the alpha channel; otherwise it's flattened onto black
/// and converted to YUV420P. Uses swscale's default BT.601 conversion (matching a plain
/// `ffmpeg -i in.png out.webp`).
fn save_webp_ffmpeg(
pixels: &[u8],
width: u32,
height: u32,
quality: u8,
allow_transparency: bool,
path: &Path,
) -> Result<(), String> {
use ffmpeg_next as ffmpeg;
ffmpeg::init().map_err(|e| format!("Failed to initialize ffmpeg: {e}"))?;
let codec = ffmpeg::encoder::find_by_name("libwebp")
.or_else(|| ffmpeg::encoder::find(ffmpeg::codec::Id::WEBP))
.ok_or("libwebp encoder not available in this ffmpeg build")?;
// Flatten onto black up front when alpha isn't wanted, so the source is fully opaque.
let src_rgba: Vec<u8> = if allow_transparency {
pixels.to_vec()
} else {
let mut v = pixels.to_vec();
for px in v.chunks_exact_mut(4) {
let a = px[3] as u32;
px[0] = (px[0] as u32 * a / 255) as u8;
px[1] = (px[1] as u32 * a / 255) as u8;
px[2] = (px[2] as u32 * a / 255) as u8;
px[3] = 255;
}
v
};
let dst_pix = if allow_transparency {
ffmpeg::format::Pixel::YUVA420P
} else {
ffmpeg::format::Pixel::YUV420P
};
// RGBA → YUV(A)420P (swscale defaults: BT.601, limited range — what libwebp expects).
let mut scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA, width, height,
dst_pix, width, height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create swscale context: {e}"))?;
let mut src = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
// Copy row-by-row honoring the frame's stride (may exceed width*4 due to alignment padding).
let stride = src.stride(0);
let row_bytes = (width * 4) as usize;
{
let dst = src.data_mut(0);
for y in 0..height as usize {
let s = y * row_bytes;
let d = y * stride;
dst[d..d + row_bytes].copy_from_slice(&src_rgba[s..s + row_bytes]);
}
}
let mut yuv = ffmpeg::frame::Video::new(dst_pix, width, height);
scaler.run(&src, &mut yuv).map_err(|e| format!("swscale conversion failed: {e}"))?;
yuv.set_pts(Some(0));
let mut octx = ffmpeg::format::output(&path)
.map_err(|e| format!("Failed to create WebP output: {e}"))?;
let mut enc = ffmpeg::codec::Context::new_with_codec(codec)
.encoder()
.video()
.map_err(|e| format!("Failed to create WebP encoder: {e}"))?;
enc.set_width(width);
enc.set_height(height);
enc.set_format(dst_pix);
enc.set_time_base(ffmpeg::Rational(1, 1));
// libwebp private options: quality 0100, lossy.
let mut opts = ffmpeg::Dictionary::new();
opts.set("quality", &quality.to_string());
opts.set("lossless", "0");
let mut enc = enc
.open_with(opts)
.map_err(|e| format!("Failed to open libwebp encoder: {e}"))?;
{
let mut stream = octx.add_stream(codec)
.map_err(|e| format!("Failed to add WebP stream: {e}"))?;
stream.set_parameters(&enc);
stream.set_time_base(ffmpeg::Rational(1, 1));
}
octx.write_header().map_err(|e| format!("Failed to write WebP header: {e}"))?;
enc.send_frame(&yuv).map_err(|e| format!("Failed to send WebP frame: {e}"))?;
enc.send_eof().map_err(|e| format!("Failed to flush WebP encoder: {e}"))?;
let mut packet = ffmpeg::Packet::empty();
while enc.receive_packet(&mut packet).is_ok() {
packet.set_stream(0);
packet
.write_interleaved(&mut octx)
.map_err(|e| format!("Failed to write WebP packet: {e}"))?;
}
octx.write_trailer().map_err(|e| format!("Failed to finalize WebP: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use lightningbeam_core::export::ImageFormat;
/// A gradient RGBA image so the encoder has real content to quantize/compress.
fn gradient(width: u32, height: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
px.push((x * 255 / width.max(1)) as u8);
px.push((y * 255 / height.max(1)) as u8);
px.push(128);
px.push(255);
} }
} }
px
}
/// The ffmpeg libwebp path must produce a valid *lossy* WebP (RIFF/WEBP container with a
/// `VP8 ` chunk — lossless would be `VP8L`), and the quality knob must actually change size.
#[test]
fn webp_export_is_real_lossy() {
let (w, h) = (96u32, 64u32);
let px = gradient(w, h);
let dir = std::env::temp_dir();
let lo = dir.join("lb_webp_q10_test.webp");
let hi = dir.join("lb_webp_q95_test.webp");
save_webp_ffmpeg(&px, w, h, 10, false, &lo).expect("low-quality webp encode");
save_webp_ffmpeg(&px, w, h, 95, false, &hi).expect("high-quality webp encode");
let lo_bytes = std::fs::read(&lo).unwrap();
let hi_bytes = std::fs::read(&hi).unwrap();
// RIFF....WEBP container.
assert_eq!(&lo_bytes[0..4], b"RIFF", "not a RIFF container");
assert_eq!(&lo_bytes[8..12], b"WEBP", "not a WEBP file");
// Lossy VP8 chunk (`VP8 ` with trailing space), NOT lossless `VP8L`.
assert_eq!(&lo_bytes[12..16], b"VP8 ", "expected lossy VP8, got {:?}", &lo_bytes[12..16]);
// The quality knob is honored: q10 is meaningfully smaller than q95.
assert!(lo_bytes.len() < hi_bytes.len(),
"quality ignored: q10 {} bytes >= q95 {} bytes", lo_bytes.len(), hi_bytes.len());
std::fs::remove_file(&lo).ok();
std::fs::remove_file(&hi).ok();
}
/// The format enum still advertises a quality control for WebP (now that it works).
#[test]
fn webp_has_quality() {
assert!(ImageFormat::WebP.has_quality());
assert!(ImageFormat::Jpeg.has_quality());
assert!(!ImageFormat::Png.has_quality());
} }
} }

View File

@ -3,8 +3,8 @@
//! This module provides the export orchestrator and progress tracking //! This module provides the export orchestrator and progress tracking
//! for exporting audio and video from the timeline. //! for exporting audio and video from the timeline.
pub mod audio_exporter;
pub mod dialog; pub mod dialog;
pub mod gif_exporter;
pub mod image_exporter; pub mod image_exporter;
pub mod video_exporter; pub mod video_exporter;
pub mod readback_pipeline; pub mod readback_pipeline;
@ -13,7 +13,8 @@ pub mod cpu_yuv_converter;
pub mod gpu_yuv; pub mod gpu_yuv;
pub mod hdr_frame; pub mod hdr_frame;
use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; use lightningbeam_core::export::{AudioExportSettings, GifExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress};
use gif_exporter::GifFrameMessage;
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::renderer::ImageCache;
use lightningbeam_core::video::VideoManager; use lightningbeam_core::video::VideoManager;
@ -68,6 +69,11 @@ pub struct VideoExportState {
readback_pipeline: Option<readback_pipeline::ReadbackPipeline>, readback_pipeline: Option<readback_pipeline::ReadbackPipeline>,
/// CPU YUV converter for RGBA→YUV420p conversion /// CPU YUV converter for RGBA→YUV420p conversion
cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>, cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>,
/// ProRes 422 export: forces the CPU (RGBA) readback path and converts to 10-bit 4:2:2 instead
/// of 8-bit 4:2:0. `true` only for `VideoCodec::ProRes422` (SDR).
prores: bool,
/// CPU RGBA→YUV422P10LE converter, used only on the ProRes path.
cpu_yuv422p10: Option<cpu_yuv_converter::CpuYuv422P10Converter>,
/// Frames that have been submitted to GPU but not yet encoded /// Frames that have been submitted to GPU but not yet encoded
frames_in_flight: usize, frames_in_flight: usize,
/// Next frame number to send to encoder (for ordering) /// Next frame number to send to encoder (for ordering)
@ -131,6 +137,38 @@ pub struct ExportOrchestrator {
/// Single-frame image export state /// Single-frame image export state
image_state: Option<ImageExportState>, image_state: Option<ImageExportState>,
/// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`).
gif_state: Option<GifExportState>,
}
/// State for an in-progress animated GIF export. Frames are rendered + read back on the UI thread
/// (one per `render_next_gif_frame` call) and streamed to the encoder thread over `frame_tx`.
struct GifExportState {
/// Resolved pixel dimensions (after applying any width/height overrides).
width: u32,
height: u32,
/// Total frames to render.
total_frames: usize,
/// Next frame index to render (0-based).
next_frame: usize,
/// Document time (seconds) of frame 0.
start_time: f64,
/// Seconds between frames (1 / framerate).
frame_step: f64,
/// How the document is fit into the export frame.
fit: lightningbeam_core::export::ExportFitMode,
/// Preserve alpha as GIF transparency (else the encoder flattens onto black).
transparency: bool,
/// GPU resources allocated on the first render call, reused each frame.
gpu_resources: Option<video_exporter::ExportGpuResources>,
/// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues).
output_texture: Option<wgpu::Texture>,
output_texture_view: Option<wgpu::TextureView>,
/// Staging buffer for synchronous GPU→CPU readback (reused each frame).
staging_buffer: Option<wgpu::Buffer>,
/// Sender to the encoder thread; dropped after the final frame to signal completion.
frame_tx: Option<Sender<GifFrameMessage>>,
} }
/// State for parallel audio+video export /// State for parallel audio+video export
@ -168,6 +206,7 @@ impl ExportOrchestrator {
video_state: None, video_state: None,
parallel_export: None, parallel_export: None,
image_state: None, image_state: None,
gif_state: None,
} }
} }
@ -250,7 +289,7 @@ impl ExportOrchestrator {
/// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every /// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every
/// repaint forever after an export finishes. /// repaint forever after an export finishes.
pub fn has_pending_progress(&self) -> bool { pub fn has_pending_progress(&self) -> bool {
self.parallel_export.is_some() || self.image_state.is_some() || self.progress_rx.is_some() self.parallel_export.is_some() || self.image_state.is_some() || self.gif_state.is_some() || self.progress_rx.is_some()
} }
/// Poll progress for parallel video+audio export /// Poll progress for parallel video+audio export
@ -534,6 +573,9 @@ impl ExportOrchestrator {
} }
self.video_state = None; self.video_state = None;
self.image_state = None; self.image_state = None;
// Dropping gif_state drops its frame sender, unblocking the encoder thread's recv() so it
// observes the cancel flag, removes the partial file, and exits.
self.gif_state = None;
self.progress_rx = None; self.progress_rx = None;
self.thread_handle = None; self.thread_handle = None;
} }
@ -542,6 +584,7 @@ impl ExportOrchestrator {
pub fn is_exporting(&self) -> bool { pub fn is_exporting(&self) -> bool {
if self.parallel_export.is_some() { return true; } if self.parallel_export.is_some() { return true; }
if self.image_state.is_some() { return true; } if self.image_state.is_some() { return true; }
if self.gif_state.is_some() { return true; }
if let Some(handle) = &self.thread_handle { if let Some(handle) = &self.thread_handle {
!handle.is_finished() !handle.is_finished()
} else { } else {
@ -719,6 +762,196 @@ impl ExportOrchestrator {
result.map(|_| true) result.map(|_| true)
} }
/// Enqueue an animated GIF export. Spawns the encoder thread now; call `render_next_gif_frame()`
/// from the egui update loop (where the wgpu device/queue are available) to render + stream each
/// frame to it.
pub fn start_gif_export(
&mut self,
settings: GifExportSettings,
output_path: PathBuf,
doc_width: u32,
doc_height: u32,
) {
self.cancel_flag.store(false, Ordering::Relaxed);
let width = settings.width.unwrap_or(doc_width).max(1);
let height = settings.height.unwrap_or(doc_height).max(1);
let total_frames = settings.total_frames();
let delay_ms = settings.frame_delay_ms();
let frame_step = 1.0 / settings.framerate;
let (progress_tx, progress_rx) = channel();
let (frame_tx, frame_rx) = channel();
self.progress_rx = Some(progress_rx);
let cancel_flag = Arc::clone(&self.cancel_flag);
let loop_forever = settings.loop_forever;
let transparency = settings.transparency;
let handle = std::thread::spawn(move || {
gif_exporter::run_gif_encoder(
frame_rx, output_path, width, height, total_frames, delay_ms,
loop_forever, transparency, progress_tx, cancel_flag,
);
});
self.thread_handle = Some(handle);
self.gif_state = Some(GifExportState {
width,
height,
total_frames,
next_frame: 0,
start_time: settings.start_time,
frame_step,
fit: settings.fit,
transparency,
gpu_resources: None,
output_texture: None,
output_texture_view: None,
staging_buffer: None,
frame_tx: Some(frame_tx),
});
}
/// Drive the animated GIF export: render + read back one frame per call and stream it to the
/// encoder thread. Returns `Ok(true)` while more frames remain (call again next egui frame),
/// `Ok(false)` once every frame has been sent (encoding then finishes on the background thread).
pub fn render_next_gif_frame(
&mut self,
document: &mut Document,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
image_cache: &mut ImageCache,
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
) -> Result<bool, String> {
if self.cancel_flag.load(Ordering::Relaxed) {
// Dropping frame_tx unblocks the encoder thread's recv() so it can clean up.
self.gif_state = None;
return Ok(false);
}
let state = match self.gif_state.as_mut() {
Some(s) => s,
None => return Ok(false),
};
// All frames sent → drop the sender (signals the encoder to finalize) and finish.
if state.next_frame >= state.total_frames {
if let Some(tx) = state.frame_tx.take() {
let _ = tx.send(GifFrameMessage::Done);
drop(tx);
}
self.gif_state = None;
return Ok(false);
}
let w = state.width;
let h = state.height;
let fit = state.fit;
let timestamp = state.start_time + state.next_frame as f64 * state.frame_step;
if state.gpu_resources.is_none() {
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h));
}
if state.output_texture.is_none() {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("gif_export_output"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default()));
state.output_texture = Some(tex);
}
// Render the frame (transparency preserved through readback; the encoder flattens if needed).
{
let gpu = state.gpu_resources.as_mut().unwrap();
let output_view = state.output_texture_view.as_ref().unwrap();
let encoder = video_exporter::render_frame_to_gpu_rgba(
document,
timestamp,
w, h,
device, queue, renderer, image_cache, video_manager,
gpu,
output_view,
None, // no floating raster selection during export
state.transparency,
raster_store,
true, // GIF export composites on the shared device
fit,
)?;
queue.submit(Some(encoder.finish()));
}
// Synchronous readback (wgpu requires bytes_per_row aligned to 256).
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes_per_row = (w * 4 + align - 1) / align * align;
if state.staging_buffer.is_none() {
state.staging_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("gif_export_staging"),
size: (bytes_per_row * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}));
}
let staging = state.staging_buffer.as_ref().unwrap();
let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gif_export_copy"),
});
let output_tex = state.output_texture.as_ref().unwrap();
copy_enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: output_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
queue.submit(Some(copy_enc.finish()));
let slice = staging.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
let _ = device.poll(wgpu::PollType::wait_indefinitely());
let pixels: Vec<u8> = {
let mapped = slice.get_mapped_range();
let mut out = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h {
let start = (row * bytes_per_row) as usize;
out.extend_from_slice(&mapped[start..start + (w * 4) as usize]);
}
out
};
staging.unmap();
let frame_num = state.next_frame;
if let Some(tx) = state.frame_tx.as_ref() {
// If the encoder thread died, stop — its dropped receiver returns an error here.
if tx.send(GifFrameMessage::Frame { frame_num, pixels }).is_err() {
self.gif_state = None;
return Ok(false);
}
}
state.next_frame += 1;
Ok(true)
}
/// Wait for the export to complete /// Wait for the export to complete
/// ///
/// This blocks until the export thread finishes. /// This blocks until the export thread finishes.
@ -774,6 +1007,12 @@ impl ExportOrchestrator {
start_time: daw_backend::Seconds(settings.start_time), start_time: daw_backend::Seconds(settings.start_time),
end_time: daw_backend::Seconds(settings.end_time), end_time: daw_backend::Seconds(settings.end_time),
tempo_map: daw_backend::TempoMap::constant(settings.bpm), tempo_map: daw_backend::TempoMap::constant(settings.bpm),
metadata: settings
.metadata
.pairs()
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}; };
// Use DAW backend export for all formats // Use DAW backend export for all formats
@ -872,6 +1111,8 @@ impl ExportOrchestrator {
let hdr = settings.hdr; let hdr = settings.hdr;
let fit = settings.fit; let fit = settings.fit;
let full_range = settings.color_range.is_full(); let full_range = settings.color_range.is_full();
let prores = matches!(settings.codec, lightningbeam_core::export::VideoCodec::ProRes422)
&& !hdr.is_hdr();
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
}); });
@ -891,6 +1132,8 @@ impl ExportOrchestrator {
gpu_resources: None, gpu_resources: None,
readback_pipeline: None, readback_pipeline: None,
cpu_yuv_converter: None, cpu_yuv_converter: None,
prores,
cpu_yuv422p10: None,
frames_in_flight: 0, frames_in_flight: 0,
next_frame_to_encode: 0, next_frame_to_encode: 0,
perf_metrics: Some(perf_metrics::ExportMetrics::new()), perf_metrics: Some(perf_metrics::ExportMetrics::new()),
@ -1121,7 +1364,10 @@ impl ExportOrchestrator {
.unwrap() .unwrap()
.as_secs(); .as_secs();
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.mp4", timestamp)); // Use the codec's real container for the temp video, not a hardcoded .mp4 — VP8 isn't a
// valid MP4 codec, so an .mp4 temp made `write_header` fail for any VP8+audio export.
let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.{}",
timestamp, video_settings.codec.container_format()));
let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}", let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}",
timestamp, timestamp,
match audio_settings.format { match audio_settings.format {
@ -1331,24 +1577,34 @@ impl ExportOrchestrator {
// Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize // Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize
// padding) — then the packed GPU planes copy in without row misalignment. // padding) — then the packed GPU planes copy in without row misalignment.
// Otherwise fall back to RGBA readback + CPU swscale. // Otherwise fall back to RGBA readback + CPU swscale.
let gpu_yuv_tight = std::env::var("LB_DISABLE_GPU_YUV").is_err() && { // ProRes needs 10-bit 4:2:2 (built on the CPU from the RGBA readback), so it forces the
// RGBA path — the GPU YUV converter only produces 8-bit 4:2:0.
let gpu_yuv_tight = !state.prores && std::env::var("LB_DISABLE_GPU_YUV").is_err() && {
let probe = ffmpeg_next::frame::Video::new( let probe = ffmpeg_next::frame::Video::new(
ffmpeg_next::format::Pixel::YUV420P, width, height, ffmpeg_next::format::Pixel::YUV420P, width, height,
); );
probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize
}; };
if !gpu_yuv_tight { if !gpu_yuv_tight && !state.prores {
println!("🎬 [VIDEO EXPORT] YUV planes are padded at {width}x{height}; using CPU YUV path"); println!("🎬 [VIDEO EXPORT] YUV planes are padded at {width}x{height}; using CPU YUV path");
} }
state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight, state.full_range)); state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight, state.full_range));
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?); if state.prores {
state.cpu_yuv422p10 = Some(cpu_yuv_converter::CpuYuv422P10Converter::new(width, height, state.full_range)?);
println!("🎬 [VIDEO EXPORT] ProRes 422: 10-bit 4:2:2 (YUV422P10LE) CPU converter initialized");
} else {
state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?);
}
println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized"); println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized");
println!("🚀 [CPU YUV] swscale converter initialized"); println!("🚀 [CPU YUV] swscale converter initialized");
} }
let pipeline = state.readback_pipeline.as_mut().unwrap(); let pipeline = state.readback_pipeline.as_mut().unwrap();
let gpu_resources = state.gpu_resources.as_mut().unwrap(); let gpu_resources = state.gpu_resources.as_mut().unwrap();
let cpu_converter = state.cpu_yuv_converter.as_mut().unwrap(); // Exactly one of these is present: cpu_yuv422p10 on the ProRes path, cpu_converter on the
// SDR fallback path (or neither is used when the GPU YUV converter is active).
let mut cpu_converter = state.cpu_yuv_converter.as_mut();
let mut cpu_yuv422p10 = state.cpu_yuv422p10.as_mut();
let mut metrics = state.perf_metrics.as_mut(); let mut metrics = state.perf_metrics.as_mut();
// Poll for completed async readbacks (non-blocking) // Poll for completed async readbacks (non-blocking)
@ -1375,12 +1631,17 @@ impl ExportOrchestrator {
let data = pipeline.extract_rgba_data(result.buffer_id); let data = pipeline.extract_rgba_data(result.buffer_id);
let extraction_end = Instant::now(); let extraction_end = Instant::now();
// YUV planes: GPU-converted (just slice) or CPU swscale fallback (timed). // YUV planes: ProRes 10-bit 4:2:2, else GPU-converted (just slice), else CPU
// swscale 8-bit 4:2:0 fallback (timed).
let conversion_start = Instant::now(); let conversion_start = Instant::now();
let (y, u, v) = if pipeline.is_yuv_mode() { let (y, u, v) = if let Some(conv) = cpu_yuv422p10.as_deref_mut() {
conv.convert(&data)?
} else if pipeline.is_yuv_mode() {
pipeline.split_yuv(&data) pipeline.split_yuv(&data)
} else { } else {
cpu_converter.convert(&data)? cpu_converter.as_deref_mut()
.ok_or("SDR export missing its CPU YUV converter")?
.convert(&data)?
}; };
let conversion_end = Instant::now(); let conversion_end = Instant::now();
@ -1469,6 +1730,7 @@ impl ExportOrchestrator {
state.gpu_resources = None; state.gpu_resources = None;
state.readback_pipeline = None; state.readback_pipeline = None;
state.cpu_yuv_converter = None; state.cpu_yuv_converter = None;
state.cpu_yuv422p10 = None;
state.perf_metrics = None; state.perf_metrics = None;
return Ok(false); return Ok(false);
} }
@ -1718,6 +1980,8 @@ impl ExportOrchestrator {
// Pixel format the encoder frames are built in (matches setup_video_encoder). // Pixel format the encoder frames are built in (matches setup_video_encoder).
let pixel_format = if settings.hdr.is_hdr() { let pixel_format = if settings.hdr.is_hdr() {
ffmpeg_next::format::Pixel::YUV420P10LE ffmpeg_next::format::Pixel::YUV420P10LE
} else if matches!(settings.codec, VideoCodec::ProRes422) {
ffmpeg_next::format::Pixel::YUV422P10LE // ProRes 422: 10-bit 4:2:2
} else { } else {
ffmpeg_next::format::Pixel::YUV420P ffmpeg_next::format::Pixel::YUV420P
}; };
@ -1832,8 +2096,17 @@ impl ExportOrchestrator {
// Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have // Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have
// row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size. // row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size.
let ten_bit = matches!(pixel_format, ffmpeg_next::format::Pixel::YUV420P10LE); // Sample size + chroma subsampling depend on the pixel format:
let sample_bytes = if ten_bit { 2usize } else { 1usize }; // YUV420P → 8-bit, 4:2:0 (chroma = w/2 × h/2)
// YUV420P10LE → 10-bit, 4:2:0 (chroma = w/2 × h/2)
// YUV422P10LE → 10-bit, 4:2:2 (chroma = w/2 × h, full-height) [ProRes]
use ffmpeg_next::format::Pixel;
let (sample_bytes, chroma_h_div) = match pixel_format {
Pixel::YUV420P => (1usize, 2usize),
Pixel::YUV420P10LE => (2usize, 2usize),
Pixel::YUV422P10LE => (2usize, 1usize),
_ => (1usize, 2usize),
};
let copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| { let copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| {
let bytes_per_row = w * sample_bytes; let bytes_per_row = w * sample_bytes;
let stride = frame.stride(idx); let stride = frame.stride(idx);
@ -1848,8 +2121,8 @@ impl ExportOrchestrator {
}; };
let (w, h) = (width as usize, height as usize); let (w, h) = (width as usize, height as usize);
copy_plane(&mut video_frame, 0, y_plane, w, h); copy_plane(&mut video_frame, 0, y_plane, w, h);
copy_plane(&mut video_frame, 1, u_plane, w / 2, h / 2); copy_plane(&mut video_frame, 1, u_plane, w / 2, h / chroma_h_div);
copy_plane(&mut video_frame, 2, v_plane, w / 2, h / 2); copy_plane(&mut video_frame, 2, v_plane, w / 2, h / chroma_h_div);
// Set PTS (presentation timestamp) in encoder's time base // Set PTS (presentation timestamp) in encoder's time base
// Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000) // Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000)

View File

@ -616,9 +616,12 @@ pub fn setup_video_encoder(
// Configure encoder parameters BEFORE opening (critical!) // Configure encoder parameters BEFORE opening (critical!)
encoder.set_width(aligned_width); encoder.set_width(aligned_width);
encoder.set_height(aligned_height); encoder.set_height(aligned_height);
// HDR encodes 10-bit BT.2020 (limited range); SDR keeps 8-bit full-range BT.709. // ProRes needs 10-bit 4:2:2; HDR needs 10-bit 4:2:0 BT.2020; other SDR is 8-bit 4:2:0.
let is_prores = codec_id == ffmpeg::codec::Id::PRORES;
if hdr.is_hdr() { if hdr.is_hdr() {
encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE); encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE);
} else if is_prores {
encoder.set_format(ffmpeg::format::Pixel::YUV422P10LE);
} else { } else {
encoder.set_format(ffmpeg::format::Pixel::YUV420P); encoder.set_format(ffmpeg::format::Pixel::YUV420P);
} }
@ -650,6 +653,10 @@ pub fn setup_video_encoder(
}); });
color_opts.set("color_primaries", "bt709"); color_opts.set("color_primaries", "bt709");
color_opts.set("color_trc", "bt709"); color_opts.set("color_trc", "bt709");
if is_prores {
// prores_ks profile: 3 = HQ (4:2:2 10-bit). Matches the YUV422P10LE frames we feed.
color_opts.set("profile", "3");
}
} }
println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}", println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}",
@ -1433,6 +1440,30 @@ mod tests {
assert!(v[0] > 128, "V value: {}", v[0]); assert!(v[0] > 128, "V value: {}", v[0]);
} }
/// ProRes must actually open with the 10-bit 4:2:2 format we now feed it. Before the fix the
/// SDR path handed prores_ks 8-bit YUV420P and `open` failed every time — so this opening
/// successfully is the regression guard for "ProRes export always errored".
#[test]
fn prores_encoder_opens_with_yuv422p10() {
ffmpeg::init().unwrap();
// Skip cleanly if this ffmpeg build lacks a ProRes encoder (rather than false-fail).
if ffmpeg::encoder::find(ffmpeg::codec::Id::PRORES).is_none()
&& ffmpeg::encoder::find_by_name("prores_ks").is_none()
{
eprintln!("prores encoder not present in this ffmpeg build; skipping");
return;
}
let r = setup_video_encoder(
ffmpeg::codec::Id::PRORES,
640, 480, 30.0, 20_000,
lightningbeam_core::export::HdrExportMode::Sdr,
false,
);
assert!(r.is_ok(), "ProRes encoder failed to open: {:?}", r.err());
let (encoder, _codec) = r.unwrap();
assert_eq!(encoder.format(), ffmpeg::format::Pixel::YUV422P10LE);
}
// NOTE: `rgba_to_yuv420p` rounds dimensions up to multiples of 16 (H.264 // NOTE: `rgba_to_yuv420p` rounds dimensions up to multiples of 16 (H.264
// macroblock alignment), so its plane lengths are the aligned sizes, not the // macroblock alignment), so its plane lengths are the aligned sizes, not the
// tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and // tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
//! Lucide icon font (ISC license — see `assets/fonts/LICENSE-lucide.txt`).
#![allow(dead_code)] // a forward-looking icon palette; not all are wired up yet
//!
//! The font is embedded and registered as a named egui font family ("lucide"). Icons are drawn as
//! text using [`font`] for the `FontId`, with the codepoint constants below (Lucide's private-use
//! area). Run [`install`] once at startup.
use std::sync::Arc;
use eframe::egui;
/// The egui font-family name the Lucide glyphs live under.
pub const FAMILY: &str = "lucide";
/// Embed + register the Lucide icon font. Call once with the egui context at startup.
pub fn install(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"lucide".to_owned(),
Arc::new(egui::FontData::from_static(include_bytes!(
"../../assets/fonts/lucide.ttf"
))),
);
fonts
.families
.insert(egui::FontFamily::Name(FAMILY.into()), vec!["lucide".to_owned()]);
ctx.set_fonts(fonts);
}
/// A `FontId` in the Lucide family at `size`.
pub fn font(size: f32) -> egui::FontId {
egui::FontId::new(size, egui::FontFamily::Name(FAMILY.into()))
}
// --- Codepoints (Lucide PUA). Add more as the mobile UI needs them. ---
pub const MAXIMIZE: &str = "\u{e112}";
pub const MINIMIZE: &str = "\u{e11a}";
pub const ARROW_LEFT_RIGHT: &str = "\u{e24a}";
pub const ARROW_RIGHT_FROM_LINE: &str = "\u{e458}"; // output port
pub const ARROW_RIGHT_TO_LINE: &str = "\u{e459}"; // input port
pub const GRIP_HORIZONTAL: &str = "\u{e0ea}";
pub const CHEVRONS_UP: &str = "\u{e074}";
pub const PLAY: &str = "\u{e13c}";
pub const PAUSE: &str = "\u{e12e}";
pub const SETTINGS: &str = "\u{e154}";
pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}";
pub const X: &str = "\u{e1b2}";
pub const MENU: &str = "\u{e115}";
// Intent-picker icons.
pub const BRUSH: &str = "\u{e1d3}";
pub const FILM: &str = "\u{e0d0}";
pub const MUSIC: &str = "\u{e122}";
pub const MIC: &str = "\u{e118}";
pub const CLAPPERBOARD: &str = "\u{e29b}";
pub const SQUARE_DASHED: &str = "\u{e1cb}";
pub const FOLDER_OPEN: &str = "\u{e247}";
// Tool icons (omnibutton).
pub const MOUSE_POINTER_2: &str = "\u{e1c3}";
pub const PENCIL: &str = "\u{e1f9}";
pub const MOVE: &str = "\u{e121}";
pub const VECTOR_SQUARE: &str = "\u{e67c}";
pub const SQUARE: &str = "\u{e167}";
pub const CIRCLE: &str = "\u{e076}";
pub const MINUS: &str = "\u{e11c}";
pub const HEXAGON: &str = "\u{e0f3}";
pub const PAINT_BUCKET: &str = "\u{e2e6}";
pub const PIPETTE: &str = "\u{e13b}";
pub const TYPE: &str = "\u{e198}";
pub const ERASER: &str = "\u{e28f}";
pub const PEN_TOOL: &str = "\u{e131}";
pub const BLEND: &str = "\u{e59c}";
pub const WAND_SPARKLES: &str = "\u{e357}";
pub const LASSO_SELECT: &str = "\u{e1cf}";
pub const SCISSORS: &str = "\u{e14e}";
// Create-menu icons.
pub const AUDIO_WAVEFORM: &str = "\u{e55b}";
pub const PIANO: &str = "\u{e561}";
pub const FOLDER: &str = "\u{e0d7}";
pub const FILE_PLUS: &str = "\u{e0c9}";
pub const COPY: &str = "\u{e09e}";
pub const LAYERS: &str = "\u{e529}";
pub const ELLIPSIS: &str = "\u{e0b6}";

View File

@ -0,0 +1,210 @@
//! Selection inspector bottom sheet. When something is selected/focused it rises above the
//! transport, showing the focused object's properties by reusing `InfopanelPane` full-bleed.
//! Jump-to chips slide the stack window to the related surface; the ✕ deselects.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
use lightningbeam_core::selection::FocusSelection;
use super::{surface, MobileState, Palette, MOBILE_NS};
use crate::panes::{NodePath, SharedPaneState};
use crate::RenderContext;
const GRAB_H: f32 = 16.0;
const HEAD_H: f32 = 44.0;
fn inspector_path() -> NodePath {
vec![MOBILE_NS, 200]
}
/// Whether anything is selected/focused (i.e. the inspector should be shown).
pub fn is_active(shared: &SharedPaneState) -> bool {
!shared.focus.is_none() || !shared.selection.is_empty()
}
/// A cheap content-sensitive signature of the current selection, so the shell can detect when the
/// selection *changes* (to re-show a manually-dismissed inspector for the new thing).
pub fn selection_sig(shared: &SharedPaneState) -> u64 {
let mut h: u64 = 0;
for id in shared.selection.clip_instances() {
h ^= (id.as_u128() as u64).wrapping_mul(0x9E3779B97F4A7C15);
}
for f in shared.selection.selected_fills() {
h ^= (f.idx() as u64).wrapping_mul(0xD1B54A32D192ED03);
}
for e in shared.selection.selected_edges() {
h ^= (e.idx() as u64).wrapping_mul(0xA24BAED4963EE407);
}
h
}
/// The stack slot (see `super::STACK`) where the current selection lives, so we can tell if the
/// sheet would cover it. Geometry/selection lives on the Stage; clips/layers on the Timeline; etc.
#[allow(dead_code)] // selection→pane mapping; kept for reflow/jump heuristics
pub fn target_slot(shared: &SharedPaneState) -> usize {
match &*shared.focus {
FocusSelection::Notes { .. } => 4, // PianoRoll
FocusSelection::Nodes(_) => 5, // Node/Instrument
FocusSelection::Assets(_) => 1, // Asset Library
FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline
FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage
}
}
/// A short title describing what's selected.
fn title(shared: &SharedPaneState) -> String {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
let plural = |n: usize, s: &str| {
if n == 1 {
format!("1 {s}")
} else {
format!("{n} {s}s")
}
};
match &*shared.focus {
FocusSelection::Layers(ids) => {
let doc = shared.action_executor.document();
match ids.len() {
0 => "Layer".to_string(),
1 => {
if let Some(l) = doc.get_layer(&ids[0]) {
let ty = match l {
AnyLayer::Vector(_) => "Vector",
AnyLayer::Audio(a) => match a.audio_layer_type {
AudioLayerType::Midi => "MIDI",
AudioLayerType::Sampled => "Audio",
},
AnyLayer::Video(_) => "Video",
AnyLayer::Effect(_) => "Effect",
AnyLayer::Group(_) => "Group",
AnyLayer::Raster(_) => "Raster",
AnyLayer::Text(_) => "Text",
};
format!("{} · {} layer", l.name(), ty)
} else {
"Layer".to_string()
}
}
n => plural(n, "layer"),
}
}
FocusSelection::ClipInstances(ids) => plural(ids.len(), "clip"),
FocusSelection::Notes { indices, .. } => plural(indices.len().max(1), "note"),
FocusSelection::Nodes(ids) => plural(ids.len(), "node"),
FocusSelection::Assets(ids) => plural(ids.len(), "asset"),
FocusSelection::Geometry { .. } | FocusSelection::None => "Selection".to_string(),
}
}
/// A jump-to chip: its label and the stack window it brings into view (top, count). These reframe
/// to a related surface with the object still selected.
struct Chip {
label: &'static str,
window: (usize, usize),
}
const CHIPS: [Chip; 2] = [
Chip { label: "Timeline", window: (3, 1) }, // Timeline = STACK index 3
Chip { label: "Nodes", window: (5, 1) }, // Node/Instrument = STACK index 5
];
pub fn render(
ui: &mut egui::Ui,
rect: egui::Rect,
region: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
landscape: bool,
) {
// Panel background + border, with rounded corners on the edge that faces the content (top in
// portrait, left in landscape).
let radius = if landscape {
egui::CornerRadius { nw: 14, ne: 0, sw: 14, se: 0 }
} else {
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 }
};
ui.painter().rect_filled(rect, radius, pal.surface_alt);
ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
// Grab handle — top strip (portrait, drags height) or left strip (landscape, drags width).
// `content_area` is the panel minus the grab strip.
let content_area = if landscape {
let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.left() + GRAB_H, rect.bottom()));
let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag());
let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(4.0, 34.0));
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line });
if gresp.dragged() && region.width() > 1.0 {
state.inspector_width_frac = (state.inspector_width_frac - gresp.drag_delta().x / region.width()).clamp(0.2, 0.7);
}
if gresp.hovered() || gresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
egui::Rect::from_min_max(egui::pos2(grab.right(), rect.top()), rect.max)
} else {
let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + GRAB_H));
let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag());
let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0));
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line });
if gresp.dragged() && region.height() > 1.0 {
state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region.height()).clamp(0.2, 0.85);
}
if gresp.hovered() || gresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
egui::Rect::from_min_max(egui::pos2(rect.left(), grab.bottom()), rect.max)
};
// Single header row (egui widgets — they inherit the mobile touch sizing + theme visuals):
// title on the left, [Timeline] [Nodes] jump buttons + ✕ close on the right.
let head = egui::Rect::from_min_max(
content_area.min,
egui::pos2(content_area.right(), content_area.top() + HEAD_H),
);
let title_text = title(&rc.shared);
let mut jump: Option<(usize, usize)> = None;
let mut do_close = false;
let mut hui = ui.new_child(
egui::UiBuilder::new()
.max_rect(head.shrink2(egui::vec2(12.0, 5.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
hui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new(super::icons::X).font(super::icons::font(18.0))).frame(false))
.clicked()
{
do_close = true;
}
ui.add_space(4.0);
// Reversed so the visual order stays Timeline, then Nodes.
for chip in CHIPS.iter().rev() {
if ui.button(chip.label).clicked() {
jump = Some(chip.window);
}
}
// Title fills the remaining space on the left.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(title_text).color(pal.text)).truncate());
});
});
if do_close {
rc.shared.selection.clear();
*rc.shared.focus = FocusSelection::None;
}
if let Some((top, count)) = jump {
state.window_top = top;
state.window_count = count;
state.weights = [1.0, 1.0, 1.0];
state.anim = None;
}
// Properties content — reuse the Infopanel full-bleed.
let content = egui::Rect::from_min_max(
egui::pos2(content_area.left(), head.bottom()),
content_area.max,
);
if content.height() > 1.0 {
surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc);
}
}

View File

@ -0,0 +1,184 @@
//! Mobile new-file intent picker — the phone equivalent of the desktop start screen.
//!
//! Mirrors the desktop "new project" flow: each intent calls
//! [`EditorApp::create_new_project_with_focus`] (which builds the document, picks a default layer,
//! and switches to editor mode) and then sets the initial mobile stack window so the relevant panes
//! are in view.
use eframe::egui;
use super::{icons, Palette};
use crate::EditorApp;
/// One intent card.
struct Intent {
label: &'static str,
icon: &'static str,
accent: egui::Color32,
/// Argument to `create_new_project_with_focus` (0=Animation, 1=Video, 2=Music, 5=Painting).
focus: usize,
/// Initial mobile stack window: (window_top, window_count).
window: (usize, usize),
/// Initial pane weights (relative heights) for the window.
weights: [f32; 3],
}
fn intents(pal: &Palette) -> [Intent; 6] {
let [coral, cyan, amber, pink, violet] = pal.accents;
let even = [1.0, 1.0, 1.0];
// Compose/Record: compressed Timeline ribbon on top; the tall Piano Roll (which now hosts the
// instrument header + keyboard as one surface) fills the rest.
let music = [0.4, 1.6, 1.0];
[
// Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5.
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1), weights: even },
Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2), weights: even },
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 2), weights: music },
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 2), weights: music },
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2), weights: even },
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2), weights: even },
]
}
pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
let pal = Palette::from_theme(&app.theme, ctx);
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(pal.bg))
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
let margin = 16.0;
let left = rect.left() + margin;
let right = rect.right() - margin;
// Header.
ui.painter().text(
egui::pos2(left, rect.top() + 22.0),
egui::Align2::LEFT_TOP,
"Start something",
egui::FontId::proportional(22.0),
pal.text,
);
let landscape = rect.width() > rect.height();
let content_top = rect.top() + 62.0;
let content_bottom = rect.bottom() - margin;
let gap = 10.0;
// Portrait: 3 rows × 2 cols of intent cards up top, recent list below.
// Landscape: 2 rows × 3 cols on the left, recent list to the right.
let (cols, rows) = if landscape { (3usize, 2usize) } else { (2usize, 3usize) };
let (grid_rect, recent_rect) = if landscape {
let split = left + (right - left) * 0.62;
(
egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(split - gap, content_bottom)),
egui::Rect::from_min_max(egui::pos2(split + gap, content_top), egui::pos2(right, content_bottom)),
)
} else {
let grid_h = (content_bottom - content_top) * 0.66;
(
egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(right, content_top + grid_h)),
egui::Rect::from_min_max(egui::pos2(left, content_top + grid_h + 14.0), egui::pos2(right, content_bottom)),
)
};
// Floor at 0 so a very small window can't produce negative/inverted card rects.
let col_w = ((grid_rect.width() - (cols as f32 - 1.0) * gap) / cols as f32).max(0.0);
let card_h = ((grid_rect.height() - (rows as f32 - 1.0) * gap) / rows as f32).max(0.0);
for (i, intent) in intents(&pal).iter().enumerate() {
let col = (i % cols) as f32;
let row = (i / cols) as f32;
let cx = grid_rect.left() + col * (col_w + gap);
let cy = grid_rect.top() + row * (card_h + gap);
let card = egui::Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(col_w, card_h));
let resp = ui.interact(card, ui.id().with(("mobile_intent", i)), egui::Sense::click());
let p = ui.painter();
p.rect_filled(card, 12.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
p.text(
egui::pos2(card.center().x, card.top() + card.height() * 0.40),
egui::Align2::CENTER_CENTER,
intent.icon,
icons::font(30.0),
intent.accent,
);
p.text(
egui::pos2(card.center().x, card.bottom() - 18.0),
egui::Align2::CENTER_CENTER,
intent.label,
egui::FontId::proportional(15.0),
pal.text,
);
if resp.clicked() {
app.create_new_project_with_focus(intent.focus);
app.mobile_state.window_top = intent.window.0;
app.mobile_state.window_count = intent.window.1;
app.mobile_state.weights = intent.weights;
}
}
// Recent projects list (below the grid in portrait, beside it in landscape).
let rleft = recent_rect.left();
let rright = recent_rect.right();
ui.painter().text(
egui::pos2(rleft, recent_rect.top()),
egui::Align2::LEFT_TOP,
"Recent",
egui::FontId::proportional(13.0),
pal.text_dim,
);
let list_top = recent_rect.top() + 22.0;
let recents = app.config.get_recent_files();
if recents.is_empty() {
ui.painter().text(
egui::pos2(rleft, list_top + 8.0),
egui::Align2::LEFT_TOP,
"No recent projects",
egui::FontId::proportional(12.0),
pal.text_dim,
);
} else {
let row_h = 38.0;
let row_gap = 6.0;
let avail = recent_rect.bottom() - list_top;
let max_rows = ((avail + row_gap) / (row_h + row_gap)).floor().max(0.0) as usize;
let mut chosen: Option<std::path::PathBuf> = None;
for (j, path) in recents.iter().take(max_rows).enumerate() {
let ry = list_top + j as f32 * (row_h + row_gap);
let row_rect =
egui::Rect::from_min_max(egui::pos2(rleft, ry), egui::pos2(rright, ry + row_h));
let resp =
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
let p = ui.painter();
p.rect_filled(row_rect, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
p.text(
egui::pos2(row_rect.left() + 12.0, row_rect.center().y),
egui::Align2::LEFT_CENTER,
icons::FOLDER_OPEN,
icons::font(15.0),
pal.text_dim,
);
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
p.text(
egui::pos2(row_rect.left() + 36.0, row_rect.center().y),
egui::Align2::LEFT_CENTER,
&name,
egui::FontId::proportional(13.0),
pal.text,
);
if resp.clicked() {
chosen = Some(path.clone());
}
}
if let Some(path) = chosen {
app.load_from_file(path);
app.app_mode = crate::AppMode::Editor;
}
}
});
}

View File

@ -0,0 +1,488 @@
//! Mobile / phone UI shell.
//!
//! Developed on desktop behind the `LB_MOBILE_UI` env var (see [`is_mobile_env`]).
//!
//! The shell is a single **vertical sliding-window stack** of the existing panes: a fixed
//! top→bottom order, with a window of 2 or 3 consecutive panes visible at once. Dragging the
//! dividers between panes and the top/bottom screen edges grows, shrinks, or slides the window
//! (the R1R6 state machine in `stack.rs`). A transport bar is pinned at the bottom as the floor.
//!
//! Each visible pane reuses the existing `PaneInstance::render_content` full-bleed (see
//! `surface.rs`). Per the wireframe, the Toolbar becomes the omnibutton and the Infopanel becomes
//! the selection inspector (later phases), so neither is in the stack.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
use crate::panes::NodePath;
use crate::RenderContext;
pub mod icons;
mod inspector;
pub mod intent;
mod omni;
mod palette;
mod stack;
mod surface;
mod topbar;
mod transport;
use palette::Palette;
/// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are built
/// from small child indices, so prefixing with `usize::MAX` guarantees mobile slots never alias a
/// real layout path in the shared `pane_instances` map.
pub const MOBILE_NS: usize = usize::MAX;
const TRANSPORT_H: f32 = 60.0;
const TOPBAR_H: f32 = 40.0;
/// Clamp a desktop dialog width to fit the current screen (with side margins). A no-op on wide
/// desktop screens (`min` keeps the desired width); on a phone-aspect window it shrinks to fit.
pub fn dialog_width(ctx: &egui::Context, desired: f32) -> f32 {
let avail = ctx.content_rect().width() - 24.0;
desired.min(avail.max(200.0))
}
/// Enlarge egui's spacing/sizing so the standard widgets (buttons, dropdowns, sliders, text fields)
/// in panes and dialogs are touch-friendly. Applied every frame after the theme visuals when the
/// mobile shell is active. The mobile chrome (transport, omnibutton, headers) is hand-sized already;
/// this targets the egui-widget content inside panes.
pub fn apply_touch_style(ctx: &egui::Context) {
use egui::{FontId, TextStyle};
ctx.style_mut(|s| {
let sp = &mut s.spacing;
// Minimum touch target height for buttons/sliders/checkboxes.
sp.interact_size = egui::vec2(sp.interact_size.x.max(44.0), 38.0);
sp.button_padding = egui::vec2(12.0, 9.0);
sp.item_spacing = egui::vec2(10.0, 10.0);
sp.slider_width = 200.0;
sp.slider_rail_height = 10.0;
sp.combo_width = 200.0;
sp.combo_height = 360.0;
sp.text_edit_width = 220.0;
sp.icon_width = 24.0;
sp.icon_width_inner = 14.0;
sp.icon_spacing = 8.0;
sp.scroll.bar_width = 16.0;
sp.menu_margin = egui::Margin::same(8);
// Larger text for touch legibility.
s.text_styles.insert(TextStyle::Body, FontId::proportional(15.0));
s.text_styles.insert(TextStyle::Button, FontId::proportional(15.0));
s.text_styles.insert(TextStyle::Monospace, FontId::monospace(13.0));
s.text_styles.insert(TextStyle::Small, FontId::proportional(12.0));
});
}
/// Returns true if the mobile UI is requested via the `LB_MOBILE_UI` env var.
/// Any non-empty value other than "0" enables it.
pub fn is_mobile_env() -> bool {
std::env::var("LB_MOBILE_UI")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
/// `LB_MOBILE_UI=2` develops the mobile shell in landscape (opens a landscape phone window).
/// Orientation itself is aspect-based at render time; this only picks the initial window aspect.
pub fn is_mobile_landscape_env() -> bool {
std::env::var("LB_MOBILE_UI").map(|v| v == "2").unwrap_or(false)
}
/// The panes that make up the vertical stack, in fixed top→bottom order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackPane {
Outliner,
AssetLibrary,
Stage,
Timeline,
/// The instrument surface: keyboard-primary on mobile, hosting the falling-notes roll above the
/// keys (the standalone Virtual Piano is embedded here, so it isn't a separate stack slot).
PianoRoll,
/// Node editor, or (toggled) the instrument/preset browser.
NodeInstrument,
ScriptEditor,
}
/// The stack order. Index into this is a pane's stable slot id.
pub const STACK: [StackPane; 7] = [
StackPane::Outliner,
StackPane::AssetLibrary,
StackPane::Stage,
StackPane::Timeline,
StackPane::PianoRoll,
StackPane::NodeInstrument,
StackPane::ScriptEditor,
];
impl StackPane {
/// The backing pane type. The Node/Instrument slot picks NodeEditor or PresetBrowser based on
/// the toggle.
pub fn pane_type(self, show_instruments: bool) -> PaneType {
match self {
StackPane::Outliner => PaneType::Outliner,
StackPane::AssetLibrary => PaneType::AssetLibrary,
StackPane::Stage => PaneType::Stage,
StackPane::Timeline => PaneType::Timeline,
StackPane::PianoRoll => PaneType::PianoRoll,
StackPane::NodeInstrument => {
if show_instruments {
PaneType::PresetBrowser
} else {
PaneType::NodeEditor
}
}
StackPane::ScriptEditor => PaneType::ScriptEditor,
}
}
pub fn label(self, show_instruments: bool) -> &'static str {
match self {
StackPane::Outliner => "Outliner",
StackPane::AssetLibrary => "Assets",
StackPane::Stage => "Stage",
StackPane::Timeline => "Timeline",
StackPane::PianoRoll => "Instrument",
StackPane::NodeInstrument => {
if show_instruments {
"Instruments"
} else {
"Nodes"
}
}
StackPane::ScriptEditor => "Script",
}
}
}
/// `pane_instances` key for a stack slot.
fn slot_path(slot: usize) -> NodePath {
vec![MOBILE_NS, slot]
}
/// An in-progress drag of a stack handle.
#[derive(Debug, Clone, Copy)]
pub struct StackDrag {
pub handle: stack::Handle,
/// Accumulated pointer offset (px) since the drag began (downward positive).
pub offset: f32,
}
/// A short ease between two stack layouts (resize snap, membership change, or fullscreen toggle).
/// Both endpoints are full configs so the same animation covers panes resizing AND panes
/// entering/leaving the window.
#[derive(Debug, Clone, Copy)]
pub struct LayoutAnim {
pub from_top: usize,
pub from_count: usize,
pub from_w: [f32; 3],
pub to_top: usize,
pub to_count: usize,
pub to_w: [f32; 3],
/// egui time (seconds) when the animation started (may be back-dated to continue a drag).
pub start: f64,
}
/// Persistent mobile-shell state, cached on `EditorApp`.
pub struct MobileState {
/// Index into `STACK` of the topmost visible pane.
pub window_top: usize,
/// Number of visible panes (1, 2, or 3; 1 = a single pane filling the stack).
pub window_count: usize,
/// Relative heights of the visible panes (first `window_count` entries are used; normalized on
/// use). Reset to even on any membership change; adjusted by intermediate divider snapping.
pub weights: [f32; 3],
/// Node/Instrument band: false = node editor, true = instrument/preset browser.
pub show_instruments: bool,
/// Inspector sheet height as a fraction of the region above the transport (portrait).
pub inspector_frac: f32,
/// Inspector side-panel width as a fraction of the region (landscape).
pub inspector_width_frac: f32,
/// Last frame's orientation (landscape?), to reset orientation-specific cached state on rotation.
pub was_landscape: bool,
/// Whether the inspector sheet is currently shown. Gated to appear on pointer *release* (a tap),
/// not on press, so press+drag interactions aren't interrupted by the sheet popping up.
pub inspector_visible: bool,
/// Screen-y of the tap that opened the inspector — used to decide whether the tapped thing would
/// be hidden by the sheet (only then do we reflow the stack).
pub inspector_anchor_y: f32,
/// Set when the user taps outside the sheet to dismiss it; suppresses re-showing until the
/// selection changes. Reset when the selection signature below changes.
pub inspector_dismissed: bool,
/// Cheap signature of the current selection, to detect when it changes (re-show the inspector).
pub inspector_sel_sig: u64,
/// Whether the omnibutton radial tool menu is open.
pub omni_open: bool,
/// Whether the omnibutton "more" grid (all tools) is open.
pub omni_grid_open: bool,
/// Whether the omnibutton "+New" create grid is open.
pub omni_create_open: bool,
/// Top-bar overflow (⋯ commands) sheet open.
pub overflow_open: bool,
/// Top-bar command palette (⌕) open, and its search query.
pub palette_open: bool,
pub palette_query: String,
/// Active handle drag (transient).
pub drag: Option<StackDrag>,
/// In-flight layout ease (transient).
pub anim: Option<LayoutAnim>,
}
impl Default for MobileState {
fn default() -> Self {
Self {
// Launch on {Stage, Timeline}.
window_top: 2,
window_count: 2,
weights: [1.0, 1.0, 1.0],
show_instruments: false,
inspector_frac: 0.45,
inspector_width_frac: 0.4,
was_landscape: false,
inspector_visible: false,
inspector_anchor_y: 0.0,
inspector_dismissed: false,
inspector_sel_sig: 0,
omni_open: false,
omni_grid_open: false,
omni_create_open: false,
overflow_open: false,
palette_open: false,
palette_query: String::new(),
drag: None,
anim: None,
}
}
}
/// Render the whole mobile shell into `available_rect`.
pub fn render_mobile_shell(
ui: &mut egui::Ui,
available_rect: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
) {
let pal = Palette::from_theme(rc.shared.theme, ui.ctx());
// Orientation (aspect-based; rotation = a resize). Published to panes before anything renders.
let landscape = available_rect.width() > available_rect.height();
rc.shared.is_portrait = !landscape;
// Rotating while 3 panes are open drops to 2 (landscape caps the stack at 2).
if landscape && state.window_count > 2 {
state.window_count = 2;
state.window_top = state.window_top.min(STACK.len().saturating_sub(2));
}
// On an orientation change, the cached inspector tap-anchor is in the old layout's coordinate
// space; drop it to a non-covering default so the portrait reflow doesn't mis-decide until the
// next tap re-anchors.
if landscape != state.was_landscape {
state.was_landscape = landscape;
state.inspector_anchor_y = available_rect.top();
}
// Background (device color; bands paint over most of it).
ui.painter().rect_filled(available_rect, 0.0, pal.bg);
// In landscape the top bar is folded into the top pane header (no separate band), reclaiming its
// height for the stack.
let topbar_h = if landscape { 0.0 } else { TOPBAR_H };
let topbar_rect = egui::Rect::from_min_max(
available_rect.min,
egui::pos2(available_rect.right(), available_rect.top() + topbar_h),
);
let transport_rect = egui::Rect::from_min_max(
egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H),
available_rect.max,
);
// Region between the top bar and the transport, shared between the stack and (when something is
// selected) the inspector sheet that rises above the transport.
let region = egui::Rect::from_min_max(
egui::pos2(available_rect.left(), topbar_rect.bottom()),
egui::pos2(available_rect.right(), transport_rect.top()),
);
// The inspector sheet appears on pointer *release* (a tap), not on press — so press+drag on the
// canvas isn't interrupted by the sheet popping up. Track visibility: hide when nothing's
// selected; show once the pointer is up with a selection; keep it shown while it's being
// interacted with (pointer down but still selected).
let active = inspector::is_active(&rc.shared);
let any_down = ui.input(|i| i.pointer.any_down());
let sel_sig = inspector::selection_sig(&rc.shared);
if sel_sig != state.inspector_sel_sig {
// Selection changed → a fresh thing to inspect; clear any prior manual dismissal.
state.inspector_dismissed = false;
state.inspector_sel_sig = sel_sig;
}
if !active {
state.inspector_visible = false;
state.inspector_dismissed = false;
} else if !state.inspector_dismissed && !any_down {
if !state.inspector_visible {
// Just opened (tap released): anchor to the release position for the reflow test below.
state.inspector_anchor_y = ui
.input(|i| i.pointer.interact_pos())
.map(|p| p.y)
.unwrap_or(region.bottom());
}
state.inspector_visible = true;
}
let mut inspector_shown = state.inspector_visible;
// Inspector geometry: a bottom sheet in portrait, a right-side vertical column in landscape.
// Clamp defensively: on a very small region the desired minimum can exceed the available space,
// and `f32::clamp` panics if `min > max`, so keep `min <= max`.
let inspector_rect = if landscape {
let lo = 180.0_f32.min(region.width());
let hi = (region.width() - 140.0).max(lo);
let w = (region.width() * state.inspector_width_frac).clamp(lo, hi);
egui::Rect::from_min_max(egui::pos2(region.right() - w, region.top()), region.max)
} else {
let lo = 120.0_f32.min(region.height());
let hi = (region.height() - 60.0).max(lo);
let h = (region.height() * state.inspector_frac).clamp(lo, hi);
egui::Rect::from_min_max(egui::pos2(region.left(), region.bottom() - h), region.max)
};
// Tapping outside the inspector (but not on the transport) dismisses (hides) it. We only hide —
// NOT clear the selection — so actions dispatched from overlays (context menu, "+New" grid) still
// see the selection. It stays dismissed until the selection changes (sig above).
if inspector_shown {
let dismiss = ui.input(|i| {
i.pointer.primary_pressed()
&& i.pointer.press_origin().map_or(false, |p| {
!inspector_rect.contains(p) && !transport_rect.contains(p)
})
});
if dismiss {
state.inspector_visible = false;
state.inspector_dismissed = true;
inspector_shown = false;
}
}
// Reflow: shrink the stack to make room for the inspector. Landscape always carves horizontally
// (side-by-side); portrait only carves when the tapped thing sits below the sheet (else overlay).
// Restoring on dismiss is automatic — reflow only changes the render rect.
let stack_rect = if !inspector_shown {
region
} else if landscape {
egui::Rect::from_min_max(region.min, egui::pos2(inspector_rect.left(), region.bottom()))
} else if state.inspector_anchor_y > inspector_rect.top() {
egui::Rect::from_min_max(region.min, egui::pos2(region.right(), inspector_rect.top()))
} else {
region
};
// Decide whether the instrument pane (PianoRoll = STACK index 4) reveals the roll, from the
// *committed* (snapped) window weights — so the keyboard↔roll transition lands on a stack snap
// rather than at an arbitrary mid-drag height. Roll shows once the pane is past the smallest snap.
rc.shared.instrument_show_roll = {
let idx = 4i32 - state.window_top as i32;
if idx >= 0 && (idx as usize) < state.window_count {
let sum: f32 = state.weights[..state.window_count].iter().map(|w| w.max(0.0)).sum();
let w = if sum > 0.0 { state.weights[idx as usize].max(0.0) / sum } else { 0.0 };
w > 0.30 // 0.25 preset ⇒ keyboard only; 0.33/0.5/0.75 ⇒ keyboard + roll
} else {
true
}
};
stack::render(ui, stack_rect, rc, state, &pal);
if inspector_shown {
inspector::render(ui, inspector_rect, region, rc, state, &pal, landscape);
}
// Transport floor: drawn last = always on top, the persistent spine.
transport::render(ui, transport_rect, &mut rc.shared, &pal);
// Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else.
omni::render(ui, region, rc, state, &pal);
// Instrument-browser request from the music pane's header → show the Preset Browser fullscreen.
if *rc.shared.open_instrument_browser {
*rc.shared.open_instrument_browser = false;
state.show_instruments = true;
state.window_top = 5; // Node/Instrument band (PresetBrowser when show_instruments)
state.window_count = 1;
state.weights = [1.0, 1.0, 1.0];
state.anim = None;
}
// Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last.
// Portrait: its own band at the top. Landscape: folded into the middle of the top pane header.
if landscape {
let hh = stack::header_height(false);
let top_header = egui::Rect::from_min_max(
egui::pos2(stack_rect.left(), region.top()),
egui::pos2(stack_rect.right(), region.top() + hh),
);
topbar::render_inline(ui, top_header, available_rect, rc, state, &pal);
} else {
topbar::render(ui, topbar_rect, available_rect, rc, state, &pal);
}
// Long-press context menu (populated by whichever pane was long-pressed). Persistent popup,
// dispatched via pending_menu_actions.
render_context_menu(ui, available_rect, rc);
}
/// Render the pane-populated long-press context menu (`shared.mobile_context_menu`) as a persistent
/// popup: it stays open until an item is chosen or the user taps outside it. Styled to match the
/// timeline's context menu (default themed popup frame + full-width, touch-height items).
fn render_context_menu(ui: &mut egui::Ui, available: egui::Rect, rc: &mut RenderContext) {
let Some(menu) = rc.shared.mobile_context_menu.clone() else {
return;
};
// Keep the popup on screen (rough clamp against its estimated size).
let est = egui::vec2(200.0, menu.items.len() as f32 * ui.spacing().interact_size.y + 16.0);
let pos = egui::pos2(
menu.pos.x.min(available.right() - est.x - 8.0).max(available.left() + 8.0),
menu.pos.y.min(available.bottom() - est.y - 8.0).max(available.top() + 8.0),
);
let mut chosen: Option<crate::menu::MenuAction> = None;
let area = egui::Area::new(ui.id().with("mobile_ctx_menu"))
.order(egui::Order::Foreground)
.fixed_pos(pos)
.interactable(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(180.0);
for (label, action) in &menu.items {
let w = ui.available_width();
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(w, ui.spacing().interact_size.y), egui::Sense::click());
if ui.is_rect_visible(rect) {
if resp.hovered() {
ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
}
let tc = if resp.hovered() {
ui.visuals().widgets.hovered.text_color()
} else {
ui.visuals().widgets.inactive.text_color()
};
ui.painter().text(
rect.min + egui::vec2(10.0, (rect.height() - 14.0) / 2.0),
egui::Align2::LEFT_TOP,
label,
egui::FontId::proportional(14.0),
tc,
);
}
if resp.clicked() {
chosen = Some(*action);
}
}
});
});
// Dismiss on item choice or a primary click outside the popup. (The secondary click that opened
// it won't dismiss, so there's no just-opened race.)
let primary_click = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Primary));
let dismiss = chosen.is_some() || (primary_click && !area.response.contains_pointer());
if let Some(action) = chosen {
rc.shared.pending_menu_actions.push(action);
}
if dismiss {
*rc.shared.mobile_context_menu = None;
}
}

View File

@ -0,0 +1,337 @@
//! Omnibutton — a floating action button whose radial holds the active layer's drawing tools plus
//! a "+New" petal (create layers / import / clip ops) and, when there are extra tools, a "⋯ more"
//! petal that opens a full-tools grid. Tools drive `shared.selected_tool`; create actions dispatch
//! `MenuAction`s. The radial fans up and to the left, away from the thumb. Pane-internal adds
//! (nodes/notes/instruments) are handled inside their panes (P6), not here.
use eframe::egui;
use lightningbeam_core::layer::{AnyLayer, LayerType};
use lightningbeam_core::selection::FocusSelection;
use lightningbeam_core::tool::Tool;
use super::{icons, MobileState, Palette};
use crate::menu::MenuAction;
use crate::RenderContext;
const FAB_R: f32 = 26.0;
const IR: f32 = 20.0;
const PRIMARY: [Tool; 14] = [
Tool::Select,
Tool::Draw,
Tool::Transform,
Tool::Rectangle,
Tool::Ellipse,
Tool::Line,
Tool::Polygon,
Tool::PaintBucket,
Tool::Gradient,
Tool::Eyedropper,
Tool::Text,
Tool::Erase,
Tool::BezierEdit,
Tool::Split,
];
const RING_CAP: usize = 10;
#[derive(Clone, Copy)]
enum Special {
More,
New,
}
fn tool_icon(t: Tool) -> &'static str {
match t {
Tool::Select | Tool::RegionSelect => icons::MOUSE_POINTER_2,
Tool::SelectLasso => icons::LASSO_SELECT,
Tool::Draw | Tool::Pencil | Tool::Pen => icons::PENCIL,
Tool::Transform => icons::VECTOR_SQUARE,
Tool::Rectangle => icons::SQUARE,
Tool::Ellipse => icons::CIRCLE,
Tool::Line => icons::MINUS,
Tool::Polygon => icons::HEXAGON,
Tool::PaintBucket => icons::PAINT_BUCKET,
Tool::Gradient => icons::BLEND,
Tool::Eyedropper => icons::PIPETTE,
Tool::Text => icons::TYPE,
Tool::Erase => icons::ERASER,
Tool::BezierEdit => icons::PEN_TOOL,
Tool::MagicWand | Tool::QuickSelect => icons::WAND_SPARKLES,
Tool::Split => icons::SCISSORS,
_ => icons::BRUSH,
}
}
fn active_layer_type(rc: &RenderContext) -> Option<LayerType> {
rc.shared
.active_layer_id
.and_then(|id| rc.shared.action_executor.document().get_layer(&id))
.map(|layer| match layer {
AnyLayer::Vector(_) => LayerType::Vector,
AnyLayer::Audio(_) => LayerType::Audio,
AnyLayer::Video(_) => LayerType::Video,
AnyLayer::Effect(_) => LayerType::Effect,
AnyLayer::Group(_) => LayerType::Group,
AnyLayer::Raster(_) => LayerType::Raster,
AnyLayer::Text(_) => LayerType::Text,
})
}
/// The create-menu items: (label, icon, action, enabled). Split/Duplicate need a selected clip.
fn create_items(rc: &RenderContext) -> Vec<(&'static str, &'static str, MenuAction, bool)> {
let clip_sel = matches!(&*rc.shared.focus, FocusSelection::ClipInstances(ids) if !ids.is_empty());
vec![
("Vector", icons::PEN_TOOL, MenuAction::AddLayer, true),
("Audio", icons::AUDIO_WAVEFORM, MenuAction::AddAudioTrack, true),
("MIDI", icons::PIANO, MenuAction::AddMidiTrack, true),
("Raster", icons::BRUSH, MenuAction::AddRasterLayer, true),
("Video", icons::CLAPPERBOARD, MenuAction::AddVideoLayer, true),
("Group", icons::FOLDER, MenuAction::Group, true),
("Import", icons::FILE_PLUS, MenuAction::Import, true),
("Split", icons::SCISSORS, MenuAction::SplitClip, clip_sel),
("Duplicate", icons::COPY, MenuAction::DuplicateClip, clip_sel),
]
}
fn close_all(state: &mut MobileState) {
state.omni_open = false;
state.omni_grid_open = false;
state.omni_create_open = false;
}
pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let ctx_tools: Vec<Tool> = Tool::for_layer_type(active_layer_type(rc)).to_vec();
let primary: Vec<Tool> = PRIMARY
.iter()
.copied()
.filter(|t| ctx_tools.contains(t))
.take(RING_CAP)
.collect();
let has_more = ctx_tools.len() > primary.len();
// Tools are only relevant when the Stage is on screen. Off-Stage the omnibutton is purely a
// "+New" create button.
let stage_slot = super::STACK
.iter()
.position(|p| *p == super::StackPane::Stage)
.unwrap_or(2);
let stage_visible = (state.window_top..state.window_top + state.window_count).contains(&stage_slot);
if !stage_visible {
state.omni_open = false; // no tool radial off-Stage
}
let fab_center = egui::pos2(region.right() - 18.0 - FAB_R, region.bottom() - 18.0 - FAB_R);
if state.omni_grid_open {
// Full-tools grid.
let cells: Vec<Cell> = ctx_tools
.iter()
.map(|t| Cell {
icon: tool_icon(*t),
label: t.display_name(),
selected: *rc.shared.selected_tool == *t,
enabled: true,
})
.collect();
let (close, clicked) = draw_grid(ui, region, "Tools", &cells, pal);
if let Some(i) = clicked {
*rc.shared.selected_tool = ctx_tools[i];
close_all(state);
} else if close {
close_all(state);
}
} else if state.omni_create_open {
// Create grid.
let items = create_items(rc);
let cells: Vec<Cell> = items
.iter()
.map(|it| Cell {
icon: it.1,
label: it.0,
selected: false,
enabled: it.3,
})
.collect();
let (close, clicked) = draw_grid(ui, region, "New", &cells, pal);
if let Some(i) = clicked {
rc.shared.pending_menu_actions.push(items[i].2);
close_all(state);
} else if close {
close_all(state);
}
} else if state.omni_open {
// Radial: scrim, then tool petals + special petals (more / new).
let scrim = ui.interact(region, ui.id().with("mobile_omni_scrim"), egui::Sense::click());
ui.painter().rect_filled(region, 0.0, pal.scrim);
if scrim.clicked() {
state.omni_open = false;
}
let mut specials: Vec<Special> = Vec::new();
if has_more {
specials.push(Special::More);
}
specials.push(Special::New);
let n = primary.len() + specials.len();
for k in 0..n {
let frac = if n > 1 { k as f32 / (n as f32 - 1.0) } else { 0.5 };
let deg = 74.0 + frac * 126.0; // 74°..200°, up and to the left
let a = deg.to_radians();
let radius = if k % 2 == 0 { 114.0 } else { 170.0 };
let c = fab_center + egui::vec2(radius * a.cos(), -radius * a.sin());
let rect = egui::Rect::from_center_size(c, egui::vec2(IR * 2.0, IR * 2.0));
if k < primary.len() {
let t = primary[k];
let resp = ui.interact(rect, ui.id().with(("mobile_omni_tool", k)), egui::Sense::click());
let selected = *rc.shared.selected_tool == t;
petal(ui, c, if selected { pal.accent } else { petal_bg(&resp, pal) }, tool_icon(t), if selected { pal.on_accent } else { pal.text }, pal);
if resp.clicked() {
*rc.shared.selected_tool = t;
state.omni_open = false;
}
} else {
match specials[k - primary.len()] {
Special::More => {
let resp = ui.interact(rect, ui.id().with("mobile_omni_more"), egui::Sense::click());
petal(ui, c, petal_bg(&resp, pal), icons::MENU, pal.text, pal);
if resp.clicked() {
state.omni_grid_open = true;
}
}
Special::New => {
let resp = ui.interact(rect, ui.id().with("mobile_omni_new"), egui::Sense::click());
petal(ui, c, pal.accent, icons::PLUS, pal.on_accent, pal);
if resp.hovered() {
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, pal.text));
}
if resp.clicked() {
state.omni_create_open = true;
}
}
}
}
}
}
// The FAB itself (on top). Closed → current tool icon; open → ✕.
let fab_rect = egui::Rect::from_center_size(fab_center, egui::vec2(FAB_R * 2.0, FAB_R * 2.0));
let fresp = ui.interact(fab_rect, ui.id().with("mobile_omni_fab"), egui::Sense::click());
ui.painter().circle_filled(fab_center, FAB_R, pal.accent);
let open = state.omni_open || state.omni_grid_open || state.omni_create_open;
let glyph = if open {
icons::X
} else if stage_visible {
tool_icon(*rc.shared.selected_tool)
} else {
icons::PLUS // off-Stage: a create button
};
ui.painter()
.text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), pal.on_accent);
if fresp.clicked() {
if open {
close_all(state);
} else if stage_visible {
state.omni_open = true;
} else {
state.omni_create_open = true;
}
}
}
fn petal_bg(resp: &egui::Response, pal: &Palette) -> egui::Color32 {
if resp.hovered() {
pal.line
} else {
pal.surface_alt
}
}
fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32, pal: &Palette) {
ui.painter().circle_filled(c, IR, bg);
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, pal.line));
ui.painter().text(c, egui::Align2::CENTER_CENTER, glyph, icons::font(18.0), fg);
}
struct Cell {
icon: &'static str,
label: &'static str,
selected: bool,
enabled: bool,
}
/// A modal grid sheet of icon+label cells. Returns (backdrop-tapped, Some(clicked-enabled-index)).
fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell], pal: &Palette) -> (bool, Option<usize>) {
let scrim = ui.interact(region, ui.id().with(("mobile_omni_gridscrim", title)), egui::Sense::click());
ui.painter().rect_filled(region, 0.0, pal.scrim);
let panel = region.shrink2(egui::vec2(16.0, 40.0));
ui.painter().rect_filled(panel, 14.0, pal.surface);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
ui.painter().text(
egui::pos2(panel.left() + 16.0, panel.top() + 18.0),
egui::Align2::LEFT_CENTER,
title,
egui::FontId::proportional(14.0),
pal.text,
);
let cols = 4usize;
let n = cells.len();
let rows = n.div_ceil(cols).max(1);
let grid = egui::Rect::from_min_max(
egui::pos2(panel.left() + 10.0, panel.top() + 36.0),
egui::pos2(panel.right() - 10.0, panel.bottom() - 10.0),
);
let cw = grid.width() / cols as f32;
let chh = (grid.height() / rows as f32).min(96.0);
let mut clicked = None;
for (i, cell) in cells.iter().enumerate() {
let col = (i % cols) as f32;
let row = (i / cols) as f32;
let r = egui::Rect::from_min_size(
egui::pos2(grid.left() + col * cw, grid.top() + row * chh),
egui::vec2(cw, chh),
)
.shrink(5.0);
let resp = if cell.enabled {
ui.interact(r, ui.id().with(("mobile_omni_cell", title, i)), egui::Sense::click())
} else {
ui.interact(r, ui.id().with(("mobile_omni_cell_off", title, i)), egui::Sense::hover())
};
let bg = if cell.selected {
pal.accent
} else if cell.enabled && resp.hovered() {
pal.surface_alt
} else {
pal.surface
};
ui.painter().rect_filled(r, 10.0, bg);
ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
let fg = if cell.selected {
pal.on_accent
} else if cell.enabled {
pal.text
} else {
pal.line
};
ui.painter().text(
egui::pos2(r.center().x, r.top() + r.height() * 0.38),
egui::Align2::CENTER_CENTER,
cell.icon,
icons::font(22.0),
fg,
);
ui.painter().text(
egui::pos2(r.center().x, r.bottom() - 12.0),
egui::Align2::CENTER_CENTER,
cell.label,
egui::FontId::proportional(9.5),
if cell.selected { pal.on_accent } else if cell.enabled { pal.text_dim } else { pal.line },
);
if cell.enabled && resp.clicked() {
clicked = Some(i);
}
}
(scrim.clicked() && clicked.is_none(), clicked)
}

View File

@ -0,0 +1,59 @@
//! The mobile UI's semantic color palette, sourced from the shared theme CSS variables so it
//! matches the main UI and responds to light/dark/user themes. Built once per frame from the active
//! [`Theme`](crate::theme::Theme) and passed (by `Copy`) into the mobile render functions, replacing
//! the per-file hardcoded color constants.
use eframe::egui::{Color32, Context};
use crate::theme::Theme;
#[derive(Clone, Copy)]
pub struct Palette {
/// App/device background behind everything.
pub bg: Color32,
/// Panels, cards, bands, sheets.
pub surface: Color32,
/// Raised / hovered surface.
pub surface_alt: Color32,
/// Band headers, the top bar.
pub header: Color32,
/// Borders and dividers.
pub line: Color32,
/// Primary text / icons.
pub text: Color32,
/// Secondary (dim) text / icons.
pub text_dim: Color32,
/// Selection / active accent.
pub accent: Color32,
/// Text / icons drawn on top of `accent`.
pub on_accent: Color32,
/// Modal backdrop (translucent).
pub scrim: Color32,
/// Decorative category accents (intent picker, chips): coral, cyan, amber, pink, violet.
pub accents: [Color32; 5],
}
impl Palette {
pub fn from_theme(theme: &Theme, ctx: &Context) -> Self {
let c = |name: &str, fb: Color32| theme.var(name, ctx).unwrap_or(fb);
Self {
bg: c("bg-app", Color32::from_rgb(0x2a, 0x2a, 0x2a)),
surface: c("bg-panel", Color32::from_rgb(0x22, 0x22, 0x22)),
surface_alt: c("bg-surface-raised", Color32::from_rgb(0x3f, 0x3f, 0x3f)),
header: c("bg-header", Color32::from_rgb(0x35, 0x35, 0x35)),
line: c("border-default", Color32::from_rgb(0x44, 0x44, 0x44)),
text: c("text-primary", Color32::from_rgb(0xf6, 0xf6, 0xf6)),
text_dim: c("text-secondary", Color32::from_rgb(0xaa, 0xaa, 0xaa)),
accent: c("accent", Color32::from_rgb(0x39, 0x6c, 0xd8)),
on_accent: c("text-on-accent", Color32::WHITE),
scrim: c("scrim", Color32::from_rgba_unmultiplied(0x10, 0x14, 0x1a, 0xb0)),
accents: [
c("accent-coral", Color32::from_rgb(0xe8, 0x82, 0x6b)),
c("accent-cyan", Color32::from_rgb(0x54, 0xc3, 0xe8)),
c("accent-amber", Color32::from_rgb(0xf4, 0xa3, 0x40)),
c("accent-pink", Color32::from_rgb(0xc7, 0x5b, 0x8a)),
c("accent-violet", Color32::from_rgb(0x8a, 0x6e, 0xc0)),
],
}
}
}

View File

@ -0,0 +1,862 @@
//! The vertical sliding-window stack engine.
//!
//! A window of 23 consecutive panes (from [`super::STACK`]) is visible. Dragging the dividers
//! between visible panes and the top/bottom screen edges grows, shrinks, or slides the window via
//! the operations below (verified against the user's transition table):
//!
//! ```text
//! R1 upper divider, UP -> slide window DOWN one (top+=1)
//! R4 upper divider, DOWN -> drop BOTTOM pane (count 3->2)
//! R3 lower divider, UP -> drop TOP pane (top+=1, count 3->2)
//! R2 lower divider, DOWN -> slide window UP one (top-=1)
//! R5 bottom edge, UP -> grow at bottom; if already 3, slide down
//! R6 top edge, DOWN -> grow at top; if already 3, slide up
//! ```
//!
//! At count==2 the single divider is "upper" when dragged up (R1) and "lower" when dragged
//! down (R2). The window content interpolates continuously during a drag and snaps on release.
use eframe::egui;
use super::{icons, slot_path, LayoutAnim, MobileState, Palette, StackDrag, StackPane, STACK};
use crate::RenderContext;
const N: usize = STACK.len();
/// Height of each band's drag header (and the bottom-edge footer). The whole header is the grab
/// target — there's no thin divider bar.
const HEADER_H: f32 = 52.0;
/// Landscape headers are shorter — vertical space is scarce, and the top one hosts the app bar.
const HEADER_H_LANDSCAPE: f32 = 34.0;
/// Header height for the current orientation.
pub fn header_height(is_portrait: bool) -> f32 {
if is_portrait { HEADER_H } else { HEADER_H_LANDSCAPE }
}
/// Corner radius (px) for the rounded top of each header.
const HEADER_RADIUS: u8 = 9;
const FOOTER_H: f32 = 28.0;
/// Width of a header right-side button (fullscreen, node toggle).
const BTN_W: f32 = 44.0;
/// Max pixels of drag to complete a window transition (so you don't drag half the screen).
const TRIGGER_MAX: f32 = 150.0;
/// A draggable boundary of the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Handle {
TopEdge,
/// Divider below visible pane `k` (between visible pane `k` and `k+1`).
Divider(usize),
BottomEdge,
}
// --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> ---
fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> {
(top + count < N).then(|| (top + 1, count)) // slide down
}
fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> {
(top > 0).then(|| (top - 1, count)) // slide up
}
fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then(|| (top + 1, 2)) // drop top
}
fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then(|| (top, 2)) // drop bottom
}
fn op_r5(top: usize, count: usize, max: usize) -> Option<(usize, usize)> {
if count < max && top + count < N {
Some((top, count + 1)) // grow bottom
} else if count == max && top + count < N {
Some((top + 1, count)) // slide down
} else {
None
}
}
fn op_r6(top: usize, count: usize, max: usize) -> Option<(usize, usize)> {
if count < max && top > 0 {
Some((top - 1, count + 1)) // grow top
} else if count == max && top > 0 {
Some((top - 1, count)) // slide up
} else {
None
}
}
/// Given a handle and signed drag offset, resolve the target window config and progress `t`.
/// Returns None if the drag direction has no valid operation (e.g. at a list boundary).
fn resolve(
handle: Handle,
offset: f32,
top: usize,
count: usize,
trigger: f32,
max: usize,
) -> Option<(usize, usize, f32)> {
let going_up = offset < 0.0;
let t = (offset.abs() / trigger.max(1.0)).clamp(0.0, 1.0);
let target = match handle {
Handle::TopEdge => (!going_up).then(|| op_r6(top, count, max)).flatten(),
Handle::BottomEdge => going_up.then(|| op_r5(top, count, max)).flatten(),
Handle::Divider(k) => {
if count == 2 {
if going_up { op_r1(top, count) } else { op_r2(top, count) }
} else if k == 0 {
// upper divider
if going_up { op_r1(top, count) } else { op_r4(top, count) }
} else {
// lower divider
if going_up { op_r3(top, count) } else { op_r2(top, count) }
}
}
};
target.map(|(tt, tc)| (tt, tc, t))
}
// --- rect layout ---
/// Below/above these boundary positions, a divider release collapses a group → membership change.
const COLLAPSE_LO: f32 = 0.12;
const COLLAPSE_HI: f32 = 0.88;
// The only allowed pane-weight distributions per window size. Snapping to these keeps the minimum
// pane height manageable (no pane below ~1/4).
const PRESETS_1: [[f32; 3]; 1] = [[1.0, 0.0, 0.0]];
const PRESETS_2: [[f32; 3]; 3] = [[0.25, 0.75, 0.0], [0.5, 0.5, 0.0], [0.75, 0.25, 0.0]];
const PRESETS_3: [[f32; 3]; 3] = [
[0.5, 0.25, 0.25],
[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0],
[0.25, 0.25, 0.5],
];
fn presets(count: usize) -> &'static [[f32; 3]] {
match count {
2 => &PRESETS_2,
3 => &PRESETS_3,
_ => &PRESETS_1,
}
}
/// The allowed preset nearest (L2) to the current weights.
fn nearest_preset(cur: &[f32], count: usize) -> [f32; 3] {
let dist = |p: &[f32; 3]| (0..count).map(|i| (p[i] - cur[i]).powi(2)).sum::<f32>();
presets(count)
.iter()
.min_by(|a, b| dist(a).total_cmp(&dist(b)))
.copied()
.unwrap_or([1.0, 0.0, 0.0])
}
// 3-pane snapping rides a single path parameter s in [0, 2] through the three presets, so the two
// dividers move together and reach their snaps simultaneously. The dragged divider's boundary
// position (pane0 for the upper divider; pane0+pane1 for the lower) maps onto s.
const PATH3_UPPER: [f32; 3] = [0.5, 1.0 / 3.0, 0.25]; // pane0 at s = 0, 1, 2
const PATH3_LOWER: [f32; 3] = [0.75, 2.0 / 3.0, 0.5]; // pane0+pane1 at s = 0, 1, 2
/// A pane grown beyond this collapses the 3-pane window to 2 panes on release.
const COLLAPSE_GROW: f32 = 0.66;
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
lerp_f(a[0], b[0], t),
lerp_f(a[1], b[1], t),
lerp_f(a[2], b[2], t),
]
}
/// Weights along the 3-pane preset path at parameter `s` ∈ [0, 2].
fn path3(s: f32) -> [f32; 3] {
let s = s.clamp(0.0, 2.0);
if s <= 1.0 {
lerp3(PRESETS_3[0], PRESETS_3[1], s)
} else {
lerp3(PRESETS_3[1], PRESETS_3[2], s - 1.0)
}
}
/// Path parameter for divider `k` whose boundary position is `b` (within the path's range).
fn s_from_boundary(k: usize, b: f32) -> f32 {
let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER };
if b >= pts[1] {
(pts[0] - b) / (pts[0] - pts[1]) // 0 at pts[0] … 1 at pts[1]
} else {
1.0 + (pts[1] - b) / (pts[1] - pts[2]) // 1 at pts[1] … 2 at pts[2]
}
}
/// The 3-pane path boundary range for divider `k`: (min, max) of its position across the presets.
fn path3_range(k: usize) -> (f32, f32) {
let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER };
(pts[2], pts[0])
}
/// Live weights while dragging divider `k` to boundary `b` in a 3-pane window: follow the linked
/// path within range, and beyond it group-resize from the nearer path endpoint (so the motion is
/// continuous across the extreme presets — no jump in the other divider).
fn live_weights3(k: usize, b_unclamped: f32) -> [f32; 3] {
let (lo, hi) = path3_range(k);
if b_unclamped >= lo && b_unclamped <= hi {
path3(s_from_boundary(k, b_unclamped))
} else {
let anchor = if b_unclamped > hi { path3(0.0) } else { path3(2.0) };
let (bmin, bmax) = boundary_bounds(3, k);
to_arr(&weights_for_boundary(&anchor, k, b_unclamped.clamp(bmin, bmax)))
}
}
/// Minimum normalized size a pane may be squeezed to during a live divider drag.
const MIN_FRAC: f32 = 0.05;
/// Duration (seconds) of the ease into a snapped divider position.
const SNAP_ANIM_SECS: f64 = 0.10;
fn ease_out(p: f32) -> f32 {
1.0 - (1.0 - p).powi(3)
}
/// Normalized pane weights (the first `count` stored weights, summing to 1).
fn nweights(weights: &[f32; 3], count: usize) -> Vec<f32> {
let mut w: Vec<f32> = weights[..count].iter().map(|x| x.max(0.0001)).collect();
let s: f32 = w.iter().sum();
if s > 0.0 {
for x in &mut w {
*x /= s;
}
} else {
let e = 1.0 / count as f32;
w.iter_mut().for_each(|x| *x = e);
}
w
}
/// The bottom Y of the given stack slot's band within `region` (using the current window +
/// weights), or None if that slot isn't in the visible window. Used to decide whether the
/// inspector sheet would cover the selected pane.
#[allow(dead_code)] // kept for pane-coverage heuristics
pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option<f32> {
let (top, count) = (state.window_top, state.window_count);
if slot < top || slot >= top + count {
return None;
}
let nw = nweights(&state.weights, count);
let cum: f32 = nw[..=(slot - top)].iter().sum();
Some(region.top() + cum * region.height())
}
/// Lay out `count` panes in `rect` using normalized weights `nw`.
fn config_rects(top: usize, count: usize, rect: egui::Rect, nw: &[f32]) -> Vec<(usize, egui::Rect)> {
let h = rect.height();
let mut out = Vec::with_capacity(count);
let mut y = rect.top();
for i in 0..count {
let ph = nw[i] * h;
out.push((
top + i,
egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y + ph)),
));
y += ph;
}
out
}
fn lerp_f(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn lerp_rect(a: egui::Rect, b: egui::Rect, t: f32) -> egui::Rect {
egui::Rect::from_min_max(
egui::pos2(lerp_f(a.min.x, b.min.x, t), lerp_f(a.min.y, b.min.y, t)),
egui::pos2(lerp_f(a.max.x, b.max.x, t), lerp_f(a.max.y, b.max.y, t)),
)
}
fn collapsed(rect: egui::Rect, at_top: bool) -> egui::Rect {
let y = if at_top { rect.top() } else { rect.bottom() };
egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y))
}
/// Allowed range of divider `k`'s boundary position (normalized) given MIN_FRAC per pane.
/// The boundary splits the window into the group above (panes 0..=k) and below (panes k+1..).
fn boundary_bounds(count: usize, k: usize) -> (f32, f32) {
let above = (k + 1) as f32;
let below = (count - 1 - k) as f32;
(above * MIN_FRAC, 1.0 - below * MIN_FRAC)
}
/// New normalized weights when divider `k` is moved to boundary position `b`: the panes above the
/// divider are scaled to fill `[0, b]` and those below to fill `[b, 1]`, each group keeping its
/// internal proportions. This makes the *other* dividers in the group move with the dragged one.
fn weights_for_boundary(nw: &[f32], k: usize, b: f32) -> Vec<f32> {
let count = nw.len();
let above_sum: f32 = nw[..=k].iter().sum();
let below_sum: f32 = nw[k + 1..].iter().sum();
let mut w = nw.to_vec();
if above_sum > 0.0 {
let s = b / above_sum;
for x in &mut w[..=k] {
*x *= s;
}
}
if below_sum > 0.0 {
let s = (1.0 - b) / below_sum;
for x in &mut w[k + 1..count] {
*x *= s;
}
}
w
}
fn to_arr(v: &[f32]) -> [f32; 3] {
let mut a = [0.0; 3];
for (i, x) in v.iter().take(3).enumerate() {
a[i] = *x;
}
a
}
fn even_arr(count: usize) -> [f32; 3] {
let mut a = [0.0; 3];
let e = 1.0 / count as f32;
for x in a.iter_mut().take(count) {
*x = e;
}
a
}
/// Interpolate between two precomputed rect lists by `t` (used for edge membership transitions).
fn interp_layout(
c: &[(usize, egui::Rect)],
tt: &[(usize, egui::Rect)],
top_c: usize,
top_t: usize,
t: f32,
rect: egui::Rect,
) -> Vec<(usize, egui::Rect)> {
let find = |v: &[(usize, egui::Rect)], slot: usize| v.iter().find(|(s, _)| *s == slot).map(|(_, r)| *r);
let lo = c.first().map(|(s, _)| *s).unwrap_or(0).min(tt.first().map(|(s, _)| *s).unwrap_or(0));
let hi = c.last().map(|(s, _)| *s + 1).unwrap_or(0).max(tt.last().map(|(s, _)| *s + 1).unwrap_or(0));
let mut out = Vec::new();
for slot in lo..hi {
let r = match (find(c, slot), find(tt, slot)) {
(Some(rc), Some(rt)) => lerp_rect(rc, rt, t),
(Some(rc), None) => lerp_rect(rc, collapsed(rect, slot < top_t), t), // exiting
(None, Some(rt)) => lerp_rect(collapsed(rect, slot < top_c), rt, t), // entering
(None, None) => continue,
};
out.push((slot, r));
}
out
}
// --- rendering ---
/// The interaction (drag) target for a band's boundary is its full-width header bar. Bands are
/// laid out in the area above a reserved bottom footer (the BottomEdge handle). Band 0's header is
/// the TopEdge handle; band i's header (i≥1) is the divider above it; the footer is the BottomEdge.
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let top = state.window_top;
let count = state.window_count;
// Landscape caps the window at 2 panes; portrait allows 3.
let max_panes = if rc.shared.is_portrait { 3 } else { 2 };
let header_h = header_height(rc.shared.is_portrait);
// Reserve a footer bar at the very bottom for the BottomEdge handle.
let footer_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - FOOTER_H),
rect.max,
);
let content_area = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.right(), footer_rect.top()),
);
let pane_h = content_area.height() / count as f32;
let trigger = pane_h.min(TRIGGER_MAX);
let now = ui.input(|i| i.time);
// Resting band rects from the committed weights — used for interaction so handles sit at their
// final positions even while the visuals animate.
let nw = nweights(&state.weights, count);
let rest_bands = config_rects(top, count, content_area, &nw);
// The draw layout: a live drag, an in-flight layout ease, or the resting config.
let draw_layout = if let Some(d) = state.drag {
match d.handle {
Handle::Divider(k) if k + 1 < count => {
// Live resize: move divider k's boundary. In 3-pane the dividers are linked along the
// preset path; in 2-pane it's a simple group resize.
let off_frac = d.offset / content_area.height().max(1.0);
let b0: f32 = nw[..=k].iter().sum();
let b = b0 + off_frac;
let w = if count == 3 {
live_weights3(k, b)
} else {
let (bmin, bmax) = boundary_bounds(count, k);
to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)))
};
config_rects(top, count, content_area, &nweights(&w, count))
}
Handle::BottomEdge if count == 1 && top + 1 < N => {
// Continuous reveal: dragging the bottom edge up grows the pane below out of the
// bottom, its divider tracking the finger across the FULL height (not just `trigger`),
// so one drag sweeps the current pane → split → revealed-pane-fullscreen.
let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0);
config_rects(top, 2, content_area, &nweights(&[1.0 - frac, frac, 0.0], 2))
}
Handle::TopEdge if count == 1 && top > 0 => {
// Symmetric reveal of the pane above: dragging the top edge down grows it from the top.
let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0);
config_rects(top - 1, 2, content_area, &nweights(&[frac, 1.0 - frac, 0.0], 2))
}
Handle::BottomEdge if count == 2 && max_panes == 2 => {
// 2-pane (landscape) → 1-pane reveal: dragging the bottom edge up first slides the
// window down to reveal the pane below, then collapses onto it — the whole sweep
// mapped over the FULL height so the dragged boundary reaches the top (rather than
// stalling at the even split after the slide).
let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0);
let even2 = nweights(&even_arr(2), 2);
let even1 = nweights(&even_arr(1), 1);
if top + 2 < N {
if frac <= 0.5 {
let from = config_rects(top, 2, content_area, &even2);
let to = config_rects(top + 1, 2, content_area, &even2);
interp_layout(&from, &to, top, top + 1, frac / 0.5, content_area)
} else {
let from = config_rects(top + 1, 2, content_area, &even2);
let to = config_rects(top + 2, 1, content_area, &even1);
interp_layout(&from, &to, top + 1, top + 2, (frac - 0.5) / 0.5, content_area)
}
} else {
// No pane below → just collapse onto the bottom pane.
let from = config_rects(top, 2, content_area, &even2);
let to = config_rects(top + 1, 1, content_area, &even1);
interp_layout(&from, &to, top, top + 1, frac, content_area)
}
}
Handle::TopEdge if count == 2 && max_panes == 2 => {
// Symmetric 2→1 reveal from the top: dragging the top edge down slides the window up
// to reveal the pane above, then collapses onto it.
let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0);
let even2 = nweights(&even_arr(2), 2);
let even1 = nweights(&even_arr(1), 1);
if top > 0 {
if frac <= 0.5 {
let from = config_rects(top, 2, content_area, &even2);
let to = config_rects(top - 1, 2, content_area, &even2);
interp_layout(&from, &to, top, top - 1, frac / 0.5, content_area)
} else {
let from = config_rects(top - 1, 2, content_area, &even2);
let to = config_rects(top - 1, 1, content_area, &even1);
interp_layout(&from, &to, top - 1, top - 1, (frac - 0.5) / 0.5, content_area)
}
} else {
// No pane above → collapse onto the top pane (drop the bottom).
let from = config_rects(top, 2, content_area, &even2);
let to = config_rects(top, 1, content_area, &even1);
interp_layout(&from, &to, top, top, frac, content_area)
}
}
_ => resolve(d.handle, d.offset, top, count, trigger, max_panes)
.map(|(tt, tc, t)| {
let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc));
interp_layout(&rest_bands, &target, top, tt, t, content_area)
})
.unwrap_or_else(|| rest_bands.clone()),
}
} else if let Some(a) = state.anim {
let p = (((now - a.start) / SNAP_ANIM_SECS) as f32).clamp(0.0, 1.0);
if p >= 1.0 {
state.anim = None;
rest_bands.clone()
} else {
ui.ctx().request_repaint();
let e = ease_out(p);
let from = config_rects(a.from_top, a.from_count, content_area, &nweights(&a.from_w, a.from_count));
let to = config_rects(a.to_top, a.to_count, content_area, &nweights(&a.to_w, a.to_count));
interp_layout(&from, &to, a.from_top, a.to_top, e, content_area)
}
} else {
rest_bands.clone()
};
// 1) Pane content, carving the header off the top of each band.
for (slot, brect) in &draw_layout {
let hh = header_h.min(brect.height());
let content_rect = egui::Rect::from_min_max(
egui::pos2(brect.left(), brect.top() + hh),
brect.max,
);
if content_rect.height() > 1.0 {
let sp = STACK[*slot];
super::surface::render_surface_fullbleed(
ui,
content_rect,
&slot_path(*slot),
sp.pane_type(state.show_instruments),
rc,
);
}
}
// 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single
// pane fills the stack.
let fullscreen = draw_layout.len() == 1;
for (slot, brect) in &draw_layout {
if brect.height() < 6.0 {
continue;
}
let hr = egui::Rect::from_min_max(
brect.left_top(),
egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())),
);
draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal);
}
draw_footer(ui, footer_rect, top + count >= N, pal);
// 3) Interactions on the resting header/footer rects (added last → they win the press).
handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state, max_panes, header_h);
}
fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) {
let p = ui.painter();
// Rounded top corners so the header reads as a tab atop the pane; square at the bottom where
// it meets the pane content.
p.rect_filled(
hr,
egui::CornerRadius { nw: HEADER_RADIUS, ne: HEADER_RADIUS, sw: 0, se: 0 },
pal.header,
);
p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, pal.line));
let cy = hr.center().y;
// Grip glyph on the left (Lucide).
p.text(
egui::pos2(hr.left() + 15.0, cy),
egui::Align2::CENTER_CENTER,
icons::GRIP_HORIZONTAL,
icons::font(16.0),
pal.text_dim,
);
p.text(
egui::pos2(hr.left() + 32.0, cy),
egui::Align2::LEFT_CENTER,
sp.label(show_instruments),
egui::FontId::proportional(15.0),
pal.text,
);
// Right-side buttons: fullscreen / restore rightmost, Node/Instrument toggle just left of it.
p.text(
egui::pos2(hr.right() - BTN_W * 0.5, cy),
egui::Align2::CENTER_CENTER,
if fullscreen { icons::MINIMIZE } else { icons::MAXIMIZE },
icons::font(17.0),
pal.text_dim,
);
if sp == StackPane::NodeInstrument {
p.text(
egui::pos2(hr.right() - BTN_W * 1.5, cy),
egui::Align2::CENTER_CENTER,
icons::ARROW_LEFT_RIGHT,
icons::font(17.0),
pal.accent,
);
}
}
fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool, pal: &Palette) {
let p = ui.painter();
p.rect_filled(fr, 0.0, pal.header);
p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, pal.line));
let col = if at_end { pal.line } else { pal.text_dim };
let cy = fr.center().y;
let galley = p.layout_no_wrap(
"pull up for more".to_string(),
egui::FontId::proportional(11.0),
col,
);
let total_w = 22.0 + galley.size().x;
let start_x = fr.center().x - total_w * 0.5;
p.text(
egui::pos2(start_x + 8.0, cy),
egui::Align2::CENTER_CENTER,
icons::CHEVRONS_UP,
icons::font(14.0),
col,
);
let gy = cy - galley.size().y * 0.5;
p.galley(egui::pos2(start_x + 22.0, gy), galley, col);
}
fn handle_key(h: Handle) -> (usize, usize) {
match h {
Handle::TopEdge => (0, 0),
Handle::Divider(k) => (1, k + 1),
Handle::BottomEdge => (2, 0),
}
}
/// Toggle a slot between filling the stack (count==1) and a 2-pane split with an adjacent pane.
fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64, max: usize) {
if state.window_count == 1 && state.window_top == slot {
// Restore: split with the pane below if possible, else above.
if let Some((t, c)) = op_r5(slot, 1, max).or_else(|| op_r6(slot, 1, max)) {
set_window(state, t, c, now);
}
} else {
set_window(state, slot, 1, now);
}
}
fn handle_interactions(
ui: &mut egui::Ui,
rest_bands: &[(usize, egui::Rect)],
content_area: egui::Rect,
footer_rect: egui::Rect,
trigger: f32,
now: f64,
state: &mut MobileState,
max: usize,
header_h: f32,
) {
// (handle, header_rect, slot) — slot is Some for band headers, None for the footer.
let mut handles: Vec<(Handle, egui::Rect, Option<usize>)> = Vec::new();
for (i, (slot, brect)) in rest_bands.iter().enumerate() {
let hr = egui::Rect::from_min_max(
brect.left_top(),
egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())),
);
let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) };
handles.push((handle, hr, Some(*slot)));
}
handles.push((Handle::BottomEdge, footer_rect, None));
for (handle, hrect, slot_opt) in handles {
let id = ui.id().with(("mobile_stack_handle", handle_key(handle)));
let resp = ui.interact(hrect, id, egui::Sense::click_and_drag());
// Right-side header buttons are interacted AFTER the header (so they're on top and win the
// press there); the rest of the header drives the drag.
if let Some(slot) = slot_opt {
let fs = egui::Rect::from_min_max(
egui::pos2(hrect.right() - BTN_W, hrect.top()),
hrect.max,
);
let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click());
if fsresp.clicked() {
toggle_fullscreen(state, slot, now, max);
}
if STACK[slot] == StackPane::NodeInstrument {
let nt = egui::Rect::from_min_max(
egui::pos2(hrect.right() - 2.0 * BTN_W, hrect.top()),
egui::pos2(hrect.right() - BTN_W, hrect.bottom()),
);
let ntresp = ui.interact(nt, ui.id().with("mobile_node_toggle"), egui::Sense::click());
if ntresp.clicked() {
state.show_instruments = !state.show_instruments;
}
}
}
if resp.drag_started() {
state.drag = Some(StackDrag { handle, offset: 0.0 });
state.anim = None; // a fresh drag cancels any in-flight ease
}
if resp.dragged() {
if let Some(d) = &mut state.drag {
if d.handle == handle {
d.offset += resp.drag_delta().y;
}
}
}
if resp.drag_stopped() {
if let Some(d) = state.drag.take() {
commit_drag(d, state, content_area, trigger, now, max);
}
}
if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
}
}
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64, max: usize) {
let h = content_area.height().max(1.0);
match d.handle {
Handle::Divider(k) if k + 1 < state.window_count => {
commit_divider(state, k, d.offset / h, now);
}
Handle::BottomEdge if state.window_count == 1 && state.window_top + 1 < N => {
// Snap the continuous reveal: near the top → revealed pane fullscreen; barely moved →
// back to the current pane; in between → a 2-pane split at the nearest preset.
let top = state.window_top;
let frac = (-d.offset / h).clamp(0.0, 1.0);
let from_w = [1.0 - frac, frac, 0.0];
if frac >= COLLAPSE_HI {
begin_anim(state, top, 2, from_w, top + 1, 1, even_arr(1), 0.0, now);
} else if frac <= COLLAPSE_LO {
begin_anim(state, top, 2, from_w, top, 1, even_arr(1), 0.0, now);
} else {
begin_anim(state, top, 2, from_w, top, 2, nearest_preset(&from_w, 2), 0.0, now);
}
}
Handle::TopEdge if state.window_count == 1 && state.window_top > 0 => {
let top = state.window_top;
let frac = (d.offset / h).clamp(0.0, 1.0);
let from_w = [frac, 1.0 - frac, 0.0];
if frac >= COLLAPSE_HI {
begin_anim(state, top - 1, 2, from_w, top - 1, 1, even_arr(1), 0.0, now);
} else if frac <= COLLAPSE_LO {
begin_anim(state, top - 1, 2, from_w, top, 1, even_arr(1), 0.0, now);
} else {
begin_anim(state, top - 1, 2, from_w, top - 1, 2, nearest_preset(&from_w, 2), 0.0, now);
}
}
Handle::BottomEdge if state.window_count == 2 && max == 2 => {
// Snap the two-phase 2→1 reveal to match the preview's phases: past the midpoint (phase 2,
// collapsing) → revealed pane fullscreen; a middling drag (phase 1, sliding) → the next
// 2-pane split; barely moved → stay put.
let top = state.window_top;
let frac = (-d.offset / h).clamp(0.0, 1.0);
let even2 = even_arr(2);
if top + 2 < N {
if frac >= 0.5 {
let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0);
begin_anim(state, top + 1, 2, even2, top + 2, 1, even_arr(1), t, now);
} else if frac > COLLAPSE_LO {
let t = (frac / 0.5).clamp(0.0, 1.0);
begin_anim(state, top, 2, even2, top + 1, 2, even2, t, now);
}
// else: barely moved → stay on the current [top, top+1] split.
} else if frac >= 0.5 {
begin_anim(state, top, 2, even2, top + 1, 1, even_arr(1), frac, now);
}
}
Handle::TopEdge if state.window_count == 2 && max == 2 => {
// Symmetric snap for the top-edge 2→1 reveal.
let top = state.window_top;
let frac = (d.offset / h).clamp(0.0, 1.0);
let even2 = even_arr(2);
if top > 0 {
if frac >= 0.5 {
let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0);
begin_anim(state, top - 1, 2, even2, top - 1, 1, even_arr(1), t, now);
} else if frac > COLLAPSE_LO {
let t = (frac / 0.5).clamp(0.0, 1.0);
begin_anim(state, top, 2, even2, top - 1, 2, even2, t, now);
}
// else: barely moved → stay on the current split.
} else if frac >= 0.5 {
begin_anim(state, top, 2, even2, top, 1, even_arr(1), frac, now);
}
}
_ => {
// Edges (and degenerate dividers): membership transition. Animate from the current
// config to the new one, continuing from where the drag's interp left off (~progress t).
let top = state.window_top;
let count = state.window_count;
if let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger, max) {
if t >= 0.5 {
let from_w = to_arr(&nweights(&state.weights, count));
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), t, now);
}
}
}
}
}
/// Apply a divider release. 3-pane and 2-pane have different snap rules (see below). Either way the
/// result is eased in from whatever was on screen at release.
fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) {
let top = state.window_top;
let count = state.window_count;
let nw = nweights(&state.weights, count);
let b0: f32 = nw[..=k].iter().sum();
let b = b0 + offset_frac; // unclamped boundary position of divider k
if count == 3 {
// `from` = what's on screen at release.
let from_w = live_weights3(k, b);
// Sizes of the two extreme panes after this drag, to test the "grown past 66%" rule.
let pane_top = from_w[0]; // grown when dragging the upper divider down
let pane_bot = from_w[2]; // grown when dragging the lower divider up
if k == 0 && pane_top >= COLLAPSE_GROW {
// Upper pane grown out → drop the bottom pane; the surviving {0,1} divider snaps to
// where it was dropped.
if let Some((tt, tc)) = op_r4(top, 3) {
let to_w = collapse_2pane(from_w[0], from_w[1]);
begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now);
}
} else if k == 1 && pane_bot >= COLLAPSE_GROW {
// Bottom pane grown out → drop the top pane; surviving {1,2} divider snaps to drop.
if let Some((tt, tc)) = op_r3(top, 3) {
let to_w = collapse_2pane(from_w[1], from_w[2]);
begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now);
}
} else if b <= COLLAPSE_LO {
if let Some((tt, tc)) = op_r1(top, 3) {
begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now);
}
} else if b >= COLLAPSE_HI {
if let Some((tt, tc)) = op_r2(top, 3) {
begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now);
}
} else {
// Snap both dividers together: round the path parameter to the nearest preset.
let (lo, hi) = path3_range(k);
let s = s_from_boundary(k, b.clamp(lo, hi)).round().clamp(0.0, 2.0);
let to_w = PRESETS_3[s as usize];
begin_anim(state, top, 3, from_w, top, 3, to_w, 0.0, now);
}
return;
}
// 2-pane: group resize snapping to the nearest 2-pane preset, but releasing near an edge
// collapses to a single fullscreen pane (the one that grew). It does NOT slide in the next pane —
// that's the job of the top/bottom edge handles.
let (bmin, bmax) = boundary_bounds(count, k);
let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)));
if b <= COLLAPSE_LO {
// Divider dragged to the top → the top pane is squeezed out; the bottom pane goes fullscreen.
begin_anim(state, top, count, from_w, top + 1, 1, even_arr(1), 0.0, now);
} else if b >= COLLAPSE_HI {
// Divider dragged to the bottom → the bottom pane is squeezed out; the top pane goes fullscreen.
begin_anim(state, top, count, from_w, top, 1, even_arr(1), 0.0, now);
} else {
let to_w = nearest_preset(&from_w, count);
begin_anim(state, top, count, from_w, top, count, to_w, 0.0, now);
}
}
/// Given the two surviving panes' (unnormalized) sizes after a 3→2 collapse, snap to the nearest
/// 2-pane preset by the first pane's proportion.
fn collapse_2pane(a: f32, b: f32) -> [f32; 3] {
let total = (a + b).max(1e-4);
let p0 = a / total;
nearest_preset(&[p0, 1.0 - p0, 0.0], 2)
}
/// Switch to a new window config, easing from `from_*` to the new state over the snap duration.
/// `start_p` back-dates the animation so it can continue an in-progress drag.
fn begin_anim(
state: &mut MobileState,
from_top: usize,
from_count: usize,
from_w: [f32; 3],
to_top: usize,
to_count: usize,
to_w: [f32; 3],
start_p: f32,
now: f64,
) {
state.window_top = to_top;
state.window_count = to_count;
state.weights = to_w;
state.anim = Some(LayoutAnim {
from_top,
from_count,
from_w,
to_top,
to_count,
to_w,
start: now - start_p as f64 * SNAP_ANIM_SECS,
});
}
/// Switch the visible window and reset to even pane sizing, easing the transition.
fn set_window(state: &mut MobileState, top: usize, count: usize, now: f64) {
let from_w = to_arr(&nweights(&state.weights, state.window_count));
begin_anim(state, state.window_top, state.window_count, from_w, top, count, even_arr(count), 0.0, now);
}

View File

@ -0,0 +1,33 @@
//! Full-bleed hero surface rendering: maps a mobile surface to an existing pane and renders
//! its content into a rect with no header/divider chrome. This is the de-chromed core of
//! `render_pane` (main.rs) — see the get-or-create + `render_content` pattern there.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
use crate::panes::{NodePath, PaneInstance, PaneRenderer};
use crate::RenderContext;
/// Render `pane_type` full-bleed into `rect`, reusing (or creating) the cached pane instance
/// keyed by `path`. The pane clips its own drawing to `rect`, so no extra clipping is needed.
pub fn render_surface_fullbleed(
ui: &mut egui::Ui,
rect: egui::Rect,
path: &NodePath,
pane_type: PaneType,
rc: &mut RenderContext,
) {
// Get-or-create the instance for this slot (recreate if the type changed).
let needs_new = rc
.pane_instances
.get(path)
.map(|inst| inst.pane_type() != pane_type)
.unwrap_or(true);
if needs_new {
rc.pane_instances.insert(path.clone(), PaneInstance::new(pane_type));
}
if let Some(inst) = rc.pane_instances.get_mut(path) {
inst.render_content(ui, rect, path, &mut rc.shared);
}
}

View File

@ -0,0 +1,271 @@
//! The mobile top bar: the project filename on the left, and on the right ⌕ (command palette) and
//! ⋯ (overflow commands). Both open a modal sheet whose items dispatch `MenuAction`s (which
//! `main.rs::handle_menu_action` already implements). Per the wireframe these are the
//! "commands/destinations", as opposed to the omnibutton's "tools/objects".
use eframe::egui;
use super::{icons, MobileState, Palette};
use crate::menu::{MenuAction, MenuDef, MenuItemDef};
use crate::RenderContext;
const BTN: f32 = 40.0;
/// Curated overflow (⋯) commands.
fn overflow_items() -> [(&'static str, MenuAction); 9] {
[
("Save", MenuAction::Save),
("Save As…", MenuAction::SaveAs),
("Open File…", MenuAction::OpenFile),
("New file…", MenuAction::NewFile),
("Import…", MenuAction::Import),
("Export…", MenuAction::Export),
("Undo", MenuAction::Undo),
("Redo", MenuAction::Redo),
("Preferences", MenuAction::Preferences),
]
}
/// Flatten the whole menu tree into (path-label, action) for the command palette.
fn flatten(defs: &'static [MenuDef], prefix: &str, out: &mut Vec<(String, MenuAction)>) {
for d in defs {
match d {
MenuDef::Item(item) => out.push((format!("{prefix}{}", item.label), item.action)),
MenuDef::Submenu { label, children } => {
flatten(children, &format!("{prefix}{label} "), out);
}
MenuDef::Separator => {}
}
}
}
fn all_commands() -> Vec<(String, MenuAction)> {
let mut v = Vec::new();
flatten(MenuItemDef::menu_structure(), "", &mut v);
v
}
pub fn render(
ui: &mut egui::Ui,
bar: egui::Rect,
full: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
) {
ui.painter().rect_filled(bar, 0.0, pal.header);
ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, pal.line));
// Filename (or app name when unsaved).
let name = rc
.shared
.container_path
.as_ref()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Lightningbeam".to_string());
ui.painter().text(
egui::pos2(bar.left() + 14.0, bar.center().y),
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(14.0),
pal.text,
);
// Right cluster: ⌕ (palette) then ⋯ (overflow).
let overflow_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - BTN, bar.top()), egui::vec2(BTN, bar.height()));
let palette_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - 2.0 * BTN, bar.top()), egui::vec2(BTN, bar.height()));
let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click());
ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0),
if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim });
if sresp.clicked() {
state.palette_open = !state.palette_open;
state.overflow_open = false;
state.palette_query.clear();
}
let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click());
ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0),
if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim });
if oresp.clicked() {
state.overflow_open = !state.overflow_open;
state.palette_open = false;
}
if state.overflow_open {
render_overflow(ui, full, rc, state, pal);
} else if state.palette_open {
render_palette(ui, full, rc, state, pal);
}
}
/// Landscape: the app bar folded into the middle of the top pane header — filename + ⌕ + ⋯ as a
/// centered cluster (the pane's own grip/label sits to the left, its buttons to the right). No bar
/// background or divider line; the header already drew them.
pub fn render_inline(
ui: &mut egui::Ui,
header: egui::Rect,
full: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
) {
let name = rc
.shared
.container_path
.as_ref()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Lightningbeam".to_string());
let btn = header.height().min(BTN);
let gap = 6.0;
// Reserve gutters so the cluster never overlaps the pane header's own grip/label (left) or its
// fullscreen / node-toggle buttons (right).
let left_gutter = 150.0;
let right_gutter = 2.0 * BTN + 16.0;
let span_left = header.left() + left_gutter;
let span_right = (header.right() - right_gutter).max(span_left);
let span_w = span_right - span_left;
// Filename elided to fit whatever's left after the two buttons.
let max_name_w = (span_w - gap - 2.0 * btn).max(0.0);
let galley = {
let mut job =
egui::text::LayoutJob::simple_singleline(name, egui::FontId::proportional(14.0), pal.text);
job.wrap.max_width = max_name_w;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
ui.painter().layout_job(job)
};
let name_w = galley.rect.width();
let name_h = galley.rect.height();
let cluster_w = name_w + gap + 2.0 * btn;
// Center within the reserved span, then clamp so the right edge stays inside it.
let x0 = (span_left + (span_w - cluster_w) / 2.0).clamp(span_left, (span_right - cluster_w).max(span_left));
let cy = header.center().y;
let bt = header.top() + (header.height() - btn) / 2.0;
ui.painter().galley(egui::pos2(x0, cy - name_h / 2.0), galley, pal.text);
let palette_rect = egui::Rect::from_min_size(egui::pos2(x0 + name_w + gap, bt), egui::vec2(btn, btn));
let overflow_rect = egui::Rect::from_min_size(egui::pos2(palette_rect.right(), bt), egui::vec2(btn, btn));
let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click());
ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0),
if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim });
if sresp.clicked() {
state.palette_open = !state.palette_open;
state.overflow_open = false;
state.palette_query.clear();
}
let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click());
ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0),
if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim });
if oresp.clicked() {
state.overflow_open = !state.overflow_open;
state.palette_open = false;
}
if state.overflow_open {
render_overflow(ui, full, rc, state, pal);
} else if state.palette_open {
render_palette(ui, full, rc, state, pal);
}
}
/// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect).
fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (bool, egui::Rect) {
let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click());
ui.painter().rect_filled(full, 0.0, pal.scrim);
let panel = egui::Rect::from_min_max(
egui::pos2(full.left() + 16.0, full.top() + 44.0),
egui::pos2(full.right() - 16.0, full.bottom() - 60.0),
);
ui.painter().rect_filled(panel, 14.0, pal.surface);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
(scrim.clicked(), panel)
}
fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize), pal: &Palette) -> bool {
let resp = ui.interact(r, ui.id().with(("mobile_cmd", key.0, key.1)), egui::Sense::click());
ui.painter().rect_filled(r, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, pal.line));
ui.painter().text(
egui::pos2(r.left() + 14.0, r.center().y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
pal.text,
);
resp.clicked()
}
fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "overflow", pal);
let mut close = backdrop;
let items = overflow_items();
let row_h = 44.0;
let inner = panel.shrink(8.0);
for (i, (label, action)) in items.iter().enumerate() {
let r = egui::Rect::from_min_size(
egui::pos2(inner.left(), inner.top() + i as f32 * row_h),
egui::vec2(inner.width(), row_h),
);
if r.bottom() > inner.bottom() {
break;
}
if command_button(ui, r, label, ("of", i), pal) {
rc.shared.pending_menu_actions.push(*action);
close = true;
}
}
if close {
state.overflow_open = false;
}
}
fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "palette", pal);
let mut close = backdrop;
let inner = panel.shrink(8.0);
// Search field (real egui widget).
let field = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), 30.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::left_to_right(egui::Align::Center)));
let te = child.add(
egui::TextEdit::singleline(&mut state.palette_query)
.hint_text("Search commands…")
.desired_width(inner.width()),
);
// Focus the field only when it isn't already focused — re-requesting every frame would stomp
// focus and prevent anything else in the panel from taking it.
if !te.has_focus() {
te.request_focus();
}
// Filtered list.
let q = state.palette_query.to_lowercase();
let cmds = all_commands();
let row_h = 38.0;
let list_top = inner.top() + 38.0;
let mut y = list_top;
for (i, (label, action)) in cmds.iter().enumerate() {
if !q.is_empty() && !label.to_lowercase().contains(&q) {
continue;
}
let r = egui::Rect::from_min_size(egui::pos2(inner.left(), y), egui::vec2(inner.width(), row_h));
if r.bottom() > inner.bottom() {
break;
}
if command_button(ui, r, label, ("pal", i), pal) {
rc.shared.pending_menu_actions.push(*action);
close = true;
}
y += row_h;
}
if close {
state.palette_open = false;
}
}

View File

@ -0,0 +1,116 @@
//! The transport "floor": the always-present bottom bar with play/pause, the timecode, and a
//! project scrub. Wired to the audio controller exactly like `TimelinePane`'s header.
use eframe::egui;
use super::{icons, Palette};
use crate::panes::SharedPaneState;
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, pal: &Palette) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, pal.surface);
painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
let cy = rect.center().y;
// --- Play / pause button (circle on the left) ---
let btn_r = 18.0;
let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy);
let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0));
let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click());
painter.circle_filled(btn_center, btn_r, pal.accent);
let glyph = if *shared.is_playing { icons::PAUSE } else { icons::PLAY };
painter.text(
btn_center,
egui::Align2::CENTER_CENTER,
glyph,
icons::font(16.0),
pal.on_accent,
);
if btn_resp.clicked() {
*shared.is_playing = !*shared.is_playing;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
if *shared.is_playing {
controller.seek(*shared.playback_time);
controller.play();
} else {
controller.pause();
}
}
}
// --- Playhead readout: format follows the project's TimelineMode (set at creation by type). ---
let t = *shared.playback_time;
let tc = {
use lightningbeam_core::document::TimelineMode;
let doc = shared.action_executor.document();
match doc.timeline_mode {
TimelineMode::Measures => {
let pos = lightningbeam_core::beat_time::time_to_measure(t, doc.tempo_map(), &doc.time_signature);
format!("{}.{}.{:02}", pos.measure, pos.beat, pos.tick / 10)
}
TimelineMode::Frames => format_timecode(t, doc.framerate.max(1.0)),
TimelineMode::Seconds => {
let total = t.max(0.0);
let m = (total / 60.0).floor() as u32;
format!("{:02}:{:06.3}", m, total % 60.0)
}
}
};
let tc_left = btn_rect.right() + 12.0;
painter.text(
egui::pos2(tc_left, cy),
egui::Align2::LEFT_CENTER,
&tc,
egui::FontId::monospace(13.0),
pal.text,
);
let tc_width = 78.0;
// --- Project scrub (fills the remaining width) ---
let duration = shared.action_executor.document().duration.max(1.0);
let scrub_left = tc_left + tc_width;
let scrub_rect = egui::Rect::from_min_max(
egui::pos2(scrub_left, cy - 3.0),
egui::pos2(rect.right() - 14.0, cy + 3.0),
);
let scrub_resp = ui.interact(
scrub_rect.expand2(egui::vec2(0.0, 12.0)),
ui.id().with("mobile_transport_scrub"),
egui::Sense::click_and_drag(),
);
painter.rect_filled(scrub_rect, 3.0, pal.line);
let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32;
let filled = egui::Rect::from_min_max(
scrub_rect.min,
egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()),
);
painter.rect_filled(filled, 3.0, pal.accent.gamma_multiply(0.5));
let head_x = scrub_rect.left() + scrub_rect.width() * frac;
painter.vline(
head_x,
(scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0),
egui::Stroke::new(2.0, pal.accent),
);
if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 {
if let Some(pos) = scrub_resp.interact_pointer_pos() {
let f = ((pos.x - scrub_rect.left()) / scrub_rect.width()).clamp(0.0, 1.0) as f64;
let new_time = f * duration;
*shared.playback_time = new_time;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(new_time);
}
}
}
}
fn format_timecode(seconds: f64, fps: f64) -> String {
let total = seconds.max(0.0);
let minutes = (total / 60.0).floor() as u32;
let secs = (total % 60.0).floor() as u32;
let frames = ((total - total.floor()) * fps).floor() as u32;
format!("{:02}:{:02}:{:02}", minutes, secs, frames)
}

View File

@ -1292,6 +1292,9 @@ impl AssetLibraryPane {
lightningbeam_core::layer::AnyLayer::Raster(_) => { lightningbeam_core::layer::AnyLayer::Raster(_) => {
// Raster layers don't have their own clip instances // Raster layers don't have their own clip instances
} }
lightningbeam_core::layer::AnyLayer::Text(_) => {
// Text layers don't have their own clip instances
}
} }
} }
false false

View File

@ -44,12 +44,23 @@ pub struct InfopanelPane {
selected_tool_gradient_stop: Option<usize>, selected_tool_gradient_stop: Option<usize>,
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit) /// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
fps_drag_start: Option<f64>, fps_drag_start: Option<f64>,
/// In-progress layer-name edit buffer, keyed by the layer being edited.
layer_name_edit: Option<(Uuid, String)>,
/// Layer opacity/volume captured when a slider drag starts (single-undo on commit).
layer_opacity_drag_start: Option<f64>,
layer_volume_drag_start: Option<f64>,
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas). /// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode, raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
/// Font families already registered into egui for the family-picker previews
/// (rendered each entry in its own font), so we only load each once.
registered_preview_fonts: std::collections::HashSet<String>,
} }
impl InfopanelPane { impl InfopanelPane {
pub fn new() -> Self { pub fn new() -> Self {
// Kick off background loading of the font-picker fonts at startup so the dropdown
// is ready without a hitch by the time the user opens it.
lightningbeam_core::fonts::start_preload();
let presets = bundled_brushes(); let presets = bundled_brushes();
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
Self { Self {
@ -63,9 +74,30 @@ impl InfopanelPane {
selected_shape_gradient_stop: None, selected_shape_gradient_stop: None,
selected_tool_gradient_stop: None, selected_tool_gradient_stop: None,
fps_drag_start: None, fps_drag_start: None,
layer_name_edit: None,
layer_opacity_drag_start: None,
layer_volume_drag_start: None,
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale, raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
registered_preview_fonts: std::collections::HashSet::new(),
} }
} }
/// Register pre-loaded `bytes` for `family` into egui under `FontFamily::Name(family)`
/// so a label can be rendered in that font. Cheap (no font IO); the add is idempotent
/// and batched into one atlas rebuild at frame end, so the preview appears next frame.
fn register_font_bytes(&mut self, ctx: &egui::Context, family: &str, bytes: Vec<u8>) {
if family.is_empty() || !self.registered_preview_fonts.insert(family.to_string()) {
return;
}
ctx.add_font(egui::epaint::text::FontInsert::new(
family,
egui::FontData::from_owned(bytes),
vec![egui::epaint::text::InsertFontFamily {
family: egui::FontFamily::Name(family.into()),
priority: egui::epaint::text::FontPriority::Highest,
}],
));
}
} }
/// Aggregated info about the current DCEL selection /// Aggregated info about the current DCEL selection
@ -1141,8 +1173,9 @@ impl InfopanelPane {
} }
/// Render layer info section /// Render layer info section
fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) { fn render_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_ids: &[Uuid]) {
let document = shared.action_executor.document(); use lightningbeam_core::actions::{set_layer_properties::LayerProperty, SetLayerPropertiesAction};
use lightningbeam_core::layer::AudioLayerType;
egui::CollapsingHeader::new("Layer") egui::CollapsingHeader::new("Layer")
.id_salt(("layer_info", path)) .id_salt(("layer_info", path))
@ -1150,52 +1183,153 @@ impl InfopanelPane {
.show(ui, |ui| { .show(ui, |ui| {
ui.add_space(4.0); ui.add_space(4.0);
if layer_ids.len() == 1 { if layer_ids.len() != 1 {
if let Some(layer) = document.get_layer(&layer_ids[0]) { ui.label(format!("{} layers selected", layer_ids.len()));
ui.horizontal(|ui| { ui.add_space(4.0);
ui.label("Name:"); return;
ui.label(layer.name()); }
}); let layer_id = layer_ids[0];
// Snapshot the current values, then drop the document borrow before mutating shared.
let Some((type_name, is_audio, name, opacity, volume, muted, soloed, locked)) = ({
let document = shared.action_executor.document();
document.get_layer(&layer_id).map(|layer| {
let type_name = match layer { let type_name = match layer {
AnyLayer::Vector(_) => "Vector", AnyLayer::Vector(_) => "Vector",
AnyLayer::Audio(a) => match a.audio_layer_type { AnyLayer::Audio(a) => match a.audio_layer_type {
lightningbeam_core::layer::AudioLayerType::Midi => "MIDI", AudioLayerType::Midi => "MIDI",
lightningbeam_core::layer::AudioLayerType::Sampled => "Audio", AudioLayerType::Sampled => "Audio",
}, },
AnyLayer::Video(_) => "Video", AnyLayer::Video(_) => "Video",
AnyLayer::Effect(_) => "Effect", AnyLayer::Effect(_) => "Effect",
AnyLayer::Group(_) => "Group", AnyLayer::Group(_) => "Group",
AnyLayer::Raster(_) => "Raster", AnyLayer::Raster(_) => "Raster",
AnyLayer::Text(_) => "Text",
}; };
ui.horizontal(|ui| { (
ui.label("Type:"); type_name,
ui.label(type_name); matches!(layer, AnyLayer::Audio(_)),
}); layer.name().to_string(),
layer.opacity(),
layer.volume(),
layer.muted(),
layer.soloed(),
layer.locked(),
)
})
}) else {
return;
};
ui.horizontal(|ui| { // Name (editable; commits on Enter / focus loss).
ui.label("Opacity:"); if self.layer_name_edit.as_ref().map(|(id, _)| *id) != Some(layer_id) {
ui.label(format!("{:.0}%", layer.opacity() * 100.0)); self.layer_name_edit = Some((layer_id, name.clone()));
}); }
ui.horizontal(|ui| {
if matches!(layer, AnyLayer::Audio(_)) { ui.label("Name:");
ui.horizontal(|ui| { let buf = &mut self.layer_name_edit.as_mut().unwrap().1;
ui.label("Volume:"); let resp = ui.text_edit_singleline(buf);
ui.label(format!("{:.0}%", layer.volume() * 100.0)); let commit = resp.lost_focus()
}); && (ui.input(|i| i.key_pressed(egui::Key::Enter)) || !resp.has_focus());
} if commit {
let new_name = buf.trim().to_string();
if layer.muted() { if !new_name.is_empty() && new_name != name {
ui.label("Muted"); shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
} layer_id,
if layer.locked() { LayerProperty::Name(new_name),
ui.label("Locked"); )));
} }
} }
} else { });
ui.label(format!("{} layers selected", layer_ids.len()));
ui.horizontal(|ui| {
ui.label("Type:");
ui.label(type_name);
});
// Opacity slider (single-undo: live-preview while dragging, commit on release).
ui.horizontal(|ui| {
ui.label("Opacity:");
let mut op = opacity;
let r = ui.add(egui::Slider::new(&mut op, 0.0..=1.0).show_value(false));
ui.label(format!("{:.0}%", op * 100.0));
if (r.drag_started() || r.gained_focus()) && self.layer_opacity_drag_start.is_none() {
self.layer_opacity_drag_start = Some(opacity);
}
if r.changed() {
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
l.set_opacity(op);
}
}
if r.drag_stopped() || r.lost_focus() {
if let Some(start) = self.layer_opacity_drag_start.take() {
if (start - op).abs() > 1e-6 {
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
l.set_opacity(start); // revert so the action owns the change
}
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
layer_id,
LayerProperty::Opacity(op),
)));
}
}
}
});
if is_audio {
ui.horizontal(|ui| {
ui.label("Volume:");
let mut vol = volume;
let r = ui.add(egui::Slider::new(&mut vol, 0.0..=1.0).show_value(false));
ui.label(format!("{:.0}%", vol * 100.0));
if (r.drag_started() || r.gained_focus()) && self.layer_volume_drag_start.is_none() {
self.layer_volume_drag_start = Some(volume);
}
if r.changed() {
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
l.set_volume(vol);
}
}
if r.drag_stopped() || r.lost_focus() {
if let Some(start) = self.layer_volume_drag_start.take() {
if (start - vol).abs() > 1e-6 {
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
l.set_volume(start);
}
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
layer_id,
LayerProperty::Volume(vol),
)));
}
}
}
});
} }
// Toggles: mute / solo (audio) / lock.
ui.horizontal(|ui| {
if is_audio {
if ui.selectable_label(muted, "Mute").clicked() {
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
layer_id,
LayerProperty::Muted(!muted),
)));
}
if ui.selectable_label(soloed, "Solo").clicked() {
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
layer_id,
LayerProperty::Soloed(!soloed),
)));
}
}
if ui.selectable_label(locked, "Lock").clicked() {
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
layer_id,
LayerProperty::Locked(!locked),
)));
}
});
ui.add_space(4.0); ui.add_space(4.0);
}); });
} }
@ -1247,6 +1381,186 @@ impl InfopanelPane {
}); });
} }
/// Render a text-layer section: edit the text content, font, size, color,
/// alignment, and box size of the active text layer. Driven by the *active*
/// layer so it appears whenever a text layer is selected.
fn render_text_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) {
use lightningbeam_core::text_layer::TextAlign;
use lightningbeam_core::actions::{ResizeTextBoxAction, SetTextContentAction};
// Snapshot current values, then drop the document borrow before mutating shared.
let snapshot = {
let document = shared.action_executor.document();
match document.get_layer(&layer_id) {
Some(AnyLayer::Text(tl)) => {
Some((tl.content.clone(), tl.box_origin, tl.box_width, tl.box_height))
}
_ => None,
}
};
let Some((content0, origin, w0, h0)) = snapshot else { return };
// Families egui has actually bound (added fonts take effect next frame). Using an
// unbound `FontFamily::Name` panics, so only preview-render families in this set.
let bound_families: std::collections::HashSet<egui::FontFamily> =
ui.ctx().fonts(|f| f.families().into_iter().collect());
// Set when the font dropdown popup is open this frame (its closure only runs then),
// so we can synchronously finish loading any not-yet-preloaded fonts the user sees.
let mut dropdown_open = false;
egui::CollapsingHeader::new("Text")
.id_salt(("text_layer", path))
.default_open(true)
.show(ui, |ui| {
ui.add_space(4.0);
let mut content = content0.clone();
let mut content_changed = false;
ui.label("Text:");
if ui
.add(egui::TextEdit::multiline(&mut content.text).desired_rows(2).desired_width(f32::INFINITY))
.changed()
{
content_changed = true;
}
ui.horizontal(|ui| {
ui.label("Size:");
if ui
.add(egui::DragValue::new(&mut content.font_size).range(1.0..=2000.0).speed(0.5))
.changed()
{
content_changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Color:");
// content.color is sRGB-encoded RGBA in 0..1 (matches peniko Color::new).
let mut col = egui::Color32::from_rgba_unmultiplied(
(content.color[0] * 255.0).round() as u8,
(content.color[1] * 255.0).round() as u8,
(content.color[2] * 255.0).round() as u8,
(content.color[3] * 255.0).round() as u8,
);
if ui.color_edit_button_srgba(&mut col).changed() {
content.color = [
col.r() as f32 / 255.0,
col.g() as f32 / 255.0,
col.b() as f32 / 255.0,
col.a() as f32 / 255.0,
];
content_changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Align:");
for (label, a) in [("L", TextAlign::Left), ("C", TextAlign::Center), ("R", TextAlign::Right), ("J", TextAlign::Justify)] {
if ui.selectable_label(content.align == a, label).clicked() {
content.align = a;
content_changed = true;
}
}
});
// Font family picker (bundled families first, then system; embedded fonts
// register under their own names on load).
ui.horizontal(|ui| {
ui.label("Font:");
let current_label = if content.font_family.is_empty() {
"(Default)".to_string()
} else {
content.font_family.clone()
};
egui::ComboBox::from_id_salt(("text_font", path))
.selected_text(current_label)
.show_ui(ui, |ui| {
dropdown_open = true;
if ui.selectable_label(content.font_family.is_empty(), "(Default)").clicked() {
content.font_family.clear();
content_changed = true;
}
// The popup closure only runs while the dropdown is open, so each
// entry is rendered in its own font and queued for registration.
for fam in lightningbeam_core::fonts::families() {
let named = egui::FontFamily::Name(fam.as_str().into());
// Render in its own font only once egui has bound it; otherwise
// plain (and queue it for registration so it previews next frame).
let label = if bound_families.contains(&named) {
egui::RichText::new(&fam).family(named)
} else {
egui::RichText::new(&fam)
};
if ui.selectable_label(content.font_family == fam, label).clicked() {
content.font_family = fam.clone();
content_changed = true;
}
}
});
// Missing-font indicator.
if !content.font_family.is_empty()
&& !lightningbeam_core::fonts::family_available(&content.font_family)
{
ui.colored_label(egui::Color32::from_rgb(230, 160, 60), "⚠ missing");
}
});
// Box size.
let mut bw = w0;
let mut bh = h0;
let mut box_changed = false;
ui.horizontal(|ui| {
ui.label("Box:");
if ui.add(egui::DragValue::new(&mut bw).range(1.0..=100000.0).speed(1.0)).changed() {
box_changed = true;
}
ui.label("×");
if ui.add(egui::DragValue::new(&mut bh).range(1.0..=100000.0).speed(1.0)).changed() {
box_changed = true;
}
});
ui.add_space(4.0);
if content_changed {
shared.pending_actions.push(Box::new(SetTextContentAction::new(layer_id, content)));
}
if box_changed {
shared.pending_actions.push(Box::new(ResizeTextBoxAction::new(layer_id, origin, bw, bh)));
}
});
// Register fonts the background preloader has finished (cheap: bytes already loaded),
// a few per frame so each atlas rebuild stays small. Only fall back to synchronous
// loading for families the user is actively viewing in the open dropdown that the
// background hasn't reached yet. Repaint while there's more to do or the preloader
// is still running, so registration keeps flowing without input.
let ctx = ui.ctx().clone();
const PER_FRAME: usize = 4;
let mut budget = PER_FRAME;
let mut more = false;
for fam in lightningbeam_core::fonts::families() {
if self.registered_preview_fonts.contains(&fam) {
continue;
}
let bytes = lightningbeam_core::fonts::take_preloaded(&fam).or_else(|| {
if dropdown_open { lightningbeam_core::fonts::family_font_bytes(&fam) } else { None }
});
if let Some(bytes) = bytes {
if budget == 0 {
more = true;
break;
}
self.register_font_bytes(&ctx, &fam, bytes);
budget -= 1;
}
}
if more || !lightningbeam_core::fonts::preload_done() {
ctx.request_repaint();
}
}
/// Render clip instance info section /// Render clip instance info section
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) { fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
let document = shared.action_executor.document(); let document = shared.action_executor.document();
@ -1270,6 +1584,7 @@ impl InfopanelPane {
AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}; };
if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { if let Some(ci) = instances.iter().find(|c| c.id == ci_id) {
found = true; found = true;
@ -1601,6 +1916,7 @@ impl PaneRenderer for InfopanelPane {
// active (independent of selection focus, since painting doesn't focus the layer). // active (independent of selection focus, since painting doesn't focus the layer).
if let Some(active_id) = *shared.active_layer_id { if let Some(active_id) = *shared.active_layer_id {
self.render_raster_layer_section(ui, path, shared, active_id); self.render_raster_layer_section(ui, path, shared, active_id);
self.render_text_layer_section(ui, path, shared, active_id);
} }
// Onion-skinning view settings — always available, regardless of selection. // Onion-skinning view settings — always available, regardless of selection.

View File

@ -0,0 +1,108 @@
//! Shared horizontal keyboard geometry (pitch → x), used by both the Virtual Piano and the mobile
//! portrait "Synthesia" Piano Roll so their columns line up with the keys. It is **width-driven**
//! (key width from the pane width, not its height) and supports a smooth horizontal **pan** (pixels)
//! so the roll and keyboard scroll together and stay aligned.
/// Number of white keys strictly before each pitch-class within its octave.
const WHITES_BEFORE: [i32; 12] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6];
/// White pitch-classes, indexed by white-key-within-octave.
const WHITE_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
/// Target on-screen white-key width (px); the visible white-key count approximates it.
const TARGET_WHITE_KEY_W: f32 = 40.0;
/// Absolute white-key index of a note, counting white keys from MIDI 0.
fn awi(note: u8) -> i32 {
(note as i32 / 12) * 7 + WHITES_BEFORE[(note % 12) as usize]
}
/// The white note at a given absolute white-key index.
fn white_note_from_awi(a: i32) -> u8 {
let oct = a.div_euclid(7);
let rem = a.rem_euclid(7) as usize;
(oct * 12 + WHITE_PC[rem]).clamp(0, 127) as u8
}
/// A horizontal piano key layout for a pane width, octave center, and horizontal pan.
#[derive(Clone, Copy)]
pub struct KeyboardLayout {
pub white_key_width: f32,
pub black_key_width: f32,
/// `note_x(white n) == base_x + awi(n) * white_key_width`.
base_x: f32,
rect_left: f32,
rect_width: f32,
}
impl KeyboardLayout {
pub fn is_black_key(note: u8) -> bool {
matches!(note % 12, 1 | 3 | 6 | 8 | 10)
}
/// Build a layout. `pan_x` shifts everything right (drag-right reveals lower keys); at `pan_x == 0`
/// the octave-center key sits in the middle of the pane.
pub fn from_width(origin_x: f32, width: f32, octave: i8, pan_x: f32) -> Self {
let vis = ((width / TARGET_WHITE_KEY_W).round() as u32).clamp(7, 24) as f32;
let white_key_width = width / vis;
let black_key_width = white_key_width * 0.6;
let center = (60 + octave as i32 * 12).clamp(0, 127) as u8;
let base_x = origin_x + width / 2.0 - (awi(center) as f32 + 0.5) * white_key_width + pan_x;
Self { white_key_width, black_key_width, base_x, rect_left: origin_x, rect_width: width }
}
/// Snap a pan offset to the nearest whole key (for release-snap).
pub fn snap_pan(&self, pan_x: f32) -> f32 {
(pan_x / self.white_key_width).round() * self.white_key_width
}
/// Left x of a key's rect (black keys straddle the white boundary).
pub fn note_x(&self, note: u8) -> f32 {
let x = self.base_x + awi(note) as f32 * self.white_key_width;
if Self::is_black_key(note) {
x - self.black_key_width / 2.0
} else {
x
}
}
pub fn note_width(&self, note: u8) -> f32 {
if Self::is_black_key(note) {
self.black_key_width
} else {
self.white_key_width
}
}
pub fn note_center_x(&self, note: u8) -> f32 {
self.note_x(note) + self.note_width(note) / 2.0
}
/// Leftmost visible white key (with one key of margin).
pub fn first_visible_white(&self) -> u8 {
let a = ((self.rect_left - self.base_x) / self.white_key_width).floor() as i32 - 1;
white_note_from_awi(a.max(0))
}
/// Rightmost visible note (with one key of margin).
pub fn last_visible_note(&self) -> u8 {
let a = ((self.rect_left + self.rect_width - self.base_x) / self.white_key_width).ceil() as i32 + 1;
white_note_from_awi(a).min(127)
}
pub fn visible_notes(&self) -> std::ops::RangeInclusive<u8> {
self.first_visible_white()..=self.last_visible_note()
}
/// Which key is at screen x (black keys, drawn on top, take precedence).
pub fn x_to_note(&self, x: f32) -> u8 {
for note in self.visible_notes() {
if Self::is_black_key(note) {
let c = self.note_center_x(note);
if (x - c).abs() <= self.black_key_width / 2.0 {
return note;
}
}
}
let a = ((x - self.base_x) / self.white_key_width).floor() as i32;
white_note_from_awi(a.max(0)).min(127)
}
}

View File

@ -72,6 +72,7 @@ pub mod gradient_editor;
pub mod timeline; pub mod timeline;
pub mod infopanel; pub mod infopanel;
pub mod outliner; pub mod outliner;
pub mod keyboard_layout;
pub mod piano_roll; pub mod piano_roll;
pub mod virtual_piano; pub mod virtual_piano;
pub mod node_editor; pub mod node_editor;
@ -212,10 +213,15 @@ pub struct SharedPaneState<'a> {
pub editing_instance_id: Option<uuid::Uuid>, pub editing_instance_id: Option<uuid::Uuid>,
/// The parent layer ID containing the clip instance being edited /// The parent layer ID containing the clip instance being edited
pub editing_parent_layer_id: Option<uuid::Uuid>, pub editing_parent_layer_id: Option<uuid::Uuid>,
/// The full clip_id path being edited, outermost → current (for breadcrumbs). Empty at root.
pub editing_clip_path: Vec<uuid::Uuid>,
/// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id) /// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id)
pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>, pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>,
/// Request to exit the current movie clip /// Request to exit the current movie clip
pub pending_exit_clip: &'a mut bool, pub pending_exit_clip: &'a mut bool,
/// Request to exit up to a specific editing depth (number of clips to keep); e.g. a breadcrumb
/// click. `Some(0)` exits all the way to the document root.
pub pending_exit_to_depth: &'a mut Option<usize>,
/// Currently active layer ID /// Currently active layer ID
pub active_layer_id: &'a mut Option<uuid::Uuid>, pub active_layer_id: &'a mut Option<uuid::Uuid>,
/// Current tool interaction state (mutable for tools to modify) /// Current tool interaction state (mutable for tools to modify)
@ -356,6 +362,37 @@ pub struct SharedPaneState<'a> {
/// on the first frame; panes (e.g. infopanel) convert the pixel data to egui /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui
/// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`.
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>, pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
/// True when rendering the phone/mobile shell (panes can render more compactly).
pub is_mobile: bool,
/// Device orientation for the mobile shell: portrait (tall) vs landscape (wide). Defaults to
/// `true`; the mobile shell sets it from the available rect. Panes that reflow (e.g. the Piano
/// Roll's vertical vs conventional layout) key off this.
pub is_portrait: bool,
/// Shared keyboard octave offset (C4-relative), so the mobile Virtual Piano and the portrait
/// Piano Roll agree on which keys are visible and stay column-aligned.
pub keyboard_octave: &'a mut i8,
/// Shared horizontal keyboard pan (px) for smooth left/right scroll of the mobile keyboard+roll.
pub keyboard_pan_x: &'a mut f32,
/// Whether the mobile instrument pane should show the falling-notes roll above the keys. Driven
/// by the shell from the *snapped* pane size-class so the reveal happens at a stack snap point.
pub instrument_show_roll: bool,
/// Set by the mobile instrument header's "Presets" button; the shell opens the Preset Browser.
pub open_instrument_browser: &'a mut bool,
/// Set by the mobile instrument header's REC button; the Timeline pane picks it up and toggles
/// recording (reusing its full count-in / clip-creation flow).
pub pending_record_toggle: &'a mut bool,
/// Mobile long-press context menu request. A pane sets this on `response.secondary_clicked()`
/// (which fires on long-press) with the items relevant to what was pressed; the mobile shell
/// renders one persistent popup and dispatches the chosen `MenuAction`. `None` = no menu.
pub mobile_context_menu: &'a mut Option<MobileContextMenu>,
}
/// A mobile long-press context menu: a screen position and a list of `(label, action)` items.
/// Rendered by the mobile shell; each item dispatches its `MenuAction` via `pending_menu_actions`.
#[derive(Clone)]
pub struct MobileContextMenu {
pub pos: egui::Pos2,
pub items: Vec<(String, crate::menu::MenuAction)>,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -0,0 +1,738 @@
//! Mobile (touch) node editor — Focus & Patch views (wireframe Plate 07).
//!
//! Swapped in for the desktop `draw_graph_editor` canvas when `shared.is_mobile`. Focus shows one
//! module's parameters as big touch controls plus navigation; Patch does tap-to-cable wiring. All
//! edits reuse the existing dispatch: mutating a param's `ValueType` in place is picked up by
//! `check_parameter_changes`, and add/connect/disconnect go through `NodeGraphAction`.
use super::graph_data::{DataType, NodeTemplate, ValueType};
use super::{actions, NodeGraphPane};
use crate::mobile::icons;
use eframe::egui;
use egui_node_graph2::{InputId, InputParamKind, NodeDataTrait, NodeId, NodeTemplateTrait, OutputId};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum NodeViewMode {
Focus,
Patch,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PortDir {
In,
Out,
}
/// An armed port awaiting a compatible endpoint to cable to.
#[derive(Clone, Copy)]
pub struct PatchPick {
node: NodeId,
port: usize,
dir: PortDir,
typ: DataType,
}
pub struct MobileNodeState {
pub mode: NodeViewMode,
/// The module currently shown in Focus (and centred in Patch).
pub focus_node: Option<NodeId>,
/// Whether the add-node picker overlay is open.
pub show_add: bool,
/// Search filter in the add-node picker.
pub add_search: String,
/// Armed port in Patch awaiting a compatible endpoint.
patch_pick: Option<PatchPick>,
}
impl Default for MobileNodeState {
fn default() -> Self {
Self {
mode: NodeViewMode::Focus,
focus_node: None,
show_add: false,
add_search: String::new(),
patch_pick: None,
}
}
}
impl NodeGraphPane {
pub(super) fn render_mobile(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
shared: &mut crate::panes::SharedPaneState,
) {
let bg = shared.theme.bg_color(&["#node-editor", ".pane-content"], ui.ctx(), egui::Color32::from_gray(28));
ui.painter().rect_filled(rect, 0.0, bg);
// Resolve the focus node: keep the current one if it still exists, else the selected node,
// else the first node in the graph.
let focus_valid = self
.mobile
.focus_node
.map_or(false, |id| self.state.graph.nodes.get(id).is_some());
if !focus_valid {
self.mobile.focus_node = self
.state
.selected_nodes
.iter()
.next()
.copied()
.or_else(|| self.state.graph.iter_nodes().next());
}
// Header: Focus/Patch toggle + focused node name.
let header_h = 44.0;
let header = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), rect.top() + header_h));
let body = egui::Rect::from_min_max(egui::pos2(rect.left(), header.bottom()), rect.max);
let name = self
.mobile
.focus_node
.and_then(|id| self.state.graph.nodes.get(id))
.map(|n| n.label.clone())
.unwrap_or_else(|| "".to_string());
ui.painter().line_segment(
[egui::pos2(header.left(), header.bottom()), egui::pos2(header.right(), header.bottom())],
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
);
let mut mode = self.mobile.mode;
ui.scope_builder(
egui::UiBuilder::new().max_rect(header.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui.selectable_label(mode == NodeViewMode::Focus, "Focus").clicked() {
mode = NodeViewMode::Focus;
}
if ui.selectable_label(mode == NodeViewMode::Patch, "Patch").clicked() {
mode = NodeViewMode::Patch;
}
ui.add_space(8.0);
ui.label(egui::RichText::new(name).strong());
},
);
self.mobile.mode = mode;
// Keep backend-id map current so embedded `bottom_ui` (sampler/script/etc.) targets the
// right backend node (mirrors the desktop pre-draw sync).
self.user_state.node_backend_ids = self
.node_id_map
.iter()
.map(|(&nid, bid)| (nid, bid.index()))
.collect();
match self.mobile.mode {
NodeViewMode::Focus => self.render_mobile_focus(ui, body, shared),
NodeViewMode::Patch => self.render_mobile_patch(ui, body, shared),
}
// Same dispatch tail as the desktop path: apply param edits and run any queued action.
self.check_parameter_changes(shared);
self.execute_pending_action(shared);
// Drain the custom-UI loads that have self-contained handlers (sampler + script sample).
// Other bespoke interactions (sequencer grid, NAM model, script canvas) are a case-by-case
// follow-up; clear their queues so they don't accumulate.
if let Some(load) = self.user_state.pending_sampler_load.take() {
self.handle_pending_sampler_load(load, shared);
}
if let Some(load) = self.user_state.pending_script_sample_load.take() {
self.handle_pending_script_sample_load(load, shared);
}
self.user_state.pending_sequencer_changes.clear();
self.user_state.pending_draw_param_changes.clear();
self.user_state.pending_root_note_changes.clear();
}
fn render_mobile_focus(
&mut self,
ui: &mut egui::Ui,
body: egui::Rect,
shared: &mut crate::panes::SharedPaneState,
) {
let Some(focus) = self.mobile.focus_node else {
return;
};
// Full-width minimap strip across the top; everything else below it.
let mm_h = 96.0;
let mm = egui::Rect::from_min_max(body.min, egui::pos2(body.right(), body.top() + mm_h));
// Reserve an add-node button strip at the bottom.
let add_h = 46.0;
let add_rect = egui::Rect::from_min_max(egui::pos2(body.left(), body.bottom() - add_h), body.max);
let content = egui::Rect::from_min_max(egui::pos2(body.left(), mm.bottom()), egui::pos2(body.right(), add_rect.top()));
// Minimap first (tap a node to focus it).
let mut jump: Option<NodeId> = self.render_minimap(ui, mm, focus);
// Connection travel chips (owned) + params.
let (in_chips, out_chips) = self.focus_chips(focus);
let inputs: Vec<(String, InputId)> = self
.state
.graph
.nodes
.get(focus)
.map(|n| n.inputs.clone())
.unwrap_or_default();
ui.scope_builder(
egui::UiBuilder::new().max_rect(content.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
if !in_chips.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("in").weak());
for (label, node) in &in_chips {
if ui.button(label.as_str()).clicked() {
jump = Some(*node);
}
}
});
}
if !out_chips.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new("out").weak());
for (label, node) in &out_chips {
if ui.button(label.as_str()).clicked() {
jump = Some(*node);
}
}
});
}
if !in_chips.is_empty() || !out_chips.is_empty() {
ui.separator();
}
egui::ScrollArea::vertical().show(ui, |ui| {
let mut any = false;
for (name, input_id) in &inputs {
let Some(param) = self.state.graph.inputs.get_mut(*input_id) else {
continue;
};
if matches!(param.kind, InputParamKind::ConnectionOnly) {
continue;
}
any = true;
render_param_row(ui, name, &mut param.value);
ui.add_space(6.0);
}
if !any {
ui.label(egui::RichText::new("No editable parameters").weak());
}
// Embedded desktop custom UI (sampler picker, sequencer grid, script UI, …).
// Standard nodes render nothing here.
if let Some(node) = self.state.graph.nodes.get(focus) {
let _ = node.user_data.bottom_ui(ui, focus, &self.state.graph, &mut self.user_state);
}
});
},
);
// Add-node button.
ui.scope_builder(
egui::UiBuilder::new().max_rect(add_rect.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui.button(egui::RichText::new(" Add node").size(15.0)).clicked() {
self.mobile.show_add = true;
}
},
);
if let Some(nid) = jump {
self.mobile.focus_node = Some(nid);
}
if self.mobile.show_add {
self.render_add_picker(ui, body, shared);
}
}
/// Connection chips for the focus node: (label, remote node) for upstream inputs and downstream
/// outputs. Label names the remote endpoint (`node ▸ port`).
fn focus_chips(&self, focus: NodeId) -> (Vec<(String, NodeId)>, Vec<(String, NodeId)>) {
let mut ins = Vec::new();
let mut outs = Vec::new();
for (input_id, outputs) in self.state.graph.iter_connection_groups() {
let in_node = self.state.graph.inputs.get(input_id).map(|p| p.node);
for output_id in outputs {
let out_node = self.state.graph.outputs.get(output_id).map(|p| p.node);
if in_node == Some(focus) {
if let Some(src) = out_node {
ins.push((format!("{} · {}", self.node_label(src), self.output_port_name(src, output_id)), src));
}
}
if out_node == Some(focus) {
if let Some(dst) = in_node {
outs.push((format!("{} · {}", self.node_label(dst), self.input_port_name(dst, input_id)), dst));
}
}
}
}
(ins, outs)
}
fn node_label(&self, n: NodeId) -> String {
self.state.graph.nodes.get(n).map(|x| x.label.clone()).unwrap_or_default()
}
fn output_port_name(&self, n: NodeId, oid: OutputId) -> String {
self.state
.graph
.nodes
.get(n)
.and_then(|node| node.outputs.iter().find(|(_, id)| *id == oid).map(|(nm, _)| nm.clone()))
.unwrap_or_default()
}
fn input_port_name(&self, n: NodeId, iid: InputId) -> String {
self.state
.graph
.nodes
.get(n)
.and_then(|node| node.inputs.iter().find(|(_, id)| *id == iid).map(|(nm, _)| nm.clone()))
.unwrap_or_default()
}
/// Draw a minimap of the graph; returns a node id if the user tapped one.
fn render_minimap(&self, ui: &mut egui::Ui, rect: egui::Rect, focus: NodeId) -> Option<NodeId> {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120));
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.0, egui::Color32::from_gray(70)), egui::StrokeKind::Inside);
let nodes: Vec<(NodeId, egui::Pos2)> = self
.state
.graph
.iter_nodes()
.filter_map(|n| self.state.node_positions.get(n).map(|p| (n, *p)))
.collect();
if nodes.is_empty() {
return None;
}
let (mut mnx, mut mny, mut mxx, mut mxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (_, p) in &nodes {
mnx = mnx.min(p.x);
mny = mny.min(p.y);
mxx = mxx.max(p.x);
mxy = mxy.max(p.y);
}
let pad = 10.0;
let inner = rect.shrink(pad);
let span = egui::vec2((mxx - mnx).max(1.0), (mxy - mny).max(1.0));
let map = |p: egui::Pos2| {
egui::pos2(
inner.left() + (p.x - mnx) / span.x * inner.width(),
inner.top() + (p.y - mny) / span.y * inner.height(),
)
};
// Connection lines.
for (input_id, outputs) in self.state.graph.iter_connection_groups() {
let in_pos = self.state.graph.inputs.get(input_id).and_then(|p| self.state.node_positions.get(p.node)).copied();
for output_id in outputs {
let out_pos = self.state.graph.outputs.get(output_id).and_then(|p| self.state.node_positions.get(p.node)).copied();
if let (Some(a), Some(b)) = (out_pos, in_pos) {
painter.line_segment([map(a), map(b)], egui::Stroke::new(1.0, egui::Color32::from_gray(90)));
}
}
}
let resp = ui.interact(rect, ui.id().with("node_minimap"), egui::Sense::click());
let click = if resp.clicked() { resp.interact_pointer_pos() } else { None };
let mut hit = None;
let mut best_d = f32::MAX;
for (nid, p) in &nodes {
let c = map(*p);
let focused = *nid == focus;
let color = if focused { egui::Color32::from_rgb(0x4a, 0xa3, 0xff) } else { egui::Color32::from_gray(200) };
painter.circle_filled(c, if focused { 7.0 } else { 5.0 }, color);
if focused {
painter.circle_stroke(c, 9.0, egui::Stroke::new(1.5, color));
}
// Tap selects the nearest node (dots are small, so don't require a precise hit).
if let Some(cp) = click {
let d = (cp - c).length();
if d < best_d {
best_d = d;
hit = Some(*nid);
}
}
}
hit
}
/// The add-node picker overlay: a searchable list of templates.
fn render_add_picker(&mut self, ui: &mut egui::Ui, body: egui::Rect, shared: &mut crate::panes::SharedPaneState) {
let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(420.0)));
let mut chosen: Option<NodeTemplate> = None;
let mut close = false;
ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Add node").strong());
if ui.button("").clicked() {
close = true;
}
});
ui.add(egui::TextEdit::singleline(&mut self.mobile.add_search).hint_text("Search…"));
let q = self.mobile.add_search.to_lowercase();
egui::ScrollArea::vertical().show(ui, |ui| {
for template in NodeTemplate::all_finder_kinds() {
let label = template.node_finder_label(&mut self.user_state).to_string();
if !q.is_empty() && !label.to_lowercase().contains(&q) {
continue;
}
if ui.button(&label).clicked() {
chosen = Some(template);
}
}
});
});
});
if let Some(t) = chosen {
self.mobile_add_node(t, shared);
self.mobile.show_add = false;
self.mobile.add_search.clear();
}
if close {
self.mobile.show_add = false;
}
}
/// Create a node (frontend + backend action) and focus it, mirroring the desktop CreatedNode path.
fn mobile_add_node(&mut self, template: NodeTemplate, _shared: &mut crate::panes::SharedPaneState) {
let Some(track_id) = self.track_id else {
return;
};
// Place near the current focus node, else the origin.
let base = self
.mobile
.focus_node
.and_then(|id| self.state.node_positions.get(id).copied())
.unwrap_or(egui::Pos2::ZERO);
let pos = base + egui::vec2(160.0, 0.0);
let label = template.node_graph_label(&mut self.user_state);
let user_data = template.user_data(&mut self.user_state);
let new_id = self
.state
.graph
.add_node(label, user_data, |graph, id| template.build_node(graph, &mut self.user_state, id));
self.state.node_positions.insert(new_id, pos);
let node_type = template.backend_type_name().to_string();
self.pending_action = Some(Box::new(actions::NodeGraphAction::AddNode(
actions::AddNodeAction::new(track_id, node_type.clone(), (pos.x, pos.y)),
)));
self.pending_node_addition = Some((new_id, node_type, (pos.x, pos.y)));
self.mobile.focus_node = Some(new_id);
}
fn render_mobile_patch(
&mut self,
ui: &mut egui::Ui,
body: egui::Rect,
_shared: &mut crate::panes::SharedPaneState,
) {
let Some(focus) = self.mobile.focus_node else {
return;
};
// Build owned row data (immutable reads) so rendering can freely queue ops.
let inputs: Vec<(String, InputId)> = self.state.graph.nodes.get(focus).map(|n| n.inputs.clone()).unwrap_or_default();
let outputs: Vec<(String, OutputId)> = self.state.graph.nodes.get(focus).map(|n| n.outputs.clone()).unwrap_or_default();
// Rows: (idx, name, type, cables[(remote_node, remote_port, other_node, other_port)]).
let mut in_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new();
for (idx, (name, input_id)) in inputs.iter().enumerate() {
let Some(typ) = self.state.graph.inputs.get(*input_id).map(|p| p.typ) else { continue };
let mut cables = Vec::new();
if let Some(outs) = self.state.graph.connections.get(*input_id) {
for oid in outs {
if let Some(src) = self.state.graph.outputs.get(*oid).map(|o| o.node) {
let sp = self.output_port_index(src, *oid);
cables.push((self.node_label(src), self.output_port_name(src, *oid), src, sp));
}
}
}
in_rows.push((idx, name.clone(), typ, cables));
}
let mut out_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new();
for (idx, (name, output_id)) in outputs.iter().enumerate() {
let Some(typ) = self.state.graph.outputs.get(*output_id).map(|o| o.typ) else { continue };
let mut cables = Vec::new();
for (input_id, outs) in self.state.graph.iter_connection_groups() {
if outs.iter().any(|o| o == output_id) {
if let Some(dst) = self.state.graph.inputs.get(input_id).map(|i| i.node) {
let dp = self.input_port_index(dst, input_id);
cables.push((self.node_label(dst), self.input_port_name(dst, input_id), dst, dp));
}
}
}
out_rows.push((idx, name.clone(), typ, cables));
}
// Queue ops during render, apply after (avoids borrowing self mid-render).
enum Op {
Disconnect(NodeId, usize, NodeId, usize), // (out_node, out_port, in_node, in_port)
Connect(NodeId, usize, NodeId, usize),
Pick(PatchPick),
ClearPick,
}
let mut ops: Vec<Op> = Vec::new();
let pick = self.mobile.patch_pick;
ui.scope_builder(
egui::UiBuilder::new().max_rect(body.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
// A Grid aligns each section's port arrows into a column. Tapping a port arrow
// arms a cable from it (also for unconnected ports); tapping a cable chip removes
// it. Inputs read `[cables] ⥓ name`; outputs read `name ↦ [cables]`.
ui.label(egui::RichText::new("Inputs").weak());
egui::Grid::new("patch_inputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
for (idx, name, typ, cables) in &in_rows {
ui.horizontal(|ui| {
for (rnode, rport, sn, sp) in cables {
if cable_chip(ui, rnode, rport, *typ).clicked() {
ops.push(Op::Disconnect(*sn, *sp, focus, *idx));
}
}
});
if arrow_button(ui, icons::ARROW_RIGHT_TO_LINE, *typ).clicked() {
ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::In, typ: *typ }));
}
ui.label(name.as_str());
ui.end_row();
}
});
ui.add_space(10.0);
ui.label(egui::RichText::new("Outputs").weak());
egui::Grid::new("patch_outputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
for (idx, name, typ, cables) in &out_rows {
ui.label(name.as_str());
if arrow_button(ui, icons::ARROW_RIGHT_FROM_LINE, *typ).clicked() {
ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::Out, typ: *typ }));
}
ui.horizontal(|ui| {
for (rnode, rport, dn, dp) in cables {
if cable_chip(ui, rnode, rport, *typ).clicked() {
ops.push(Op::Disconnect(focus, *idx, *dn, *dp));
}
}
});
ui.end_row();
}
});
});
},
);
// Compatible-endpoint picker overlay when a port is armed.
if let Some(p) = pick {
let candidates = self.patch_candidates(p);
let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(360.0)));
ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
port_marker(ui, p.typ, p.dir);
ui.label(egui::RichText::new("Connect to…").strong());
if ui.button("").clicked() {
ops.push(Op::ClearPick);
}
});
egui::ScrollArea::vertical().show(ui, |ui| {
if candidates.is_empty() {
ui.label(egui::RichText::new("No compatible ports").weak());
}
for (label, other, other_port) in &candidates {
if ui.button(egui::RichText::new(label.as_str()).color(type_color(p.typ))).clicked() {
// Orient the cable: armed In needs an Out source; armed Out needs an In target.
match p.dir {
PortDir::In => ops.push(Op::Connect(*other, *other_port, p.node, p.port)),
PortDir::Out => ops.push(Op::Connect(p.node, p.port, *other, *other_port)),
}
ops.push(Op::ClearPick);
}
}
});
});
});
}
for op in ops {
match op {
Op::Disconnect(on, op_, inn, ip) => self.mobile_disconnect(on, op_, inn, ip),
Op::Connect(on, op_, inn, ip) => self.mobile_connect(on, op_, inn, ip),
Op::Pick(p) => self.mobile.patch_pick = Some(p),
Op::ClearPick => self.mobile.patch_pick = None,
}
}
}
/// Compatible endpoints on OTHER nodes for an armed port (opposite direction, same type).
fn patch_candidates(&self, p: PatchPick) -> Vec<(String, NodeId, usize)> {
let mut out = Vec::new();
for node in self.state.graph.iter_nodes() {
if node == p.node {
continue;
}
let Some(n) = self.state.graph.nodes.get(node) else { continue };
match p.dir {
// Armed input → list outputs of matching type.
PortDir::In => {
for (i, (name, oid)) in n.outputs.iter().enumerate() {
if self.state.graph.outputs.get(*oid).map(|o| o.typ) == Some(p.typ) {
out.push((format!("{} · {}", self.node_label(node), name), node, i));
}
}
}
// Armed output → list inputs of matching type.
PortDir::Out => {
for (i, (name, iid)) in n.inputs.iter().enumerate() {
if self.state.graph.inputs.get(*iid).map(|x| x.typ) == Some(p.typ) {
out.push((format!("{} · {}", self.node_label(node), name), node, i));
}
}
}
}
}
out
}
fn output_port_index(&self, n: NodeId, oid: OutputId) -> usize {
self.state.graph.nodes.get(n).and_then(|node| node.outputs.iter().position(|(_, id)| *id == oid)).unwrap_or(0)
}
fn input_port_index(&self, n: NodeId, iid: InputId) -> usize {
self.state.graph.nodes.get(n).and_then(|node| node.inputs.iter().position(|(_, id)| *id == iid)).unwrap_or(0)
}
/// Cable an output port → input port: update the frontend graph + dispatch `Connect`.
fn mobile_connect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) {
// Mobile has no subgraph navigation yet, so cables always target the track-level graph. If
// subgraph nav is added, route through `va_context()` / `graph_connect_in_template` like desktop.
debug_assert!(self.subgraph_stack.is_empty(), "mobile node connect assumes the top-level graph");
let Some(track_id) = self.track_id else { return };
let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id);
let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id);
let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return };
if let Some(conns) = self.state.graph.connections.get_mut(input_id) {
if !conns.contains(&output_id) {
conns.push(output_id);
}
} else {
self.state.graph.connections.insert(input_id, vec![output_id]);
}
if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) {
self.pending_action = Some(Box::new(actions::NodeGraphAction::Connect(
actions::ConnectAction::new(track_id, from_id, out_port, to_id, in_port),
)));
}
}
/// Remove a cable: update the frontend graph + dispatch `Disconnect`. Like `mobile_connect`, this
/// assumes the track-level graph (mobile has no subgraph navigation).
fn mobile_disconnect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) {
debug_assert!(self.subgraph_stack.is_empty(), "mobile node disconnect assumes the top-level graph");
let Some(track_id) = self.track_id else { return };
let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id);
let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id);
let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return };
if let Some(conns) = self.state.graph.connections.get_mut(input_id) {
conns.retain(|o| *o != output_id);
}
if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) {
self.pending_action = Some(Box::new(actions::NodeGraphAction::Disconnect(
actions::DisconnectAction::new(track_id, from_id, out_port, to_id, in_port),
)));
}
}
}
/// Desktop port color per signal type (matches `DataType::data_type_color`).
fn type_color(t: DataType) -> egui::Color32 {
match t {
DataType::Audio => egui::Color32::from_rgb(100, 150, 255), // blue
DataType::Midi => egui::Color32::from_rgb(100, 255, 100), // green
DataType::CV => egui::Color32::from_rgb(255, 150, 100), // orange
}
}
/// A single Lucide direction arrow glyph, tinted by signal type.
fn arrow(ui: &mut egui::Ui, glyph: &str, t: DataType) {
ui.label(
egui::RichText::new(glyph)
.color(type_color(t))
.family(egui::FontFamily::Name(icons::FAMILY.into()))
.size(15.0),
);
}
/// A port marker: a Lucide direction arrow (out = from-line, in = to-line) tinted by signal type.
fn port_marker(ui: &mut egui::Ui, t: DataType, dir: PortDir) {
let glyph = match dir {
PortDir::In => icons::ARROW_RIGHT_TO_LINE,
PortDir::Out => icons::ARROW_RIGHT_FROM_LINE,
};
arrow(ui, glyph, t);
}
/// A clickable, aligned port arrow (out = from-line, in = to-line) tinted by signal type. Tapping it
/// arms a cable from the port. Frameless so it reads like the desktop port dot.
fn arrow_button(ui: &mut egui::Ui, glyph: &str, t: DataType) -> egui::Response {
ui.add(
egui::Button::new(
egui::RichText::new(glyph)
.color(type_color(t))
.family(egui::FontFamily::Name(icons::FAMILY.into()))
.size(16.0),
)
.frame(false),
)
}
/// A cable badge naming the remote endpoint (`remote-node · remote-port`), framed and tinted by
/// signal type. Returns a click response (used to disconnect the cable).
fn cable_chip(ui: &mut egui::Ui, remote_node: &str, remote_port: &str, t: DataType) -> egui::Response {
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::symmetric(6, 1))
.show(ui, |ui| {
ui.label(egui::RichText::new(format!("{remote_node} · {remote_port}")).color(type_color(t)));
})
.response
.interact(egui::Sense::click())
}
/// One parameter row: a touch control matching the desktop widget rule (enum → dropdown, ranged →
/// slider, plain → stepper, string → field). Mutates the value in place; dispatch happens later via
/// `check_parameter_changes`.
fn render_param_row(ui: &mut egui::Ui, name: &str, value: &mut ValueType) {
match value {
ValueType::Float { value, min, max, unit, enum_labels, .. } => {
ui.label(name);
if let Some(labels) = enum_labels {
let mut sel = (*value as usize).min(labels.len().saturating_sub(1));
egui::ComboBox::from_id_salt(name)
.width(ui.available_width().min(240.0))
.selected_text(labels.get(sel).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, label) in labels.iter().enumerate() {
ui.selectable_value(&mut sel, i, *label);
}
});
*value = sel as f32;
} else if *max > *min {
// Give the rail a visible color so the full track length reads (the theme's default
// inactive fill can be near-transparent); trailing_fill shows progress along it.
ui.scope(|ui| {
let rail = egui::Color32::from_gray(90);
ui.visuals_mut().widgets.inactive.bg_fill = rail;
ui.visuals_mut().widgets.inactive.weak_bg_fill = rail;
ui.visuals_mut().widgets.hovered.bg_fill = rail;
ui.add(egui::Slider::new(value, *min..=*max).suffix(*unit).trailing_fill(true));
});
} else {
ui.add(egui::DragValue::new(value).speed(0.1));
}
}
ValueType::String { value } => {
ui.label(name);
ui.text_edit_singleline(value);
}
}
}

View File

@ -6,6 +6,7 @@ pub mod actions;
pub mod audio_backend; pub mod audio_backend;
pub mod backend; pub mod backend;
pub mod graph_data; pub mod graph_data;
mod mobile;
use backend::{BackendNodeId, GraphBackend}; use backend::{BackendNodeId, GraphBackend};
use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType}; use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType};
@ -142,6 +143,9 @@ pub struct NodeGraphPane {
last_oscilloscope_poll: std::time::Instant, last_oscilloscope_poll: std::time::Instant,
/// Backend track ID (u32) for oscilloscope queries /// Backend track ID (u32) for oscilloscope queries
backend_track_id: Option<u32>, backend_track_id: Option<u32>,
/// Mobile (touch) Focus/Patch view state.
mobile: mobile::MobileNodeState,
} }
impl NodeGraphPane { impl NodeGraphPane {
@ -171,6 +175,7 @@ impl NodeGraphPane {
pending_script_resolutions: Vec::new(), pending_script_resolutions: Vec::new(),
last_oscilloscope_poll: std::time::Instant::now(), last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: None, backend_track_id: None,
mobile: mobile::MobileNodeState::default(),
} }
} }
@ -2365,6 +2370,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
painter.galley(text_pos, galley, text_color); painter.galley(text_pos, galley, text_color);
return; return;
} }
// Mobile: touch-native Focus/Patch views instead of the desktop canvas.
if shared.is_mobile {
self.render_mobile(ui, rect, shared);
return;
}
// Poll oscilloscope data at ~20 FPS // Poll oscilloscope data at ~20 FPS
let has_oscilloscopes; let has_oscilloscopes;
if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) { if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) {

View File

@ -169,6 +169,20 @@ pub struct PianoRollPane {
snap_value: SnapValue, snap_value: SnapValue,
last_snap_selection: HashSet<usize>, last_snap_selection: HashSet<usize>,
snap_user_changed: bool, // set in render_header, consumed before handle_input snap_user_changed: bool, // set in render_header, consumed before handle_input
// Mobile portrait "Synthesia" mode: the playable keyboard is drawn as a strip at the bottom of
// this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic.
keyboard: super::virtual_piano::VirtualPianoPane,
// Landscape mobile: the Keys/Notes toggle — true shows the full keyboard, false the roll.
landscape_keys: bool,
// Manual long-press detection for the vertical roll (works with mouse-hold and touch): time the
// press started, and where. `None` = disarmed (no fresh press, or the press became a pan).
lp_press_time: Option<f64>,
lp_press_pos: egui::Pos2,
// When the long-press landed on an existing note, we resize it instead of creating: its index in
// the resolved-note list and its original duration (to compute the resize delta on commit).
editing_index: Option<usize>,
editing_orig_dur: f64,
} }
impl PianoRollPane { impl PianoRollPane {
@ -205,6 +219,12 @@ impl PianoRollPane {
snap_value: SnapValue::None, snap_value: SnapValue::None,
last_snap_selection: HashSet::new(), last_snap_selection: HashSet::new(),
snap_user_changed: false, snap_user_changed: false,
keyboard: super::virtual_piano::VirtualPianoPane::new(),
landscape_keys: false,
lp_press_time: None,
lp_press_pos: egui::Pos2::ZERO,
editing_index: None,
editing_orig_dur: 0.0,
} }
} }
@ -368,6 +388,22 @@ impl PianoRollPane {
rect: Rect, rect: Rect,
shared: &mut SharedPaneState, shared: &mut SharedPaneState,
) { ) {
// Landscape mobile: a Keys/Notes toggle bar switches this pane between the full keyboard and
// the conventional piano roll. (Portrait uses the keyboard-primary "Synthesia" view below.)
let mut rect = rect;
if shared.is_mobile && !shared.is_portrait {
// Clamp the bar height so a very short pane can't invert the remaining content rect.
let bar_h = 30.0_f32.min(rect.height());
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), rect.min.y + bar_h));
rect = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
self.render_keys_notes_toggle(ui, bar, shared);
if self.landscape_keys {
let kb_path: NodePath = Vec::new();
self.keyboard.render_content(ui, rect, &kb_path, shared);
return;
}
}
let keyboard_rect = Rect::from_min_size(rect.min, vec2(KEYBOARD_WIDTH, rect.height())); let keyboard_rect = Rect::from_min_size(rect.min, vec2(KEYBOARD_WIDTH, rect.height()));
let grid_rect = Rect::from_min_max( let grid_rect = Rect::from_min_max(
pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y), pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y),
@ -444,6 +480,13 @@ impl PianoRollPane {
} }
} }
// Mobile portrait: keyboard-primary "Synthesia" instrument surface (keys + falling-notes
// roll). Landscape falls through to the conventional horizontal roll (pitch↕ / time▸).
if shared.is_mobile && shared.is_portrait {
self.render_vertical_mode(ui, rect, shared, &clip_data);
return;
}
// Apply quantize if the user changed the snap dropdown (must happen before handle_input // Apply quantize if the user changed the snap dropdown (must happen before handle_input
// which may clear the selection when the ComboBox click propagates to the grid). // which may clear the selection when the ComboBox click propagates to the grid).
if self.snap_user_changed { if self.snap_user_changed {
@ -556,6 +599,372 @@ impl PianoRollPane {
self.render_keyboard(&painter, keyboard_rect); self.render_keyboard(&painter, keyboard_rect);
} }
/// Portrait mobile "Synthesia" view — ONE unified instrument surface: an instrument header on
/// top, then falling notes as vertical columns (pitch on X, aligned to the keys via the shared
/// `KeyboardLayout`, time falling toward the "now" line), then the playable keyboard strip at the
/// bottom (reusing the Virtual Piano). Tapping a column adds a note.
fn render_vertical_mode(
&mut self,
ui: &mut egui::Ui,
rect: Rect,
shared: &mut SharedPaneState,
clip_data: &[(u32, f64, f64, f64, Uuid)],
) {
use super::keyboard_layout::KeyboardLayout;
// Keyboard-primary layout (portrait, per wireframe Plate 08): the playable keyboard is the
// base surface, capped at an ergonomic height. Whether the falling-notes roll appears above
// is decided by the shell from the *snapped* pane size-class, so the reveal lands on a stack
// snap point (not mid-drag). Short/smallest snap ⇒ keyboard only.
const KEY_CAP: f32 = 170.0;
let header_h = 32.0;
let header_rect = Rect::from_min_max(rect.min, pos2(rect.right(), rect.top() + header_h));
let content_top = header_rect.bottom();
let content_h = (rect.bottom() - content_top).max(0.0);
let show_roll = shared.instrument_show_roll && content_h > KEY_CAP + 40.0;
let kb_h = if show_roll { content_h.min(KEY_CAP) } else { content_h };
let kb_rect = Rect::from_min_max(pos2(rect.left(), rect.bottom() - kb_h), rect.max);
let roll_rect = Rect::from_min_max(pos2(rect.left(), content_top), pos2(rect.right(), kb_rect.top()));
let pps = self.pixels_per_second; // pixels per second (vertical waterfall)
let now_y = roll_rect.bottom(); // a note reaches the keys at its onset time
let now = ui.input(|i| i.time);
// Auto-release a long-press-added preview note once its short audition elapses (the normal
// release check lives in handle_input, which the vertical mode bypasses).
if let Some(dur) = self.preview_duration {
if self.preview_note_sounding && now - self.preview_start_time >= dur {
self.preview_note_off(shared);
}
}
// Handle roll gestures BEFORE building the layout / drawing, so the roll and the keyboard both
// use the updated pan + playhead this same frame (no 1-frame follow lag between them).
let resp = if show_roll {
let r = ui.interact(roll_rect, ui.id().with("pr_vertical"), egui::Sense::click_and_drag());
if self.creating_note.is_none() && r.dragged() {
let d = r.drag_delta();
if d.y.abs() > 0.0 {
// Drag down ⇒ advance time (content follows the finger).
let nt = (*shared.playback_time + (d.y / pps) as f64).max(0.0);
*shared.playback_time = nt;
if let Some(ctrl) = shared.audio_controller.as_ref() {
if let Ok(mut c) = ctrl.lock() {
c.seek(nt);
}
}
}
*shared.keyboard_pan_x += d.x;
}
Some(r)
} else {
None
};
let layout = KeyboardLayout::from_width(rect.min.x, rect.width(), *shared.keyboard_octave, *shared.keyboard_pan_x);
// Note times are in beats; the playhead is in seconds — convert via the tempo map so the
// waterfall lines up with real playback (onset crosses the now line exactly when it sounds).
let tempo_map = shared.action_executor.document().tempo_map().clone();
let playhead = *shared.playback_time; // seconds
if let Some(resp) = resp {
let painter = ui.painter_at(roll_rect);
let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35));
painter.rect_filled(roll_rect, 0.0, bg);
// Column guides aligned to the keys: black-key columns tinted, white-key boundaries lined.
for note in layout.visible_notes() {
let x = layout.note_x(note);
if KeyboardLayout::is_black_key(note) {
let w = layout.note_width(note);
painter.rect_filled(
Rect::from_min_size(pos2(x, roll_rect.min.y), vec2(w, roll_rect.height())),
0.0,
Color32::from_rgba_unmultiplied(0, 0, 0, 40),
);
} else {
painter.line_segment([pos2(x, roll_rect.min.y), pos2(x, roll_rect.max.y)], Stroke::new(1.0, Color32::from_gray(55)));
}
}
// Notes of the selected clip, falling toward the now line.
if let Some(clip_id) = self.selected_clip_id {
if let Some(&(_, timeline_start, trim_start, duration, _)) =
clip_data.iter().find(|c| c.0 == clip_id)
{
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
let resolved = Self::resolve_notes(events);
for (ni, note) in resolved.iter().enumerate() {
// The note being resized is drawn separately (live) as the temp note.
if Some(ni) == self.editing_index {
continue;
}
if note.start_time + note.duration <= trim_start
|| note.start_time >= trim_start + duration
{
continue;
}
let global = timeline_start + (note.start_time - trim_start);
let y_on = now_y - ((tempo_map.transform(global) - playhead) as f32) * pps;
let y_off = now_y - ((tempo_map.transform(global + note.duration) - playhead) as f32) * pps;
let (top, bot) = (y_on.min(y_off), y_on.max(y_off));
if bot < roll_rect.min.y || top > roll_rect.max.y {
continue;
}
let x = layout.note_x(note.note);
let w = layout.note_width(note.note).max(3.0);
let bright = 0.35 + (note.velocity as f32 / 127.0) * 0.65;
let col = Color32::from_rgb(
(111.0 * bright) as u8,
(220.0 * bright) as u8,
(111.0 * bright) as u8,
);
painter.rect_filled(
Rect::from_min_max(
pos2(x + 1.0, top.max(roll_rect.min.y)),
pos2(x + w - 1.0, bot.min(roll_rect.max.y)),
),
2.0,
col,
);
}
}
}
}
// The note currently being created (long-press to start, drag along Y to size).
if let Some(temp) = self.creating_note.as_ref() {
if let Some(&(_, ts, trim, _d, _)) =
clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id)
{
let g = ts + (temp.start_time - trim);
let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps;
let y_off = now_y - ((tempo_map.transform(g + temp.duration) - playhead) as f32) * pps;
let x = layout.note_x(temp.note);
let w = layout.note_width(temp.note).max(3.0);
painter.rect_filled(
Rect::from_min_max(pos2(x + 1.0, y_on.min(y_off)), pos2(x + w - 1.0, y_on.max(y_off))),
2.0,
Color32::from_rgb(150, 255, 150),
);
}
}
// The amber "now" line at the boundary with the keyboard.
painter.line_segment(
[pos2(roll_rect.min.x, now_y), pos2(roll_rect.max.x, now_y)],
Stroke::new(2.0, Color32::from_rgb(0xf4, 0xa3, 0x40)),
);
const LONG_PRESS_SECS: f64 = 0.4;
let pressed = ui.input(|i| i.pointer.primary_pressed());
let down = ui.input(|i| i.pointer.any_down());
let ptr = ui.input(|i| i.pointer.latest_pos());
if self.creating_note.is_some() {
// Sizing the note (new or existing): drag along Y sets its duration; release commits.
if down {
if let (Some(p), Some(&(_, ts, trim, _d, _))) = (
ptr,
clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id),
) {
let end_sec = playhead + ((now_y - p.y) / pps) as f64;
let end_beats = tempo_map.inverse_transform(end_sec).max(0.0);
let end_local = trim + (end_beats - ts);
if let Some(t) = self.creating_note.as_mut() {
t.duration = (end_local - t.start_time).max(0.25);
}
}
} else if let (Some(temp), Some(clip_id)) =
(self.creating_note.take(), self.selected_clip_id)
{
if let Some(idx) = self.editing_index.take() {
let dt = temp.duration - self.editing_orig_dur;
self.commit_resize_note(clip_id, idx, dt, shared, clip_data);
} else {
self.commit_create_note(clip_id, temp, shared, clip_data);
}
}
} else {
// Snap the pan to the nearest key on release.
if resp.drag_stopped() {
*shared.keyboard_pan_x = layout.snap_pan(*shared.keyboard_pan_x);
}
// Manual long-press (mouse-hold or touch): armed only on a *fresh* press, and cancelled
// for the rest of that press once it moves (→ pan), so pausing mid-pan won't fire.
if pressed {
match ptr {
Some(p) if roll_rect.contains(p) => {
self.lp_press_time = Some(now);
self.lp_press_pos = p;
}
_ => self.lp_press_time = None,
}
}
if !down {
self.lp_press_time = None;
}
if let Some(t0) = self.lp_press_time {
let moved = ptr.map_or(0.0, |p| (p - self.lp_press_pos).length());
if moved > 8.0 {
self.lp_press_time = None; // became a pan → suppress for this press
} else if now - t0 >= LONG_PRESS_SECS {
self.lp_press_time = None;
let pos = self.lp_press_pos;
if let Some(clip_id) = self.selected_clip_id {
if let Some(&(_, timeline_start, trim_start, clip_dur, _)) =
clip_data.iter().find(|c| c.0 == clip_id)
{
let pitch = layout.x_to_note(pos.x);
// Hit-test an existing note in this column → resize it; else create.
let mut hit = None;
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
for (i, n) in Self::resolve_notes(events).iter().enumerate() {
if n.note != pitch
|| n.start_time < trim_start
|| n.start_time >= trim_start + clip_dur
{
continue;
}
let g = timeline_start + (n.start_time - trim_start);
let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps;
let y_off = now_y - ((tempo_map.transform(g + n.duration) - playhead) as f32) * pps;
if pos.y >= y_on.min(y_off) && pos.y <= y_on.max(y_off) {
hit = Some((i, n.start_time, n.duration, n.velocity));
break;
}
}
}
if let Some((idx, onset, dur, vel)) = hit {
self.editing_index = Some(idx);
self.editing_orig_dur = dur;
self.creating_note = Some(TempNote { note: pitch, start_time: onset, duration: dur, velocity: vel });
} else {
let onset_sec = playhead + ((now_y - pos.y) / pps) as f64;
let onset_beats = tempo_map.inverse_transform(onset_sec).max(0.0);
let onset_local = snap_to_value((trim_start + (onset_beats - timeline_start)).max(0.0), self.snap_value, &tempo_map);
self.editing_index = None;
self.creating_note = Some(TempNote { note: pitch, start_time: onset_local, duration: 0.5, velocity: DEFAULT_VELOCITY });
}
self.preview_note_on(pitch, DEFAULT_VELOCITY, Some(0.4), now, shared);
}
}
}
}
}
}
// Notes sounding at the playhead → drive the keyboard's highlights so it colors them exactly
// like pressed keys (full key, correct white/black layering) rather than an after-overlay.
let mut sounding = std::collections::HashSet::new();
if let Some(clip_id) = self.selected_clip_id {
if let Some(&(_, timeline_start, trim_start, duration, _)) =
clip_data.iter().find(|c| c.0 == clip_id)
{
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
for note in Self::resolve_notes(events) {
if note.start_time < trim_start || note.start_time >= trim_start + duration {
continue;
}
let global = timeline_start + (note.start_time - trim_start);
let s = tempo_map.transform(global);
let e = tempo_map.transform(global + note.duration);
if playhead >= s && playhead < e {
sounding.insert(note.note);
}
}
}
}
}
self.keyboard.playback_notes = sounding;
// Instrument header (in this pane's own header) + the always-present playable keyboard strip.
self.render_instrument_header(ui, header_rect, shared);
let kb_path: NodePath = Vec::new();
self.keyboard.render_content(ui, kb_rect, &kb_path, shared);
}
/// The in-pane instrument header: active track name + Presets + REC.
fn render_instrument_header(&mut self, ui: &mut egui::Ui, rect: Rect, shared: &mut SharedPaneState) {
let painter = ui.painter_at(rect);
let hdr = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28));
painter.rect_filled(rect, 0.0, hdr);
painter.line_segment(
[pos2(rect.left(), rect.bottom()), pos2(rect.right(), rect.bottom())],
Stroke::new(1.0, Color32::from_gray(60)),
);
let name = shared
.active_layer_id
.and_then(|id| shared.action_executor.document().get_layer(&id))
.map(|l| l.name().to_string())
.unwrap_or_else(|| "No instrument".to_string());
painter.text(
pos2(rect.left() + 12.0, rect.center().y),
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(14.0),
Color32::from_gray(220),
);
// REC (rightmost).
let rec = Rect::from_min_max(pos2(rect.right() - 64.0, rect.top() + 4.0), pos2(rect.right() - 8.0, rect.bottom() - 4.0));
let rec_resp = ui.interact(rec, ui.id().with("pr_rec"), egui::Sense::click());
let recording = *shared.is_recording;
let rec_bg = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) } else { Color32::from_gray(66) };
painter.rect_filled(rec, 6.0, rec_bg);
painter.circle_filled(pos2(rec.left() + 13.0, rec.center().y), 4.0, Color32::from_rgb(255, 96, 96));
painter.text(pos2(rec.left() + 23.0, rec.center().y), egui::Align2::LEFT_CENTER, "REC", egui::FontId::proportional(12.0), Color32::WHITE);
if rec_resp.clicked() {
*shared.pending_record_toggle = true;
}
// Presets (left of REC).
let pre = Rect::from_min_max(pos2(rec.left() - 84.0, rect.top() + 4.0), pos2(rec.left() - 8.0, rect.bottom() - 4.0));
let pre_resp = ui.interact(pre, ui.id().with("pr_presets"), egui::Sense::click());
painter.rect_filled(pre, 6.0, if pre_resp.hovered() { Color32::from_gray(56) } else { Color32::from_gray(42) });
painter.text(pre.center(), egui::Align2::CENTER_CENTER, "Presets", egui::FontId::proportional(12.0), Color32::from_gray(220));
if pre_resp.clicked() {
*shared.open_instrument_browser = true;
}
}
/// Landscape Keys/Notes segmented toggle drawn in `bar` (updates `self.landscape_keys`).
fn render_keys_notes_toggle(&mut self, ui: &mut egui::Ui, bar: Rect, shared: &mut SharedPaneState) {
let painter = ui.painter_at(bar);
let bg = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28));
painter.rect_filled(bar, 0.0, bg);
painter.line_segment(
[pos2(bar.left(), bar.bottom()), pos2(bar.right(), bar.bottom())],
Stroke::new(1.0, Color32::from_gray(60)),
);
let seg_w = 74.0;
let x0 = bar.center().x - seg_w;
let keys_rect = Rect::from_min_size(pos2(x0, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0));
let notes_rect = Rect::from_min_size(pos2(x0 + seg_w, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0));
if ui.interact(keys_rect, ui.id().with("pr_kn_keys"), egui::Sense::click()).clicked() {
self.landscape_keys = true;
}
if ui.interact(notes_rect, ui.id().with("pr_kn_notes"), egui::Sense::click()).clicked() {
self.landscape_keys = false;
}
let accent = Color32::from_rgb(0x4a, 0xa3, 0xff);
for (r, label, on) in [
(keys_rect, "Keys", self.landscape_keys),
(notes_rect, "Notes", !self.landscape_keys),
] {
painter.rect_filled(r, 6.0, if on { accent } else { Color32::from_gray(46) });
painter.text(
r.center(),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(13.0),
if on { Color32::WHITE } else { Color32::from_gray(200) },
);
}
}
fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) { fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) {
// Background // Background
painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45)); painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@ use std::collections::HashMap;
const RULER_HEIGHT: f32 = 30.0; const RULER_HEIGHT: f32 = 30.0;
const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEIGHT: f32 = 60.0;
const LAYER_HEADER_WIDTH: f32 = 200.0; const LAYER_HEADER_WIDTH: f32 = 200.0;
/// On mobile the header collapses to a minimal color swatch with a 1:2 width:height aspect.
const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5;
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
const MAX_PIXELS_PER_SECOND: f32 = 500.0; const MAX_PIXELS_PER_SECOND: f32 = 500.0;
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
@ -212,6 +214,7 @@ fn effective_clip_duration(
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION), AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
AnyLayer::Group(_) => None, AnyLayer::Group(_) => None,
AnyLayer::Raster(_) => None, AnyLayer::Raster(_) => None,
AnyLayer::Text(_) => None,
} }
} }
@ -265,6 +268,9 @@ pub struct TimelinePane {
/// Is the user panning the timeline? /// Is the user panning the timeline?
is_panning: bool, is_panning: bool,
last_pan_pos: Option<egui::Pos2>, last_pan_pos: Option<egui::Pos2>,
/// Manual long-press tracking for the mobile context menu: press start time + position.
lp_time: Option<f64>,
lp_pos: egui::Pos2,
/// Clip drag state (None if not dragging) /// Clip drag state (None if not dragging)
clip_drag_state: Option<ClipDragType>, clip_drag_state: Option<ClipDragType>,
@ -685,6 +691,7 @@ fn layer_clips<'a>(
AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[], AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
} }
} }
@ -718,6 +725,7 @@ fn collect_clip_instances<'a>(
} }
} }
AnyLayer::Raster(_) => {} AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
} }
} }
@ -768,6 +776,7 @@ fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) {
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
AnyLayer::Text(_) => ("Text", egui::Color32::from_rgb(220, 200, 120)),
} }
} }
@ -782,6 +791,8 @@ impl TimelinePane {
is_scrubbing: false, is_scrubbing: false,
is_panning: false, is_panning: false,
last_pan_pos: None, last_pan_pos: None,
lp_time: None,
lp_pos: egui::Pos2::ZERO,
clip_drag_state: None, clip_drag_state: None,
drag_offset: 0.0, drag_offset: 0.0,
drag_anchor_start: 0.0, drag_anchor_start: 0.0,
@ -945,7 +956,7 @@ impl TimelinePane {
/// Toggle recording on/off /// Toggle recording on/off
/// In Auto mode, records to the active layer (audio or video with camera) /// In Auto mode, records to the active layer (audio or video with camera)
fn toggle_recording(&mut self, shared: &mut SharedPaneState) { pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
if *shared.is_recording { if *shared.is_recording {
// Stop recording // Stop recording
self.stop_recording(shared); self.stop_recording(shared);
@ -1206,7 +1217,7 @@ impl TimelinePane {
/// Stop all active recordings /// Stop all active recordings
/// Called every frame; fires deferred recording commands once the count-in pre-roll ends. /// Called every frame; fires deferred recording commands once the count-in pre-roll ends.
fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { pub(crate) fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
let Some(ref pending) = self.pending_recording_start else { return }; let Some(ref pending) = self.pending_recording_start else { return };
if !*shared.is_playing || *shared.playback_time < pending.trigger_time { if !*shared.is_playing || *shared.playback_time < pending.trigger_time {
return; return;
@ -1226,7 +1237,7 @@ impl TimelinePane {
*shared.count_in_enabled = saved_count_in; *shared.count_in_enabled = saved_count_in;
} }
fn stop_recording(&mut self, shared: &mut SharedPaneState) { pub(crate) fn stop_recording(&mut self, shared: &mut SharedPaneState) {
// Cancel any in-progress count-in // Cancel any in-progress count-in
self.pending_recording_start = None; self.pending_recording_start = None;
@ -1475,6 +1486,15 @@ impl TimelinePane {
self.pixels_per_second = 100.0; self.pixels_per_second = 100.0;
} }
/// Fit the whole project duration into the given content width, scrolled to the start.
/// (Gesture P5-2: double-tap empty → zoom-to-fit.)
pub fn fit_to_project(&mut self, viewport_width: f32) {
let dur = self.duration.max(0.01);
self.pixels_per_second =
(viewport_width / dur as f32).clamp(MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND);
self.viewport_start_time = 0.0;
}
/// Apply zoom while keeping the time under the cursor stationary /// Apply zoom while keeping the time under the cursor stationary
fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) { fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) {
let old_zoom = self.pixels_per_second; let old_zoom = self.pixels_per_second;
@ -1933,6 +1953,8 @@ impl TimelinePane {
track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>, track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
input_level: f32, input_level: f32,
playback_time: f64, playback_time: f64,
header_width: f32,
is_mobile: bool,
) { ) {
// Background for header column // Background for header column
let header_style = theme.style(".timeline-header", ui.ctx()); let header_style = theme.style(".timeline-header", ui.ctx());
@ -1995,7 +2017,7 @@ impl TimelinePane {
let header_rect = egui::Rect::from_min_size( let header_rect = egui::Rect::from_min_size(
egui::pos2(rect.min.x, y), egui::pos2(rect.min.x, y),
egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT), egui::vec2(header_width, LAYER_HEIGHT),
); );
// Determine the AnyLayer or GroupLayer for this row // Determine the AnyLayer or GroupLayer for this row
@ -2029,6 +2051,27 @@ impl TimelinePane {
ui.painter().rect_filled(header_rect, 0.0, bg_color); ui.painter().rect_filled(header_rect, 0.0, bg_color);
// Mobile: collapse the header to a minimal color swatch (1:2 w:h). Selection is handled
// by handle_input via the (narrow) header rect, so we only draw here.
if is_mobile {
let sw = header_rect.shrink(2.0);
let col = if is_active || is_selected {
type_color
} else {
type_color.gamma_multiply(0.55)
};
ui.painter().rect_filled(sw, 3.0, col);
if is_active || is_selected {
ui.painter().rect_stroke(
sw,
3.0,
egui::Stroke::new(1.5, egui::Color32::WHITE),
egui::StrokeKind::Inside,
);
}
continue;
}
// Gutter area (left of indicator) — solid group color, with collapse chevron // Gutter area (left of indicator) — solid group color, with collapse chevron
if indent > 0.0 { if indent > 0.0 {
let gutter_rect = egui::Rect::from_min_size( let gutter_rect = egui::Rect::from_min_size(
@ -4935,29 +4978,40 @@ impl TimelinePane {
// Only handle scroll when mouse is over the timeline area // Only handle scroll when mouse is over the timeline area
let mut handled = false; let mut handled = false;
let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect); let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect);
// Unified pinch / Ctrl+scroll time-zoom: `zoom_delta()` folds in touch-pinch (on device) AND
// Ctrl+wheel/trackpad (on desktop). Multiplicative factor (1.0 = no change); apply_zoom_at_point
// wants an additive delta. This is the single path for factor-zoom — the raw MouseWheel branch
// below only handles plain (non-Ctrl) mouse-wheel zoom and non-Ctrl trackpad pan.
if pointer_over_timeline {
let zoom_factor = ui.input(|i| i.zoom_delta());
if (zoom_factor - 1.0).abs() > f32::EPSILON {
let center_x = ui
.input(|i| i.multi_touch().map(|m| m.center_pos.x))
.map(|x| x - content_rect.min.x)
.unwrap_or(mouse_x);
self.apply_zoom_at_point(zoom_factor - 1.0, center_x);
}
}
if pointer_over_timeline { ui.input(|i| { if pointer_over_timeline { ui.input(|i| {
for event in &i.raw.events { for event in &i.raw.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
match unit { match unit {
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
// Real mouse wheel (discrete clicks) -> always zoom horizontally // Plain mouse wheel zooms; Ctrl+wheel handled above via zoom_delta().
let zoom_delta = if ctrl_held || modifiers.ctrl { // Consume the event either way so the pan fallback doesn't also fire.
delta.y * 0.01 // Ctrl+wheel: faster zoom if !(ctrl_held || modifiers.ctrl) {
} else { self.apply_zoom_at_point(delta.y * 0.005, mouse_x);
delta.y * 0.005 // Normal zoom }
};
self.apply_zoom_at_point(zoom_delta, mouse_x);
handled = true; handled = true;
} }
egui::MouseWheelUnit::Point => { egui::MouseWheelUnit::Point => {
// Trackpad (smooth scrolling) // Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls through
// to scroll_delta panning below.
if ctrl_held || modifiers.ctrl { if ctrl_held || modifiers.ctrl {
// Ctrl held: zoom
let zoom_delta = delta.y * 0.005;
self.apply_zoom_at_point(zoom_delta, mouse_x);
handled = true; handled = true;
} }
// Otherwise let scroll_delta handle panning (below)
} }
} }
} }
@ -5000,6 +5054,18 @@ impl TimelinePane {
} }
} }
// Double-tap / double-click on empty space → fit the whole project into view. (Gesture P5-2.)
if response.double_clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let over_clip = self
.detect_clip_at_pointer(pos, document, content_rect, header_rect, editing_clip_id, &audio_cache)
.is_some();
if !over_clip {
self.fit_to_project(content_rect.width());
}
}
}
// Update cursor based on hover position (only if not scrubbing or panning) // Update cursor based on hover position (only if not scrubbing or panning)
if !self.is_scrubbing && !self.is_panning { if !self.is_scrubbing && !self.is_panning {
// If dragging a clip with trim/loop, keep the appropriate cursor // If dragging a clip with trim/loop, keep the appropriate cursor
@ -5452,15 +5518,21 @@ impl PaneRenderer for TimelinePane {
} }
self.duration = max_endpoint; self.duration = max_endpoint;
// Split into layer header column (left) and timeline content (right) // Split into layer header column (left) and timeline content (right). On mobile the header
// column collapses to a minimal color-swatch width.
let header_width = if shared.is_mobile {
MOBILE_LAYER_HEADER_WIDTH
} else {
LAYER_HEADER_WIDTH
};
let header_column_rect = egui::Rect::from_min_size( let header_column_rect = egui::Rect::from_min_size(
rect.min, rect.min,
egui::vec2(LAYER_HEADER_WIDTH, rect.height()), egui::vec2(header_width, rect.height()),
); );
let timeline_rect = egui::Rect::from_min_size( let timeline_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0), rect.min + egui::vec2(header_width, 0.0),
egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()), egui::vec2(rect.width() - header_width, rect.height()),
); );
// Split timeline into ruler and content areas // Split timeline into ruler and content areas
@ -5477,12 +5549,12 @@ impl PaneRenderer for TimelinePane {
// Split header column into ruler area (top) and layer headers (bottom) // Split header column into ruler area (top) and layer headers (bottom)
let header_ruler_spacer = egui::Rect::from_min_size( let header_ruler_spacer = egui::Rect::from_min_size(
header_column_rect.min, header_column_rect.min,
egui::vec2(LAYER_HEADER_WIDTH, RULER_HEIGHT), egui::vec2(header_width, RULER_HEIGHT),
); );
let layer_headers_rect = egui::Rect::from_min_size( let layer_headers_rect = egui::Rect::from_min_size(
header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT), header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT),
egui::vec2(LAYER_HEADER_WIDTH, header_column_rect.height() - RULER_HEIGHT), egui::vec2(header_width, header_column_rect.height() - RULER_HEIGHT),
); );
// Save original clip rect to restore at the end // Save original clip rect to restore at the end
@ -5499,7 +5571,7 @@ impl PaneRenderer for TimelinePane {
// Render layer header column with clipping // Render layer header column with clipping
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time); self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile);
// Render time ruler (clip to ruler rect) // Render time ruler (clip to ruler rect)
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
@ -5770,9 +5842,10 @@ impl PaneRenderer for TimelinePane {
} }
ui.set_clip_rect(original_clip_rect); ui.set_clip_rect(original_clip_rect);
// Context menu: detect right-click on clips or empty timeline space // Context menu: detect right-click on clips or empty timeline space (desktop only — mobile
// uses the long-press menu below).
let mut just_opened_menu = false; let mut just_opened_menu = false;
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); let secondary_clicked = !shared.is_mobile && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
if secondary_clicked { if secondary_clicked {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if content_rect.contains(pos) { if content_rect.contains(pos) {
@ -5792,6 +5865,67 @@ impl PaneRenderer for TimelinePane {
} }
} }
// Mobile: a manual long-press (hold in place ~0.4s) builds a persistent context menu rendered
// by the shell — clip actions on a clip, animation (keyframe/tween) actions on an empty lane.
let mut long_press: Option<egui::Pos2> = None;
if shared.is_mobile {
let now = ui.input(|i| i.time);
let down = ui.input(|i| i.pointer.any_down());
let ptr = ui.input(|i| i.pointer.latest_pos());
if ui.input(|i| i.pointer.primary_pressed()) {
match ptr {
Some(p) if content_rect.contains(p) => {
self.lp_time = Some(now);
self.lp_pos = p;
}
_ => self.lp_time = None,
}
}
if !down {
self.lp_time = None;
}
if let Some(t0) = self.lp_time {
let moved = ptr.map_or(0.0, |p| (p - self.lp_pos).length());
if moved > 8.0 {
self.lp_time = None;
} else if now - t0 >= 0.4 {
self.lp_time = None;
long_press = Some(self.lp_pos);
} else {
// Still counting down — request a repaint so the threshold is re-evaluated even if
// the finger is perfectly still and egui would otherwise go idle (no input events).
let remaining = (0.4 - (now - t0)).max(0.0);
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(remaining));
}
}
}
if let Some(pos) = long_press {
use crate::menu::MenuAction;
let mut items: Vec<(String, MenuAction)> = Vec::new();
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref(), &audio_cache) {
if !shared.selection.contains_clip_instance(&clip_id) {
shared.selection.select_only_clip_instance(clip_id);
}
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
items.push(("Split clip".into(), MenuAction::SplitClip));
items.push(("Duplicate clip".into(), MenuAction::DuplicateClip));
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Delete".into(), MenuAction::Delete));
} else {
items.push(("New keyframe".into(), MenuAction::NewKeyframe));
items.push(("New blank keyframe".into(), MenuAction::NewBlankKeyframe));
items.push(("Add keyframe at playhead".into(), MenuAction::AddKeyframeAtPlayhead));
items.push(("Duplicate keyframe".into(), MenuAction::DuplicateKeyframe));
items.push(("Delete frame".into(), MenuAction::DeleteFrame));
items.push(("Add motion tween".into(), MenuAction::AddMotionTween));
items.push(("Add shape tween".into(), MenuAction::AddShapeTween));
items.push(("Paste".into(), MenuAction::Paste));
}
*shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items });
}
// Render context menu // Render context menu
if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip { if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip {
let has_clip = ctx_clip_id.is_some(); let has_clip = ctx_clip_id.is_some();

View File

@ -43,6 +43,7 @@ impl PaneRenderer for ToolbarPane {
AnyLayer::Effect(_) => LayerType::Effect, AnyLayer::Effect(_) => LayerType::Effect,
AnyLayer::Group(_) => LayerType::Group, AnyLayer::Group(_) => LayerType::Group,
AnyLayer::Raster(_) => LayerType::Raster, AnyLayer::Raster(_) => LayerType::Raster,
AnyLayer::Text(_) => LayerType::Text,
}); });
// Auto-switch to Select if the current tool isn't available for this layer type // Auto-switch to Select if the current tool isn't available for this layer type

View File

@ -33,6 +33,8 @@ pub struct VirtualPianoPane {
sustain_active: bool, sustain_active: bool,
/// Notes being held by sustain pedal (not by active key/mouse press) /// Notes being held by sustain pedal (not by active key/mouse press)
sustained_notes: HashSet<u8>, sustained_notes: HashSet<u8>,
/// Externally-driven highlights (e.g. notes sounding during playback), colored like pressed keys.
pub playback_notes: HashSet<u8>,
} }
impl Default for VirtualPianoPane { impl Default for VirtualPianoPane {
@ -83,6 +85,7 @@ impl VirtualPianoPane {
note_to_key_map, note_to_key_map,
sustain_active: false, sustain_active: false,
sustained_notes: HashSet::new(), sustained_notes: HashSet::new(),
playback_notes: HashSet::new(),
} }
} }
@ -216,9 +219,17 @@ impl VirtualPianoPane {
/// Render the piano keyboard /// Render the piano keyboard
fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
// Calculate visible range and key dimensions based on pane size // Calculate visible range and key dimensions based on pane size. On mobile use the shared
let (visible_start, visible_end, white_key_width, offset_x) = // width-driven layout (so the portrait Piano Roll's columns line up with these keys).
self.calculate_visible_range(rect.width(), rect.height()); let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile {
let layout = super::keyboard_layout::KeyboardLayout::from_width(
rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x,
);
let vs = layout.first_visible_white();
(vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x)
} else {
self.calculate_visible_range(rect.width(), rect.height())
};
let white_key_height = rect.height(); let white_key_height = rect.height();
let black_key_width = white_key_width * self.black_key_width_ratio; let black_key_width = white_key_width * self.black_key_width_ratio;
@ -238,6 +249,11 @@ impl VirtualPianoPane {
return; return;
} }
// Only play notes when the press STARTED on the keyboard, so dragging in from the roll above
// (mobile) doesn't trigger notes. On desktop the whole pane is the keyboard, so a click here
// always qualifies.
let started_here = ui.input(|i| i.pointer.press_origin().map_or(false, |p| rect.contains(p)));
// Count white keys before each note for positioning // Count white keys before each note for positioning
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new(); let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
let mut white_count = 0; let mut white_count = 0;
@ -303,7 +319,8 @@ impl VirtualPianoPane {
}); });
let pointer_down = ui.input(|i| i.pointer.primary_down()); let pointer_down = ui.input(|i| i.pointer.primary_down());
let is_pressed = self.pressed_notes.contains(&note) || let is_pressed = self.pressed_notes.contains(&note) ||
(!black_key_interacted && pointer_over_key && pointer_down); self.playback_notes.contains(&note) ||
(started_here && !black_key_interacted && pointer_over_key && pointer_down);
let color = if is_pressed { let color = if is_pressed {
shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
} else { } else {
@ -320,8 +337,9 @@ impl VirtualPianoPane {
); );
if !black_key_interacted { if !black_key_interacted {
// Mouse down starts note (detect primary button pressed on this key) // Mouse down starts note (detect primary button pressed on this key). Gated on
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { // `started_here` so a drag that began on the roll above doesn't start notes.
if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
// Calculate velocity based on mouse Y position // Calculate velocity based on mouse Y position
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
@ -330,7 +348,8 @@ impl VirtualPianoPane {
self.dragging_note = Some(note); self.dragging_note = Some(note);
} }
// Mouse up stops note (detect primary button released) // Mouse up stops note (detect primary button released). NEVER gated — a held note
// must always release.
if ui.input(|i| i.pointer.primary_released()) { if ui.input(|i| i.pointer.primary_released()) {
if self.dragging_note == Some(note) { if self.dragging_note == Some(note) {
self.send_note_off(note, shared); self.send_note_off(note, shared);
@ -339,7 +358,7 @@ impl VirtualPianoPane {
} }
// Dragging over a new key (pointer is down and over a different key) // Dragging over a new key (pointer is down and over a different key)
if pointer_over_key && pointer_down { if started_here && pointer_over_key && pointer_down {
if self.dragging_note != Some(note) { if self.dragging_note != Some(note) {
// Stop previous note // Stop previous note
if let Some(prev_note) = self.dragging_note { if let Some(prev_note) = self.dragging_note {
@ -387,7 +406,8 @@ impl VirtualPianoPane {
}); });
let pointer_down = ui.input(|i| i.pointer.primary_down()); let pointer_down = ui.input(|i| i.pointer.primary_down());
let is_pressed = self.pressed_notes.contains(&note) || let is_pressed = self.pressed_notes.contains(&note) ||
(pointer_over_key && pointer_down); self.playback_notes.contains(&note) ||
(started_here && pointer_over_key && pointer_down);
let color = if is_pressed { let color = if is_pressed {
shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200)) shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200))
} else { } else {
@ -397,7 +417,7 @@ impl VirtualPianoPane {
ui.painter().rect_filled(key_rect, 2.0, color); ui.painter().rect_filled(key_rect, 2.0, color);
// Mouse down starts note // Mouse down starts note
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
// Calculate velocity based on mouse Y position // Calculate velocity based on mouse Y position
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
@ -415,7 +435,7 @@ impl VirtualPianoPane {
} }
// Dragging over a new key // Dragging over a new key
if pointer_over_key && pointer_down { if started_here && pointer_over_key && pointer_down {
if self.dragging_note != Some(note) { if self.dragging_note != Some(note) {
if let Some(prev_note) = self.dragging_note { if let Some(prev_note) = self.dragging_note {
self.send_note_off(prev_note, shared); self.send_note_off(prev_note, shared);
@ -824,6 +844,11 @@ impl PaneRenderer for VirtualPianoPane {
_path: &NodePath, _path: &NodePath,
shared: &mut SharedPaneState, shared: &mut SharedPaneState,
) { ) {
// On mobile, the octave is shared with the Piano Roll so they stay aligned.
if shared.is_mobile {
self.octave_offset = *shared.keyboard_octave;
}
// Check if there's an active MIDI layer // Check if there's an active MIDI layer
let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id { let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id {
shared.layer_to_track_map.contains_key(&active_layer_id) shared.layer_to_track_map.contains_key(&active_layer_id)
@ -869,14 +894,28 @@ impl PaneRenderer for VirtualPianoPane {
} }
// Calculate visible range (needed for both rendering and labels) // Calculate visible range (needed for both rendering and labels)
let (visible_start, visible_end, white_key_width, offset_x) = let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile {
self.calculate_visible_range(rect.width(), rect.height()); let layout = super::keyboard_layout::KeyboardLayout::from_width(
rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x,
);
let vs = layout.first_visible_white();
(vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x)
} else {
self.calculate_visible_range(rect.width(), rect.height())
};
// Render the keyboard // Render the keyboard
self.render_keyboard(ui, rect, shared); self.render_keyboard(ui, rect, shared);
// Render keyboard labels on top // Render keyboard labels on top — but not on mobile (no physical keyboard to hint at).
self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); if !shared.is_mobile {
self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x);
}
// Publish the octave so the portrait Piano Roll aligns to these keys.
if shared.is_mobile {
*shared.keyboard_octave = self.octave_offset;
}
} }
fn name(&self) -> &str { fn name(&self) -> &str {

View File

@ -151,6 +151,7 @@ impl PreferencesDialog {
ctx: &egui::Context, ctx: &egui::Context,
config: &mut AppConfig, config: &mut AppConfig,
theme: &mut Theme, theme: &mut Theme,
mobile: bool,
) -> Option<PreferencesSaveResult> { ) -> Option<PreferencesSaveResult> {
if !self.open { if !self.open {
return None; return None;
@ -160,68 +161,32 @@ impl PreferencesDialog {
let mut should_cancel = false; let mut should_cancel = false;
let mut open = self.open; let mut open = self.open;
egui::Window::new("Preferences") // On mobile, render as a screen-fitting modal sheet (dim backdrop, centered) like the other
.open(&mut open) // mobile modals; on desktop, the familiar draggable window.
.resizable(false) let width = crate::mobile::dialog_width(ctx, 550.0);
.collapsible(false) let scroll_h = if mobile {
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) (ctx.content_rect().height() - 220.0).clamp(160.0, 400.0)
.show(ctx, |ui| { } else {
ui.set_width(550.0); 400.0
};
// Error message if mobile {
if let Some(error) = &self.error_message { let resp = egui::Modal::new(egui::Id::new("preferences_modal")).show(ctx, |ui| {
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error); self.render_body(ui, width, scroll_h, &mut should_save, &mut should_cancel);
ui.add_space(8.0);
}
// Tab bar
ui.horizontal(|ui| {
ui.selectable_value(&mut self.tab, PreferencesTab::General, "General");
ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts");
});
ui.separator();
// Tab content
match self.tab {
PreferencesTab::General => {
egui::ScrollArea::vertical()
.max_height(400.0)
.show(ui, |ui| {
self.render_general_section(ui);
ui.add_space(8.0);
self.render_audio_section(ui);
ui.add_space(8.0);
self.render_appearance_section(ui);
ui.add_space(8.0);
self.render_startup_section(ui);
ui.add_space(8.0);
self.render_advanced_section(ui);
});
}
PreferencesTab::Shortcuts => {
self.render_shortcuts_tab(ui);
}
}
ui.add_space(16.0);
// Buttons
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
should_cancel = true;
}
if ui.button("Reset to Defaults").clicked() {
self.reset_to_defaults();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Save").clicked() {
should_save = true;
}
});
});
}); });
if resp.backdrop_response.clicked() {
should_cancel = true;
}
} else {
egui::Window::new("Preferences")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
self.render_body(ui, width, scroll_h, &mut should_save, &mut should_cancel);
});
}
// Update open state // Update open state
self.open = open; self.open = open;
@ -238,6 +203,70 @@ impl PreferencesDialog {
None None
} }
#[allow(clippy::too_many_arguments)]
fn render_body(
&mut self,
ui: &mut egui::Ui,
width: f32,
scroll_h: f32,
should_save: &mut bool,
should_cancel: &mut bool,
) {
ui.set_width(width);
// Error message
if let Some(error) = &self.error_message {
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error);
ui.add_space(8.0);
}
// Tab bar
ui.horizontal(|ui| {
ui.selectable_value(&mut self.tab, PreferencesTab::General, "General");
ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts");
});
ui.separator();
// Tab content
match self.tab {
PreferencesTab::General => {
egui::ScrollArea::vertical().max_height(scroll_h).show(ui, |ui| {
self.render_general_section(ui);
ui.add_space(8.0);
self.render_audio_section(ui);
ui.add_space(8.0);
self.render_appearance_section(ui);
ui.add_space(8.0);
self.render_startup_section(ui);
ui.add_space(8.0);
self.render_advanced_section(ui);
});
}
PreferencesTab::Shortcuts => {
self.render_shortcuts_tab(ui);
}
}
ui.add_space(16.0);
// Buttons
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
*should_cancel = true;
}
if ui.button("Reset to Defaults").clicked() {
self.reset_to_defaults();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Save").clicked() {
*should_save = true;
}
});
});
}
fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) { fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) {
// Capture key events for rebinding BEFORE rendering the rest // Capture key events for rebinding BEFORE rendering the rest
if let Some(rebind_action) = self.rebinding { if let Some(rebind_action) = self.rebinding {

View File

@ -58,7 +58,8 @@ impl SampleImportDialog {
.resizable(true) .resizable(true)
.collapsible(false) .collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.default_width(700.0) .default_width(crate::mobile::dialog_width(ctx, 700.0))
.max_width(crate::mobile::dialog_width(ctx, 700.0))
.default_height(500.0) .default_height(500.0)
.show(ctx, |ui| { .show(ctx, |ui| {
// Folder info // Folder info

View File

@ -519,6 +519,73 @@ impl Theme {
self.resolve(context, ctx).border_color.unwrap_or(fallback) self.resolve(context, ctx).border_color.unwrap_or(fallback)
} }
/// Look up a CSS custom property (variable) as a color, respecting the active light/dark mode.
/// `name` is given without the leading `--` (e.g. `"bg-surface"`). Dark overrides light.
pub fn var(&self, name: &str, ctx: &egui::Context) -> Option<egui::Color32> {
let mut vars = self.light_variables.clone();
if self.is_dark(ctx) {
vars.extend(self.dark_variables.clone());
}
let raw = vars.get(name)?.clone();
parse_color_value(&raw, &vars)
}
/// Apply the theme's core palette variables to egui's global `Visuals`, so the standard egui
/// widgets (buttons, text fields, dialogs, pane chrome) share the same colors as the mobile UI.
/// Cheap enough to call every frame; respects the active light/dark mode.
pub fn apply_to_egui(&self, ctx: &egui::Context) {
let is_dark = self.is_dark(ctx);
let v = |name: &str, fb: egui::Color32| self.var(name, ctx).unwrap_or(fb);
let g = |n: u8| egui::Color32::from_gray(n);
let text = v("text-primary", if is_dark { g(230) } else { g(20) });
let bg_app = v("bg-app", if is_dark { g(42) } else { g(224) });
let panel = v("bg-panel", bg_app);
let surface = v("bg-surface", panel);
let raised = v("bg-surface-raised", surface);
let sunken = v("bg-surface-sunken", bg_app);
let border = v("border-default", if is_dark { g(68) } else { g(153) });
let accent = v("accent", egui::Color32::from_rgb(0x39, 0x6c, 0xd8));
let on_accent = v("text-on-accent", egui::Color32::WHITE);
let stroke = |c: egui::Color32| egui::Stroke::new(1.0, c);
let mut visuals = if is_dark { egui::Visuals::dark() } else { egui::Visuals::light() };
visuals.panel_fill = panel;
visuals.window_fill = panel;
visuals.window_stroke = stroke(border);
visuals.extreme_bg_color = sunken;
visuals.faint_bg_color = surface;
visuals.override_text_color = Some(text);
visuals.hyperlink_color = accent;
let w = &mut visuals.widgets;
w.noninteractive.bg_fill = panel;
w.noninteractive.weak_bg_fill = panel;
w.noninteractive.bg_stroke = stroke(border);
w.noninteractive.fg_stroke = stroke(text);
w.inactive.bg_fill = surface;
w.inactive.weak_bg_fill = surface;
w.inactive.bg_stroke = stroke(border);
w.inactive.fg_stroke = stroke(text);
w.hovered.bg_fill = raised;
w.hovered.weak_bg_fill = raised;
w.hovered.bg_stroke = stroke(border);
w.hovered.fg_stroke = stroke(text);
w.active.bg_fill = accent;
w.active.weak_bg_fill = accent;
w.active.bg_stroke = stroke(accent);
w.active.fg_stroke = stroke(on_accent);
w.open.bg_fill = surface;
w.open.weak_bg_fill = surface;
w.open.bg_stroke = stroke(border);
w.open.fg_stroke = stroke(text);
visuals.selection.bg_fill = accent.linear_multiply(0.4);
visuals.selection.stroke = stroke(on_accent);
ctx.set_visuals(visuals);
}
/// Convenience: resolve and extract a dimension with fallback /// Convenience: resolve and extract a dimension with fallback
#[allow(dead_code)] #[allow(dead_code)]
pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 { pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {

842
phone-ui-sketches.html Normal file
View File

@ -0,0 +1,842 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phone UI — Wireframe Plates</title>
<style>
:root{
--bg:#0E1014;
--ink:#D7DDE4;
--ink-soft:#8B95A1;
--rule:#23282F;
--frame:#3A414D;
--device:#16191F;
--panel:#1F242C;
--panel-2:#272D37;
--line:#363D49;
--dim:#7C8693;
--bright:#EAEEF3;
--amber:#F4A340; /* time / playhead / transport — the spine */
--cyan:#54C3E8; /* selection */
--coral:#E8826B; /* annotation */
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif;
}
*{box-sizing:border-box;}
html{-webkit-text-size-adjust:100%;}
body{
margin:0; font-family:var(--sans); color:var(--ink);
background:
radial-gradient(circle, rgba(255,255,255,.022) 1px, transparent 1.4px) 0 0/26px 26px,
var(--bg);
line-height:1.5;
}
/* ---- title block ---- */
.sheet-head{ max-width:1120px; margin:0 auto; padding:48px 24px 8px;}
.titleblock{ border:1.5px solid var(--frame); display:grid; grid-template-columns:1fr auto; background:rgba(255,255,255,.012);}
.titleblock .tl-main{ padding:20px 22px;}
.titleblock .tl-meta{ border-left:1.5px solid var(--frame); display:grid; grid-template-rows:repeat(3,1fr); font-family:var(--mono); font-size:11px; min-width:200px;}
.tl-meta div{ padding:8px 14px; display:flex; flex-direction:column; justify-content:center;}
.tl-meta div + div{ border-top:1px solid var(--rule);}
.tl-meta span{ color:var(--ink-soft); letter-spacing:.04em;}
.tl-meta strong{ font-weight:600; font-size:12px; color:var(--bright);}
.eyebrow{ font-family:var(--mono); font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--coral); margin:0 0 8px;}
.titleblock h1{ margin:0; font-size:clamp(22px,3.6vw,34px); letter-spacing:-0.01em; line-height:1.05; color:var(--bright);}
.titleblock p{ margin:10px 0 0; max-width:60ch; color:var(--ink-soft); font-size:14px;}
.legend-key{ max-width:1120px; margin:14px auto 0; padding:0 24px; display:flex; flex-wrap:wrap; gap:18px; font-family:var(--mono); font-size:11px; color:var(--ink-soft);}
.legend-key b{ display:inline-flex; align-items:center; gap:6px; font-weight:500; color:var(--ink);}
.swatch{ width:11px; height:11px; border-radius:2px; display:inline-block;}
/* ---- plates ---- */
.plate{ max-width:1120px; margin:0 auto; padding:40px 24px; border-top:1px solid var(--rule);}
.plate:first-of-type{ border-top:0;}
.plate-head{ display:flex; align-items:baseline; gap:16px; margin-bottom:8px; flex-wrap:wrap;}
.plate-num{ font-family:var(--mono); font-size:13px; font-weight:600; color:var(--coral); letter-spacing:.04em;}
.plate-head h2{ margin:0; font-size:clamp(18px,2.6vw,24px); letter-spacing:-0.01em; color:var(--bright);}
.plate-head .tag{ font-family:var(--mono); font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--ink-soft); border:1px solid var(--line); padding:3px 8px; border-radius:3px;}
.plate > p.lede{ margin:0 0 26px; max-width:74ch; color:var(--ink-soft); font-size:14px;}
.stage-row{ display:flex; gap:34px; align-items:flex-start; flex-wrap:wrap;}
.figcol{ display:flex; flex-direction:column; gap:10px; align-items:center;}
.figcap{ font-family:var(--mono); font-size:11px; color:var(--ink-soft); text-align:center; max-width:300px; letter-spacing:.02em;}
.figcap b{ color:var(--bright); font-weight:600;}
/* ---- annotation callouts ---- */
.notes{ flex:1; min-width:240px; display:flex; flex-direction:column; gap:16px; padding-top:6px;}
.note{ display:grid; grid-template-columns:24px 1fr; gap:12px;}
.note .dot{ width:22px; height:22px; border-radius:50%; border:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; background:var(--panel); color:var(--ink);}
.note h4{ margin:0 0 3px; font-size:14px; letter-spacing:-0.01em; color:var(--bright);}
.note p{ margin:0; font-size:13px; color:var(--ink-soft);}
.note code{ font-family:var(--mono); font-size:12px; background:var(--panel); border:1px solid var(--line); padding:1px 5px; border-radius:3px; color:var(--bright);}
/* ================= PHONE FRAME ================= */
.phone{ width:266px; flex:0 0 auto; background:#04060A; border-radius:34px; padding:7px; box-shadow:0 0 0 1px rgba(255,255,255,.04), 0 22px 46px -22px rgba(0,0,0,.85); position:relative;}
.phone.lg{ width:300px;}
.phone .screen{ position:relative; border-radius:27px; overflow:hidden; background:var(--device); height:540px; display:flex; flex-direction:column; color:var(--bright); font-family:var(--mono);}
.phone.lg .screen{ height:610px;}
.phone.land{ width:520px;} .phone.land .screen{ height:266px;}
.hs{ position:absolute; width:20px; height:20px; border-radius:50%; background:var(--coral); color:#1a0f0a; font-family:var(--mono); font-size:11px; font-weight:700; display:flex; align-items:center; justify-content:center; z-index:9; box-shadow:0 0 0 3px rgba(232,130,107,.22);}
.statusbar{ height:20px; display:flex; align-items:center; justify-content:space-between; padding:0 14px; font-size:9px; color:var(--dim); flex:0 0 auto;}
/* surface tabs — now at TOP */
.tabs{ flex:0 0 auto; height:30px; background:var(--device); border-bottom:1px solid var(--line); display:flex;}
.tabs .tab{ flex:1; display:flex; align-items:center; justify-content:center; gap:5px; font-size:9px; color:var(--dim); border-right:1px solid #20242c; letter-spacing:.02em;}
.tabs .tab:last-child{ border-right:0;}
.tabs .tab .g{ width:10px; height:10px; border-radius:2.5px; border:1.4px solid var(--dim);}
.tabs .tab.on{ color:var(--amber); box-shadow:inset 0 -2px 0 var(--amber);}
.tabs .tab.on .g{ border-color:var(--amber); background:rgba(244,163,64,.16);}
/* transport — now at BOTTOM, with the timeline */
.transport{ flex:0 0 auto; height:34px; background:var(--panel); border-top:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px;}
.transport .play{ width:20px; height:20px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
.transport .play::after{ content:""; border-left:7px solid #1b130a; border-top:5px solid transparent; border-bottom:5px solid transparent; margin-left:2px;}
.transport .tc{ font-size:11px; color:var(--bright); letter-spacing:.02em;}
.transport .scrub{ flex:1; height:4px; border-radius:2px; background:var(--line); position:relative;}
.transport .scrub::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:38%; background:var(--amber); border-radius:2px; opacity:.45;}
.transport .scrub::after{ content:""; position:absolute; left:38%; top:-3px; width:2px; height:10px; background:var(--amber);}
.transport .mini{ font-size:9px; color:var(--dim);}
.hero{ flex:1; position:relative; overflow:hidden; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 0%, #1b1f27 0%, #14161B 70%);}
.hero .canvas-frame{ position:absolute; border:1px dashed var(--line);}
.obj{ position:absolute; border-radius:6px;}
.obj.sel{ box-shadow:0 0 0 1.5px var(--cyan), 0 0 0 4px rgba(84,195,232,.18);}
.handle{ position:absolute; width:7px; height:7px; background:var(--cyan); border:1px solid #0c2a33; border-radius:2px; z-index:3;}
/* timeline ribbon — sits above the transport */
.ribbon{ flex:0 0 auto; background:var(--panel); display:flex; flex-direction:column; overflow:hidden; position:relative;}
.ribbon .grab{ height:14px; display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
.ribbon .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
.ribbon .ruler{ height:14px; border-bottom:1px solid var(--line); border-top:1px solid var(--line); position:relative; background:repeating-linear-gradient(90deg,transparent 0 27px,var(--line) 27px 28px); flex:0 0 auto;}
.ribbon .ruler::after{ content:""; position:absolute; left:38%; top:0; height:600px; width:1.5px; background:var(--amber); z-index:4;}
.lane{ height:22px; display:flex; align-items:center; border-bottom:1px solid #20242c; padding-left:42px; position:relative; flex:0 0 auto;}
.lane .gut{ position:absolute; left:0; top:0; bottom:0; width:42px; border-right:1px solid var(--line); display:flex; align-items:center; padding-left:7px; font-size:8.5px; color:var(--dim); background:var(--panel-2);}
.clip{ position:absolute; height:13px; border-radius:3px; top:50%; transform:translateY(-50%);}
.clip.a{ background:linear-gradient(180deg,#3a6ea8,#2c5482);}
.clip.v{ background:linear-gradient(180deg,#8a5fb0,#6a4690);}
.clip.raster{ background:linear-gradient(180deg,#3f9d8f,#2c7568);}
.clip.sel{ box-shadow:0 0 0 1.5px var(--cyan);}
.clip .wav{ position:absolute; inset:2px 3px; opacity:.5; background:repeating-linear-gradient(90deg,#cfe3ff 0 1px, transparent 1px 3px);}
/* bottom sheet (inspector) — rises above the transport */
.sheet-ui{ position:absolute; left:0; right:0; bottom:34px; background:var(--panel-2); border-top:1px solid var(--line); border-radius:14px 14px 0 0; z-index:6; box-shadow:0 -10px 30px -12px rgba(0,0,0,.7);}
.sheet-ui .grab{ height:16px; display:flex; align-items:center; justify-content:center;}
.sheet-ui .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
.sheet-ui .sh-head{ display:flex; align-items:center; gap:8px; padding:2px 12px 8px;}
.sheet-ui .sh-head .ico{ width:18px; height:18px; border-radius:4px; background:var(--cyan);}
.sheet-ui .sh-head .ttl{ font-size:11px; color:var(--bright);}
.sheet-ui .sh-head .ovf{ margin-left:auto; color:var(--dim); font-size:14px; letter-spacing:1px;}
.chips{ display:flex; gap:6px; padding:0 12px 10px; flex-wrap:wrap;}
.chip{ font-size:9.5px; padding:4px 8px; border-radius:11px; border:1px solid var(--line); color:var(--dim);}
.chip.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
.prop{ display:flex; align-items:center; gap:8px; padding:7px 12px; border-top:1px solid #20242c;}
.prop .k{ font-size:10px; color:var(--dim); width:62px;}
.prop .field{ flex:1; height:18px; border-radius:4px; background:var(--panel); border:1px solid var(--line); display:flex; align-items:center; padding:0 7px; font-size:10px; color:var(--bright);}
.prop .field.slider{ position:relative; background:var(--line);}
.prop .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0;width:55%; background:var(--cyan); opacity:.55; border-radius:4px 0 0 4px;}
.omni{ position:absolute; right:14px; bottom:120px; width:46px; height:46px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; z-index:7; box-shadow:0 6px 16px -4px rgba(244,163,64,.55);}
.omni::before,.omni::after{ content:""; position:absolute; background:#1b130a;}
.omni::before{ width:16px; height:2.5px; border-radius:2px;}
.omni::after{ width:2.5px; height:16px; border-radius:2px;}
.radial-wrap{ position:absolute; inset:0; z-index:8;}
.radial-wrap .scrim{ position:absolute; inset:0; background:rgba(8,10,14,.55);}
.rad-btn{ position:absolute; width:42px; height:42px; border-radius:50%; background:var(--panel-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size:9px; color:var(--bright); text-align:center; line-height:1.1;}
.rad-btn .g{ width:16px;height:16px;border-radius:4px;}
/* intent grid */
.intent-screen{ flex:1; display:flex; flex-direction:column; padding:18px 16px; gap:14px; background:var(--device);}
.intent-screen .new-h{ font-size:13px; color:var(--bright); letter-spacing:.02em;}
.intent-screen .new-sub{ font-size:10px; color:var(--dim); margin-top:-8px;}
.intent-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:4px;}
.intent{ aspect-ratio:1/.92; border:1px solid var(--line); border-radius:12px; background:var(--panel); display:flex; flex-direction:column; justify-content:flex-end; padding:10px; gap:4px; position:relative; overflow:hidden;}
.intent .gico{ position:absolute; top:10px; left:10px; width:22px; height:22px; border-radius:6px;}
.intent .nm{ font-size:11px; color:var(--bright);}
.intent .de{ font-size:8.5px; color:var(--dim); line-height:1.25;}
.intent.draw .gico{ background:var(--coral);}
.intent.animate .gico{ background:var(--cyan);}
.intent.compose .gico{ background:var(--amber);}
.intent.record .gico{ background:#c75b8a;}
.intent.video .gico{ background:#6a4690;}
.intent.open{ grid-column:1/3; aspect-ratio:auto; flex-direction:row; align-items:center; gap:10px; padding:12px;}
.intent.open .gico{ position:static; background:var(--line);}
.keys{ flex:1; background:#0c0e12; display:flex; position:relative; padding:6px 6px 8px;}
.wkey{ flex:1; background:linear-gradient(180deg,#f3f4f6,#d9dde2); border-radius:0 0 4px 4px; margin:0 1px; box-shadow:inset 0 -6px 8px -6px rgba(0,0,0,.3);}
.bkey{ position:absolute; top:6px; width:5.4%; height:54%; background:#1b1e24; border-radius:0 0 3px 3px; z-index:2; box-shadow:0 2px 3px rgba(0,0,0,.5);}
.inst-bar{ flex:0 0 auto; height:26px; background:var(--panel); border-top:1px solid var(--line); border-bottom:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px; font-size:9.5px; color:var(--dim);}
.inst-bar .pill{ padding:3px 7px; border:1px solid var(--line); border-radius:10px; color:var(--bright);}
.gmap{ display:flex; gap:30px; flex-wrap:wrap; align-items:flex-start;}
.gtable{ flex:1; min-width:280px; border:1px solid var(--frame); border-radius:8px; overflow:hidden; background:var(--device);}
.grow{ display:grid; grid-template-columns:150px 1fr; border-top:1px solid var(--rule);}
.grow:first-child{ border-top:0;}
.grow .gg{ font-family:var(--mono); font-size:12px; padding:9px 12px; background:var(--panel); color:var(--bright); border-right:1px solid var(--rule); display:flex; align-items:center;}
.grow .gm{ padding:9px 12px; font-size:13px; color:var(--ink-soft); display:flex; align-items:center;}
.grow .gm b{ color:var(--bright); font-weight:600;}
.grow.free .gg{ color:var(--coral);}
.grow.free .gm b{ color:var(--coral);}
.foot{ max-width:1120px; margin:0 auto; padding:30px 24px 70px; border-top:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; color:var(--ink-soft); display:flex; justify-content:space-between; flex-wrap:wrap; gap:12px;}
@media (max-width:560px){
.phone{ width:240px;} .phone .screen{ height:500px;}
.phone.land{ width:300px;} .phone.land .screen{ height:200px;}
.titleblock{ grid-template-columns:1fr;} .titleblock .tl-meta{ border-left:0; border-top:1.5px solid var(--frame); grid-template-rows:none; grid-template-columns:repeat(3,1fr);}
.tl-meta div + div{ border-top:0; border-left:1px solid var(--rule);}
}
/* ---- node focus mode ---- */
.nodewrap{ position:absolute; inset:0;}
.minimap{ position:absolute; top:8px; right:8px; width:80px; height:54px; background:rgba(8,10,14,.72); border:1px solid var(--line); border-radius:6px; z-index:5;}
.minimap .mn{ position:absolute; width:11px; height:8px; border-radius:2px; background:var(--line);}
.minimap .mn.on{ background:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.3);}
.minimap .mw{ position:absolute; height:1.5px; background:var(--line);}
.node-card{ position:absolute; left:50%; top:48%; transform:translate(-50%,-50%); width:158px; background:var(--panel-2); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 30px -16px rgba(0,0,0,.85); overflow:hidden; z-index:3;}
.node-card .nh{ padding:8px 10px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:7px;}
.node-card .nh .ndot{ width:8px; height:8px; border-radius:2px; background:#9a6bc0;}
.node-card .nh .nt{ font-size:11px; color:var(--bright);}
.node-card .nh .ny{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
.node-card .nprev{ height:42px; background:linear-gradient(135deg,#3a2c52,#243042);}
.node-card .np{ display:flex; align-items:center; gap:7px; padding:6px 10px; border-top:1px solid #20242c;}
.node-card .np .k{ font-size:9px; color:var(--dim); width:42px;}
.node-card .np .field{ flex:1; height:15px; border-radius:4px; background:var(--panel); border:1px solid var(--line); font-size:9px; color:var(--bright); display:flex; align-items:center; padding:0 6px;}
.node-card .np .field.slider{ background:var(--line); position:relative;}
.node-card .np .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0; width:48%; background:var(--cyan); opacity:.5; border-radius:4px;}
.wire-line{ position:absolute; top:48%; height:1.5px; background:var(--line); z-index:1;}
.wire-line.l{ left:0; width:50px;} .wire-line.r{ right:0; width:50px; background:var(--cyan); opacity:.6;}
.port-col{ position:absolute; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:9px; z-index:4;}
.port-col.in{ left:8px;} .port-col.out{ right:8px;}
.pchip{ width:54px; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:5px 6px; font-size:8px; color:var(--bright); line-height:1.25;}
.pchip .pl{ color:var(--dim); font-size:7px; display:block; letter-spacing:.04em;}
.pchip.wire{ border-color:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.18);}
.swipehint{ position:absolute; top:50%; transform:translateY(-50%); font-size:8px; color:var(--dim); z-index:4;}
.addnode{ position:absolute; left:50%; bottom:8px; transform:translateX(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:14px; padding:5px 12px; font-size:9px; color:var(--bright); z-index:6; white-space:nowrap;}
.wirebanner{ position:absolute; top:8px; left:8px; right:96px; background:rgba(84,195,232,.14); border:1px solid var(--cyan); border-radius:8px; padding:5px 8px; font-size:8px; color:#bfe9f7; z-index:6; line-height:1.25;}
/* ---- patch (pair) view ---- */
.patch{ position:absolute; left:12px; right:12px; top:50%; transform:translateY(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:12px; overflow:hidden; z-index:2; box-shadow:0 14px 30px -16px rgba(0,0,0,.85);}
.patch .pthead{ display:flex; align-items:center; gap:6px; padding:8px 10px; border-bottom:1px solid var(--line); font-size:10px; color:var(--bright);}
.patch .pthead .ty{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
.ptrow{ display:grid; grid-template-columns:1fr 64px 1fr; align-items:center; padding:7px 10px; border-top:1px solid #20242c;}
.ptrow .sp{ display:flex; align-items:center; gap:6px; justify-content:flex-end; font-size:9px; color:var(--bright);}
.ptrow .dp{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--bright);}
.ptrow .dp.muted, .ptrow .sp.muted{ color:var(--dim);}
.ptrow .cable{ height:12px; position:relative;}
.ptrow .cable.on::before{ content:""; position:absolute; left:0; right:0; top:50%; height:2px; background:var(--cyan); transform:translateY(-50%);}
.dotport{ width:8px; height:8px; border-radius:50%; background:var(--cyan); border:1px solid #0c2a33; flex:0 0 auto;}
.dotport.empty{ background:var(--line); border-color:var(--dim);}
.patch .pthint{ padding:7px 10px; border-top:1px solid var(--line); font-size:8px; color:var(--dim);}
/* ---- conventional piano roll (landscape) ---- */
.proll{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line); background:repeating-linear-gradient(0deg,#13161c 0 13px,#181c23 13px 26px);}
.proll .glab{ position:absolute; top:5px; left:8px; font-size:8px; color:var(--dim); z-index:2;}
.proll .pn{ position:absolute; height:10px; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32);}
.proll .ph{ position:absolute; top:0; bottom:0; width:1.5px; background:var(--amber); opacity:.7; z-index:2;}
/* ---- instrument workspace ---- */
.workspace{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line);
background:repeating-linear-gradient(90deg,#13161c 0 7.14%, #181c23 7.14% 14.28%);}
.workspace .now{ position:absolute; left:0; right:0; bottom:0; height:1.5px; background:var(--amber); opacity:.7; z-index:3;}
.workspace .glab{ position:absolute; top:6px; left:8px; font-size:8px; color:var(--dim);}
.nbar{ position:absolute; width:5.2%; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32); z-index:2;}
.ws-toggle{ display:flex; gap:5px;}
.ws-toggle .seg{ font-size:8.5px; padding:3px 8px; border:1px solid var(--line); border-radius:9px; color:var(--dim);}
.ws-toggle .seg.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
@media (prefers-reduced-motion: reduce){ *{ transition:none!important; animation:none!important;}}
</style>
</head>
<body>
<div class="sheet-head">
<p class="eyebrow">Mobile port · concept plates</p>
<div class="titleblock">
<div class="tl-main">
<h1>Unified-timeline studio — phone layout</h1>
<p>One hero surface at a time, surface tabs along the top, and time controls grouped at the bottom: a fixed transport bar with the resizable timeline ribbon riding above it. Every desktop control stays reachable; almost none stay visible.</p>
</div>
<div class="tl-meta">
<div><span>CONSTRAINT</span><strong>Phone · single-pane</strong></div>
<div><span>TABS</span><strong>Top · surfaces</strong></div>
<div><span>SPINE</span><strong>Bottom · time</strong></div>
</div>
</div>
</div>
<div class="legend-key">
<b><span class="swatch" style="background:var(--amber)"></span>Time / playhead / transport</b>
<b><span class="swatch" style="background:var(--cyan)"></span>Selection</b>
<b><span class="swatch" style="background:var(--coral)"></span>Annotation</b>
</div>
<!-- ============ PLATE 01 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 01</span>
<h2>The spine &amp; the resizable ribbon</h2>
<span class="tag">Tabs top · time bottom · 3 snaps</span>
</div>
<p class="lede">Surface tabs sit along the top. At the bottom, the transport bar is fixed — the irreducible spine — and the timeline ribbon expands upward above it through three snaps, trading stage for tracks by degrees. It never hits zero on its own; the transport is always the floor.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero">
<div class="canvas-frame" style="inset:24px 30px;"></div>
<div class="obj sel" style="left:96px; top:150px; width:74px; height:54px; background:#2c5482;"></div>
</div>
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
<span class="hs" style="left:120px; top:42px;">1</span>
<span class="hs" style="left:6px; bottom:48px;">2</span>
<span class="hs" style="left:6px; bottom:8px;">3</span>
</div></div>
<div class="figcap"><b>Peek</b> — ribbon collapsed to a sliver; transport still anchors the bottom.</div>
</div>
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero" style="flex:0 0 168px;">
<div class="canvas-frame" style="inset:22px;"></div>
<div class="obj sel" style="left:80px; top:78px; width:66px; height:46px; background:#2c5482;"></div>
</div>
<div class="ribbon" style="flex:1;">
<div class="grab"></div><div class="ruler"></div>
<div class="lane"><span class="gut">RASTER</span><div class="clip raster" style="left:50px; width:80px;"></div></div>
<div class="lane"><span class="gut">VEC</span><div class="clip v sel" style="left:96px; width:64px;"></div></div>
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:60px; width:120px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:130px; width:70px;"><div class="wav"></div></div></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
</div></div>
<div class="figcap"><b>Half</b> — arrange &amp; trim across tracks while the stage stays in view.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>Tabs own the top</h4>
<p>Stage / Time / Nodes / Mixer / Tree. The active surface is home; the rest are one tap away. Moving them up clears the bottom for the controls your thumb actually lives on.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Ribbon expands upward</h4>
<p>Drag the grabber up for more tracks, down for more stage. A continuous reveal, not a modal toggle — there's always a sliver left.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Transport = the fixed floor</h4>
<p>Play, playhead time and a zoomed-out project scrub never move and never disappear. It's the irreducible spine; the ribbon is detail layered on top of it.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 02 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 02</span>
<h2>Tweak mode — the pull model</h2>
<span class="tag">Selection → inspector</span>
</div>
<p class="lede">You arrive with a full project and a vague target, so controls come <em>to</em> what you point at. Tap anything and its properties rise in a sheet above the transport. The header carries jump-to chips that reframe to another surface with the same object still selected.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>⌕ ●●●</span></div>
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero">
<div class="canvas-frame" style="inset:18px 22px 0;"></div>
<div class="obj sel" style="left:108px; top:54px; width:78px; height:58px; background:#7a4f9c;"></div>
<div class="handle" style="left:104px; top:50px;"></div>
<div class="handle" style="left:182px; top:50px;"></div>
<div class="handle" style="left:104px; top:108px;"></div>
<div class="handle" style="left:182px; top:108px;"></div>
</div>
<div class="sheet-ui" style="height:236px;">
<div class="grab"></div>
<div class="sh-head"><span class="ico" style="background:#9a6bc0;"></span><span class="ttl">Ellipse · Vector layer</span><span class="ovf"></span></div>
<div class="chips"><span class="chip on">Properties</span><span class="chip">Timeline</span><span class="chip">Nodes</span><span class="chip">Automation</span></div>
<div class="prop"><span class="k">Fill</span><div class="field"><span style="width:9px;height:9px;background:#9a6bc0;border-radius:2px;margin-right:6px;"></span>#9A6BC0</div></div>
<div class="prop"><span class="k">Opacity</span><div class="field slider"></div></div>
<div class="prop"><span class="k">Position</span><div class="field">x 108</div><div class="field">y 54</div></div>
<div class="prop"><span class="k">Z-order</span><div class="field">Front ▾</div></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
<span class="hs" style="left:170px; top:74px;">1</span>
<span class="hs" style="left:120px; bottom:250px;">2</span>
<span class="hs" style="left:6px; bottom:8px;">3</span>
</div></div>
<div class="figcap"><b>Half tier</b> shown — drag up for every advanced parameter, down to a 3-control peek. Transport stays pinned below.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>Selection-driven</h4>
<p>Tap selects and summons the sheet. Once selected, dragging the object <em>moves</em> it; dragging empty space pans. One chain: tap → inspect → move → long-press.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Jump-to chips = parity move</h4>
<p><code>Timeline</code> · <code>Nodes</code> · <code>Automation</code> reframe to that surface with the object still held — replacing the desktop trick of seeing panes side-by-side.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Sheet floats over the floor</h4>
<p>The inspector rises above the transport, never under it — so even mid-tweak you can scrub and see the playhead. Object actions like <code>send to back</code> live on the <code></code>.</p>
</div></div>
<div class="note"><div class="dot" style="border-style:dashed;">+</div><div>
<h4>Two ways to find the thing</h4>
<p>For "I don't know what I want to tweak": the <code></code> command palette and an outliner surface (the <code>Tree</code> tab) — a browsable map of the whole project.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 03 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 03</span>
<h2>Sketch mode — the push model</h2>
<span class="tag">New file · intent seeds</span>
</div>
<p class="lede">An empty project with a clearish intent, so a tool defines the surface and gets out of the way. "New" offers intents — the same non-binding menu as desktop. Picking one sets the hero surface and a minimal toolset; switching intent mid-session just swaps the hero while a real project quietly accretes underneath.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="intent-screen">
<div class="new-h">Start something</div>
<div class="new-sub">Sets your first surface — nothing is locked.</div>
<div class="intent-grid">
<div class="intent draw"><span class="gico"></span><span class="nm">Draw</span><span class="de">Full canvas · stylus · radial brushes</span></div>
<div class="intent animate"><span class="gico"></span><span class="nm">Animate</span><span class="de">Stage + keyframe scrub strip</span></div>
<div class="intent compose"><span class="gico"></span><span class="nm">Compose</span><span class="de">Instrument + arrangement ribbon</span></div>
<div class="intent record"><span class="gico"></span><span class="nm">Record</span><span class="de">Big button · waveform fills screen</span></div>
<div class="intent video"><span class="gico"></span><span class="nm">Edit video</span><span class="de">Clip bin + trim timeline</span></div>
<div class="intent draw" style="opacity:.5"><span class="gico" style="background:var(--line)"></span><span class="nm">Blank</span><span class="de">Empty unified timeline</span></div>
<div class="intent open"><span class="gico"></span><span class="nm">Open recent…</span></div>
</div>
</div>
<span class="hs" style="left:120px; top:160px;">1</span>
</div></div>
<div class="figcap"><b>Intent picker</b> — mirrors the desktop new-file flow; the seed steers nothing once content exists.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>Intent seeds, state steers</h4>
<p>At creation there's nothing else to go on, so intent is the right call. The moment content exists, the original type stops driving anything.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Home-on-open ≠ birth type</h4>
<p>Re-opening lands on the file's <b>last-focused surface</b> + last selection — not the type it was born as. A music file that grew an animation layer shouldn't open music-shaped.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Lossless underneath</h4>
<p>Every intent writes to the same document model. Whatever you rough out on the train reconstitutes as a full pane layout back on desktop.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 04 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 04</span>
<h2>Music surface — the GarageBand fix</h2>
<span class="tag">Expression + context, together</span>
</div>
<p class="lede">GarageBand denies you the <em>when</em> while you play. Here the instrument lives in the thumb zone with a compressed arrangement ribbon pinned above it — loop boundaries, the playhead sweeping toward the wrap, and squashed lanes of what you're playing against. The transport anchors the bottom as everywhere else.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="ribbon" style="flex:0 0 auto;">
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut" style="color:var(--amber);">KEYS ●</span><div class="clip a sel" style="left:42px; width:42px; background:linear-gradient(180deg,#caa15a,#a07c32);"></div></div>
</div>
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><span>Loop ⟳ 2 bars</span><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
<div class="workspace">
<span class="glab">LIVE TAKE · KEYS</span>
<div class="nbar" style="left:7.1%; bottom:0; height:30px;"></div>
<div class="nbar" style="left:21.4%; bottom:16px; height:24px;"></div>
<div class="nbar" style="left:35.7%; bottom:4px; height:40px;"></div>
<div class="now"></div>
</div>
<div class="keys" style="flex:0 0 150px;">
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
<span class="hs" style="left:120px; top:52px;">1</span>
<span class="hs" style="left:200px; top:96px;">2</span>
<span class="hs" style="left:6px; bottom:8px;">3</span>
</div></div>
<div class="figcap"><b>Record-against-context</b> — see the loop wrap and the other tracks while your thumbs are on the keys.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>The loop boundary is visible</h4>
<p>Green markers show where the 2-bar loop wraps; the amber playhead sweeps toward it — the exact <em>when</em> GarageBand's modal switch hides.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Playing against the mix</h4>
<p>Squashed drum/bass lanes stay on screen so you hear <em>and see</em> what your take lands over. The live KEYS lane fills as you record.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Same spine, same floor</h4>
<p>Transport with bars-beats + tempo anchors the bottom. Drag the ribbon down for full arrangement; the Mixer is a portrait-native surface one tab away.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 05 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 05</span>
<h2>Orientation is a per-surface reflow</h2>
<span class="tag">Not an app-global mode</span>
</div>
<p class="lede">No surface forces a rotation — that's the user's wrist, not the app's call. But the same timeline data emphasises a different axis per orientation: landscape spends pixels on time resolution, portrait spends them on track count. Rotation preserves selection, playhead and zoom-center; it only reflows.</p>
<div class="stage-row" style="align-items:flex-start;">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="ribbon" style="flex:1;">
<div class="ruler"></div>
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:50px;width:60px;"></div></div>
<div class="lane"><span class="gut">V2</span><div class="clip raster" style="left:70px;width:90px;"></div></div>
<div class="lane"><span class="gut">RAS</span><div class="clip raster" style="left:50px;width:50px;"></div></div>
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:48px;width:130px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:90px;width:80px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">A3</span><div class="clip a" style="left:60px;width:60px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">A4</span><div class="clip a" style="left:120px;width:70px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">FX</span><div class="clip v" style="left:42px;width:40px;"></div></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
</div></div>
<div class="figcap"><b>Portrait</b> — vertical room buys <b>more tracks</b>. Best for arranging, reordering, seeing structure.</div>
</div>
<div class="figcol">
<div class="phone land"><div class="screen">
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="ribbon" style="flex:1;">
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 48px,var(--line) 48px 49px);"></div>
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:60px;width:140px;"></div><div class="clip raster" style="left:210px;width:90px;"></div></div>
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:50px;width:300px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:120px;width:200px;"><div class="wav"></div></div></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div><span class="mini"></span><span class="mini"></span></div>
</div></div>
<div class="figcap"><b>Landscape</b> — wide axis buys <b>time resolution</b>. Best for scrubbing, precise trims.</div>
</div>
<div class="notes" style="min-width:200px;">
<div class="note"><div class="dot"></div><div>
<h4>Reflow, don't reset</h4>
<p>Same selection, playhead and zoom-center — just re-emphasised. A rotate that throws away where you were is its own GarageBand-tier annoyance.</p>
</div></div>
<div class="note"><div class="dot"></div><div>
<h4>Per-surface character</h4>
<p>Stage follows the <em>project's</em> aspect; instrument prefers landscape for key width; mixer is portrait-native. The ribbon absorbs whatever the hero leaves over.</p>
</div></div>
<div class="note"><div class="dot">🔒</div><div>
<h4>Manual lock</h4>
<p>People work lying down and propped at angles. The device <em>suggests</em>; the user can <em>pin</em>. Auto-rotate never fights a drawing gesture.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 06 ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 06</span>
<h2>Omnibutton &amp; the gesture map</h2>
<span class="tag">Tools vs commands · 7 + 1 meanings</span>
</div>
<p class="lede">The omnibutton produces <em>tools/objects</em>; the global menu holds <em>commands/destinations</em> — a clean tool-vs-command line. Below, the resolved stage gestures: seven meanings, no collisions, each reinforcing a desktop idiom rather than inventing a phone-only convention.</p>
<div class="stage-row" style="margin-bottom:30px;">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero">
<div class="canvas-frame" style="inset:20px;"></div>
<div class="radial-wrap">
<div class="scrim"></div>
<div class="rad-btn" style="right:18px; bottom:190px;"><span class="g" style="background:var(--coral);"></span></div>
<div class="rad-btn" style="right:64px; bottom:174px;"><span class="g" style="background:var(--cyan);"></span></div>
<div class="rad-btn" style="right:96px; bottom:140px;"><span class="g" style="background:var(--amber);"></span></div>
<div class="rad-btn" style="right:110px; bottom:98px;">Shape</div>
<div class="omni"></div>
</div>
</div>
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
<span class="hs" style="right:6px; bottom:150px;">1</span>
<span class="hs" style="right:30px; top:42px;">2</span>
</div></div>
<div class="figcap"><b>Radial fans away from the finger</b> to dodge occlusion. Global commands live in the top <code></code>.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>Omnibutton = tools/objects</h4>
<p>Brushes, shapes, instruments, nodes — things you place or use. Long-press on empty space can summon this same create menu <em>at the point you pressed</em>.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Global menu = commands</h4>
<p>Export, render, project settings, and the command palette itself live in the top <code></code>/<code></code>. If an item feels like either, it's usually a selection action in disguise → inspector.</p>
</div></div>
<div class="note"><div class="dot" style="border-style:dashed;"></div><div>
<h4>Palette is the safety net</h4>
<p>Every menu-bar verb has one contextual home <em>plus</em> the palette. That's what lets contextual homes hide rare commands without breaking parity.</p>
</div></div>
</div>
</div>
<div class="gmap">
<div class="gtable">
<div class="grow"><div class="gg">tap object</div><div class="gm"><b>select</b> &nbsp;+ summon inspector</div></div>
<div class="grow"><div class="gg">tap empty</div><div class="gm">deselect</div></div>
<div class="grow"><div class="gg">drag selected</div><div class="gm"><b>move</b> the object / clip</div></div>
<div class="grow"><div class="gg">drag empty</div><div class="gm"><b>pan</b> the view (matches timeline scroll)</div></div>
<div class="grow"><div class="gg">two-finger drag</div><div class="gm"><b>pan</b> — always, everywhere (escape valve)</div></div>
<div class="grow"><div class="gg">pinch</div><div class="gm"><b>zoom</b> — time-zoom on timeline, spatial on stage</div></div>
<div class="grow"><div class="gg">long-press object</div><div class="gm"><b>actions menu</b> for that object</div></div>
<div class="grow"><div class="gg">long-press empty</div><div class="gm">create-here radial <span style="color:var(--coral)">(proposed)</span></div></div>
<div class="grow"><div class="gg">double-tap object</div><div class="gm"><b>enter / edit</b> — go a level deeper</div></div>
<div class="grow"><div class="gg">double-tap empty</div><div class="gm">zoom-to-fit</div></div>
<div class="grow free"><div class="gg">double-tap-drag empty</div><div class="gm"><b>marquee select</b> — fast transient path</div></div>
</div>
<div class="notes" style="min-width:240px;">
<div class="note"><div class="dot"></div><div>
<h4>Two tiers for marquee</h4>
<p>The <b>double-tap-drag</b> gesture is the fast, transient path. A sustained <b>marquee mode</b> covers heavy vector multi-select — and with the gesture in hand, it may not even need a dedicated button.</p>
</div></div>
<div class="note"><div class="dot"></div><div>
<h4>Origin disambiguates</h4>
<p>Marquee is strictly empty-space-initiated. Double-tap-drag starting <em>on</em> an object never marquees — same spatial-zones logic as the ruler vs lanes.</p>
</div></div>
<div class="note"><div class="dot"></div><div>
<h4>Crisp double-tap window</h4>
<p>Empty space is where both <code>zoom-to-fit</code> and <code>marquee</code> branch off the same tap-tap, so tighten the detection window there specifically.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 07 — NODE EDITOR: FOCUS & PATCH ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 07</span>
<h2>Node editor — focus &amp; patch</h2>
<span class="tag">Audio synthesis · port-to-port</span>
</div>
<p class="lede">For synthesis the graph is patch-heavy: several cables of one type running between modules. So ports are identified by <em>name</em> — gate, velocity, pitch — not just type; type only decides what <em>may</em> connect, the name decides which is which. Connections are wire-level, parallel cables between a single pair are normal, and one output can fan out. Focus mode tunes a module's parameters; patch mode runs the cables.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero">
<div class="nodewrap">
<div class="minimap">
<span class="mw" style="left:14px; top:16px; width:18px; transform:rotate(20deg);"></span>
<span class="mw" style="left:40px; top:26px; width:20px;"></span>
<span class="mn" style="left:8px; top:10px;"></span>
<span class="mn on" style="left:30px; top:22px;"></span>
<span class="mn" style="left:56px; top:30px;"></span>
<span class="mn" style="left:34px; top:40px;"></span>
</div>
<div class="wire-line l"></div>
<div class="wire-line r"></div>
<div class="port-col in">
<div class="pchip"><span class="pl">◂ gate</span>MIDI→CV.gate</div>
<div class="pchip"><span class="pl">◂ velocity</span>MIDI→CV.vel</div>
</div>
<div class="node-card">
<div class="nh"><span class="ndot" style="background:#5aa0c2;"></span><span class="nt">ADSR</span><span class="ny">ENV · CV</span></div>
<div class="nprev" style="background:linear-gradient(135deg,#243042,#2c3a26);"></div>
<div class="np"><span class="k">Attack</span><div class="field slider"></div></div>
<div class="np"><span class="k">Decay</span><div class="field">120 ms</div></div>
<div class="np"><span class="k">Sustain</span><div class="field slider"></div></div>
</div>
<div class="port-col out">
<div class="pchip"><span class="pl">env ▸</span>VCA.in</div>
</div>
<div class="addnode"> Add node &nbsp;·&nbsp; ⌕ Search</div>
</div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
<span class="hs" style="left:6px; top:232px;">1</span>
<span class="hs" style="right:34px; top:54px;">2</span>
</div></div>
<div class="figcap"><b>Focus</b> — one module's parameters; its named ports are travel chips. Input chips name the <em>remote port</em>, so two cables from the same node never collide.</div>
</div>
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="hero">
<div class="patch">
<div class="pthead"><span>Patch</span><span style="color:var(--dim);">MIDI→CV ▸ ADSR</span><span class="ty">CV</span></div>
<div class="ptrow"><div class="sp">pitch <span class="dotport"></span></div><div class="cable"></div><div class="dp muted"><span class="dotport empty"></span>→ Osc 1</div></div>
<div class="ptrow"><div class="sp">velocity <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>velocity</div></div>
<div class="ptrow"><div class="sp">gate <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>gate</div></div>
<div class="pthint">tap a source port, then a target — both are CV, so the <b>name</b> decides the match</div>
</div>
</div>
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
<span class="hs" style="left:122px; top:250px;">3</span>
</div></div>
<div class="figcap"><b>Patch</b> — two modules as port lists, each cable a row. The CV pair (gate, velocity) is just two rows; pitch fans off to the oscillator.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>Ports are named, not just typed</h4>
<p>Input chips read the remote endpoint as <code>node.port</code><code>MIDI→CV.gate</code>, <code>MIDI→CV.vel</code> — so two cables from one node stay distinct even though both are CV. Type gates compatibility; the name carries identity.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Focus still travels &amp; renders live</h4>
<p>Tap a chip to jump to that endpoint; the minimap keeps you placed. The audio graph evaluates at the playhead, so transport stays — scrub and the envelope previews where you are.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Patch mode for cabling</h4>
<p>A pair (or small cluster) shown as two port lists; each cable is a row. Parallel cables between the same pair are normal, and an output fans out (pitch → Osc). Tap a source port then a target — compatible ports light up, incompatible dim. This is the modular workflow, not one-node-at-a-time.</p>
</div></div>
<div class="note"><div class="dot" style="border-style:dashed;">!</div><div>
<h4>Honest scope</h4>
<p>Phone is tune-a-module and patch-a-few; large graph restructuring stays a desktop job. Reachable for parity via the palette and these two views — without faking a tiny pannable canvas of boxes.</p>
</div></div>
</div>
</div>
</section>
<!-- ============ PLATE 08 — KEYBOARD CAP & RECLAIMED SPACE ============ -->
<section class="plate">
<div class="plate-head">
<span class="plate-num">PLATE 08</span>
<h2>Keyboard cap &amp; the reclaimed space</h2>
<span class="tag">Portrait · keys ≤ ⅓</span>
</div>
<p class="lede">Key <em>width</em> governs playability, and width is set by the screen's short axis — so past about a third of a portrait screen, taller keys add nothing. The resize catches at that cap; pulling further reveals an instrument workspace above the keys instead. By default it's a note view of the current part, pitch-aligned to the keys; toggle it to performance controllers. Landscape has no room for this, so it's a portrait-only affordance.</p>
<div class="stage-row">
<div class="figcol">
<div class="phone"><div class="screen">
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<!-- macro context -->
<div class="ribbon" style="flex:0 0 auto;">
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
</div>
<!-- instrument bar at the top of the surface (above the workspace, not between notes/keys) -->
<div class="inst-bar">
<span class="pill">Grand Piano ▾</span>
<div class="ws-toggle"><span class="seg on">Notes</span><span class="seg">Controllers</span></div>
<span style="margin-left:auto;color:var(--coral);">● REC</span>
</div>
<!-- reclaimed workspace: note view, aligned to keys -->
<div class="workspace">
<span class="glab">CURRENT PART · KEYS</span>
<div class="nbar" style="left:7.1%; bottom:0; height:34px;"></div>
<div class="nbar" style="left:21.4%; bottom:18px; height:26px;"></div>
<div class="nbar" style="left:35.7%; bottom:6px; height:48px;"></div>
<div class="nbar" style="left:50%; bottom:28px; height:22px;"></div>
<div class="nbar" style="left:64.2%; bottom:0; height:40px;"></div>
<div class="nbar" style="left:78.5%; bottom:14px; height:30px;"></div>
<div class="now"></div>
</div>
<!-- keys capped ~1/3 -->
<div class="keys" style="flex:0 0 150px;">
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
<span class="hs" style="left:120px; top:150px;">1</span>
<span class="hs" style="left:120px; bottom:188px;">2</span>
<span class="hs" style="left:96px; bottom:158px;">3</span>
</div></div>
<div class="figcap"><b>Macro → micro → input</b>: arrangement context up top, note workspace in the middle, capped keys in the thumb zone.</div>
</div>
<div class="figcol">
<div class="phone land"><div class="screen">
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
<div class="ribbon" style="flex:0 0 auto;">
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 80px,#2a8a5a 80px 81px,transparent 81px 160px,var(--line) 160px 161px);"></div>
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:130px;"><div class="wav"></div></div><div class="clip a" style="left:180px; width:130px;"><div class="wav"></div></div></div>
</div>
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><div class="ws-toggle"><span class="seg">Keys</span><span class="seg on">Notes</span></div><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
<div class="proll">
<span class="glab">PIANO ROLL · time ▸</span>
<div class="pn" style="left:12%; top:30px; width:14%;"></div>
<div class="pn" style="left:30%; top:56px; width:10%;"></div>
<div class="pn" style="left:44%; top:17px; width:18%;"></div>
<div class="pn" style="left:46%; top:82px; width:8%;"></div>
<div class="pn" style="left:66%; top:43px; width:12%;"></div>
<div class="pn" style="left:80%; top:69px; width:10%;"></div>
<div class="ph" style="left:44%;"></div>
</div>
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
<span class="hs" style="left:200px; top:42px;">4</span>
</div></div>
<div class="figcap"><b>Landscape · Notes</b> — standalone, the editor flips to a conventional piano roll (pitch ↕, time ▸) to spend the wide axis on time. The context ribbon still rides on top.</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><div>
<h4>The cap, and what fills it</h4>
<p>Keys grow to ~⅓ then stop; continued upward drag reveals the workspace. The keyboard holds its ergonomic height — the new room opens <em>above</em> it. Same continuous-reveal as the timeline ribbon.</p>
</div></div>
<div class="note"><div class="dot">2</div><div>
<h4>Default: see what you play</h4>
<p>A note view of the current part, each column sitting over its key below (a waterfall toward the amber <em>now</em> line). The see-what-you're-doing principle the GarageBand port breaks — and editable in place.</p>
</div></div>
<div class="note"><div class="dot">3</div><div>
<h4>Or perform: Controllers</h4>
<p>Toggle the zone to pitch/mod strips, an XY pad, an arpeggiator — the other natural use of space above keys. The same drag, a different occupant.</p>
</div></div>
<div class="note"><div class="dot">4</div><div>
<h4>Landscape splits them — and reflows</h4>
<p>No room to stack, so keys and notes become a <b>Keys / Notes</b> toggle, with the context ribbon persisting in both — you lose seeing keys-and-notes together, never the <em>when</em>. And the note view itself reflows: Synthesia-style (pitch ↔, aligned to keys) when it sits above the keyboard in portrait; a conventional piano roll (pitch ↕, time ▸) when it's standalone in landscape and the wide axis is better spent on time. Rotate back and your part, playhead and selection carry across.</p>
</div></div>
</div>
</div>
</section>
<div class="foot">
<span>UNIFIED-TIMELINE STUDIO · PHONE CONCEPT PLATES 0108</span>
<span>WIREFRAME — LAYOUT &amp; GESTURE INTENT, NOT FINAL VISUAL</span>
</div>
</body>
</html>