Compare commits

..

No commits in common. "bb6b6fa9e3290fb6d539a6c4cb40239bc3f537aa" and "943ff7c15f66022715cd0a2c816db43e488b1bb0" have entirely different histories.

23 changed files with 1928 additions and 3584 deletions

View File

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

View File

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

View File

@ -11,7 +11,6 @@ pub mod add_layer;
pub mod add_shape;
pub mod modify_shape_path;
pub mod move_clip_instances;
pub mod reorder_clip_instances;
pub mod paint_bucket;
pub mod remove_effect;
pub mod set_document_properties;
@ -55,7 +54,6 @@ pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyGraphAction;
pub use move_clip_instances::MoveClipInstancesAction;
pub use reorder_clip_instances::ReorderClipInstancesAction;
pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction;

View File

@ -1,78 +0,0 @@
//! 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

@ -351,29 +351,6 @@ 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
// -------------------------------------------------------------------

View File

@ -201,9 +201,7 @@ fn main() -> eframe::Result {
// When developing the mobile UI on desktop (LB_MOBILE_UI), open a phone-aspect window so
// the shell can be exercised at a realistic size. Gating the shell itself is done at
// render time on the flag, not on window aspect.
let initial_size = if mobile::is_mobile_landscape_env() {
[874.0, 402.0] // iPhone-ish landscape (LB_MOBILE_UI=2)
} else if mobile::is_mobile_env() {
let initial_size = if mobile::is_mobile_env() {
[402.0, 874.0] // iPhone-ish portrait
} else {
[1920.0, 1080.0]
@ -211,7 +209,6 @@ fn main() -> eframe::Result {
let mut viewport_builder = egui::ViewportBuilder::default()
.with_inner_size(initial_size)
.with_min_inner_size([360.0, 300.0]) // keep layouts (esp. the mobile shell) above degenerate sizes
.with_title("Lightningbeam Editor")
.with_app_id("lightningbeam-editor"); // Set app_id for Wayland
@ -945,21 +942,6 @@ impl EditingContext {
self.stack.pop()
}
/// The clip_id path from outermost to the current level (for breadcrumbs).
fn clip_path(&self) -> Vec<Uuid> {
self.stack.iter().map(|e| e.clip_id).collect()
}
/// Pop down to `depth` entries, returning the entry at `depth` (whose saved state should be
/// restored). Returns None if already at or above that depth.
fn exit_to_depth(&mut self, depth: usize) -> Option<EditingContextEntry> {
if depth >= self.stack.len() {
return None;
}
let restore = self.stack[depth].clone();
self.stack.truncate(depth);
Some(restore)
}
}
struct EditorApp {
@ -977,16 +959,6 @@ struct EditorApp {
mobile_ui_override: Option<bool>,
/// Persistent state for the mobile shell (active surface, ribbon tier).
mobile_state: mobile::MobileState,
/// Active mobile long-press context menu (set by a pane, rendered by the shell).
mobile_context_menu: Option<panes::MobileContextMenu>,
/// Shared keyboard octave offset (C4-relative) for the mobile Virtual Piano + Piano Roll.
keyboard_octave: i8,
/// Shared horizontal keyboard pan (px) for the mobile keyboard + roll.
keyboard_pan_x: f32,
/// Mobile: request to open the instrument Preset Browser (set by the music pane header).
open_instrument_browser: bool,
/// Mobile: request to toggle recording (set by the music pane REC button).
pending_record_toggle: bool,
icon_cache: IconCache,
tool_icon_cache: ToolIconCache,
focus_icon_cache: FocusIconCache, // Focus card icons (start screen)
@ -1348,11 +1320,6 @@ impl EditorApp {
mobile_ui: mobile::is_mobile_env(),
mobile_ui_override: None,
mobile_state: mobile::MobileState::default(),
mobile_context_menu: None,
keyboard_octave: 0,
keyboard_pan_x: 0.0,
open_instrument_browser: false,
pending_record_toggle: false,
icon_cache: IconCache::new(),
tool_icon_cache: ToolIconCache::new(),
focus_icon_cache: FocusIconCache::new(),
@ -2714,56 +2681,45 @@ impl EditorApp {
}
self.selection.clear_clip_instances();
self.focus = lightningbeam_core::selection::FocusSelection::None;
} else if self.selection.has_geometry_selection() {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
};
// Delete the selected geometry (region/marquee/click all populate the same sets).
// Selecting a fill also selects its boundary edges, so removing edges alone would leave
// the fill's face orphaned — free the fills too so the whole shape is removed.
// Delete the selected edges (region/marquee/click all populate the same sets).
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
self.selection.selected_edges().iter().copied().collect();
let fill_ids: Vec<lightningbeam_core::vector_graph::FillId> =
self.selection.selected_fills().iter().copied().collect();
if !edge_ids.is_empty() || !fill_ids.is_empty() {
if !edge_ids.is_empty() {
let document = self.action_executor.document();
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vector_layer)) = document.get_layer(&active_layer_id) {
if let Some(layer) = document.get_layer(&active_layer_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) {
let mut graph_after = graph_before.clone();
// Free fills first so their boundary edges become unreferenced and are GC'd.
for fill_id in &fill_ids {
if !graph_after.fill(*fill_id).deleted {
graph_after.free_fill(*fill_id);
}
}
for edge_id in &edge_ids {
if !graph_after.edge(*edge_id).deleted {
graph_after.remove_edge(*edge_id);
}
}
graph_after.gc_isolated_vertices();
let action = lightningbeam_core::actions::ModifyGraphAction::new(
active_layer_id,
self.playback_time,
graph_before.clone(),
graph_after,
"Delete",
"Delete selected edges",
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete geometry failed: {}", e);
eprintln!("Delete DCEL edges failed: {}", e);
}
}
}
}
}
self.selection.clear_geometry_selection();
self.focus = lightningbeam_core::selection::FocusSelection::None;
}
}
@ -2951,39 +2907,6 @@ impl EditorApp {
}
}
/// Send the selected clip/group instances to the back or front of their layer's stacking order.
fn reorder_selected_clips(&mut self, to_front: bool) {
use lightningbeam_core::layer::AnyLayer;
let Some(layer_id) = self.active_layer_id else {
return;
};
let ids: Vec<uuid::Uuid> = {
let document = self.action_executor.document();
let Some(layer) = document.get_layer(&layer_id) else {
return;
};
let instances: &[lightningbeam_core::clip::ClipInstance] = match layer {
AnyLayer::Vector(l) => &l.clip_instances,
AnyLayer::Audio(l) => &l.clip_instances,
AnyLayer::Video(l) => &l.clip_instances,
AnyLayer::Effect(l) => &l.clip_instances,
_ => &[],
};
instances
.iter()
.filter(|ci| self.selection.contains_clip_instance(&ci.id))
.map(|ci| ci.id)
.collect()
};
if ids.is_empty() {
return;
}
let action = lightningbeam_core::actions::ReorderClipInstancesAction::new(layer_id, ids, to_front);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to reorder clip instances: {e}");
}
}
/// Duplicate the selected clip instances on the active layer.
/// Each duplicate is placed immediately after the original clip.
fn duplicate_selected_clips(&mut self) {
@ -3624,10 +3547,12 @@ impl EditorApp {
}
}
MenuAction::SendToBack => {
self.reorder_selected_clips(false);
println!("Menu: Send to Back");
// TODO: Implement send to back
}
MenuAction::BringToFront => {
self.reorder_selected_clips(true);
println!("Menu: Bring to Front");
// TODO: Implement bring to front
}
MenuAction::SplitClip => {
self.split_clips_at_playhead();
@ -5421,10 +5346,6 @@ impl eframe::App for EditorApp {
// Apply the theme's palette to egui's global visuals so the standard widgets (dialogs, pane
// chrome, text) share the same colors as the mobile UI and respond to light/dark/user themes.
self.theme.apply_to_egui(ctx);
// On mobile, enlarge egui spacing/sizing so widgets in panes/dialogs are touch-friendly.
if self.mobile_active() {
mobile::apply_touch_style(ctx);
}
// === Raster fault-in (Phase 3 paging) ===
// The canvas records raster keyframe ids whose `raw_pixels` weren't resident
@ -6578,9 +6499,7 @@ impl eframe::App for EditorApp {
}
}
// Top menu bar (egui-rendered on desktop). On mobile the shell's ⌕ palette + ⋯ overflow
// expose every command, so the desktop menu bar is suppressed.
if !self.mobile_active() {
// Top menu bar (egui-rendered on all platforms)
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
if let Some(menu_system) = &self.menu_system {
let recent_files = self.config.get_recent_files();
@ -6616,7 +6535,6 @@ impl eframe::App for EditorApp {
}
}
});
}
// Render start screen or editor based on app mode
if self.app_mode == AppMode::StartScreen {
@ -6657,16 +6575,7 @@ impl eframe::App for EditorApp {
{
scratch.synthetic_input = test_mode_replay.synthetic_input;
}
// On mobile the shell is full-bleed to the window edges (it paints its own background), so
// drop the central panel's default inner margin — otherwise every pane is inset by it while
// the unclipped keyboard overdraws into it, leaving an inconsistent gutter.
let central = egui::CentralPanel::default();
let central = if self.mobile_active() {
central.frame(egui::Frame::default())
} else {
central
};
central.show(ctx, |ui| {
egui::CentralPanel::default().show(ctx, |ui| {
let available_rect = ui.available_rect_before_wrap();
// Reset hovered divider each frame
@ -6726,27 +6635,6 @@ impl eframe::App for EditorApp {
&mut bundle.rc,
bundle.mobile_state,
);
// Drive recording from the application, not the Timeline pane's render pass, so a
// REC request (from the music pane header) and count-in progression work regardless
// of whether the Timeline is currently visible. The Timeline pane still owns the
// recording *state*; we just tick it here each frame.
let rc = &mut bundle.rc;
for pane in rc.pane_instances.values_mut() {
if let PaneInstance::Timeline(t) = pane {
t.check_pending_recording_start(&mut rc.shared);
if *rc.shared.pending_record_toggle {
*rc.shared.pending_record_toggle = false;
t.toggle_recording(&mut rc.shared);
}
// Stopping playback stops recording (stop_recording clears is_recording,
// so this fires once).
if *rc.shared.is_recording && !*rc.shared.is_playing {
t.stop_recording(&mut rc.shared);
}
break;
}
}
} else {
render_layout_node(
ui,
@ -7020,15 +6908,6 @@ impl eframe::App for EditorApp {
}
}
// Breadcrumb jump: exit up to a specific depth (restoring that level's saved context).
if let Some(depth) = scratch.pending_exit_to_depth.take() {
if let Some(entry) = self.editing_context.exit_to_depth(depth) {
self.selection.clear();
self.active_layer_id = entry.saved_active_layer_id;
self.playback_time = entry.saved_playback_time;
}
}
// Set cursor based on hover state
if let Some((_, is_horizontal)) = self.hovered_divider {
if is_horizontal {
@ -7260,7 +7139,6 @@ struct FrameScratch {
pending_handlers: Vec<panes::ViewActionHandler>,
pending_enter_clip: Option<(Uuid, Uuid, Uuid)>,
pending_exit_clip: bool,
pending_exit_to_depth: Option<usize>,
pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>>,
pending_menu_actions: Vec<MenuAction>,
effect_thumbnail_requests: Vec<Uuid>,
@ -7300,13 +7178,6 @@ impl EditorApp {
rc: RenderContext {
shared: panes::SharedPaneState {
is_mobile,
is_portrait: true, // set by the mobile shell from the available rect
keyboard_octave: &mut self.keyboard_octave,
keyboard_pan_x: &mut self.keyboard_pan_x,
instrument_show_roll: false,
open_instrument_browser: &mut self.open_instrument_browser,
pending_record_toggle: &mut self.pending_record_toggle,
mobile_context_menu: &mut self.mobile_context_menu,
container_path: self.current_file_path.clone(),
onion: {
// Onion skinning is disabled during playback.
@ -7331,10 +7202,8 @@ impl EditorApp {
editing_clip_id: self.editing_context.current_clip_id(),
editing_instance_id: self.editing_context.current_instance_id(),
editing_parent_layer_id: self.editing_context.current_parent_layer_id(),
editing_clip_path: self.editing_context.clip_path(),
pending_enter_clip: &mut scratch.pending_enter_clip,
pending_exit_clip: &mut scratch.pending_exit_clip,
pending_exit_to_depth: &mut scratch.pending_exit_to_depth,
active_layer_id: &mut self.active_layer_id,
tool_state: &mut self.tool_state,
pending_actions: &mut scratch.pending_actions,

File diff suppressed because it is too large Load Diff

View File

@ -36,8 +36,6 @@ pub fn font(size: f32) -> egui::FontId {
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}";

View File

@ -11,7 +11,8 @@ use crate::panes::{NodePath, SharedPaneState};
use crate::RenderContext;
const GRAB_H: f32 = 16.0;
const HEAD_H: f32 = 44.0;
const HEAD_H: f32 = 30.0;
const CHIP_H: f32 = 30.0;
fn inspector_path() -> NodePath {
vec![MOBILE_NS, 200]
@ -22,29 +23,12 @@ 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::Nodes(_) => 6, // Node/Instrument
FocusSelection::Assets(_) => 1, // Asset Library
FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline
FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage
@ -105,104 +89,96 @@ struct Chip {
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
Chip { label: "Nodes", window: (6, 1) }, // Node/Instrument = STACK index 6
];
pub fn render(
ui: &mut egui::Ui,
rect: egui::Rect,
region: egui::Rect,
region_h: f32,
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);
// Sheet background with rounded top.
ui.painter().rect_filled(
rect,
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 },
pal.surface_alt,
);
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
// 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));
// Grab handle — drag to resize the sheet.
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.dragged() && region_h > 1.0 {
state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).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.
// Header row: title + close.
let head_y = rect.top() + GRAB_H;
let head = egui::Rect::from_min_max(
content_area.min,
egui::pos2(content_area.right(), content_area.top() + HEAD_H),
egui::pos2(rect.left(), head_y),
egui::pos2(rect.right(), head_y + 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)),
ui.painter().text(
egui::pos2(head.left() + 14.0, head.center().y),
egui::Align2::LEFT_CENTER,
title(&rc.shared),
egui::FontId::proportional(13.0),
pal.text,
);
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 {
let close = egui::Rect::from_min_size(egui::pos2(head.right() - 36.0, head.top()), egui::vec2(36.0, HEAD_H));
let cresp = ui.interact(close, ui.id().with("mobile_inspector_close"), egui::Sense::click());
ui.painter().text(
close.center(),
egui::Align2::CENTER_CENTER,
super::icons::X,
super::icons::font(16.0),
if cresp.hovered() { pal.text } else { pal.text_dim },
);
if cresp.clicked() {
rc.shared.selection.clear();
*rc.shared.focus = FocusSelection::None;
}
if let Some((top, count)) = jump {
// Jump-to chips (reframe to a related surface, keeping the selection).
let chip_y = head.bottom();
let mut cx = rect.left() + 12.0;
for (i, chip) in CHIPS.iter().enumerate() {
let galley = ui.painter().layout_no_wrap(
chip.label.to_string(),
egui::FontId::proportional(11.0),
pal.text,
);
let w = galley.size().x + 18.0;
let chip_rect = egui::Rect::from_min_size(egui::pos2(cx, chip_y + 4.0), egui::vec2(w, CHIP_H - 8.0));
let resp = ui.interact(chip_rect, ui.id().with(("mobile_inspector_chip", i)), egui::Sense::click());
ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { pal.line } else { pal.surface_alt });
ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, pal.text);
if resp.clicked() {
let (top, count) = chip.window;
state.window_top = top;
state.window_count = count;
state.weights = [1.0, 1.0, 1.0];
state.anim = None;
}
cx += w + 6.0;
}
// Properties content — reuse the Infopanel full-bleed.
let content = egui::Rect::from_min_max(
egui::pos2(content_area.left(), head.bottom()),
content_area.max,
egui::pos2(rect.left(), chip_y + CHIP_H),
rect.max,
);
if content.height() > 1.0 {
surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc);

View File

@ -19,24 +19,18 @@ struct Intent {
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 },
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) },
Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2) },
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) },
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) },
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) },
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2) },
]
}
@ -59,36 +53,20 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
pal.text,
);
let landscape = rect.width() > rect.height();
// Vertical budget: ~2/3 for the intent grid, ~1/3 for the recent list.
let content_top = rect.top() + 62.0;
let content_bottom = rect.bottom() - margin;
let content_h = (rect.bottom() - margin) - content_top;
let gap = 10.0;
let grid_h = content_h * 0.66;
// 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);
// 2×3 grid of intent cards filling the grid budget.
let col_w = (right - left - gap) / 2.0;
let card_h = (grid_h - 2.0 * gap) / 3.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 col = (i % 2) as f32;
let row = (i / 2) as f32;
let cx = left + col * (col_w + gap);
let cy = content_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());
@ -114,25 +92,24 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
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;
app.mobile_state.weights = [1.0, 1.0, 1.0];
}
}
// Recent projects list (below the grid in portrait, beside it in landscape).
let rleft = recent_rect.left();
let rright = recent_rect.right();
// Recent projects list in the bottom third.
let recent_top = content_top + grid_h + 14.0;
ui.painter().text(
egui::pos2(rleft, recent_rect.top()),
egui::pos2(left, recent_top),
egui::Align2::LEFT_TOP,
"Recent",
egui::FontId::proportional(13.0),
pal.text_dim,
);
let list_top = recent_rect.top() + 22.0;
let list_top = recent_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::pos2(left, list_top + 8.0),
egui::Align2::LEFT_TOP,
"No recent projects",
egui::FontId::proportional(12.0),
@ -141,13 +118,13 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
} else {
let row_h = 38.0;
let row_gap = 6.0;
let avail = recent_rect.bottom() - list_top;
let avail = rect.bottom() - margin - 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));
egui::Rect::from_min_max(egui::pos2(left, ry), egui::pos2(right, ry + row_h));
let resp =
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
let p = ui.painter();

View File

@ -44,36 +44,6 @@ pub fn dialog_width(ctx: &egui::Context, desired: f32) -> f32 {
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 {
@ -82,12 +52,6 @@ pub fn is_mobile_env() -> bool {
.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 {
@ -95,21 +59,21 @@ pub enum StackPane {
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,
VirtualPiano,
/// 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] = [
pub const STACK: [StackPane; 8] = [
StackPane::Outliner,
StackPane::AssetLibrary,
StackPane::Stage,
StackPane::Timeline,
StackPane::PianoRoll,
StackPane::VirtualPiano,
StackPane::NodeInstrument,
StackPane::ScriptEditor,
];
@ -124,6 +88,7 @@ impl StackPane {
StackPane::Stage => PaneType::Stage,
StackPane::Timeline => PaneType::Timeline,
StackPane::PianoRoll => PaneType::PianoRoll,
StackPane::VirtualPiano => PaneType::VirtualPiano,
StackPane::NodeInstrument => {
if show_instruments {
PaneType::PresetBrowser
@ -141,7 +106,8 @@ impl StackPane {
StackPane::AssetLibrary => "Assets",
StackPane::Stage => "Stage",
StackPane::Timeline => "Timeline",
StackPane::PianoRoll => "Instrument",
StackPane::PianoRoll => "Piano Roll",
StackPane::VirtualPiano => "Keys",
StackPane::NodeInstrument => {
if show_instruments {
"Instruments"
@ -193,23 +159,8 @@ pub struct MobileState {
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).
/// Inspector sheet height as a fraction of the region above the transport.
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.
@ -236,12 +187,6 @@ impl Default for MobileState {
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,
@ -263,31 +208,12 @@ pub fn render_mobile_shell(
) {
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),
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),
@ -299,96 +225,33 @@ pub fn render_mobile_shell(
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)
// When the inspector is up, the sheet overlays the lower part of the stack. If the selected
// pane would be *covered* by the sheet, reflow (shrink the stack above the sheet) so it stays
// visible; otherwise leave the stack full-height and just overlay. Restoring on dismiss is
// automatic — reflow only changes the render rect, not the window state.
let inspector_shown = inspector::is_active(&rc.shared);
let sheet_h = if inspector_shown {
(region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0)
} 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)
0.0
};
let sheet_top = region.bottom() - sheet_h;
// 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()))
let covered = inspector_shown
&& stack::pane_bottom_in(state, region, inspector::target_slot(&rc.shared))
.map(|bottom| bottom > sheet_top + 1.0)
.unwrap_or(false);
let stack_rect = if covered {
egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_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);
let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
inspector::render(ui, sheet_rect, region.height(), rc, state, &pal);
}
// Transport floor: drawn last = always on top, the persistent spine.
@ -397,92 +260,6 @@ pub fn render_mobile_shell(
// 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

@ -26,12 +26,6 @@ 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;
@ -63,19 +57,19 @@ fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> {
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 {
fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> {
if count < 3 && top + count < N {
Some((top, count + 1)) // grow bottom
} else if count == max && top + count < N {
} else if count == 3 && 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 {
fn op_r6(top: usize, count: usize) -> Option<(usize, usize)> {
if count < 3 && top > 0 {
Some((top - 1, count + 1)) // grow top
} else if count == max && top > 0 {
} else if count == 3 && top > 0 {
Some((top - 1, count)) // slide up
} else {
None
@ -90,13 +84,12 @@ fn resolve(
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::TopEdge => (!going_up).then(|| op_r6(top, count)).flatten(),
Handle::BottomEdge => going_up.then(|| op_r5(top, count)).flatten(),
Handle::Divider(k) => {
if count == 2 {
if going_up { op_r1(top, count) } else { op_r2(top, count) }
@ -228,7 +221,6 @@ fn nweights(weights: &[f32; 3], count: usize) -> Vec<f32> {
/// 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 {
@ -350,9 +342,6 @@ fn interp_layout(
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(
@ -389,67 +378,7 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
};
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)
_ => resolve(d.handle, d.offset, top, count, trigger)
.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)
@ -474,9 +403,9 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
// 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 header_h = HEADER_H.min(brect.height());
let content_rect = egui::Rect::from_min_max(
egui::pos2(brect.left(), brect.top() + hh),
egui::pos2(brect.left(), brect.top() + header_h),
brect.max,
);
if content_rect.height() > 1.0 {
@ -493,21 +422,21 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
// 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single
// pane fills the stack.
let fullscreen = draw_layout.len() == 1;
let fullscreen = count == 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())),
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);
handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state);
}
fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) {
@ -588,10 +517,10 @@ fn handle_key(h: Handle) -> (usize, usize) {
}
/// 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) {
fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64) {
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)) {
if let Some((t, c)) = op_r5(slot, 1).or_else(|| op_r6(slot, 1)) {
set_window(state, t, c, now);
}
} else {
@ -607,15 +536,13 @@ fn handle_interactions(
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())),
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)));
@ -635,7 +562,7 @@ fn handle_interactions(
);
let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click());
if fsresp.clicked() {
toggle_fullscreen(state, slot, now, max);
toggle_fullscreen(state, slot, now);
}
if STACK[slot] == StackPane::NodeInstrument {
let nt = egui::Rect::from_min_max(
@ -662,7 +589,7 @@ fn handle_interactions(
}
if resp.drag_stopped() {
if let Some(d) = state.drag.take() {
commit_drag(d, state, content_area, trigger, now, max);
commit_drag(d, state, content_area, trigger, now);
}
}
if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) {
@ -671,82 +598,18 @@ fn handle_interactions(
}
}
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64, max: usize) {
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64) {
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 let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger) {
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);
@ -803,17 +666,17 @@ fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64)
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.
// 2-pane: group resize, snap to nearest 2-pane preset, slide off at the extremes.
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);
if let Some((tt, tc)) = op_r1(top, count) {
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 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);
if let Some((tt, tc)) = op_r2(top, count) {
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 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);

View File

@ -100,81 +100,6 @@ pub fn render(
}
}
/// 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());
@ -239,11 +164,7 @@ fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, s
.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();

View File

@ -40,24 +40,9 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState,
}
}
// --- 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)
}
}
};
// --- Timecode (MM:SS:FF) ---
let fps = shared.action_executor.document().framerate.max(1.0);
let tc = format_timecode(*shared.playback_time, fps);
let tc_left = btn_rect.right() + 12.0;
painter.text(
egui::pos2(tc_left, cy),

View File

@ -1,108 +0,0 @@
//! 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,7 +72,6 @@ pub mod gradient_editor;
pub mod timeline;
pub mod infopanel;
pub mod outliner;
pub mod keyboard_layout;
pub mod piano_roll;
pub mod virtual_piano;
pub mod node_editor;
@ -213,15 +212,10 @@ pub struct SharedPaneState<'a> {
pub editing_instance_id: Option<uuid::Uuid>,
/// The parent layer ID containing the clip instance being edited
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)
pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>,
/// Request to exit the current movie clip
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
pub active_layer_id: &'a mut Option<uuid::Uuid>,
/// Current tool interaction state (mutable for tools to modify)
@ -364,35 +358,6 @@ pub struct SharedPaneState<'a> {
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

View File

@ -1,741 +0,0 @@
//! 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>,
/// Armed cable source in Patch: (node, output-port index).
pub patch_source: Option<(NodeId, usize)>,
/// 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,
patch_source: 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,7 +6,6 @@ pub mod actions;
pub mod audio_backend;
pub mod backend;
pub mod graph_data;
mod mobile;
use backend::{BackendNodeId, GraphBackend};
use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType};
@ -143,9 +142,6 @@ pub struct NodeGraphPane {
last_oscilloscope_poll: std::time::Instant,
/// Backend track ID (u32) for oscilloscope queries
backend_track_id: Option<u32>,
/// Mobile (touch) Focus/Patch view state.
mobile: mobile::MobileNodeState,
}
impl NodeGraphPane {
@ -175,7 +171,6 @@ impl NodeGraphPane {
pending_script_resolutions: Vec::new(),
last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: None,
mobile: mobile::MobileNodeState::default(),
}
}
@ -2370,13 +2365,6 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
painter.galley(text_pos, galley, text_color);
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
let has_oscilloscopes;
if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) {

View File

@ -169,20 +169,6 @@ pub struct PianoRollPane {
snap_value: SnapValue,
last_snap_selection: HashSet<usize>,
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 {
@ -219,12 +205,6 @@ impl PianoRollPane {
snap_value: SnapValue::None,
last_snap_selection: HashSet::new(),
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,
}
}
@ -388,22 +368,6 @@ impl PianoRollPane {
rect: Rect,
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 grid_rect = Rect::from_min_max(
pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y),
@ -480,13 +444,6 @@ 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
// which may clear the selection when the ComboBox click propagates to the grid).
if self.snap_user_changed {
@ -599,372 +556,6 @@ impl PianoRollPane {
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) {
// Background
painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));

View File

@ -3255,14 +3255,6 @@ pub struct StagePane {
// Interaction state
is_panning: bool,
last_pan_pos: Option<egui::Pos2>,
// Gesture state (P5): double-tap-drag marquee. `last_tap` = (time, canvas pos) of the last
// primary press for double-tap detection; `marquee_gesture_armed` = a double-tap landed on empty
// space and a drag will start a transient marquee regardless of the active tool.
last_tap: Option<(f64, egui::Pos2)>,
marquee_gesture_armed: bool,
/// True once an armed double-tap has actually begun dragging (so we suppress the active tool for
/// the duration of the marquee but NOT for a plain double-tap, which still zooms-to-fit).
marquee_gesture_active: bool,
// Unique ID for this stage instance (for Vello resources)
instance_id: u64,
// Eyedropper state
@ -3751,9 +3743,6 @@ impl StagePane {
needs_initial_center: true,
is_panning: false,
last_pan_pos: None,
last_tap: None,
marquee_gesture_armed: false,
marquee_gesture_active: false,
instance_id,
pending_eyedropper_sample: None,
last_viewport_rect: None,
@ -4009,9 +3998,8 @@ impl StagePane {
// geometry selection/editing there; clip-instance interaction stays available.
let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time);
// Double-click: enter/exit movie clip editing (desktop; mobile handles double-tap as a
// single tool-independent gesture in handle_input).
if !shared.is_mobile && response.double_clicked() {
// Double-click: enter/exit movie clip editing
if response.double_clicked() {
// Hit test clip instances at the click position
let document = shared.action_executor.document();
let clip_hit = hit_test::hit_test_clip_instances(
@ -4042,18 +4030,6 @@ impl StagePane {
// Double-click on empty space while inside a clip: exit
*shared.pending_exit_clip = true;
return;
} else {
// Double-tap / double-click on truly empty space (no clip and no shape under the
// pointer, at the top level) → zoom to fit the artboard. (Gesture P5-2.)
let graph_hit = if inbetween {
None
} else {
hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY)
};
if graph_hit.is_none() {
self.zoom_to_fit(shared);
return;
}
}
}
@ -11072,75 +11048,6 @@ impl StagePane {
}
}
/// True if a clip instance or vector geometry is under `point` (clip-local) on the active layer.
/// Used by the double-tap-drag marquee gesture to decide "empty vs object".
fn point_hits_object(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> bool {
use lightningbeam_core::hit_test;
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::Affine;
let Some(active_layer_id) = *shared.active_layer_id else {
return false;
};
let document = shared.action_executor.document();
let Some(AnyLayer::Vector(vl)) = document.get_layer(&active_layer_id) else {
return false;
};
if hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, *shared.playback_time).is_some() {
return true;
}
hit_test::hit_test_layer(vl, *shared.playback_time, point, 5.0, Affine::IDENTITY).is_some()
}
/// Delete the currently-selected DCEL edges on the active vector layer (snapshot-based undo).
/// Shared by the Delete/Backspace key and the long-press context menu.
fn delete_selected_geometry(&self, shared: &mut SharedPaneState) {
let Some(active_layer_id) = *shared.active_layer_id else {
return;
};
let time = *shared.playback_time;
let selected_edges: Vec<lightningbeam_core::vector_graph::EdgeId> =
shared.selection.selected_edges().iter().copied().collect();
let selected_fills: Vec<lightningbeam_core::vector_graph::FillId> =
shared.selection.selected_fills().iter().copied().collect();
if selected_edges.is_empty() && selected_fills.is_empty() {
return;
}
let graph_before = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(),
_ => None,
};
let Some(graph_before) = graph_before else {
return;
};
{
let document = shared.action_executor.document_mut();
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(graph) = vl.graph_at_time_mut(time) {
// Free fills first so their boundary edges become unreferenced and are GC'd.
for fid in &selected_fills {
if !graph.fill(*fid).deleted {
graph.free_fill(*fid);
}
}
for eid in &selected_edges {
graph.remove_edge(*eid);
}
graph.gc_isolated_vertices();
}
}
}
let graph_after = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(),
_ => None,
};
if let Some(graph_after) = graph_after {
use lightningbeam_core::actions::ModifyGraphAction;
let action = ModifyGraphAction::new(active_layer_id, time, graph_before, graph_after, "Delete");
shared.pending_actions.push(Box::new(action));
}
shared.selection.clear_geometry_selection();
}
fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
@ -11407,165 +11314,8 @@ impl StagePane {
}
}
// P5 gesture (mobile): double-tap-drag on empty space → transient marquee, regardless of the
// active tool. The second tap arms it; a drag then drives ToolState::MarqueeSelecting (resolved
// by the release handler above). While armed we suppress the normal tool dispatch so a
// brush/etc. doesn't paint during the gesture. A double-tap without a drag falls through
// (Select tool handles zoom-to-fit). Mobile-only so it can't hijack a desktop Brush/Draw drag.
if shared.is_mobile && !is_replaying && !alt_held {
use vello::kurbo::Point;
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let press_pos = mouse_canvas_pos.to_pos2();
if self.rsp_primary_pressed(ui) {
let now = ui.input(|i| i.time);
let is_double = self
.last_tap
.map_or(false, |(t, p)| now - t < 0.3 && p.distance(press_pos) < 12.0);
let hits = self.point_hits_object(point, shared);
self.marquee_gesture_armed = is_double && !hits;
self.last_tap = Some((now, press_pos));
}
if self.marquee_gesture_armed && self.rsp_dragged(&response) {
self.marquee_gesture_active = true;
match shared.tool_state {
ToolState::MarqueeSelecting { start, .. } => {
*shared.tool_state = ToolState::MarqueeSelecting { start: *start, current: point };
}
_ => {
if !shift_held {
shared.selection.clear();
*shared.focus = lightningbeam_core::selection::FocusSelection::None;
}
*shared.tool_state = ToolState::MarqueeSelecting { start: point, current: point };
}
}
}
if ui.input(|i| i.pointer.any_released()) {
self.marquee_gesture_armed = false;
self.marquee_gesture_active = false;
}
}
// P5 gesture (mobile): double-tap semantics, in priority order and regardless of the active
// tool. (double_clicked() fires on double-tap and, on desktop, double-click — so testable.)
// 1. object under the tap → enter it (movie clip or group — go a level deeper)
// 2. empty, inside a clip → exit one level
// 3. empty, at the top level → zoom to fit
// The desktop equivalent lives in handle_select_tool (gated to !is_mobile below).
if shared.is_mobile && response.double_clicked() {
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::{Affine, Point};
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let hit = (*shared.active_layer_id).and_then(|layer_id| {
let document = shared.action_executor.document();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
lightningbeam_core::hit_test::hit_test_clip_instances(
&vl.clip_instances, document, point, Affine::IDENTITY, *shared.playback_time,
)
.and_then(|iid| vl.clip_instances.iter().find(|c| c.id == iid).map(|c| (c.clip_id, iid, layer_id)))
} else {
None
}
});
if let Some((clip_id, instance_id, layer_id)) = hit {
*shared.pending_enter_clip = Some((clip_id, instance_id, layer_id));
} else if shared.editing_clip_id.is_some() {
*shared.pending_exit_clip = true;
} else {
self.zoom_to_fit(shared);
}
}
// P5 gesture (mobile only): long-press on an object opens its actions menu. In the egui fork
// `secondary_clicked()` fires on long-press (and right-click) and `context_menu()` opens on
// the same — so the menu must be attached UNCONDITIONALLY for egui to register it. Desktop
// (flag off) has no Stage context menu.
if shared.is_mobile {
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::selection::FocusSelection;
use vello::kurbo::{Affine, Point};
// Long-press selects the object under the press (clip instance first, else geometry).
if response.secondary_clicked() {
if let Some(layer_id) = *shared.active_layer_id {
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let time = *shared.playback_time;
let clip_hit = {
let document = shared.action_executor.document();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
lightningbeam_core::hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, time)
} else {
None
}
};
if let Some(instance_id) = clip_hit {
if !shared.selection.contains_clip_instance(&instance_id) {
shared.selection.select_only_clip_instance(instance_id);
*shared.focus = FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
}
} else {
// No clip — try vector geometry (edge/fill).
let graph_hit = {
let document = shared.action_executor.document();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
lightningbeam_core::hit_test::hit_test_layer(vl, time, point, 5.0, Affine::IDENTITY)
} else {
None
}
};
if let Some(gh) = graph_hit {
if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) {
if let Some(graph) = vl.graph_at_time(time) {
shared.selection.clear_geometry_selection();
match gh {
lightningbeam_core::hit_test::GraphHitResult::Edge(eid) => shared.selection.select_edge(eid, graph),
lightningbeam_core::hit_test::GraphHitResult::Fill(fid) => shared.selection.select_fill(fid, graph),
}
}
}
*shared.focus = FocusSelection::Geometry { layer_id, time };
}
}
}
}
// Build the menu items for the current selection and hand them to the shell to render
// as a persistent popup (stays open until an item or the backdrop is tapped).
if response.secondary_clicked() {
use crate::menu::MenuAction;
let mut items: Vec<(String, MenuAction)> = Vec::new();
if !shared.selection.clip_instances().is_empty() {
// A clip instance is already a clip — no "convert" here.
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Duplicate".into(), MenuAction::DuplicateClip));
items.push(("Delete".into(), MenuAction::Delete));
items.push(("Send to back".into(), MenuAction::SendToBack));
items.push(("Bring to front".into(), MenuAction::BringToFront));
} else if shared.selection.has_geometry_selection() {
// Geometry can be gathered up into a movie clip.
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip));
items.push(("Delete".into(), MenuAction::Delete));
items.push(("Send to back".into(), MenuAction::SendToBack));
items.push(("Bring to front".into(), MenuAction::BringToFront));
} else {
// Nothing selected — offer paste onto the stage.
items.push(("Paste".into(), MenuAction::Paste));
}
if !items.is_empty() {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
*shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items });
}
}
}
}
// Handle tool input (only if not using Alt modifier for panning, and not while a
// double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture).
if !alt_held && !self.marquee_gesture_active {
// Handle tool input (only if not using Alt modifier for panning)
if !alt_held {
use lightningbeam_core::tool::Tool;
// On a shape-tween in-between frame the active vector layer's geometry is an
@ -11675,26 +11425,70 @@ impl StagePane {
// Delete/Backspace: remove selected DCEL elements
if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) {
if shared.selection.has_geometry_selection() {
self.delete_selected_geometry(shared);
if let Some(active_layer_id) = *shared.active_layer_id {
let time = *shared.playback_time;
// Collect selected edge IDs before mutating
let selected_edges: Vec<lightningbeam_core::vector_graph::EdgeId> =
shared.selection.selected_edges().iter().copied().collect();
if !selected_edges.is_empty() {
// Snapshot before
let graph_before = {
let document = shared.action_executor.document();
match document.get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => {
vl.graph_at_time(time).cloned()
}
_ => None,
}
};
if let Some(graph_before) = graph_before {
// Remove selected edges
{
let document = shared.action_executor.document_mut();
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(graph) = vl.graph_at_time_mut(time) {
for eid in &selected_edges {
graph.remove_edge(*eid);
}
}
}
}
// Snapshot after
let graph_after = {
let document = shared.action_executor.document();
match document.get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => {
vl.graph_at_time(time).cloned()
}
_ => None,
}
};
if let Some(graph_after) = graph_after {
use lightningbeam_core::actions::ModifyGraphAction;
let action = ModifyGraphAction::new(
active_layer_id,
time,
graph_before,
graph_after,
"Delete",
);
shared.pending_actions.push(Box::new(action));
}
shared.selection.clear_geometry_selection();
}
}
}
}
}
// Skip real scroll/zoom/pan input during replay
if !is_replaying {
// Unified pinch / Ctrl+scroll zoom: `zoom_delta()` folds in touch-pinch (on device) AND
// Ctrl+wheel/trackpad (on desktop). It's a multiplicative factor (1.0 = no change);
// `apply_zoom_at_point` wants an additive delta (new = old * (1 + 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.
let zoom_factor = ui.input(|i| i.zoom_delta());
if (zoom_factor - 1.0).abs() > f32::EPSILON {
let center = ui
.input(|i| i.multi_touch().map(|m| m.center_pos))
.map(|p| p - rect.min)
.unwrap_or(mouse_canvas_pos);
self.apply_zoom_at_point(zoom_factor - 1.0, center);
}
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
let mut handled = false;
ui.input(|i| {
@ -11702,20 +11496,23 @@ impl StagePane {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
match unit {
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
// Plain mouse wheel zooms; Ctrl+wheel was already handled above via
// zoom_delta(). Consume the event either way so the pan fallback
// below doesn't also fire.
if !(ctrl_held || modifiers.ctrl) {
self.apply_zoom_at_point(delta.y * 0.005, mouse_canvas_pos);
}
// Real mouse wheel (discrete clicks) -> always zoom
let zoom_delta = if ctrl_held || modifiers.ctrl {
delta.y * 0.01 // Ctrl+wheel: faster zoom
} else {
delta.y * 0.005 // Normal zoom
};
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
handled = true;
}
egui::MouseWheelUnit::Point => {
// Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls
// through to scroll_delta panning.
// Trackpad (smooth scrolling) -> only zoom if Ctrl held
if ctrl_held || modifiers.ctrl {
let zoom_delta = delta.y * 0.005;
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
handled = true;
}
// Otherwise let scroll_delta handle panning
}
}
}
@ -12905,72 +12702,72 @@ impl PaneRenderer for StagePane {
}
}
// Render breadcrumb navigation showing the full editing path (root → … → current clip).
if !shared.editing_clip_path.is_empty() {
let font = egui::FontId::proportional(13.0);
let sep = " > ";
// Segments: "Scene 1" (root) + each clip name. `target` = depth to exit to when clicked
// (root → 0; clip at path index k → k+1); the current (last) level isn't clickable.
let segments: Vec<(String, Option<usize>)> = {
// Show camera info overlay
let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200));
ui.painter().text(
rect.min + egui::vec2(10.0, 10.0),
egui::Align2::LEFT_TOP,
format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})",
self.zoom, self.pan_offset.x, self.pan_offset.y),
egui::FontId::proportional(14.0),
info_color,
);
// Render breadcrumb navigation when inside a movie clip
if shared.editing_clip_id.is_some() {
let document = shared.action_executor.document();
let n = shared.editing_clip_path.len();
let mut segs: Vec<(String, Option<usize>)> = vec![("Scene 1".to_string(), Some(0))];
for (k, cid) in shared.editing_clip_path.iter().enumerate() {
let name = document
.get_vector_clip(cid)
// Build breadcrumb names from the editing context
// We only have the current clip_id, so show "Scene 1 > ClipName"
let clip_name = shared.editing_clip_id
.and_then(|id| document.get_vector_clip(&id))
.map(|c| c.name.clone())
.unwrap_or_else(|| "Unknown".to_string());
segs.push((name, if k + 1 == n { None } else { Some(k + 1) }));
}
segs
};
let bx = rect.min.x + 10.0;
let by = rect.min.y + 10.0;
let breadcrumb_y = rect.min.y + 30.0;
let breadcrumb_x = rect.min.x + 10.0;
// Background pill sized to the whole path.
let full: String = segments
.iter()
.enumerate()
.map(|(i, (s, _))| if i == 0 { s.clone() } else { format!("{sep}{s}") })
.collect();
let full_galley = ui.painter().layout_no_wrap(full, font.clone(), egui::Color32::WHITE);
let pill = egui::Rect::from_min_size(egui::pos2(bx, by), full_galley.size() + egui::vec2(16.0, 8.0));
ui.painter().rect_filled(pill, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180));
// Background pill
let scene_text = "Scene 1";
let separator = " > ";
let full_text = format!("{}{}{}", scene_text, separator, clip_name);
let font = egui::FontId::proportional(13.0);
let galley = ui.painter().layout_no_wrap(full_text.clone(), font.clone(), egui::Color32::WHITE);
let text_rect = egui::Rect::from_min_size(
egui::pos2(breadcrumb_x, breadcrumb_y),
galley.size() + egui::vec2(16.0, 8.0),
);
ui.painter().rect_filled(
text_rect,
4.0,
egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180),
);
// Draw segments left → right; clickable ancestors in blue.
let mut x = bx + 8.0;
let y = by + 4.0;
let mut clicked_target: Option<usize> = None;
for (i, (name, target)) in segments.iter().enumerate() {
if i > 0 {
let sg = ui.painter().layout_no_wrap(sep.to_string(), font.clone(), egui::Color32::from_gray(160));
let sw = sg.size().x;
ui.painter().galley(egui::pos2(x, y), sg, egui::Color32::from_gray(160));
x += sw;
// "Scene 1" as clickable (exit clip)
let scene_galley = ui.painter().layout_no_wrap(
scene_text.to_string(), font.clone(), egui::Color32::from_rgb(120, 180, 255),
);
let scene_rect = egui::Rect::from_min_size(
egui::pos2(breadcrumb_x + 8.0, breadcrumb_y + 4.0),
scene_galley.size(),
);
let scene_response = ui.allocate_rect(scene_rect, egui::Sense::click());
ui.painter().galley(scene_rect.min, scene_galley, egui::Color32::WHITE);
if scene_response.clicked() {
*shared.pending_exit_clip = true;
}
let color = if target.is_some() {
egui::Color32::from_rgb(120, 180, 255)
} else {
egui::Color32::WHITE
};
let g = ui.painter().layout_no_wrap(name.clone(), font.clone(), color);
let seg_rect = egui::Rect::from_min_size(egui::pos2(x, y), g.size());
if let Some(depth) = target {
let resp = ui.allocate_rect(seg_rect, egui::Sense::click());
if resp.clicked() {
clicked_target = Some(*depth);
}
if resp.hovered() {
if scene_response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
}
ui.painter().galley(seg_rect.min, g, color);
x += seg_rect.width();
}
if let Some(depth) = clicked_target {
*shared.pending_exit_to_depth = Some(depth);
}
// Separator + clip name (not clickable, it's the current level)
let rest_text = format!("{}{}", separator, clip_name);
ui.painter().text(
egui::pos2(scene_rect.max.x, breadcrumb_y + 4.0),
egui::Align2::LEFT_TOP,
rest_text,
font,
egui::Color32::WHITE,
);
}
// Render vector editing overlays (vertices, control points, etc.)

View File

@ -268,9 +268,6 @@ pub struct TimelinePane {
/// Is the user panning the timeline?
is_panning: bool,
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: Option<ClipDragType>,
@ -791,8 +788,6 @@ impl TimelinePane {
is_scrubbing: false,
is_panning: false,
last_pan_pos: None,
lp_time: None,
lp_pos: egui::Pos2::ZERO,
clip_drag_state: None,
drag_offset: 0.0,
drag_anchor_start: 0.0,
@ -956,7 +951,7 @@ impl TimelinePane {
/// Toggle recording on/off
/// In Auto mode, records to the active layer (audio or video with camera)
pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
if *shared.is_recording {
// Stop recording
self.stop_recording(shared);
@ -1217,7 +1212,7 @@ impl TimelinePane {
/// Stop all active recordings
/// Called every frame; fires deferred recording commands once the count-in pre-roll ends.
pub(crate) fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
let Some(ref pending) = self.pending_recording_start else { return };
if !*shared.is_playing || *shared.playback_time < pending.trigger_time {
return;
@ -1237,7 +1232,7 @@ impl TimelinePane {
*shared.count_in_enabled = saved_count_in;
}
pub(crate) fn stop_recording(&mut self, shared: &mut SharedPaneState) {
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
// Cancel any in-progress count-in
self.pending_recording_start = None;
@ -1486,15 +1481,6 @@ impl TimelinePane {
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
fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) {
let old_zoom = self.pixels_per_second;
@ -4978,40 +4964,29 @@ impl TimelinePane {
// Only handle scroll when mouse is over the timeline area
let mut handled = false;
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| {
for event in &i.raw.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
match unit {
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
// Plain mouse wheel zooms; Ctrl+wheel handled above via zoom_delta().
// Consume the event either way so the pan fallback doesn't also fire.
if !(ctrl_held || modifiers.ctrl) {
self.apply_zoom_at_point(delta.y * 0.005, mouse_x);
}
// Real mouse wheel (discrete clicks) -> always zoom horizontally
let zoom_delta = if ctrl_held || modifiers.ctrl {
delta.y * 0.01 // Ctrl+wheel: faster zoom
} else {
delta.y * 0.005 // Normal zoom
};
self.apply_zoom_at_point(zoom_delta, mouse_x);
handled = true;
}
egui::MouseWheelUnit::Point => {
// Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls through
// to scroll_delta panning below.
// Trackpad (smooth scrolling)
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;
}
// Otherwise let scroll_delta handle panning (below)
}
}
}
@ -5054,18 +5029,6 @@ 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)
if !self.is_scrubbing && !self.is_panning {
// If dragging a clip with trim/loop, keep the appropriate cursor
@ -5842,10 +5805,9 @@ impl PaneRenderer for TimelinePane {
}
ui.set_clip_rect(original_clip_rect);
// Context menu: detect right-click on clips or empty timeline space (desktop only — mobile
// uses the long-press menu below).
// Context menu: detect right-click on clips or empty timeline space
let mut just_opened_menu = false;
let secondary_clicked = !shared.is_mobile && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
if secondary_clicked {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if content_rect.contains(pos) {
@ -5865,67 +5827,6 @@ 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
if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip {
let has_clip = ctx_clip_id.is_some();

View File

@ -33,8 +33,6 @@ pub struct VirtualPianoPane {
sustain_active: bool,
/// Notes being held by sustain pedal (not by active key/mouse press)
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 {
@ -85,7 +83,6 @@ impl VirtualPianoPane {
note_to_key_map,
sustain_active: false,
sustained_notes: HashSet::new(),
playback_notes: HashSet::new(),
}
}
@ -219,17 +216,9 @@ impl VirtualPianoPane {
/// Render the piano keyboard
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. On mobile use the shared
// width-driven layout (so the portrait Piano Roll's columns line up with these keys).
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())
};
// Calculate visible range and key dimensions based on pane size
let (visible_start, visible_end, white_key_width, offset_x) =
self.calculate_visible_range(rect.width(), rect.height());
let white_key_height = rect.height();
let black_key_width = white_key_width * self.black_key_width_ratio;
@ -249,11 +238,6 @@ impl VirtualPianoPane {
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
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
let mut white_count = 0;
@ -319,8 +303,7 @@ impl VirtualPianoPane {
});
let pointer_down = ui.input(|i| i.pointer.primary_down());
let is_pressed = self.pressed_notes.contains(&note) ||
self.playback_notes.contains(&note) ||
(started_here && !black_key_interacted && pointer_over_key && pointer_down);
(!black_key_interacted && pointer_over_key && pointer_down);
let color = if is_pressed {
shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
} else {
@ -337,9 +320,8 @@ impl VirtualPianoPane {
);
if !black_key_interacted {
// Mouse down starts note (detect primary button pressed on this key). Gated on
// `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()) {
// Mouse down starts note (detect primary button pressed on this key)
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
// Calculate velocity based on mouse Y position
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
@ -348,8 +330,7 @@ impl VirtualPianoPane {
self.dragging_note = Some(note);
}
// Mouse up stops note (detect primary button released). NEVER gated — a held note
// must always release.
// Mouse up stops note (detect primary button released)
if ui.input(|i| i.pointer.primary_released()) {
if self.dragging_note == Some(note) {
self.send_note_off(note, shared);
@ -358,7 +339,7 @@ impl VirtualPianoPane {
}
// Dragging over a new key (pointer is down and over a different key)
if started_here && pointer_over_key && pointer_down {
if pointer_over_key && pointer_down {
if self.dragging_note != Some(note) {
// Stop previous note
if let Some(prev_note) = self.dragging_note {
@ -406,8 +387,7 @@ impl VirtualPianoPane {
});
let pointer_down = ui.input(|i| i.pointer.primary_down());
let is_pressed = self.pressed_notes.contains(&note) ||
self.playback_notes.contains(&note) ||
(started_here && pointer_over_key && pointer_down);
(pointer_over_key && pointer_down);
let color = if is_pressed {
shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200))
} else {
@ -417,7 +397,7 @@ impl VirtualPianoPane {
ui.painter().rect_filled(key_rect, 2.0, color);
// Mouse down starts note
if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) {
// Calculate velocity based on mouse Y position
let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y;
let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect);
@ -435,7 +415,7 @@ impl VirtualPianoPane {
}
// Dragging over a new key
if started_here && pointer_over_key && pointer_down {
if pointer_over_key && pointer_down {
if self.dragging_note != Some(note) {
if let Some(prev_note) = self.dragging_note {
self.send_note_off(prev_note, shared);
@ -844,11 +824,6 @@ impl PaneRenderer for VirtualPianoPane {
_path: &NodePath,
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
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)
@ -894,30 +869,16 @@ impl PaneRenderer for VirtualPianoPane {
}
// Calculate visible range (needed for both rendering and labels)
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 (visible_start, visible_end, white_key_width, offset_x) =
self.calculate_visible_range(rect.width(), rect.height());
// Render the keyboard
self.render_keyboard(ui, rect, shared);
// Render keyboard labels on top — but not on mobile (no physical keyboard to hint at).
if !shared.is_mobile {
// Render keyboard labels on top
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 {
"Virtual Piano"
}

View File

@ -1,842 +0,0 @@
<!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>