From 08b6aa01390fe036c529ef7200fed284446f8706 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 1 Jul 2026 01:14:31 -0400 Subject: [PATCH] Compress mobile layer headers to a swatch; conditional inspector reflow - Add `is_mobile` to SharedPaneState (set in build_frame). On mobile the timeline layer-header column collapses to a minimal type-colored swatch (1:2 w:h, ~30px), brighter/outlined when active/selected; tapping it selects the layer (handled by the existing header-column input path, which now uses the narrower width). Desktop headers unchanged. - Inspector reflow: the sheet overlaps the stack by default, but when the selected pane would be covered by the sheet, the stack reflows (renders above the sheet) so it stays visible. Restore on dismiss is automatic since reflow only changes the render rect; manual divider moves while the sheet is up persist. Adds stack::pane_bottom_in and inspector::target_slot. --- .../lightningbeam-editor/src/main.rs | 2 + .../src/mobile/inspector.rs | 12 +++++ .../lightningbeam-editor/src/mobile/mod.rs | 33 +++++++++---- .../lightningbeam-editor/src/mobile/stack.rs | 13 +++++ .../lightningbeam-editor/src/panes/mod.rs | 2 + .../src/panes/timeline.rs | 47 +++++++++++++++---- 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 90fc1a0..e2f8e0d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -7163,9 +7163,11 @@ impl EditorApp { /// `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`. fn build_frame<'a>(&'a mut self, scratch: &'a mut FrameScratch) -> FrameBundle<'a> { + let is_mobile = self.mobile_active(); FrameBundle { rc: RenderContext { shared: panes::SharedPaneState { + is_mobile, container_path: self.current_file_path.clone(), onion: { // Onion skinning is disabled during playback. diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs index 7145a91..2cbeb4f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -29,6 +29,18 @@ pub fn is_active(shared: &SharedPaneState) -> bool { !shared.focus.is_none() || !shared.selection.is_empty() } +/// The stack slot (see `super::STACK`) where the current selection lives, so we can tell if the +/// sheet would cover it. Geometry/selection lives on the Stage; clips/layers on the Timeline; etc. +pub fn target_slot(shared: &SharedPaneState) -> usize { + match &*shared.focus { + FocusSelection::Notes { .. } => 4, // PianoRoll + FocusSelection::Nodes(_) => 6, // Node/Instrument + FocusSelection::Assets(_) => 1, // Asset Library + FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline + FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage + } +} + /// A short title describing what's selected. fn title(shared: &SharedPaneState) -> String { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 9d90bd4..0c12ca5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -190,15 +190,32 @@ pub fn render_mobile_shell( available_rect.min, egui::pos2(available_rect.right(), transport_rect.top()), ); - // The stack always fills the region; the inspector sheet overlays its lower part (no reflow). - stack::render(ui, region, rc, state); + // When the inspector is up, the sheet overlays the lower part of the stack. If the selected + // pane would be *covered* by the sheet, reflow (shrink the stack above the sheet) so it stays + // visible; otherwise leave the stack full-height and just overlay. Restoring on dismiss is + // automatic — reflow only changes the render rect, not the window state. + let inspector_shown = inspector::is_active(&rc.shared); + let sheet_h = if inspector_shown { + (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0) + } else { + 0.0 + }; + let sheet_top = region.bottom() - sheet_h; - if inspector::is_active(&rc.shared) { - let sheet_h = (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0); - let sheet_rect = egui::Rect::from_min_max( - egui::pos2(region.left(), region.bottom() - sheet_h), - region.max, - ); + let covered = inspector_shown + && stack::pane_bottom_in(state, region, inspector::target_slot(&rc.shared)) + .map(|bottom| bottom > sheet_top + 1.0) + .unwrap_or(false); + let stack_rect = if covered { + egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top)) + } else { + region + }; + + stack::render(ui, stack_rect, rc, state); + + if inspector_shown { + let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max); inspector::render(ui, sheet_rect, region.height(), rc, state); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs index ab46feb..8debad8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -224,6 +224,19 @@ fn nweights(weights: &[f32; 3], count: usize) -> Vec { w } +/// The bottom Y of the given stack slot's band within `region` (using the current window + +/// weights), or None if that slot isn't in the visible window. Used to decide whether the +/// inspector sheet would cover the selected pane. +pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option { + let (top, count) = (state.window_top, state.window_count); + if slot < top || slot >= top + count { + return None; + } + let nw = nweights(&state.weights, count); + let cum: f32 = nw[..=(slot - top)].iter().sum(); + Some(region.top() + cum * region.height()) +} + /// Lay out `count` panes in `rect` using normalized weights `nw`. fn config_rects(top: usize, count: usize, rect: egui::Rect, nw: &[f32]) -> Vec<(usize, egui::Rect)> { let h = rect.height(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 7d1acb8..d1f1fe4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -356,6 +356,8 @@ pub struct SharedPaneState<'a> { /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. pub brush_preview_pixels: &'a std::sync::Arc)>>>, + /// True when rendering the phone/mobile shell (panes can render more compactly). + pub is_mobile: bool, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index f233858..158ec7b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -17,6 +17,8 @@ use std::collections::HashMap; const RULER_HEIGHT: f32 = 30.0; const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; +/// On mobile the header collapses to a minimal color swatch with a 1:2 width:height aspect. +const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5; const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes const MAX_PIXELS_PER_SECOND: f32 = 500.0; const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles @@ -1937,6 +1939,8 @@ impl TimelinePane { track_levels: &std::collections::HashMap, input_level: f32, playback_time: f64, + header_width: f32, + is_mobile: bool, ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); @@ -1999,7 +2003,7 @@ impl TimelinePane { let header_rect = egui::Rect::from_min_size( egui::pos2(rect.min.x, y), - egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT), + egui::vec2(header_width, LAYER_HEIGHT), ); // Determine the AnyLayer or GroupLayer for this row @@ -2033,6 +2037,27 @@ impl TimelinePane { ui.painter().rect_filled(header_rect, 0.0, bg_color); + // Mobile: collapse the header to a minimal color swatch (1:2 w:h). Selection is handled + // by handle_input via the (narrow) header rect, so we only draw here. + if is_mobile { + let sw = header_rect.shrink(2.0); + let col = if is_active || is_selected { + type_color + } else { + type_color.gamma_multiply(0.55) + }; + ui.painter().rect_filled(sw, 3.0, col); + if is_active || is_selected { + ui.painter().rect_stroke( + sw, + 3.0, + egui::Stroke::new(1.5, egui::Color32::WHITE), + egui::StrokeKind::Inside, + ); + } + continue; + } + // Gutter area (left of indicator) — solid group color, with collapse chevron if indent > 0.0 { let gutter_rect = egui::Rect::from_min_size( @@ -5456,15 +5481,21 @@ impl PaneRenderer for TimelinePane { } self.duration = max_endpoint; - // Split into layer header column (left) and timeline content (right) + // Split into layer header column (left) and timeline content (right). On mobile the header + // column collapses to a minimal color-swatch width. + let header_width = if shared.is_mobile { + MOBILE_LAYER_HEADER_WIDTH + } else { + LAYER_HEADER_WIDTH + }; let header_column_rect = egui::Rect::from_min_size( rect.min, - egui::vec2(LAYER_HEADER_WIDTH, rect.height()), + egui::vec2(header_width, rect.height()), ); let timeline_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0), - egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()), + rect.min + egui::vec2(header_width, 0.0), + egui::vec2(rect.width() - header_width, rect.height()), ); // Split timeline into ruler and content areas @@ -5481,12 +5512,12 @@ impl PaneRenderer for TimelinePane { // Split header column into ruler area (top) and layer headers (bottom) let header_ruler_spacer = egui::Rect::from_min_size( header_column_rect.min, - egui::vec2(LAYER_HEADER_WIDTH, RULER_HEIGHT), + egui::vec2(header_width, RULER_HEIGHT), ); let layer_headers_rect = egui::Rect::from_min_size( header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT), - egui::vec2(LAYER_HEADER_WIDTH, header_column_rect.height() - RULER_HEIGHT), + egui::vec2(header_width, header_column_rect.height() - RULER_HEIGHT), ); // Save original clip rect to restore at the end @@ -5503,7 +5534,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));