Add mobile UI shell scaffold behind LB_MOBILE_UI

New src/mobile/ module renders a phone-style shell: top surface tabs
(Stage/Time/Nodes/Mixer/Tree), a single full-bleed hero pane reusing the
existing PaneInstance renderers, a resizable timeline ribbon (peek/half/full
snap tiers riding above the floor), and a fixed transport bar wired to the
audio controller (play/pause, timecode, project scrub).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-29 22:40:54 -04:00
parent da0b39fef0
commit 5ee2df5db7
6 changed files with 549 additions and 15 deletions

View File

@ -10,6 +10,7 @@ use std::sync::Arc;
use clap::Parser;
use uuid::Uuid;
mod mobile;
mod panes;
use panes::{PaneInstance, PaneRenderer};
@ -197,8 +198,17 @@ 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_env() {
[402.0, 874.0] // iPhone-ish portrait
} else {
[1920.0, 1080.0]
};
let mut viewport_builder = egui::ViewportBuilder::default()
.with_inner_size([1920.0, 1080.0])
.with_inner_size(initial_size)
.with_title("Lightningbeam Editor")
.with_app_id("lightningbeam-editor"); // Set app_id for Wayland
@ -942,6 +952,13 @@ struct EditorApp {
hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal)
selected_pane: Option<NodePath>, // Currently selected pane for editing
split_preview_mode: SplitPreviewMode,
// === Mobile / phone UI (developed on desktop behind LB_MOBILE_UI) ===
/// True if the mobile shell is requested via the env var (read once at startup).
mobile_ui: bool,
/// Runtime override of the mobile flag (None = follow `mobile_ui`).
mobile_ui_override: Option<bool>,
/// Persistent state for the mobile shell (active surface, ribbon tier).
mobile_state: mobile::MobileState,
icon_cache: IconCache,
tool_icon_cache: ToolIconCache,
focus_icon_cache: FocusIconCache, // Focus card icons (start screen)
@ -1297,6 +1314,9 @@ impl EditorApp {
hovered_divider: None,
selected_pane: None,
split_preview_mode: SplitPreviewMode::default(),
mobile_ui: mobile::is_mobile_env(),
mobile_ui_override: None,
mobile_state: mobile::MobileState::default(),
icon_cache: IconCache::new(),
tool_icon_cache: ToolIconCache::new(),
focus_icon_cache: FocusIconCache::new(),
@ -6584,10 +6604,21 @@ impl eframe::App for EditorApp {
}
// Build the per-frame context (single source of truth for SharedPaneState,
// shared with the mobile path). The bundle borrows `self` + `scratch`; it
// is dropped before the post-render drain below re-touches them.
// shared by the desktop and mobile paths). The bundle borrows `self` + `scratch`;
// it is dropped before the post-render drain below re-touches them.
let is_mobile = self.mobile_active();
let mut bundle = self.build_frame(&mut scratch);
if is_mobile {
// Phone shell: a single hero surface + top tabs + resizable timeline ribbon
// + a fixed transport floor, reusing the same panes the desktop layout uses.
mobile::render_mobile_shell(
ui,
available_rect,
&mut bundle.rc,
bundle.mobile_state,
);
} else {
render_layout_node(
ui,
bundle.current_layout,
@ -6600,6 +6631,7 @@ impl eframe::App for EditorApp {
&Vec::new(), // Root path
&mut bundle.rc,
);
}
drop(bundle);
// Process collected effect thumbnail requests
@ -7111,9 +7143,15 @@ struct FrameBundle<'a> {
hovered_divider: &'a mut Option<(NodePath, bool)>,
selected_pane: &'a mut Option<NodePath>,
split_preview_mode: &'a mut SplitPreviewMode,
mobile_state: &'a mut mobile::MobileState,
}
impl EditorApp {
/// Whether the mobile shell is active this frame (runtime override wins over the env flag).
fn mobile_active(&self) -> bool {
self.mobile_ui_override.unwrap_or(self.mobile_ui)
}
/// Build the per-frame [`FrameBundle`]. The returned bundle borrows `self` and
/// `scratch` for `'a`; it must be dropped before the post-render drain touches
/// `self`/`scratch` again. This is the single source of truth for `SharedPaneState`.
@ -7225,6 +7263,7 @@ impl EditorApp {
hovered_divider: &mut self.hovered_divider,
selected_pane: &mut self.selected_pane,
split_preview_mode: &mut self.split_preview_mode,
mobile_state: &mut self.mobile_state,
}
}
}

View File

@ -0,0 +1,229 @@
//! Mobile / phone UI shell.
//!
//! Developed on desktop behind the `LB_MOBILE_UI` env var (see [`is_mobile_env`]).
//! The shell reuses the existing egui panes (`PaneInstance` / `render_content`) for its
//! "hero" surfaces, composing them as a single surface at a time with a top tab bar, a
//! resizable timeline ribbon, and a fixed transport "floor" at the bottom — per the
//! `phone-ui-sketches.html` wireframe spec.
//!
//! Not every pane becomes a surface: per the spec the Toolbar becomes the omnibutton and
//! the Infopanel becomes the selection inspector (later phases). The tab list here mirrors
//! the *mobile-relevant* subset of `PaneType`.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
use crate::panes::NodePath;
use crate::RenderContext;
mod ribbon;
mod surface;
mod topbar;
mod transport;
/// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are
/// built from small child indices, so prefixing with `usize::MAX` guarantees mobile slots
/// never alias a real layout path in the shared `pane_instances` map.
pub const MOBILE_NS: usize = usize::MAX;
const TOPBAR_H: f32 = 50.0;
const TRANSPORT_H: f32 = 60.0;
const GRABBER_H: f32 = 16.0;
/// Returns true if the mobile UI is requested via the `LB_MOBILE_UI` env var.
/// Any non-empty value other than "0" enables it.
pub fn is_mobile_env() -> bool {
std::env::var("LB_MOBILE_UI")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
/// The hero surfaces selectable from the top tab bar. These mirror the pane list minus the
/// panes that become other mobile affordances (Toolbar → omnibutton, Infopanel → inspector).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MobileSurface {
Stage,
Time,
Nodes,
Mixer,
Tree,
}
impl MobileSurface {
/// All surfaces, in tab order.
pub const TABS: [MobileSurface; 5] = [
MobileSurface::Stage,
MobileSurface::Time,
MobileSurface::Nodes,
MobileSurface::Mixer,
MobileSurface::Tree,
];
/// The existing pane that backs this surface. Phase 1 reuses panes directly; the Nodes
/// surface is a placeholder for the focus/patch rework (it currently shows the desktop
/// node editor) and Mixer maps to the closest existing audio pane.
pub fn pane_type(self) -> PaneType {
match self {
MobileSurface::Stage => PaneType::Stage,
MobileSurface::Time => PaneType::Timeline,
MobileSurface::Nodes => PaneType::NodeEditor,
MobileSurface::Mixer => PaneType::VirtualPiano,
MobileSurface::Tree => PaneType::Outliner,
}
}
pub fn label(self) -> &'static str {
match self {
MobileSurface::Stage => "Stage",
MobileSurface::Time => "Time",
MobileSurface::Nodes => "Nodes",
MobileSurface::Mixer => "Mixer",
MobileSurface::Tree => "Tree",
}
}
fn index(self) -> usize {
match self {
MobileSurface::Stage => 0,
MobileSurface::Time => 1,
MobileSurface::Nodes => 2,
MobileSurface::Mixer => 3,
MobileSurface::Tree => 4,
}
}
/// Stable `pane_instances` key for this surface's cached pane.
fn path(self) -> NodePath {
vec![MOBILE_NS, self.index()]
}
}
/// How far the timeline ribbon is expanded. The transport floor is always present, so the
/// ribbon never collapses to zero — `Peek` is the floor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RibbonTier {
Peek,
Half,
Full,
}
impl RibbonTier {
/// Ribbon body height (excluding the grabber) for a given available middle-region
/// height (the space between the top bar and the transport floor).
fn height(self, region_h: f32) -> f32 {
match self {
RibbonTier::Peek => 96.0_f32.min(region_h * 0.45),
RibbonTier::Half => region_h * 0.45,
RibbonTier::Full => region_h * 0.72,
}
}
fn snap_from(region_h: f32, target_h: f32) -> RibbonTier {
// Snap to whichever tier height is closest to the dragged target.
let candidates = [RibbonTier::Peek, RibbonTier::Half, RibbonTier::Full];
*candidates
.iter()
.min_by(|a, b| {
let da = (a.height(region_h) - target_h).abs();
let db = (b.height(region_h) - target_h).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(&RibbonTier::Peek)
}
}
/// Persistent mobile-shell UI state, cached on `EditorApp`.
pub struct MobileState {
pub active_surface: MobileSurface,
pub ribbon_tier: RibbonTier,
/// Transient live-drag offset (px) applied to the ribbon height while the grabber is
/// held; folded into `ribbon_tier` on release.
pub ribbon_drag: f32,
}
impl Default for MobileState {
fn default() -> Self {
Self {
active_surface: MobileSurface::Stage,
ribbon_tier: RibbonTier::Peek,
ribbon_drag: 0.0,
}
}
}
/// Render the whole mobile shell into `available_rect`. Reuses `rc` (the shared
/// `RenderContext`) for all pane content and `state` for the persistent shell state.
pub fn render_mobile_shell(
ui: &mut egui::Ui,
available_rect: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
) {
// Background (wireframe device color; the bands below paint over most of it).
let bg = egui::Color32::from_rgb(0x14, 0x16, 0x1b);
ui.painter().rect_filled(available_rect, 0.0, bg);
let top = available_rect.top();
let bottom = available_rect.bottom();
let left = available_rect.left();
let right = available_rect.right();
// Fixed bands.
let topbar_rect = egui::Rect::from_min_max(
egui::pos2(left, top),
egui::pos2(right, top + TOPBAR_H),
);
let transport_rect = egui::Rect::from_min_max(
egui::pos2(left, bottom - TRANSPORT_H),
egui::pos2(right, bottom),
);
// Middle region between the top bar and the transport floor.
let region_top = topbar_rect.bottom();
let region_bottom = transport_rect.top();
let region_h = (region_bottom - region_top).max(0.0);
// The ribbon is hidden when the Time surface is the hero (it already *is* the timeline).
let show_ribbon = state.active_surface != MobileSurface::Time && region_h > GRABBER_H + 40.0;
let (hero_rect, ribbon_rects) = if show_ribbon {
let base_h = state.ribbon_tier.height(region_h);
let ribbon_h = (base_h + state.ribbon_drag)
.clamp(40.0, region_h - GRABBER_H - 80.0);
let grabber_top = region_bottom - ribbon_h - GRABBER_H;
let grabber_rect = egui::Rect::from_min_max(
egui::pos2(left, grabber_top),
egui::pos2(right, grabber_top + GRABBER_H),
);
let ribbon_body = egui::Rect::from_min_max(
egui::pos2(left, grabber_rect.bottom()),
egui::pos2(right, region_bottom),
);
let hero = egui::Rect::from_min_max(
egui::pos2(left, region_top),
egui::pos2(right, grabber_top),
);
(hero, Some((grabber_rect, ribbon_body)))
} else {
let hero = egui::Rect::from_min_max(
egui::pos2(left, region_top),
egui::pos2(right, region_bottom),
);
(hero, None)
};
// Hero surface (reuses an existing pane full-bleed).
let surface = state.active_surface;
surface::render_surface_fullbleed(ui, hero_rect, &surface.path(), surface.pane_type(), rc);
// Resizable timeline ribbon.
if let Some((grabber_rect, ribbon_body)) = ribbon_rects {
ribbon::render(ui, grabber_rect, ribbon_body, region_h, state, rc);
}
// Top tab bar (drawn after the hero so its hit area wins along the top edge).
topbar::render(ui, topbar_rect, state, rc);
// Transport floor (drawn last = always on top, the persistent spine).
transport::render(ui, transport_rect, &mut rc.shared);
}

View File

@ -0,0 +1,57 @@
//! Resizable timeline ribbon. A grabber strip drags the ribbon through three snap tiers
//! (Peek / Half / Full); the ribbon body reuses the existing `TimelinePane` full-bleed.
//! The transport floor below it is always present, so the ribbon never collapses to zero.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
use super::{surface, MobileState, RibbonTier, MOBILE_NS};
use crate::panes::NodePath;
use crate::RenderContext;
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
/// Stable `pane_instances` key for the ribbon's timeline — distinct from the Time surface so
/// their zoom/scroll state don't fight.
fn ribbon_path() -> NodePath {
vec![MOBILE_NS, 100]
}
pub fn render(
ui: &mut egui::Ui,
grabber_rect: egui::Rect,
ribbon_body: egui::Rect,
region_h: f32,
state: &mut MobileState,
rc: &mut RenderContext,
) {
// Grabber strip.
let painter = ui.painter_at(grabber_rect);
painter.rect_filled(grabber_rect, 0.0, C_PANEL);
painter.hline(grabber_rect.x_range(), grabber_rect.top(), egui::Stroke::new(1.0, C_LINE));
// Handle pill.
let pill = egui::Rect::from_center_size(grabber_rect.center(), egui::vec2(34.0, 4.0));
painter.rect_filled(pill, 2.0, C_LINE);
let resp = ui.interact(
grabber_rect,
ui.id().with("mobile_ribbon_grabber"),
egui::Sense::drag(),
);
if resp.dragged() {
// Dragging up (negative y) expands the ribbon.
state.ribbon_drag -= resp.drag_delta().y;
}
if resp.drag_stopped() {
let target_h = state.ribbon_tier.height(region_h) + state.ribbon_drag;
state.ribbon_tier = RibbonTier::snap_from(region_h, target_h);
state.ribbon_drag = 0.0;
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
// Ribbon body = the timeline, full-bleed.
surface::render_surface_fullbleed(ui, ribbon_body, &ribbon_path(), PaneType::Timeline, rc);
}

View File

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

View File

@ -0,0 +1,70 @@
//! Top surface-tab bar. One tap switches the hero surface. Mirrors the wireframe's top tabs
//! (Stage / Time / Nodes / Mixer / Tree); the active tab is marked with an amber underline.
use eframe::egui;
use super::{MobileState, MobileSurface};
use crate::RenderContext;
// Wireframe palette.
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
pub fn render(
ui: &mut egui::Ui,
rect: egui::Rect,
state: &mut MobileState,
_rc: &mut RenderContext,
) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, C_PANEL);
// Bottom rule.
painter.hline(rect.x_range(), rect.bottom(), egui::Stroke::new(1.0, C_LINE));
let tabs = MobileSurface::TABS;
let tab_w = rect.width() / tabs.len() as f32;
for (i, surface) in tabs.iter().enumerate() {
let tab_rect = egui::Rect::from_min_max(
egui::pos2(rect.left() + i as f32 * tab_w, rect.top()),
egui::pos2(rect.left() + (i as f32 + 1.0) * tab_w, rect.bottom()),
);
let id = ui.id().with(("mobile_tab", i));
let resp = ui.interact(tab_rect, id, egui::Sense::click());
if resp.clicked() {
state.active_surface = *surface;
}
let active = state.active_surface == *surface;
let color = if active {
C_AMBER
} else if resp.hovered() {
C_BRIGHT
} else {
C_DIM
};
painter.text(
tab_rect.center(),
egui::Align2::CENTER_CENTER,
surface.label(),
egui::FontId::proportional(13.0),
color,
);
if active {
// Underline marking the active surface.
let y = tab_rect.bottom() - 1.5;
painter.hline(
(tab_rect.left() + 10.0)..=(tab_rect.right() - 10.0),
y,
egui::Stroke::new(2.0, C_AMBER),
);
}
// Vertical separators between tabs.
if i > 0 {
painter.vline(tab_rect.left(), rect.y_range(), egui::Stroke::new(1.0, C_LINE));
}
}
}

View File

@ -0,0 +1,106 @@
//! The transport "floor": the always-present bottom bar with play/pause, the timecode, and a
//! project scrub. Wired to the audio controller exactly like `TimelinePane`'s header.
use eframe::egui;
use crate::panes::SharedPaneState;
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a);
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, C_PANEL);
painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE));
let cy = rect.center().y;
// --- Play / pause button (circle on the left) ---
let btn_r = 18.0;
let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy);
let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0));
let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click());
painter.circle_filled(btn_center, btn_r, C_AMBER);
let glyph = if *shared.is_playing { "" } else { "" };
painter.text(
btn_center,
egui::Align2::CENTER_CENTER,
glyph,
egui::FontId::proportional(16.0),
C_DARK,
);
if btn_resp.clicked() {
*shared.is_playing = !*shared.is_playing;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
if *shared.is_playing {
controller.seek(*shared.playback_time);
controller.play();
} else {
controller.pause();
}
}
}
// --- 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),
egui::Align2::LEFT_CENTER,
&tc,
egui::FontId::monospace(13.0),
C_BRIGHT,
);
let tc_width = 78.0;
// --- Project scrub (fills the remaining width) ---
let duration = shared.action_executor.document().duration.max(1.0);
let scrub_left = tc_left + tc_width;
let scrub_rect = egui::Rect::from_min_max(
egui::pos2(scrub_left, cy - 3.0),
egui::pos2(rect.right() - 14.0, cy + 3.0),
);
let scrub_resp = ui.interact(
scrub_rect.expand2(egui::vec2(0.0, 12.0)),
ui.id().with("mobile_transport_scrub"),
egui::Sense::click_and_drag(),
);
painter.rect_filled(scrub_rect, 3.0, C_LINE);
let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32;
let filled = egui::Rect::from_min_max(
scrub_rect.min,
egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()),
);
painter.rect_filled(filled, 3.0, C_AMBER.gamma_multiply(0.5));
let head_x = scrub_rect.left() + scrub_rect.width() * frac;
painter.vline(
head_x,
(scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0),
egui::Stroke::new(2.0, C_AMBER),
);
if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 {
if let Some(pos) = scrub_resp.interact_pointer_pos() {
let f = ((pos.x - scrub_rect.left()) / scrub_rect.width()).clamp(0.0, 1.0) as f64;
let new_time = f * duration;
*shared.playback_time = new_time;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(new_time);
}
}
}
}
fn format_timecode(seconds: f64, fps: f64) -> String {
let total = seconds.max(0.0);
let minutes = (total / 60.0).floor() as u32;
let secs = (total % 60.0).floor() as u32;
let frames = ((total - total.floor()) * fps).floor() as u32;
format!("{:02}:{:02}:{:02}", minutes, secs, frames)
}