From 6f67ddb70974e45db6a966be78665fac4b9dcbe2 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 3 Jul 2026 01:07:44 -0400 Subject: [PATCH] Mobile P7: landscape / orientation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the phone shell orientation-aware (portrait vs landscape). Orientation is aspect-based (rotation = a resize the shell re-lays-out each frame); LB_MOBILE_UI=2 opens a landscape dev window. `SharedPaneState.is_portrait` is published to panes. - Stack caps at 2 panes in landscape (max_panes through the R1–R6 drag ops), with a per-frame clamp so rotating while 3 are open drops to 2. - Edge drags are a continuous full-height reveal instead of stalling at the capped trigger: 1-pane → split → next-pane-fullscreen, and (landscape 2-pane) a two-phase slide-then-collapse to 1 pane. Both top and bottom edges, mirrored. Release snaps to stay / split / fullscreen. Dividers resize and collapse-to-1 at the extremes. - Inspector becomes a right-side vertical column in landscape (inspector_width_frac, left-edge horizontal resize); portrait keeps the bottom sheet. - Piano roll: vertical "Synthesia" view in portrait; landscape shows a Keys/Notes segmented toggle over the conventional horizontal roll / full keyboard. - Start screen reflows to 2 rows × 3 with the recent list beside it in landscape. - Landscape headers are shorter (52→34px) and the top bar folds into the middle of the top pane header (no separate band), reclaiming vertical space. All gated to mobile; desktop unchanged (is_portrait defaults true). --- .../lightningbeam-editor/src/main.rs | 5 +- .../src/mobile/inspector.rs | 67 +++--- .../lightningbeam-editor/src/mobile/intent.rs | 50 +++-- .../lightningbeam-editor/src/mobile/mod.rs | 80 +++++--- .../lightningbeam-editor/src/mobile/stack.rs | 191 +++++++++++++++--- .../lightningbeam-editor/src/mobile/topbar.rs | 59 ++++++ .../lightningbeam-editor/src/panes/mod.rs | 4 + .../src/panes/piano_roll.rs | 59 +++++- 8 files changed, 418 insertions(+), 97 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 3a6f03b..10d5a6a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -201,7 +201,9 @@ 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() { + let initial_size = if mobile::is_mobile_landscape_env() { + [874.0, 402.0] // iPhone-ish landscape (LB_MOBILE_UI=2) + } else if mobile::is_mobile_env() { [402.0, 874.0] // iPhone-ish portrait } else { [1920.0, 1080.0] @@ -7294,6 +7296,7 @@ impl EditorApp { rc: RenderContext { shared: panes::SharedPaneState { is_mobile, + is_portrait: true, // set by the mobile shell from the available rect keyboard_octave: &mut self.keyboard_octave, keyboard_pan_x: &mut self.keyboard_pan_x, instrument_show_roll: false, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs index 9edbfb1..5a3e5ca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -44,7 +44,7 @@ pub fn selection_sig(shared: &SharedPaneState) -> u64 { pub fn target_slot(shared: &SharedPaneState) -> usize { match &*shared.focus { FocusSelection::Notes { .. } => 4, // PianoRoll - FocusSelection::Nodes(_) => 6, // Node/Instrument + FocusSelection::Nodes(_) => 5, // Node/Instrument FocusSelection::Assets(_) => 1, // Asset Library FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage @@ -105,44 +105,61 @@ struct Chip { const CHIPS: [Chip; 2] = [ Chip { label: "Timeline", window: (3, 1) }, // Timeline = STACK index 3 - Chip { label: "Nodes", window: (6, 1) }, // Node/Instrument = STACK index 6 + Chip { label: "Nodes", window: (5, 1) }, // Node/Instrument = STACK index 5 ]; pub fn render( ui: &mut egui::Ui, rect: egui::Rect, - region_h: f32, + region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette, + landscape: bool, ) { - // Sheet background with rounded top + a border stroke that follows the rounded corners (a plain - // hline would overrun the rounded corners and detach from them). - let radius = egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 }; + // Panel background + border, with rounded corners on the edge that faces the content (top in + // portrait, left in landscape). + let radius = if landscape { + egui::CornerRadius { nw: 14, ne: 0, sw: 14, se: 0 } + } else { + egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 } + }; ui.painter().rect_filled(rect, radius, pal.surface_alt); ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); - // Grab handle — drag to resize the sheet. - let grab = egui::Rect::from_min_max( - rect.left_top(), - egui::pos2(rect.right(), rect.top() + GRAB_H), - ); - let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); - let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0)); - ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); - if gresp.dragged() && region_h > 1.0 { - state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).clamp(0.2, 0.85); - } - if gresp.hovered() || gresp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - } + // Grab handle — top strip (portrait, drags height) or left strip (landscape, drags width). + // `content_area` is the panel minus the grab strip. + let content_area = if landscape { + let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.left() + GRAB_H, rect.bottom())); + let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); + let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(4.0, 34.0)); + ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); + if gresp.dragged() && region.width() > 1.0 { + state.inspector_width_frac = (state.inspector_width_frac - gresp.drag_delta().x / region.width()).clamp(0.2, 0.7); + } + if gresp.hovered() || gresp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + egui::Rect::from_min_max(egui::pos2(grab.right(), rect.top()), rect.max) + } else { + let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + GRAB_H)); + let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); + let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0)); + ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); + if gresp.dragged() && region.height() > 1.0 { + state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region.height()).clamp(0.2, 0.85); + } + if gresp.hovered() || gresp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + egui::Rect::from_min_max(egui::pos2(rect.left(), grab.bottom()), rect.max) + }; // Single header row (egui widgets — they inherit the mobile touch sizing + theme visuals): // title on the left, [Timeline] [Nodes] jump buttons + ✕ close on the right. - let head_y = rect.top() + GRAB_H; let head = egui::Rect::from_min_max( - egui::pos2(rect.left(), head_y), - egui::pos2(rect.right(), head_y + HEAD_H), + content_area.min, + egui::pos2(content_area.right(), content_area.top() + HEAD_H), ); let title_text = title(&rc.shared); let mut jump: Option<(usize, usize)> = None; @@ -184,8 +201,8 @@ pub fn render( // Properties content — reuse the Infopanel full-bleed. let content = egui::Rect::from_min_max( - egui::pos2(rect.left(), head.bottom()), - rect.max, + egui::pos2(content_area.left(), head.bottom()), + content_area.max, ); if content.height() > 1.0 { surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs index 590bdf4..b48e0ea 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -59,20 +59,35 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { pal.text, ); - // Vertical budget: ~2/3 for the intent grid, ~1/3 for the recent list. + let landscape = rect.width() > rect.height(); let content_top = rect.top() + 62.0; - let content_h = (rect.bottom() - margin) - content_top; + let content_bottom = rect.bottom() - margin; let gap = 10.0; - let grid_h = content_h * 0.66; - // 2×3 grid of intent cards filling the grid budget. - let col_w = (right - left - gap) / 2.0; - let card_h = (grid_h - 2.0 * gap) / 3.0; + // Portrait: 3 rows × 2 cols of intent cards up top, recent list below. + // Landscape: 2 rows × 3 cols on the left, recent list to the right. + let (cols, rows) = if landscape { (3usize, 2usize) } else { (2usize, 3usize) }; + let (grid_rect, recent_rect) = if landscape { + let split = left + (right - left) * 0.62; + ( + egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(split - gap, content_bottom)), + egui::Rect::from_min_max(egui::pos2(split + gap, content_top), egui::pos2(right, content_bottom)), + ) + } else { + let grid_h = (content_bottom - content_top) * 0.66; + ( + egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(right, content_top + grid_h)), + egui::Rect::from_min_max(egui::pos2(left, content_top + grid_h + 14.0), egui::pos2(right, content_bottom)), + ) + }; + + let col_w = (grid_rect.width() - (cols as f32 - 1.0) * gap) / cols as f32; + let card_h = (grid_rect.height() - (rows as f32 - 1.0) * gap) / rows as f32; for (i, intent) in intents(&pal).iter().enumerate() { - let col = (i % 2) as f32; - let row = (i / 2) as f32; - let cx = left + col * (col_w + gap); - let cy = content_top + row * (card_h + gap); + let col = (i % cols) as f32; + let row = (i / cols) as f32; + let cx = grid_rect.left() + col * (col_w + gap); + let cy = grid_rect.top() + row * (card_h + gap); let card = egui::Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(col_w, card_h)); let resp = ui.interact(card, ui.id().with(("mobile_intent", i)), egui::Sense::click()); @@ -102,20 +117,21 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { } } - // Recent projects list in the bottom third. - let recent_top = content_top + grid_h + 14.0; + // Recent projects list (below the grid in portrait, beside it in landscape). + let rleft = recent_rect.left(); + let rright = recent_rect.right(); ui.painter().text( - egui::pos2(left, recent_top), + egui::pos2(rleft, recent_rect.top()), egui::Align2::LEFT_TOP, "Recent", egui::FontId::proportional(13.0), pal.text_dim, ); - let list_top = recent_top + 22.0; + let list_top = recent_rect.top() + 22.0; let recents = app.config.get_recent_files(); if recents.is_empty() { ui.painter().text( - egui::pos2(left, list_top + 8.0), + egui::pos2(rleft, list_top + 8.0), egui::Align2::LEFT_TOP, "No recent projects", egui::FontId::proportional(12.0), @@ -124,13 +140,13 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { } else { let row_h = 38.0; let row_gap = 6.0; - let avail = rect.bottom() - margin - list_top; + let avail = recent_rect.bottom() - list_top; let max_rows = ((avail + row_gap) / (row_h + row_gap)).floor().max(0.0) as usize; let mut chosen: Option = None; for (j, path) in recents.iter().take(max_rows).enumerate() { let ry = list_top + j as f32 * (row_h + row_gap); let row_rect = - egui::Rect::from_min_max(egui::pos2(left, ry), egui::pos2(right, ry + row_h)); + egui::Rect::from_min_max(egui::pos2(rleft, ry), egui::pos2(rright, ry + row_h)); let resp = ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click()); let p = ui.painter(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 56cc7e7..e7be8a7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -82,6 +82,12 @@ pub fn is_mobile_env() -> bool { .unwrap_or(false) } +/// `LB_MOBILE_UI=2` develops the mobile shell in landscape (opens a landscape phone window). +/// Orientation itself is aspect-based at render time; this only picks the initial window aspect. +pub fn is_mobile_landscape_env() -> bool { + std::env::var("LB_MOBILE_UI").map(|v| v == "2").unwrap_or(false) +} + /// The panes that make up the vertical stack, in fixed top→bottom order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StackPane { @@ -187,8 +193,10 @@ pub struct MobileState { pub weights: [f32; 3], /// Node/Instrument band: false = node editor, true = instrument/preset browser. pub show_instruments: bool, - /// Inspector sheet height as a fraction of the region above the transport. + /// Inspector sheet height as a fraction of the region above the transport (portrait). pub inspector_frac: f32, + /// Inspector side-panel width as a fraction of the region (landscape). + pub inspector_width_frac: f32, /// Whether the inspector sheet is currently shown. Gated to appear on pointer *release* (a tap), /// not on press, so press+drag interactions aren't interrupted by the sheet popping up. pub inspector_visible: bool, @@ -226,6 +234,7 @@ impl Default for MobileState { weights: [1.0, 1.0, 1.0], show_instruments: false, inspector_frac: 0.45, + inspector_width_frac: 0.4, inspector_visible: false, inspector_anchor_y: 0.0, inspector_dismissed: false, @@ -251,12 +260,24 @@ pub fn render_mobile_shell( ) { let pal = Palette::from_theme(rc.shared.theme, ui.ctx()); + // Orientation (aspect-based; rotation = a resize). Published to panes before anything renders. + let landscape = available_rect.width() > available_rect.height(); + rc.shared.is_portrait = !landscape; + // Rotating while 3 panes are open drops to 2 (landscape caps the stack at 2). + if landscape && state.window_count > 2 { + state.window_count = 2; + state.window_top = state.window_top.min(STACK.len().saturating_sub(2)); + } + // Background (device color; bands paint over most of it). ui.painter().rect_filled(available_rect, 0.0, pal.bg); + // In landscape the top bar is folded into the top pane header (no separate band), reclaiming its + // height for the stack. + let topbar_h = if landscape { 0.0 } else { TOPBAR_H }; let topbar_rect = egui::Rect::from_min_max( available_rect.min, - egui::pos2(available_rect.right(), available_rect.top() + TOPBAR_H), + egui::pos2(available_rect.right(), available_rect.top() + topbar_h), ); let transport_rect = egui::Rect::from_min_max( egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H), @@ -294,38 +315,42 @@ pub fn render_mobile_shell( state.inspector_visible = true; } let mut inspector_shown = state.inspector_visible; - let sheet_h = if inspector_shown { - (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0) - } else { - 0.0 - }; - let mut sheet_top = region.bottom() - sheet_h; - // Tapping outside the sheet (but not on the transport) dismisses (hides) the inspector. We only - // hide it — NOT clear the selection — so actions dispatched from overlays (context menu, "+New" - // grid) still see the selection. It stays dismissed until the selection changes (sig above). + // Inspector geometry: a bottom sheet in portrait, a right-side vertical column in landscape. + let inspector_rect = if landscape { + let w = (region.width() * state.inspector_width_frac).clamp(180.0, region.width() - 140.0); + egui::Rect::from_min_max(egui::pos2(region.right() - w, region.top()), region.max) + } else { + let h = (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0); + egui::Rect::from_min_max(egui::pos2(region.left(), region.bottom() - h), region.max) + }; + + // Tapping outside the inspector (but not on the transport) dismisses (hides) it. We only hide — + // NOT clear the selection — so actions dispatched from overlays (context menu, "+New" grid) still + // see the selection. It stays dismissed until the selection changes (sig above). if inspector_shown { - let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max); let dismiss = ui.input(|i| { i.pointer.primary_pressed() && i.pointer.press_origin().map_or(false, |p| { - !sheet_rect.contains(p) && !transport_rect.contains(p) + !inspector_rect.contains(p) && !transport_rect.contains(p) }) }); if dismiss { state.inspector_visible = false; state.inspector_dismissed = true; inspector_shown = false; - sheet_top = region.bottom(); } } - // Reflow (shrink the stack above the sheet) only when the thing that was tapped would actually be - // hidden by the sheet — i.e. the tap landed below where the sheet now sits. Otherwise just - // overlay. Restoring on dismiss is automatic — reflow only changes the render rect. - let covered = inspector_shown && state.inspector_anchor_y > sheet_top; - let stack_rect = if covered { - egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top)) + // Reflow: shrink the stack to make room for the inspector. Landscape always carves horizontally + // (side-by-side); portrait only carves when the tapped thing sits below the sheet (else overlay). + // Restoring on dismiss is automatic — reflow only changes the render rect. + let stack_rect = if !inspector_shown { + region + } else if landscape { + egui::Rect::from_min_max(region.min, egui::pos2(inspector_rect.left(), region.bottom())) + } else if state.inspector_anchor_y > inspector_rect.top() { + egui::Rect::from_min_max(region.min, egui::pos2(region.right(), inspector_rect.top())) } else { region }; @@ -347,8 +372,7 @@ pub fn render_mobile_shell( stack::render(ui, stack_rect, rc, state, &pal); 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, &pal); + inspector::render(ui, inspector_rect, region, rc, state, &pal, landscape); } // Transport floor: drawn last = always on top, the persistent spine. @@ -368,7 +392,17 @@ pub fn render_mobile_shell( } // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. - topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); + // Portrait: its own band at the top. Landscape: folded into the middle of the top pane header. + if landscape { + let hh = stack::header_height(false); + let top_header = egui::Rect::from_min_max( + egui::pos2(stack_rect.left(), region.top()), + egui::pos2(stack_rect.right(), region.top() + hh), + ); + topbar::render_inline(ui, top_header, available_rect, rc, state, &pal); + } else { + topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); + } // Long-press context menu (populated by whichever pane was long-pressed). Persistent popup, // dispatched via pending_menu_actions. diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs index ad47a8e..566ba52 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -26,6 +26,12 @@ const N: usize = STACK.len(); /// Height of each band's drag header (and the bottom-edge footer). The whole header is the grab /// target — there's no thin divider bar. const HEADER_H: f32 = 52.0; +/// Landscape headers are shorter — vertical space is scarce, and the top one hosts the app bar. +const HEADER_H_LANDSCAPE: f32 = 34.0; +/// Header height for the current orientation. +pub fn header_height(is_portrait: bool) -> f32 { + if is_portrait { HEADER_H } else { HEADER_H_LANDSCAPE } +} /// Corner radius (px) for the rounded top of each header. const HEADER_RADIUS: u8 = 9; const FOOTER_H: f32 = 28.0; @@ -57,19 +63,19 @@ fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> { (count == 3).then(|| (top, 2)) // drop bottom } -fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> { - if count < 3 && top + count < N { +fn op_r5(top: usize, count: usize, max: usize) -> Option<(usize, usize)> { + if count < max && top + count < N { Some((top, count + 1)) // grow bottom - } else if count == 3 && top + count < N { + } else if count == max && 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 { +fn op_r6(top: usize, count: usize, max: usize) -> Option<(usize, usize)> { + if count < max && top > 0 { Some((top - 1, count + 1)) // grow top - } else if count == 3 && top > 0 { + } else if count == max && top > 0 { Some((top - 1, count)) // slide up } else { None @@ -84,12 +90,13 @@ fn resolve( top: usize, count: usize, trigger: f32, + max: usize, ) -> Option<(usize, usize, f32)> { let going_up = offset < 0.0; let t = (offset.abs() / trigger.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::TopEdge => (!going_up).then(|| op_r6(top, count, max)).flatten(), + Handle::BottomEdge => going_up.then(|| op_r5(top, count, max)).flatten(), Handle::Divider(k) => { if count == 2 { if going_up { op_r1(top, count) } else { op_r2(top, count) } @@ -343,6 +350,9 @@ fn interp_layout( pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { let top = state.window_top; let count = state.window_count; + // Landscape caps the window at 2 panes; portrait allows 3. + let max_panes = if rc.shared.is_portrait { 3 } else { 2 }; + let header_h = header_height(rc.shared.is_portrait); // Reserve a footer bar at the very bottom for the BottomEdge handle. let footer_rect = egui::Rect::from_min_max( @@ -379,7 +389,67 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state }; config_rects(top, count, content_area, &nweights(&w, count)) } - _ => resolve(d.handle, d.offset, top, count, trigger) + Handle::BottomEdge if count == 1 && top + 1 < N => { + // Continuous reveal: dragging the bottom edge up grows the pane below out of the + // bottom, its divider tracking the finger across the FULL height (not just `trigger`), + // so one drag sweeps the current pane → split → revealed-pane-fullscreen. + let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + config_rects(top, 2, content_area, &nweights(&[1.0 - frac, frac, 0.0], 2)) + } + Handle::TopEdge if count == 1 && top > 0 => { + // Symmetric reveal of the pane above: dragging the top edge down grows it from the top. + let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + config_rects(top - 1, 2, content_area, &nweights(&[frac, 1.0 - frac, 0.0], 2)) + } + Handle::BottomEdge if count == 2 && max_panes == 2 => { + // 2-pane (landscape) → 1-pane reveal: dragging the bottom edge up first slides the + // window down to reveal the pane below, then collapses onto it — the whole sweep + // mapped over the FULL height so the dragged boundary reaches the top (rather than + // stalling at the even split after the slide). + let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + let even2 = nweights(&even_arr(2), 2); + let even1 = nweights(&even_arr(1), 1); + if top + 2 < N { + if frac <= 0.5 { + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top + 1, 2, content_area, &even2); + interp_layout(&from, &to, top, top + 1, frac / 0.5, content_area) + } else { + let from = config_rects(top + 1, 2, content_area, &even2); + let to = config_rects(top + 2, 1, content_area, &even1); + interp_layout(&from, &to, top + 1, top + 2, (frac - 0.5) / 0.5, content_area) + } + } else { + // No pane below → just collapse onto the bottom pane. + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top + 1, 1, content_area, &even1); + interp_layout(&from, &to, top, top + 1, frac, content_area) + } + } + Handle::TopEdge if count == 2 && max_panes == 2 => { + // Symmetric 2→1 reveal from the top: dragging the top edge down slides the window up + // to reveal the pane above, then collapses onto it. + let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + let even2 = nweights(&even_arr(2), 2); + let even1 = nweights(&even_arr(1), 1); + if top > 0 { + if frac <= 0.5 { + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top - 1, 2, content_area, &even2); + interp_layout(&from, &to, top, top - 1, frac / 0.5, content_area) + } else { + let from = config_rects(top - 1, 2, content_area, &even2); + let to = config_rects(top - 1, 1, content_area, &even1); + interp_layout(&from, &to, top - 1, top - 1, (frac - 0.5) / 0.5, content_area) + } + } else { + // No pane above → collapse onto the top pane (drop the bottom). + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top, 1, content_area, &even1); + interp_layout(&from, &to, top, top, frac, content_area) + } + } + _ => resolve(d.handle, d.offset, top, count, trigger, max_panes) .map(|(tt, tc, t)| { let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc)); interp_layout(&rest_bands, &target, top, tt, t, content_area) @@ -404,9 +474,9 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state // 1) Pane content, carving the header off the top of each band. for (slot, brect) in &draw_layout { - let header_h = HEADER_H.min(brect.height()); + let hh = header_h.min(brect.height()); let content_rect = egui::Rect::from_min_max( - egui::pos2(brect.left(), brect.top() + header_h), + egui::pos2(brect.left(), brect.top() + hh), brect.max, ); if content_rect.height() > 1.0 { @@ -423,21 +493,21 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state // 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single // pane fills the stack. - let fullscreen = count == 1; + let fullscreen = draw_layout.len() == 1; for (slot, brect) in &draw_layout { if brect.height() < 6.0 { continue; } let hr = egui::Rect::from_min_max( brect.left_top(), - egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())), + egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())), ); draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal); } draw_footer(ui, footer_rect, top + count >= N, pal); // 3) Interactions on the resting header/footer rects (added last → they win the press). - handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state); + handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state, max_panes, header_h); } fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) { @@ -518,10 +588,10 @@ fn handle_key(h: Handle) -> (usize, usize) { } /// Toggle a slot between filling the stack (count==1) and a 2-pane split with an adjacent pane. -fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64) { +fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64, max: usize) { if state.window_count == 1 && state.window_top == slot { // Restore: split with the pane below if possible, else above. - if let Some((t, c)) = op_r5(slot, 1).or_else(|| op_r6(slot, 1)) { + if let Some((t, c)) = op_r5(slot, 1, max).or_else(|| op_r6(slot, 1, max)) { set_window(state, t, c, now); } } else { @@ -537,13 +607,15 @@ fn handle_interactions( trigger: f32, now: f64, state: &mut MobileState, + max: usize, + header_h: f32, ) { // (handle, header_rect, slot) — slot is Some for band headers, None for the footer. let mut handles: Vec<(Handle, egui::Rect, Option)> = Vec::new(); for (i, (slot, brect)) in rest_bands.iter().enumerate() { let hr = egui::Rect::from_min_max( brect.left_top(), - egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())), + egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())), ); let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) }; handles.push((handle, hr, Some(*slot))); @@ -563,7 +635,7 @@ fn handle_interactions( ); let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click()); if fsresp.clicked() { - toggle_fullscreen(state, slot, now); + toggle_fullscreen(state, slot, now, max); } if STACK[slot] == StackPane::NodeInstrument { let nt = egui::Rect::from_min_max( @@ -590,7 +662,7 @@ fn handle_interactions( } if resp.drag_stopped() { if let Some(d) = state.drag.take() { - commit_drag(d, state, content_area, trigger, now); + commit_drag(d, state, content_area, trigger, now, max); } } if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) { @@ -599,18 +671,81 @@ fn handle_interactions( } } -fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64) { +fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64, max: usize) { let h = content_area.height().max(1.0); match d.handle { Handle::Divider(k) if k + 1 < state.window_count => { commit_divider(state, k, d.offset / h, now); } + Handle::BottomEdge if state.window_count == 1 && state.window_top + 1 < N => { + // Snap the continuous reveal: near the top → revealed pane fullscreen; barely moved → + // back to the current pane; in between → a 2-pane split at the nearest preset. + let top = state.window_top; + let frac = (-d.offset / h).clamp(0.0, 1.0); + let from_w = [1.0 - frac, frac, 0.0]; + if frac >= COLLAPSE_HI { + begin_anim(state, top, 2, from_w, top + 1, 1, even_arr(1), 0.0, now); + } else if frac <= COLLAPSE_LO { + begin_anim(state, top, 2, from_w, top, 1, even_arr(1), 0.0, now); + } else { + begin_anim(state, top, 2, from_w, top, 2, nearest_preset(&from_w, 2), 0.0, now); + } + } + Handle::TopEdge if state.window_count == 1 && state.window_top > 0 => { + let top = state.window_top; + let frac = (d.offset / h).clamp(0.0, 1.0); + let from_w = [frac, 1.0 - frac, 0.0]; + if frac >= COLLAPSE_HI { + begin_anim(state, top - 1, 2, from_w, top - 1, 1, even_arr(1), 0.0, now); + } else if frac <= COLLAPSE_LO { + begin_anim(state, top - 1, 2, from_w, top, 1, even_arr(1), 0.0, now); + } else { + begin_anim(state, top - 1, 2, from_w, top - 1, 2, nearest_preset(&from_w, 2), 0.0, now); + } + } + Handle::BottomEdge if state.window_count == 2 && max == 2 => { + // Snap the two-phase 2→1 reveal: near the top → revealed pane fullscreen; a middling drag + // → slid down to the next 2-pane split; barely moved → stay put. + let top = state.window_top; + let frac = (-d.offset / h).clamp(0.0, 1.0); + let even2 = even_arr(2); + if top + 2 < N { + if frac >= COLLAPSE_HI { + let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0); + begin_anim(state, top + 1, 2, even2, top + 2, 1, even_arr(1), t, now); + } else if frac > COLLAPSE_LO { + let t = (frac / 0.5).clamp(0.0, 1.0); + begin_anim(state, top, 2, even2, top + 1, 2, even2, t, now); + } + // else: barely moved → stay on the current [top, top+1] split. + } else if frac >= COLLAPSE_HI { + begin_anim(state, top, 2, even2, top + 1, 1, even_arr(1), frac, now); + } + } + Handle::TopEdge if state.window_count == 2 && max == 2 => { + // Symmetric snap for the top-edge 2→1 reveal. + let top = state.window_top; + let frac = (d.offset / h).clamp(0.0, 1.0); + let even2 = even_arr(2); + if top > 0 { + if frac >= COLLAPSE_HI { + let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0); + begin_anim(state, top - 1, 2, even2, top - 1, 1, even_arr(1), t, now); + } else if frac > COLLAPSE_LO { + let t = (frac / 0.5).clamp(0.0, 1.0); + begin_anim(state, top, 2, even2, top - 1, 2, even2, t, now); + } + // else: barely moved → stay on the current split. + } else if frac >= COLLAPSE_HI { + begin_anim(state, top, 2, even2, top, 1, even_arr(1), frac, now); + } + } _ => { // Edges (and degenerate dividers): membership transition. Animate from the current // config to the new one, continuing from where the drag's interp left off (~progress t). let top = state.window_top; let count = state.window_count; - if let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger) { + if let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger, max) { if t >= 0.5 { let from_w = to_arr(&nweights(&state.weights, count)); begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), t, now); @@ -667,17 +802,17 @@ fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) return; } - // 2-pane: group resize, snap to nearest 2-pane preset, slide off at the extremes. + // 2-pane: group resize snapping to the nearest 2-pane preset, but releasing near an edge + // collapses to a single fullscreen pane (the one that grew). It does NOT slide in the next pane — + // that's the job of the top/bottom edge handles. let (bmin, bmax) = boundary_bounds(count, k); let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax))); if b <= COLLAPSE_LO { - if let Some((tt, tc)) = op_r1(top, count) { - begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now); - } + // Divider dragged to the top → the top pane is squeezed out; the bottom pane goes fullscreen. + begin_anim(state, top, count, from_w, top + 1, 1, even_arr(1), 0.0, now); } else if b >= COLLAPSE_HI { - if let Some((tt, tc)) = op_r2(top, count) { - begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now); - } + // Divider dragged to the bottom → the bottom pane is squeezed out; the top pane goes fullscreen. + begin_anim(state, top, count, from_w, top, 1, even_arr(1), 0.0, now); } else { let to_w = nearest_preset(&from_w, count); begin_anim(state, top, count, from_w, top, count, to_w, 0.0, now); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs index 982382e..50f4858 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -100,6 +100,65 @@ pub fn render( } } +/// Landscape: the app bar folded into the middle of the top pane header — filename + ⌕ + ⋯ as a +/// centered cluster (the pane's own grip/label sits to the left, its buttons to the right). No bar +/// background or divider line; the header already drew them. +pub fn render_inline( + ui: &mut egui::Ui, + header: egui::Rect, + full: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, + pal: &Palette, +) { + let name = rc + .shared + .container_path + .as_ref() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Lightningbeam".to_string()); + let galley = ui + .painter() + .layout_no_wrap(name, egui::FontId::proportional(14.0), pal.text); + let name_w = galley.rect.width(); + let name_h = galley.rect.height(); + let btn = header.height().min(BTN); + let gap = 6.0; + let cluster_w = name_w + gap + 2.0 * btn; + let x0 = (header.center().x - cluster_w / 2.0).max(header.left() + 8.0); + let cy = header.center().y; + let bt = header.top() + (header.height() - btn) / 2.0; + + ui.painter().galley(egui::pos2(x0, cy - name_h / 2.0), galley, pal.text); + + let palette_rect = egui::Rect::from_min_size(egui::pos2(x0 + name_w + gap, bt), egui::vec2(btn, btn)); + let overflow_rect = egui::Rect::from_min_size(egui::pos2(palette_rect.right(), bt), egui::vec2(btn, btn)); + + let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click()); + ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0), + if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim }); + if sresp.clicked() { + state.palette_open = !state.palette_open; + state.overflow_open = false; + state.palette_query.clear(); + } + + let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click()); + ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0), + if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim }); + if oresp.clicked() { + state.overflow_open = !state.overflow_open; + state.palette_open = false; + } + + if state.overflow_open { + render_overflow(ui, full, rc, state, pal); + } else if state.palette_open { + render_palette(ui, full, rc, state, pal); + } +} + /// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect). fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (bool, egui::Rect) { let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click()); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index fdca5ce..d79eebe 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -364,6 +364,10 @@ pub struct SharedPaneState<'a> { pub brush_preview_pixels: &'a std::sync::Arc)>>>, /// True when rendering the phone/mobile shell (panes can render more compactly). pub is_mobile: bool, + /// Device orientation for the mobile shell: portrait (tall) vs landscape (wide). Defaults to + /// `true`; the mobile shell sets it from the available rect. Panes that reflow (e.g. the Piano + /// Roll's vertical vs conventional layout) key off this. + pub is_portrait: bool, /// Shared keyboard octave offset (C4-relative), so the mobile Virtual Piano and the portrait /// Piano Roll agree on which keys are visible and stay column-aligned. pub keyboard_octave: &'a mut i8, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 19ddd0b..08296f6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -173,6 +173,8 @@ pub struct PianoRollPane { // Mobile portrait "Synthesia" mode: the playable keyboard is drawn as a strip at the bottom of // this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic. keyboard: super::virtual_piano::VirtualPianoPane, + // Landscape mobile: the Keys/Notes toggle — true shows the full keyboard, false the roll. + landscape_keys: bool, // Manual long-press detection for the vertical roll (works with mouse-hold and touch): time the // press started, and where. `None` = disarmed (no fresh press, or the press became a pan). lp_press_time: Option, @@ -218,6 +220,7 @@ impl PianoRollPane { last_snap_selection: HashSet::new(), snap_user_changed: false, keyboard: super::virtual_piano::VirtualPianoPane::new(), + landscape_keys: false, lp_press_time: None, lp_press_pos: egui::Pos2::ZERO, editing_index: None, @@ -385,6 +388,20 @@ impl PianoRollPane { rect: Rect, shared: &mut SharedPaneState, ) { + // Landscape mobile: a Keys/Notes toggle bar switches this pane between the full keyboard and + // the conventional piano roll. (Portrait uses the keyboard-primary "Synthesia" view below.) + let mut rect = rect; + if shared.is_mobile && !shared.is_portrait { + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), rect.min.y + 30.0)); + rect = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + self.render_keys_notes_toggle(ui, bar, shared); + if self.landscape_keys { + let kb_path: NodePath = Vec::new(); + self.keyboard.render_content(ui, rect, &kb_path, shared); + return; + } + } + let keyboard_rect = Rect::from_min_size(rect.min, vec2(KEYBOARD_WIDTH, rect.height())); let grid_rect = Rect::from_min_max( pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y), @@ -461,9 +478,9 @@ impl PianoRollPane { } } - // Mobile: keyboard-primary "Synthesia" instrument surface (keys + falling-notes roll). Always - // used on mobile (device is portrait; the landscape/conventional flip is P7). - if shared.is_mobile { + // Mobile portrait: keyboard-primary "Synthesia" instrument surface (keys + falling-notes + // roll). Landscape falls through to the conventional horizontal roll (pitch↕ / time▸). + if shared.is_mobile && shared.is_portrait { self.render_vertical_mode(ui, rect, shared, &clip_data); return; } @@ -910,6 +927,42 @@ impl PianoRollPane { } } + /// Landscape Keys/Notes segmented toggle drawn in `bar` (updates `self.landscape_keys`). + fn render_keys_notes_toggle(&mut self, ui: &mut egui::Ui, bar: Rect, shared: &mut SharedPaneState) { + let painter = ui.painter_at(bar); + let bg = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28)); + painter.rect_filled(bar, 0.0, bg); + painter.line_segment( + [pos2(bar.left(), bar.bottom()), pos2(bar.right(), bar.bottom())], + Stroke::new(1.0, Color32::from_gray(60)), + ); + + let seg_w = 74.0; + let x0 = bar.center().x - seg_w; + let keys_rect = Rect::from_min_size(pos2(x0, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0)); + let notes_rect = Rect::from_min_size(pos2(x0 + seg_w, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0)); + if ui.interact(keys_rect, ui.id().with("pr_kn_keys"), egui::Sense::click()).clicked() { + self.landscape_keys = true; + } + if ui.interact(notes_rect, ui.id().with("pr_kn_notes"), egui::Sense::click()).clicked() { + self.landscape_keys = false; + } + let accent = Color32::from_rgb(0x4a, 0xa3, 0xff); + for (r, label, on) in [ + (keys_rect, "Keys", self.landscape_keys), + (notes_rect, "Notes", !self.landscape_keys), + ] { + painter.rect_filled(r, 6.0, if on { accent } else { Color32::from_gray(46) }); + painter.text( + r.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(13.0), + if on { Color32::WHITE } else { Color32::from_gray(200) }, + ); + } + } + fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) { // Background painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));