diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 56c76a0..907afca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -1,14 +1,15 @@ //! 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`. +//! The shell is a single **vertical sliding-window stack** of the existing panes: a fixed +//! top→bottom order, with a window of 2 or 3 consecutive panes visible at once. Dragging the +//! dividers between panes and the top/bottom screen edges grows, shrinks, or slides the window +//! (the R1–R6 state machine in `stack.rs`). A transport bar is pinned at the bottom as the floor. +//! +//! Each visible pane reuses the existing `PaneInstance::render_content` full-bleed (see +//! `surface.rs`). Per the wireframe, the Toolbar becomes the omnibutton and the Infopanel becomes +//! the selection inspector (later phases), so neither is in the stack. use eframe::egui; use lightningbeam_core::pane::PaneType; @@ -16,19 +17,16 @@ use lightningbeam_core::pane::PaneType; use crate::panes::NodePath; use crate::RenderContext; -mod ribbon; +mod stack; 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. +/// 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. @@ -38,192 +36,133 @@ pub fn is_mobile_env() -> bool { .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). +/// The panes that make up the vertical stack, in fixed top→bottom order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MobileSurface { +pub enum StackPane { + Outliner, + AssetLibrary, Stage, - Time, - Nodes, - Mixer, - Tree, + Timeline, + PianoRoll, + VirtualPiano, + /// Node editor, or (toggled) the instrument/preset browser. + NodeInstrument, + ScriptEditor, } -impl MobileSurface { - /// All surfaces, in tab order. - pub const TABS: [MobileSurface; 5] = [ - MobileSurface::Stage, - MobileSurface::Time, - MobileSurface::Nodes, - MobileSurface::Mixer, - MobileSurface::Tree, - ]; +/// The stack order. Index into this is a pane's stable slot id. +pub const STACK: [StackPane; 8] = [ + StackPane::Outliner, + StackPane::AssetLibrary, + StackPane::Stage, + StackPane::Timeline, + StackPane::PianoRoll, + StackPane::VirtualPiano, + StackPane::NodeInstrument, + StackPane::ScriptEditor, +]; - /// 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 { +impl StackPane { + /// The backing pane type. The Node/Instrument slot picks NodeEditor or PresetBrowser based on + /// the toggle. + pub fn pane_type(self, show_instruments: bool) -> PaneType { match self { - MobileSurface::Stage => PaneType::Stage, - MobileSurface::Time => PaneType::Timeline, - MobileSurface::Nodes => PaneType::NodeEditor, - MobileSurface::Mixer => PaneType::VirtualPiano, - MobileSurface::Tree => PaneType::Outliner, + StackPane::Outliner => PaneType::Outliner, + StackPane::AssetLibrary => PaneType::AssetLibrary, + StackPane::Stage => PaneType::Stage, + StackPane::Timeline => PaneType::Timeline, + StackPane::PianoRoll => PaneType::PianoRoll, + StackPane::VirtualPiano => PaneType::VirtualPiano, + StackPane::NodeInstrument => { + if show_instruments { + PaneType::PresetBrowser + } else { + PaneType::NodeEditor + } + } + StackPane::ScriptEditor => PaneType::ScriptEditor, } } - pub fn label(self) -> &'static str { + pub fn label(self, show_instruments: bool) -> &'static str { match self { - MobileSurface::Stage => "Stage", - MobileSurface::Time => "Time", - MobileSurface::Nodes => "Nodes", - MobileSurface::Mixer => "Mixer", - MobileSurface::Tree => "Tree", + StackPane::Outliner => "Outliner", + StackPane::AssetLibrary => "Assets", + StackPane::Stage => "Stage", + StackPane::Timeline => "Timeline", + StackPane::PianoRoll => "Piano Roll", + StackPane::VirtualPiano => "Keys", + StackPane::NodeInstrument => { + if show_instruments { + "Instruments" + } else { + "Nodes" + } + } + StackPane::ScriptEditor => "Script", } } - - 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, +/// `pane_instances` key for a stack slot. +fn slot_path(slot: usize) -> NodePath { + vec![MOBILE_NS, slot] } -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) - } +/// An in-progress drag of a stack handle. +#[derive(Debug, Clone, Copy)] +pub struct StackDrag { + pub handle: stack::Handle, + /// Accumulated pointer offset (px) since the drag began (downward positive). + pub offset: f32, } -/// Persistent mobile-shell UI state, cached on `EditorApp`. +/// Persistent mobile-shell 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, + /// Index into `STACK` of the topmost visible pane. + pub window_top: usize, + /// Number of visible panes (2 or 3). + pub window_count: usize, + /// Node/Instrument band: false = node editor, true = instrument/preset browser. + pub show_instruments: bool, + /// Active handle drag (transient). + pub drag: Option, } impl Default for MobileState { fn default() -> Self { Self { - active_surface: MobileSurface::Stage, - ribbon_tier: RibbonTier::Peek, - ribbon_drag: 0.0, + // Launch on {Stage, Timeline}. + window_top: 2, + window_count: 2, + show_instruments: false, + drag: None, } } } -/// Render the whole mobile shell into `available_rect`. Reuses `rc` (the shared -/// `RenderContext`) for all pane content and `state` for the persistent shell state. +/// Render the whole mobile shell into `available_rect`. pub fn render_mobile_shell( ui: &mut egui::Ui, available_rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, ) { - // 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); + // Background (wireframe device color; bands paint over most of it). + ui.painter() + .rect_filled(available_rect, 0.0, egui::Color32::from_rgb(0x14, 0x16, 0x1b)); - 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), + egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H), + available_rect.max, + ); + let stack_rect = egui::Rect::from_min_max( + available_rect.min, + egui::pos2(available_rect.right(), transport_rect.top()), ); - // 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); + stack::render(ui, stack_rect, rc, state); - // 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 floor: drawn last = always on top, the persistent spine. transport::render(ui, transport_rect, &mut rc.shared); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs deleted file mode 100644 index ee6a46a..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/ribbon.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 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); -} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs new file mode 100644 index 0000000..39ca14b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -0,0 +1,310 @@ +//! The vertical sliding-window stack engine. +//! +//! A window of 2–3 consecutive panes (from [`super::STACK`]) is visible. Dragging the dividers +//! between visible panes and the top/bottom screen edges grows, shrinks, or slides the window via +//! the operations below (verified against the user's transition table): +//! +//! ```text +//! R1 upper divider, UP -> slide window DOWN one (top+=1) +//! R4 upper divider, DOWN -> drop BOTTOM pane (count 3->2) +//! R3 lower divider, UP -> drop TOP pane (top+=1, count 3->2) +//! R2 lower divider, DOWN -> slide window UP one (top-=1) +//! R5 bottom edge, UP -> grow at bottom; if already 3, slide down +//! R6 top edge, DOWN -> grow at top; if already 3, slide up +//! ``` +//! +//! At count==2 the single divider is "upper" when dragged up (R1) and "lower" when dragged +//! down (R2). The window content interpolates continuously during a drag and snaps on release. + +use eframe::egui; + +use super::{slot_path, MobileState, StackDrag, StackPane, STACK}; +use crate::RenderContext; + +const N: usize = STACK.len(); + +const EDGE_GRAB_H: f32 = 16.0; +const DIV_GRAB_H: f32 = 18.0; + +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_CHIP_BG: egui::Color32 = egui::Color32::from_rgba_premultiplied(0x1b, 0x1f, 0x27, 0xcc); + +/// A draggable boundary of the stack. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Handle { + TopEdge, + /// Divider below visible pane `k` (between visible pane `k` and `k+1`). + Divider(usize), + BottomEdge, +} + +// --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> --- + +fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> { + (top + count < N).then_some((top + 1, count)) // slide down +} +fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> { + (top > 0).then_some((top - 1, count)) // slide up +} +fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> { + (count == 3).then_some((top + 1, 2)) // drop top +} +fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> { + (count == 3).then_some((top, 2)) // drop bottom +} +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 == 3 && top + count < N { + Some((top + 1, count)) // slide down + } else { + None + } +} +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 == 3 && top > 0 { + Some((top - 1, count)) // slide up + } else { + None + } +} + +/// Given a handle and signed drag offset, resolve the target window config and progress `t`. +/// Returns None if the drag direction has no valid operation (e.g. at a list boundary). +fn resolve( + handle: Handle, + offset: f32, + top: usize, + count: usize, + pane_h: f32, +) -> Option<(usize, usize, f32)> { + let going_up = offset < 0.0; + let t = (offset.abs() / pane_h.max(1.0)).clamp(0.0, 1.0); + let target = match handle { + 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) } + } else if k == 0 { + // upper divider + if going_up { op_r1(top, count) } else { op_r4(top, count) } + } else { + // lower divider + if going_up { op_r3(top, count) } else { op_r2(top, count) } + } + } + }; + target.map(|(tt, tc)| (tt, tc, t)) +} + +// --- rect layout --- + +fn config_rects(top: usize, count: usize, rect: egui::Rect) -> Vec<(usize, egui::Rect)> { + let h = rect.height() / count as f32; + (0..count) + .map(|i| { + let y0 = rect.top() + i as f32 * h; + ( + top + i, + egui::Rect::from_min_max( + egui::pos2(rect.left(), y0), + egui::pos2(rect.right(), y0 + h), + ), + ) + }) + .collect() +} + +fn lerp_f(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} +fn lerp_rect(a: egui::Rect, b: egui::Rect, t: f32) -> egui::Rect { + egui::Rect::from_min_max( + egui::pos2(lerp_f(a.min.x, b.min.x, t), lerp_f(a.min.y, b.min.y, t)), + egui::pos2(lerp_f(a.max.x, b.max.x, t), lerp_f(a.max.y, b.max.y, t)), + ) +} +fn collapsed(rect: egui::Rect, at_top: bool) -> egui::Rect { + let y = if at_top { rect.top() } else { rect.bottom() }; + egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)) +} + +/// Interpolate the visible-pane rects from config C toward config T by progress `t`. +fn interp_rects( + (top_c, count_c): (usize, usize), + (top_t, count_t): (usize, usize), + t: f32, + rect: egui::Rect, +) -> Vec<(usize, egui::Rect)> { + let c = config_rects(top_c, count_c, rect); + let tt = config_rects(top_t, count_t, rect); + let find = |v: &[(usize, egui::Rect)], slot: usize| v.iter().find(|(s, _)| *s == slot).map(|(_, r)| *r); + + let lo = top_c.min(top_t); + let hi = (top_c + count_c).max(top_t + count_t); + let mut out = Vec::new(); + for slot in lo..hi { + let in_c = find(&c, slot); + let in_t = find(&tt, slot); + let r = match (in_c, in_t) { + (Some(rc), Some(rt)) => lerp_rect(rc, rt, t), + (Some(rc), None) => lerp_rect(rc, collapsed(rect, slot < top_t), t), // exiting + (None, Some(rt)) => lerp_rect(collapsed(rect, slot < top_c), rt, t), // entering + (None, None) => continue, + }; + out.push((slot, r)); + } + out +} + +// --- rendering --- + +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { + let top = state.window_top; + let count = state.window_count; + let pane_h = rect.height() / count as f32; + + // Layout from the (previous frame's) drag, if any has a valid op; else the resting config. + let layout = state + .drag + .and_then(|d| resolve(d.handle, d.offset, top, count, pane_h)) + .map(|(tt, tc, t)| interp_rects((top, count), (tt, tc), t, rect)) + .unwrap_or_else(|| config_rects(top, count, rect)); + + // 1) Pane content (top to bottom). + for (slot, prect) in &layout { + if prect.height() < 1.0 { + continue; + } + let sp = STACK[*slot]; + super::surface::render_surface_fullbleed( + ui, + *prect, + &slot_path(*slot), + sp.pane_type(state.show_instruments), + rc, + ); + } + + // 2) Per-band label chips + the Node/Instrument toggle (drawn over content). + for (slot, prect) in &layout { + if prect.height() < 24.0 { + continue; + } + draw_band_chip(ui, *prect, STACK[*slot], state); + } + + // 3) Handles (interacted last so they win the initial press on their thin strips). + handle_interactions(ui, rect, state); +} + +fn draw_band_chip(ui: &mut egui::Ui, prect: egui::Rect, sp: StackPane, state: &mut MobileState) { + let label = sp.label(state.show_instruments); + let pos = prect.left_top() + egui::vec2(8.0, 6.0); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + egui::FontId::proportional(11.0), + C_DIM, + ); + let chip = egui::Rect::from_min_size(pos, galley.size() + egui::vec2(12.0, 5.0)); + ui.painter().rect_filled(chip, 4.0, C_CHIP_BG); + ui.painter() + .galley(chip.min + egui::vec2(6.0, 2.0), galley, C_DIM); + + // Node/Instrument toggle sits just to the right of the label chip. + if sp == StackPane::NodeInstrument { + let tog = egui::Rect::from_min_size( + egui::pos2(chip.right() + 6.0, chip.top()), + egui::vec2(24.0, chip.height()), + ); + let resp = ui.interact(tog, ui.id().with("mobile_node_toggle"), egui::Sense::click()); + ui.painter().rect_filled(tog, 4.0, C_CHIP_BG); + ui.painter().text( + tog.center(), + egui::Align2::CENTER_CENTER, + "⇄", + egui::FontId::proportional(13.0), + if resp.hovered() { C_AMBER } else { C_DIM }, + ); + if resp.clicked() { + state.show_instruments = !state.show_instruments; + } + } +} + +fn handle_key(h: Handle) -> (usize, usize) { + match h { + Handle::TopEdge => (0, 0), + Handle::Divider(k) => (1, k + 1), + Handle::BottomEdge => (2, 0), + } +} + +fn handle_interactions(ui: &mut egui::Ui, rect: egui::Rect, state: &mut MobileState) { + let count = state.window_count; + let pane_h = rect.height() / count as f32; + + let mut handles: Vec<(Handle, egui::Rect)> = Vec::new(); + handles.push(( + Handle::TopEdge, + egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + EDGE_GRAB_H)), + )); + for k in 0..count.saturating_sub(1) { + let y = rect.top() + (k + 1) as f32 * pane_h; + handles.push(( + Handle::Divider(k), + egui::Rect::from_min_max( + egui::pos2(rect.left(), y - DIV_GRAB_H * 0.5), + egui::pos2(rect.right(), y + DIV_GRAB_H * 0.5), + ), + )); + } + handles.push(( + Handle::BottomEdge, + egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - EDGE_GRAB_H), rect.max), + )); + + for (handle, hrect) in handles { + let id = ui.id().with(("mobile_stack_handle", handle_key(handle))); + let resp = ui.interact(hrect, id, egui::Sense::drag()); + + let active = state.drag.map(|d| d.handle == handle).unwrap_or(false); + // Grab pill. + let pill = egui::Rect::from_center_size(hrect.center(), egui::vec2(40.0, 4.0)); + let pill_col = if resp.hovered() || active { C_AMBER } else { C_LINE }; + ui.painter().rect_filled(pill, 2.0, pill_col); + + if resp.drag_started() { + state.drag = Some(StackDrag { handle, offset: 0.0 }); + } + if resp.dragged() { + if let Some(d) = &mut state.drag { + if d.handle == handle { + d.offset += resp.drag_delta().y; + } + } + } + if resp.drag_stopped() { + if let Some(d) = state.drag.take() { + commit_drag(d, state, pane_h); + } + } + if resp.hovered() || active { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + } +} + +fn commit_drag(d: StackDrag, state: &mut MobileState, pane_h: f32) { + if let Some((tt, tc, t)) = resolve(d.handle, d.offset, state.window_top, state.window_count, pane_h) { + if t >= 0.5 { + state.window_top = tt; + state.window_count = tc; + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs deleted file mode 100644 index d71b1b2..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! 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)); - } - } -}