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:
parent
5ee2df5db7
commit
79f063d00c
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
MobileSurface::Stage => 0,
|
||||
MobileSurface::Time => 1,
|
||||
MobileSurface::Nodes => 2,
|
||||
MobileSurface::Mixer => 3,
|
||||
MobileSurface::Tree => 4,
|
||||
StackPane::ScriptEditor => "Script",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue