Replace mobile tabbed shell with vertical sliding-window stack

Per design feedback, drop the top-tabs + single-ribbon model. The mobile UI
is now one vertical stack of all panes in a fixed order (Outliner, Asset
Library, Stage, Timeline, Piano Roll, Virtual Piano, Node/Instrument, Script
editor) with a window of 2-3 consecutive panes visible at a time.

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

Each visible band reuses PaneInstance::render_content full-bleed (surface.rs).
Removes topbar.rs and ribbon.rs; rewrites mod.rs state (window_top,
window_count, show_instruments, drag); adds stack.rs.
This commit is contained in:
Skyler Lehmkuhl 2026-06-29 23:57:29 -04:00
parent 5ee2df5db7
commit 79f063d00c
4 changed files with 411 additions and 289 deletions

View File

@ -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 R1R6 state machine in `stack.rs`). A transport bar is pinned at the bottom as the floor.
//!
//! Each visible pane reuses the existing `PaneInstance::render_content` full-bleed (see
//! `surface.rs`). Per the wireframe, the Toolbar becomes the omnibutton and the Infopanel becomes
//! the selection inspector (later phases), so neither is in the stack.
use eframe::egui;
use lightningbeam_core::pane::PaneType;
@ -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,
}
/// `pane_instances` key for a stack slot.
fn slot_path(slot: usize) -> NodePath {
vec![MOBILE_NS, slot]
}
/// Stable `pane_instances` key for this surface's cached pane.
fn path(self) -> NodePath {
vec![MOBILE_NS, self.index()]
}
/// 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,
}
/// 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`.
/// 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<StackDrag>,
}
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);
}

View File

@ -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);
}

View File

@ -0,0 +1,310 @@
//! The vertical sliding-window stack engine.
//!
//! A window of 23 consecutive panes (from [`super::STACK`]) is visible. Dragging the dividers
//! between visible panes and the top/bottom screen edges grows, shrinks, or slides the window via
//! the operations below (verified against the user's transition table):
//!
//! ```text
//! R1 upper divider, UP -> slide window DOWN one (top+=1)
//! R4 upper divider, DOWN -> drop BOTTOM pane (count 3->2)
//! R3 lower divider, UP -> drop TOP pane (top+=1, count 3->2)
//! R2 lower divider, DOWN -> slide window UP one (top-=1)
//! R5 bottom edge, UP -> grow at bottom; if already 3, slide down
//! R6 top edge, DOWN -> grow at top; if already 3, slide up
//! ```
//!
//! At count==2 the single divider is "upper" when dragged up (R1) and "lower" when dragged
//! down (R2). The window content interpolates continuously during a drag and snaps on release.
use eframe::egui;
use super::{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;
}
}
}

View File

@ -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));
}
}
}