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
|
// 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
|
// 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.
|
// 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
|
[402.0, 874.0] // iPhone-ish portrait
|
||||||
} else {
|
} else {
|
||||||
[1920.0, 1080.0]
|
[1920.0, 1080.0]
|
||||||
|
|
@ -7294,6 +7296,7 @@ impl EditorApp {
|
||||||
rc: RenderContext {
|
rc: RenderContext {
|
||||||
shared: panes::SharedPaneState {
|
shared: panes::SharedPaneState {
|
||||||
is_mobile,
|
is_mobile,
|
||||||
|
is_portrait: true, // set by the mobile shell from the available rect
|
||||||
keyboard_octave: &mut self.keyboard_octave,
|
keyboard_octave: &mut self.keyboard_octave,
|
||||||
keyboard_pan_x: &mut self.keyboard_pan_x,
|
keyboard_pan_x: &mut self.keyboard_pan_x,
|
||||||
instrument_show_roll: false,
|
instrument_show_roll: false,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ pub fn selection_sig(shared: &SharedPaneState) -> u64 {
|
||||||
pub fn target_slot(shared: &SharedPaneState) -> usize {
|
pub fn target_slot(shared: &SharedPaneState) -> usize {
|
||||||
match &*shared.focus {
|
match &*shared.focus {
|
||||||
FocusSelection::Notes { .. } => 4, // PianoRoll
|
FocusSelection::Notes { .. } => 4, // PianoRoll
|
||||||
FocusSelection::Nodes(_) => 6, // Node/Instrument
|
FocusSelection::Nodes(_) => 5, // Node/Instrument
|
||||||
FocusSelection::Assets(_) => 1, // Asset Library
|
FocusSelection::Assets(_) => 1, // Asset Library
|
||||||
FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline
|
FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline
|
||||||
FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage
|
FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage
|
||||||
|
|
@ -105,44 +105,61 @@ struct Chip {
|
||||||
|
|
||||||
const CHIPS: [Chip; 2] = [
|
const CHIPS: [Chip; 2] = [
|
||||||
Chip { label: "Timeline", window: (3, 1) }, // Timeline = STACK index 3
|
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(
|
pub fn render(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
region_h: f32,
|
region: egui::Rect,
|
||||||
rc: &mut RenderContext,
|
rc: &mut RenderContext,
|
||||||
state: &mut MobileState,
|
state: &mut MobileState,
|
||||||
pal: &Palette,
|
pal: &Palette,
|
||||||
|
landscape: bool,
|
||||||
) {
|
) {
|
||||||
// Sheet background with rounded top + a border stroke that follows the rounded corners (a plain
|
// Panel background + border, with rounded corners on the edge that faces the content (top in
|
||||||
// hline would overrun the rounded corners and detach from them).
|
// portrait, left in landscape).
|
||||||
let radius = egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 };
|
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_filled(rect, radius, pal.surface_alt);
|
||||||
ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
|
ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
|
||||||
|
|
||||||
// Grab handle — drag to resize the sheet.
|
// Grab handle — top strip (portrait, drags height) or left strip (landscape, drags width).
|
||||||
let grab = egui::Rect::from_min_max(
|
// `content_area` is the panel minus the grab strip.
|
||||||
rect.left_top(),
|
let content_area = if landscape {
|
||||||
egui::pos2(rect.right(), rect.top() + GRAB_H),
|
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 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));
|
||||||
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 });
|
||||||
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line });
|
if gresp.dragged() && region.width() > 1.0 {
|
||||||
if gresp.dragged() && region_h > 1.0 {
|
state.inspector_width_frac = (state.inspector_width_frac - gresp.drag_delta().x / region.width()).clamp(0.2, 0.7);
|
||||||
state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).clamp(0.2, 0.85);
|
}
|
||||||
}
|
if gresp.hovered() || gresp.dragged() {
|
||||||
if gresp.hovered() || gresp.dragged() {
|
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
}
|
||||||
}
|
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):
|
// 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.
|
// 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(
|
let head = egui::Rect::from_min_max(
|
||||||
egui::pos2(rect.left(), head_y),
|
content_area.min,
|
||||||
egui::pos2(rect.right(), head_y + HEAD_H),
|
egui::pos2(content_area.right(), content_area.top() + HEAD_H),
|
||||||
);
|
);
|
||||||
let title_text = title(&rc.shared);
|
let title_text = title(&rc.shared);
|
||||||
let mut jump: Option<(usize, usize)> = None;
|
let mut jump: Option<(usize, usize)> = None;
|
||||||
|
|
@ -184,8 +201,8 @@ pub fn render(
|
||||||
|
|
||||||
// Properties content — reuse the Infopanel full-bleed.
|
// Properties content — reuse the Infopanel full-bleed.
|
||||||
let content = egui::Rect::from_min_max(
|
let content = egui::Rect::from_min_max(
|
||||||
egui::pos2(rect.left(), head.bottom()),
|
egui::pos2(content_area.left(), head.bottom()),
|
||||||
rect.max,
|
content_area.max,
|
||||||
);
|
);
|
||||||
if content.height() > 1.0 {
|
if content.height() > 1.0 {
|
||||||
surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc);
|
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,
|
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_top = rect.top() + 62.0;
|
||||||
let content_h = (rect.bottom() - margin) - content_top;
|
let content_bottom = rect.bottom() - margin;
|
||||||
let gap = 10.0;
|
let gap = 10.0;
|
||||||
let grid_h = content_h * 0.66;
|
|
||||||
|
|
||||||
// 2×3 grid of intent cards filling the grid budget.
|
// Portrait: 3 rows × 2 cols of intent cards up top, recent list below.
|
||||||
let col_w = (right - left - gap) / 2.0;
|
// Landscape: 2 rows × 3 cols on the left, recent list to the right.
|
||||||
let card_h = (grid_h - 2.0 * gap) / 3.0;
|
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() {
|
for (i, intent) in intents(&pal).iter().enumerate() {
|
||||||
let col = (i % 2) as f32;
|
let col = (i % cols) as f32;
|
||||||
let row = (i / 2) as f32;
|
let row = (i / cols) as f32;
|
||||||
let cx = left + col * (col_w + gap);
|
let cx = grid_rect.left() + col * (col_w + gap);
|
||||||
let cy = content_top + row * (card_h + 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 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());
|
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.
|
// Recent projects list (below the grid in portrait, beside it in landscape).
|
||||||
let recent_top = content_top + grid_h + 14.0;
|
let rleft = recent_rect.left();
|
||||||
|
let rright = recent_rect.right();
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
egui::pos2(left, recent_top),
|
egui::pos2(rleft, recent_rect.top()),
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
"Recent",
|
"Recent",
|
||||||
egui::FontId::proportional(13.0),
|
egui::FontId::proportional(13.0),
|
||||||
pal.text_dim,
|
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();
|
let recents = app.config.get_recent_files();
|
||||||
if recents.is_empty() {
|
if recents.is_empty() {
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
egui::pos2(left, list_top + 8.0),
|
egui::pos2(rleft, list_top + 8.0),
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
"No recent projects",
|
"No recent projects",
|
||||||
egui::FontId::proportional(12.0),
|
egui::FontId::proportional(12.0),
|
||||||
|
|
@ -124,13 +140,13 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
|
||||||
} else {
|
} else {
|
||||||
let row_h = 38.0;
|
let row_h = 38.0;
|
||||||
let row_gap = 6.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 max_rows = ((avail + row_gap) / (row_h + row_gap)).floor().max(0.0) as usize;
|
||||||
let mut chosen: Option<std::path::PathBuf> = None;
|
let mut chosen: Option<std::path::PathBuf> = None;
|
||||||
for (j, path) in recents.iter().take(max_rows).enumerate() {
|
for (j, path) in recents.iter().take(max_rows).enumerate() {
|
||||||
let ry = list_top + j as f32 * (row_h + row_gap);
|
let ry = list_top + j as f32 * (row_h + row_gap);
|
||||||
let row_rect =
|
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 =
|
let resp =
|
||||||
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
|
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
|
||||||
let p = ui.painter();
|
let p = ui.painter();
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ pub fn is_mobile_env() -> bool {
|
||||||
.unwrap_or(false)
|
.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.
|
/// The panes that make up the vertical stack, in fixed top→bottom order.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StackPane {
|
pub enum StackPane {
|
||||||
|
|
@ -187,8 +193,10 @@ pub struct MobileState {
|
||||||
pub weights: [f32; 3],
|
pub weights: [f32; 3],
|
||||||
/// Node/Instrument band: false = node editor, true = instrument/preset browser.
|
/// Node/Instrument band: false = node editor, true = instrument/preset browser.
|
||||||
pub show_instruments: bool,
|
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,
|
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),
|
/// 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.
|
/// not on press, so press+drag interactions aren't interrupted by the sheet popping up.
|
||||||
pub inspector_visible: bool,
|
pub inspector_visible: bool,
|
||||||
|
|
@ -226,6 +234,7 @@ impl Default for MobileState {
|
||||||
weights: [1.0, 1.0, 1.0],
|
weights: [1.0, 1.0, 1.0],
|
||||||
show_instruments: false,
|
show_instruments: false,
|
||||||
inspector_frac: 0.45,
|
inspector_frac: 0.45,
|
||||||
|
inspector_width_frac: 0.4,
|
||||||
inspector_visible: false,
|
inspector_visible: false,
|
||||||
inspector_anchor_y: 0.0,
|
inspector_anchor_y: 0.0,
|
||||||
inspector_dismissed: false,
|
inspector_dismissed: false,
|
||||||
|
|
@ -251,12 +260,24 @@ pub fn render_mobile_shell(
|
||||||
) {
|
) {
|
||||||
let pal = Palette::from_theme(rc.shared.theme, ui.ctx());
|
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).
|
// Background (device color; bands paint over most of it).
|
||||||
ui.painter().rect_filled(available_rect, 0.0, pal.bg);
|
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(
|
let topbar_rect = egui::Rect::from_min_max(
|
||||||
available_rect.min,
|
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(
|
let transport_rect = egui::Rect::from_min_max(
|
||||||
egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H),
|
egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H),
|
||||||
|
|
@ -294,38 +315,42 @@ pub fn render_mobile_shell(
|
||||||
state.inspector_visible = true;
|
state.inspector_visible = true;
|
||||||
}
|
}
|
||||||
let mut inspector_shown = state.inspector_visible;
|
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
|
// Inspector geometry: a bottom sheet in portrait, a right-side vertical column in landscape.
|
||||||
// hide it — NOT clear the selection — so actions dispatched from overlays (context menu, "+New"
|
let inspector_rect = if landscape {
|
||||||
// grid) still see the selection. It stays dismissed until the selection changes (sig above).
|
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 {
|
if inspector_shown {
|
||||||
let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
|
|
||||||
let dismiss = ui.input(|i| {
|
let dismiss = ui.input(|i| {
|
||||||
i.pointer.primary_pressed()
|
i.pointer.primary_pressed()
|
||||||
&& i.pointer.press_origin().map_or(false, |p| {
|
&& 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 {
|
if dismiss {
|
||||||
state.inspector_visible = false;
|
state.inspector_visible = false;
|
||||||
state.inspector_dismissed = true;
|
state.inspector_dismissed = true;
|
||||||
inspector_shown = false;
|
inspector_shown = false;
|
||||||
sheet_top = region.bottom();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflow (shrink the stack above the sheet) only when the thing that was tapped would actually be
|
// Reflow: shrink the stack to make room for the inspector. Landscape always carves horizontally
|
||||||
// hidden by the sheet — i.e. the tap landed below where the sheet now sits. Otherwise just
|
// (side-by-side); portrait only carves when the tapped thing sits below the sheet (else overlay).
|
||||||
// overlay. Restoring on dismiss is automatic — reflow only changes the render rect.
|
// 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 !inspector_shown {
|
||||||
let stack_rect = if covered {
|
region
|
||||||
egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top))
|
} 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 {
|
} else {
|
||||||
region
|
region
|
||||||
};
|
};
|
||||||
|
|
@ -347,8 +372,7 @@ pub fn render_mobile_shell(
|
||||||
stack::render(ui, stack_rect, rc, state, &pal);
|
stack::render(ui, stack_rect, rc, state, &pal);
|
||||||
|
|
||||||
if inspector_shown {
|
if inspector_shown {
|
||||||
let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
|
inspector::render(ui, inspector_rect, region, rc, state, &pal, landscape);
|
||||||
inspector::render(ui, sheet_rect, region.height(), rc, state, &pal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transport floor: drawn last = always on top, the persistent spine.
|
// 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.
|
// 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,
|
// Long-press context menu (populated by whichever pane was long-pressed). Persistent popup,
|
||||||
// dispatched via pending_menu_actions.
|
// 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
|
/// 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.
|
/// target — there's no thin divider bar.
|
||||||
const HEADER_H: f32 = 52.0;
|
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.
|
/// Corner radius (px) for the rounded top of each header.
|
||||||
const HEADER_RADIUS: u8 = 9;
|
const HEADER_RADIUS: u8 = 9;
|
||||||
const FOOTER_H: f32 = 28.0;
|
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)> {
|
fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> {
|
||||||
(count == 3).then(|| (top, 2)) // drop bottom
|
(count == 3).then(|| (top, 2)) // drop bottom
|
||||||
}
|
}
|
||||||
fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> {
|
fn op_r5(top: usize, count: usize, max: usize) -> Option<(usize, usize)> {
|
||||||
if count < 3 && top + count < N {
|
if count < max && top + count < N {
|
||||||
Some((top, count + 1)) // grow bottom
|
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
|
Some((top + 1, count)) // slide down
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn op_r6(top: usize, count: usize) -> Option<(usize, usize)> {
|
fn op_r6(top: usize, count: usize, max: usize) -> Option<(usize, usize)> {
|
||||||
if count < 3 && top > 0 {
|
if count < max && top > 0 {
|
||||||
Some((top - 1, count + 1)) // grow top
|
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
|
Some((top - 1, count)) // slide up
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -84,12 +90,13 @@ fn resolve(
|
||||||
top: usize,
|
top: usize,
|
||||||
count: usize,
|
count: usize,
|
||||||
trigger: f32,
|
trigger: f32,
|
||||||
|
max: usize,
|
||||||
) -> Option<(usize, usize, f32)> {
|
) -> Option<(usize, usize, f32)> {
|
||||||
let going_up = offset < 0.0;
|
let going_up = offset < 0.0;
|
||||||
let t = (offset.abs() / trigger.max(1.0)).clamp(0.0, 1.0);
|
let t = (offset.abs() / trigger.max(1.0)).clamp(0.0, 1.0);
|
||||||
let target = match handle {
|
let target = match handle {
|
||||||
Handle::TopEdge => (!going_up).then(|| op_r6(top, count)).flatten(),
|
Handle::TopEdge => (!going_up).then(|| op_r6(top, count, max)).flatten(),
|
||||||
Handle::BottomEdge => going_up.then(|| op_r5(top, count)).flatten(),
|
Handle::BottomEdge => going_up.then(|| op_r5(top, count, max)).flatten(),
|
||||||
Handle::Divider(k) => {
|
Handle::Divider(k) => {
|
||||||
if count == 2 {
|
if count == 2 {
|
||||||
if going_up { op_r1(top, count) } else { op_r2(top, count) }
|
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) {
|
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
|
||||||
let top = state.window_top;
|
let top = state.window_top;
|
||||||
let count = state.window_count;
|
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.
|
// Reserve a footer bar at the very bottom for the BottomEdge handle.
|
||||||
let footer_rect = egui::Rect::from_min_max(
|
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))
|
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)| {
|
.map(|(tt, tc, t)| {
|
||||||
let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc));
|
let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc));
|
||||||
interp_layout(&rest_bands, &target, top, tt, t, content_area)
|
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.
|
// 1) Pane content, carving the header off the top of each band.
|
||||||
for (slot, brect) in &draw_layout {
|
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(
|
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,
|
brect.max,
|
||||||
);
|
);
|
||||||
if content_rect.height() > 1.0 {
|
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
|
// 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single
|
||||||
// pane fills the stack.
|
// pane fills the stack.
|
||||||
let fullscreen = count == 1;
|
let fullscreen = draw_layout.len() == 1;
|
||||||
for (slot, brect) in &draw_layout {
|
for (slot, brect) in &draw_layout {
|
||||||
if brect.height() < 6.0 {
|
if brect.height() < 6.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let hr = egui::Rect::from_min_max(
|
let hr = egui::Rect::from_min_max(
|
||||||
brect.left_top(),
|
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_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal);
|
||||||
}
|
}
|
||||||
draw_footer(ui, footer_rect, top + count >= N, pal);
|
draw_footer(ui, footer_rect, top + count >= N, pal);
|
||||||
|
|
||||||
// 3) Interactions on the resting header/footer rects (added last → they win the press).
|
// 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) {
|
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.
|
/// 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 {
|
if state.window_count == 1 && state.window_top == slot {
|
||||||
// Restore: split with the pane below if possible, else above.
|
// 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);
|
set_window(state, t, c, now);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -537,13 +607,15 @@ fn handle_interactions(
|
||||||
trigger: f32,
|
trigger: f32,
|
||||||
now: f64,
|
now: f64,
|
||||||
state: &mut MobileState,
|
state: &mut MobileState,
|
||||||
|
max: usize,
|
||||||
|
header_h: f32,
|
||||||
) {
|
) {
|
||||||
// (handle, header_rect, slot) — slot is Some for band headers, None for the footer.
|
// (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();
|
let mut handles: Vec<(Handle, egui::Rect, Option<usize>)> = Vec::new();
|
||||||
for (i, (slot, brect)) in rest_bands.iter().enumerate() {
|
for (i, (slot, brect)) in rest_bands.iter().enumerate() {
|
||||||
let hr = egui::Rect::from_min_max(
|
let hr = egui::Rect::from_min_max(
|
||||||
brect.left_top(),
|
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) };
|
let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) };
|
||||||
handles.push((handle, hr, Some(*slot)));
|
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());
|
let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click());
|
||||||
if fsresp.clicked() {
|
if fsresp.clicked() {
|
||||||
toggle_fullscreen(state, slot, now);
|
toggle_fullscreen(state, slot, now, max);
|
||||||
}
|
}
|
||||||
if STACK[slot] == StackPane::NodeInstrument {
|
if STACK[slot] == StackPane::NodeInstrument {
|
||||||
let nt = egui::Rect::from_min_max(
|
let nt = egui::Rect::from_min_max(
|
||||||
|
|
@ -590,7 +662,7 @@ fn handle_interactions(
|
||||||
}
|
}
|
||||||
if resp.drag_stopped() {
|
if resp.drag_stopped() {
|
||||||
if let Some(d) = state.drag.take() {
|
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) {
|
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);
|
let h = content_area.height().max(1.0);
|
||||||
match d.handle {
|
match d.handle {
|
||||||
Handle::Divider(k) if k + 1 < state.window_count => {
|
Handle::Divider(k) if k + 1 < state.window_count => {
|
||||||
commit_divider(state, k, d.offset / h, now);
|
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
|
// 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).
|
// config to the new one, continuing from where the drag's interp left off (~progress t).
|
||||||
let top = state.window_top;
|
let top = state.window_top;
|
||||||
let count = state.window_count;
|
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 {
|
if t >= 0.5 {
|
||||||
let from_w = to_arr(&nweights(&state.weights, count));
|
let from_w = to_arr(&nweights(&state.weights, count));
|
||||||
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), t, now);
|
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;
|
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 (bmin, bmax) = boundary_bounds(count, k);
|
||||||
let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)));
|
let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)));
|
||||||
if b <= COLLAPSE_LO {
|
if b <= COLLAPSE_LO {
|
||||||
if let Some((tt, tc)) = op_r1(top, count) {
|
// Divider dragged to the top → the top pane is squeezed out; the bottom pane goes fullscreen.
|
||||||
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now);
|
begin_anim(state, top, count, from_w, top + 1, 1, even_arr(1), 0.0, now);
|
||||||
}
|
|
||||||
} else if b >= COLLAPSE_HI {
|
} else if b >= COLLAPSE_HI {
|
||||||
if let Some((tt, tc)) = op_r2(top, count) {
|
// Divider dragged to the bottom → the bottom pane is squeezed out; the top pane goes fullscreen.
|
||||||
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now);
|
begin_anim(state, top, count, from_w, top, 1, even_arr(1), 0.0, now);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let to_w = nearest_preset(&from_w, count);
|
let to_w = nearest_preset(&from_w, count);
|
||||||
begin_anim(state, top, count, from_w, top, count, to_w, 0.0, now);
|
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).
|
/// 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) {
|
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());
|
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>)>>>,
|
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).
|
/// True when rendering the phone/mobile shell (panes can render more compactly).
|
||||||
pub is_mobile: bool,
|
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
|
/// 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.
|
/// Piano Roll agree on which keys are visible and stay column-aligned.
|
||||||
pub keyboard_octave: &'a mut i8,
|
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
|
// 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.
|
// this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic.
|
||||||
keyboard: super::virtual_piano::VirtualPianoPane,
|
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
|
// 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).
|
// press started, and where. `None` = disarmed (no fresh press, or the press became a pan).
|
||||||
lp_press_time: Option<f64>,
|
lp_press_time: Option<f64>,
|
||||||
|
|
@ -218,6 +220,7 @@ impl PianoRollPane {
|
||||||
last_snap_selection: HashSet::new(),
|
last_snap_selection: HashSet::new(),
|
||||||
snap_user_changed: false,
|
snap_user_changed: false,
|
||||||
keyboard: super::virtual_piano::VirtualPianoPane::new(),
|
keyboard: super::virtual_piano::VirtualPianoPane::new(),
|
||||||
|
landscape_keys: false,
|
||||||
lp_press_time: None,
|
lp_press_time: None,
|
||||||
lp_press_pos: egui::Pos2::ZERO,
|
lp_press_pos: egui::Pos2::ZERO,
|
||||||
editing_index: None,
|
editing_index: None,
|
||||||
|
|
@ -385,6 +388,20 @@ impl PianoRollPane {
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
shared: &mut SharedPaneState,
|
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 keyboard_rect = Rect::from_min_size(rect.min, vec2(KEYBOARD_WIDTH, rect.height()));
|
||||||
let grid_rect = Rect::from_min_max(
|
let grid_rect = Rect::from_min_max(
|
||||||
pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y),
|
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
|
// Mobile portrait: keyboard-primary "Synthesia" instrument surface (keys + falling-notes
|
||||||
// used on mobile (device is portrait; the landscape/conventional flip is P7).
|
// roll). Landscape falls through to the conventional horizontal roll (pitch↕ / time▸).
|
||||||
if shared.is_mobile {
|
if shared.is_mobile && shared.is_portrait {
|
||||||
self.render_vertical_mode(ui, rect, shared, &clip_data);
|
self.render_vertical_mode(ui, rect, shared, &clip_data);
|
||||||
return;
|
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) {
|
fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) {
|
||||||
// Background
|
// Background
|
||||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));
|
painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue