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:
parent
da0b39fef0
commit
5ee2df5db7
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue