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