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:
parent
2cbc35f181
commit
6f67ddb709
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue