Mobile P7: landscape / orientation support

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).
This commit is contained in:
Skyler Lehmkuhl 2026-07-03 01:07:44 -04:00
parent 2cbc35f181
commit 6f67ddb709
8 changed files with 418 additions and 97 deletions

View File

@ -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,

View File

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

View File

@ -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<std::path::PathBuf> = 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();

View File

@ -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.

View File

@ -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<usize>)> = 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);

View File

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

View File

@ -364,6 +364,10 @@ pub struct SharedPaneState<'a> {
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,
/// 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,

View File

@ -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<f64>,
@ -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));