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.
This commit is contained in:
Skyler Lehmkuhl 2026-07-01 01:14:31 -04:00
parent 87263489c0
commit 08b6aa0139
6 changed files with 93 additions and 16 deletions

View File

@ -7163,9 +7163,11 @@ impl EditorApp {
/// `scratch` for `'a`; it must be dropped before the post-render drain touches /// `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`. /// `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> { fn build_frame<'a>(&'a mut self, scratch: &'a mut FrameScratch) -> FrameBundle<'a> {
let is_mobile = self.mobile_active();
FrameBundle { FrameBundle {
rc: RenderContext { rc: RenderContext {
shared: panes::SharedPaneState { shared: panes::SharedPaneState {
is_mobile,
container_path: self.current_file_path.clone(), container_path: self.current_file_path.clone(),
onion: { onion: {
// Onion skinning is disabled during playback. // Onion skinning is disabled during playback.

View File

@ -29,6 +29,18 @@ pub fn is_active(shared: &SharedPaneState) -> bool {
!shared.focus.is_none() || !shared.selection.is_empty() !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. /// A short title describing what's selected.
fn title(shared: &SharedPaneState) -> String { fn title(shared: &SharedPaneState) -> String {
use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType};

View File

@ -190,15 +190,32 @@ pub fn render_mobile_shell(
available_rect.min, available_rect.min,
egui::pos2(available_rect.right(), transport_rect.top()), egui::pos2(available_rect.right(), transport_rect.top()),
); );
// The stack always fills the region; the inspector sheet overlays its lower part (no reflow). // When the inspector is up, the sheet overlays the lower part of the stack. If the selected
stack::render(ui, region, rc, state); // 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 covered = inspector_shown
let sheet_h = (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0); && stack::pane_bottom_in(state, region, inspector::target_slot(&rc.shared))
let sheet_rect = egui::Rect::from_min_max( .map(|bottom| bottom > sheet_top + 1.0)
egui::pos2(region.left(), region.bottom() - sheet_h), .unwrap_or(false);
region.max, 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); inspector::render(ui, sheet_rect, region.height(), rc, state);
} }

View File

@ -224,6 +224,19 @@ fn nweights(weights: &[f32; 3], count: usize) -> Vec<f32> {
w 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<f32> {
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`. /// 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)> { fn config_rects(top: usize, count: usize, rect: egui::Rect, nw: &[f32]) -> Vec<(usize, egui::Rect)> {
let h = rect.height(); let h = rect.height();

View File

@ -356,6 +356,8 @@ pub struct SharedPaneState<'a> {
/// on the first frame; panes (e.g. infopanel) convert the pixel data to egui /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui
/// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`.
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>, pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
/// True when rendering the phone/mobile shell (panes can render more compactly).
pub is_mobile: bool,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -17,6 +17,8 @@ use std::collections::HashMap;
const RULER_HEIGHT: f32 = 30.0; const RULER_HEIGHT: f32 = 30.0;
const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEIGHT: f32 = 60.0;
const LAYER_HEADER_WIDTH: f32 = 200.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 MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
const MAX_PIXELS_PER_SECOND: f32 = 500.0; const MAX_PIXELS_PER_SECOND: f32 = 500.0;
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles 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<daw_backend::TrackId, f32>, track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
input_level: f32, input_level: f32,
playback_time: f64, playback_time: f64,
header_width: f32,
is_mobile: bool,
) { ) {
// Background for header column // Background for header column
let header_style = theme.style(".timeline-header", ui.ctx()); let header_style = theme.style(".timeline-header", ui.ctx());
@ -1999,7 +2003,7 @@ impl TimelinePane {
let header_rect = egui::Rect::from_min_size( let header_rect = egui::Rect::from_min_size(
egui::pos2(rect.min.x, y), 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 // Determine the AnyLayer or GroupLayer for this row
@ -2033,6 +2037,27 @@ impl TimelinePane {
ui.painter().rect_filled(header_rect, 0.0, bg_color); 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 // Gutter area (left of indicator) — solid group color, with collapse chevron
if indent > 0.0 { if indent > 0.0 {
let gutter_rect = egui::Rect::from_min_size( let gutter_rect = egui::Rect::from_min_size(
@ -5456,15 +5481,21 @@ impl PaneRenderer for TimelinePane {
} }
self.duration = max_endpoint; 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( let header_column_rect = egui::Rect::from_min_size(
rect.min, rect.min,
egui::vec2(LAYER_HEADER_WIDTH, rect.height()), egui::vec2(header_width, rect.height()),
); );
let timeline_rect = egui::Rect::from_min_size( let timeline_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0), rect.min + egui::vec2(header_width, 0.0),
egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()), egui::vec2(rect.width() - header_width, rect.height()),
); );
// Split timeline into ruler and content areas // 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) // Split header column into ruler area (top) and layer headers (bottom)
let header_ruler_spacer = egui::Rect::from_min_size( let header_ruler_spacer = egui::Rect::from_min_size(
header_column_rect.min, 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( let layer_headers_rect = egui::Rect::from_min_size(
header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT), 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 // Save original clip rect to restore at the end
@ -5503,7 +5534,7 @@ impl PaneRenderer for TimelinePane {
// Render layer header column with clipping // Render layer header column with clipping
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); 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) // Render time ruler (clip to ruler rect)
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));