Add omnibutton FAB (P4); fix Stage playback freeze when timeline hidden

Omnibutton (mobile/omni.rs): a context FAB whose radial holds the active layer's
drawing tools laid out in a staggered inner/outer arc, plus a "+New" petal
(create Vector/Audio/MIDI/Raster/Video layer, Group, Import, and Split/Duplicate
when a clip is selected — dispatched as MenuActions) and a "more" petal that
opens a full-tools grid when the layer has extra tools. Contextual: the tool ring
only appears when the Stage is in the visible window; off-Stage the FAB is a
"+New" create button. Tools/creates use Lucide icons. Pane-internal adds
(nodes/notes/instruments) deferred to P6.

Playback fix: document.current_time (read by the renderer for animation eval and
video-frame decode) was only synced from the audio playback position inside
TimelinePane::render_content. In the mobile shell, panes render on demand, so with
the timeline off-screen current_time went stale and the Stage froze during
playback. Move the sync into the per-frame block in update() so it runs regardless
of which panes are visible.
This commit is contained in:
Skyler Lehmkuhl 2026-07-01 05:42:46 -04:00
parent 08b6aa0139
commit b8419778a5
4 changed files with 390 additions and 0 deletions

View File

@ -6582,6 +6582,11 @@ impl eframe::App for EditorApp {
{ {
let time = self.playback_time; let time = self.playback_time;
let document = self.action_executor.document_mut(); let document = self.action_executor.document_mut();
// Keep document.current_time synced from the audio playback position every frame.
// The renderer reads current_time for animation eval + video-frame decode, so this
// must not depend on any particular pane (e.g. the timeline) being rendered —
// otherwise the Stage freezes during playback when that pane is off-screen.
document.current_time = time;
// Bake animation transforms for root layers // Bake animation transforms for root layers
for layer in document.root.children.iter_mut() { for layer in document.root.children.iter_mut() {
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {

View File

@ -53,3 +53,28 @@ pub const MIC: &str = "\u{e118}";
pub const CLAPPERBOARD: &str = "\u{e29b}"; pub const CLAPPERBOARD: &str = "\u{e29b}";
pub const SQUARE_DASHED: &str = "\u{e1cb}"; pub const SQUARE_DASHED: &str = "\u{e1cb}";
pub const FOLDER_OPEN: &str = "\u{e247}"; pub const FOLDER_OPEN: &str = "\u{e247}";
// Tool icons (omnibutton).
pub const MOUSE_POINTER_2: &str = "\u{e1c3}";
pub const PENCIL: &str = "\u{e1f9}";
pub const MOVE: &str = "\u{e121}";
pub const VECTOR_SQUARE: &str = "\u{e67c}";
pub const SQUARE: &str = "\u{e167}";
pub const CIRCLE: &str = "\u{e076}";
pub const MINUS: &str = "\u{e11c}";
pub const HEXAGON: &str = "\u{e0f3}";
pub const PAINT_BUCKET: &str = "\u{e2e6}";
pub const PIPETTE: &str = "\u{e13b}";
pub const TYPE: &str = "\u{e198}";
pub const ERASER: &str = "\u{e28f}";
pub const PEN_TOOL: &str = "\u{e131}";
pub const BLEND: &str = "\u{e59c}";
pub const WAND_SPARKLES: &str = "\u{e357}";
pub const LASSO_SELECT: &str = "\u{e1cf}";
pub const SCISSORS: &str = "\u{e14e}";
// Create-menu icons.
pub const AUDIO_WAVEFORM: &str = "\u{e55b}";
pub const PIANO: &str = "\u{e561}";
pub const FOLDER: &str = "\u{e0d7}";
pub const FILE_PLUS: &str = "\u{e0c9}";
pub const COPY: &str = "\u{e09e}";
pub const LAYERS: &str = "\u{e529}";

View File

@ -20,6 +20,7 @@ use crate::RenderContext;
pub mod icons; pub mod icons;
mod inspector; mod inspector;
pub mod intent; pub mod intent;
mod omni;
mod stack; mod stack;
mod surface; mod surface;
mod transport; mod transport;
@ -148,6 +149,12 @@ pub struct MobileState {
pub show_instruments: bool, pub show_instruments: bool,
/// Inspector sheet height as a fraction of the region above the transport. /// Inspector sheet height as a fraction of the region above the transport.
pub inspector_frac: f32, pub inspector_frac: f32,
/// Whether the omnibutton radial tool menu is open.
pub omni_open: bool,
/// Whether the omnibutton "more" grid (all tools) is open.
pub omni_grid_open: bool,
/// Whether the omnibutton "+New" create grid is open.
pub omni_create_open: bool,
/// Active handle drag (transient). /// Active handle drag (transient).
pub drag: Option<StackDrag>, pub drag: Option<StackDrag>,
/// In-flight layout ease (transient). /// In-flight layout ease (transient).
@ -163,6 +170,9 @@ impl Default for MobileState {
weights: [1.0, 1.0, 1.0], weights: [1.0, 1.0, 1.0],
show_instruments: false, show_instruments: false,
inspector_frac: 0.45, inspector_frac: 0.45,
omni_open: false,
omni_grid_open: false,
omni_create_open: false,
drag: None, drag: None,
anim: None, anim: None,
} }
@ -221,4 +231,7 @@ pub fn render_mobile_shell(
// Transport floor: drawn last = always on top, the persistent spine. // Transport floor: drawn last = always on top, the persistent spine.
transport::render(ui, transport_rect, &mut rc.shared); transport::render(ui, transport_rect, &mut rc.shared);
// Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else.
omni::render(ui, region, rc, state);
} }

View File

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