Add mobile new-file intent picker (P2)

When LB_MOBILE_UI is set, the desktop start screen is replaced by a phone
intent picker: a 2x3 grid of icon cards (Draw / Animate / Compose / Record /
Edit video / Blank) taking the top ~2/3, and a scrollable Recent-projects list
in the bottom ~1/3.

Each intent reuses EditorApp::create_new_project_with_focus and then sets the
initial stack window (e.g. Draw -> fullscreen Stage, Compose -> Timeline +
Piano Roll + Keys). Recent rows load the project and enter the editor. Cards are
icon-only (Lucide glyph + label) for legibility on a phone. Adds the intent
icons to the Lucide set.
This commit is contained in:
Skyler Lehmkuhl 2026-06-30 02:33:49 -04:00
parent b25e639a1c
commit f2cb516e8a
4 changed files with 185 additions and 1 deletions

View File

@ -6533,7 +6533,11 @@ impl eframe::App for EditorApp {
// Render start screen or editor based on app mode
if self.app_mode == AppMode::StartScreen {
self.render_start_screen(ctx);
if self.mobile_active() {
mobile::intent::render(self, ctx);
} else {
self.render_start_screen(ctx);
}
return; // Skip editor rendering
}

View File

@ -45,3 +45,11 @@ pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}";
pub const X: &str = "\u{e1b2}";
pub const MENU: &str = "\u{e115}";
// Intent-picker icons.
pub const BRUSH: &str = "\u{e1d3}";
pub const FILM: &str = "\u{e0d0}";
pub const MUSIC: &str = "\u{e122}";
pub const MIC: &str = "\u{e118}";
pub const CLAPPERBOARD: &str = "\u{e29b}";
pub const SQUARE_DASHED: &str = "\u{e1cb}";
pub const FOLDER_OPEN: &str = "\u{e247}";

View File

@ -0,0 +1,171 @@
//! Mobile new-file intent picker — the phone equivalent of the desktop start screen.
//!
//! Mirrors the desktop "new project" flow: each intent calls
//! [`EditorApp::create_new_project_with_focus`] (which builds the document, picks a default layer,
//! and switches to editor mode) and then sets the initial mobile stack window so the relevant panes
//! are in view.
use eframe::egui;
use super::icons;
use crate::EditorApp;
const C_DEVICE: egui::Color32 = egui::Color32::from_rgb(0x14, 0x16, 0x1b);
const C_CARD: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_CARD_HOT: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
/// One intent card.
struct Intent {
label: &'static str,
icon: &'static str,
accent: egui::Color32,
/// Argument to `create_new_project_with_focus` (0=Animation, 1=Video, 2=Music, 5=Painting).
focus: usize,
/// Initial mobile stack window: (window_top, window_count).
window: (usize, usize),
}
fn intents() -> [Intent; 6] {
let coral = egui::Color32::from_rgb(0xe8, 0x82, 0x6b);
let cyan = egui::Color32::from_rgb(0x54, 0xc3, 0xe8);
let amber = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
let pink = egui::Color32::from_rgb(0xc7, 0x5b, 0x8a);
let violet = egui::Color32::from_rgb(0x6a, 0x46, 0x90);
[
// Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5.
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) },
Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2) },
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) },
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) },
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) },
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: C_DIM, focus: 0, window: (2, 2) },
]
}
pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(C_DEVICE))
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
let margin = 16.0;
let left = rect.left() + margin;
let right = rect.right() - margin;
// Header.
ui.painter().text(
egui::pos2(left, rect.top() + 22.0),
egui::Align2::LEFT_TOP,
"Start something",
egui::FontId::proportional(22.0),
C_BRIGHT,
);
// Vertical budget: ~2/3 for the intent grid, ~1/3 for the recent list.
let content_top = rect.top() + 62.0;
let content_h = (rect.bottom() - margin) - content_top;
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;
for (i, intent) in intents().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 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 p = ui.painter();
p.rect_filled(card, 12.0, if resp.hovered() { C_CARD_HOT } else { C_CARD });
p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
p.text(
egui::pos2(card.center().x, card.top() + card.height() * 0.40),
egui::Align2::CENTER_CENTER,
intent.icon,
icons::font(30.0),
intent.accent,
);
p.text(
egui::pos2(card.center().x, card.bottom() - 18.0),
egui::Align2::CENTER_CENTER,
intent.label,
egui::FontId::proportional(15.0),
C_BRIGHT,
);
if resp.clicked() {
app.create_new_project_with_focus(intent.focus);
app.mobile_state.window_top = intent.window.0;
app.mobile_state.window_count = intent.window.1;
app.mobile_state.weights = [1.0, 1.0, 1.0];
}
}
// Recent projects list in the bottom third.
let recent_top = content_top + grid_h + 14.0;
ui.painter().text(
egui::pos2(left, recent_top),
egui::Align2::LEFT_TOP,
"Recent",
egui::FontId::proportional(13.0),
C_DIM,
);
let list_top = recent_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::Align2::LEFT_TOP,
"No recent projects",
egui::FontId::proportional(12.0),
C_DIM,
);
} else {
let row_h = 38.0;
let row_gap = 6.0;
let avail = rect.bottom() - margin - 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));
let resp =
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
let p = ui.painter();
p.rect_filled(row_rect, 8.0, if resp.hovered() { C_CARD_HOT } else { C_CARD });
p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
p.text(
egui::pos2(row_rect.left() + 12.0, row_rect.center().y),
egui::Align2::LEFT_CENTER,
icons::FOLDER_OPEN,
icons::font(15.0),
C_DIM,
);
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
p.text(
egui::pos2(row_rect.left() + 36.0, row_rect.center().y),
egui::Align2::LEFT_CENTER,
&name,
egui::FontId::proportional(13.0),
C_BRIGHT,
);
if resp.clicked() {
chosen = Some(path.clone());
}
}
if let Some(path) = chosen {
app.load_from_file(path);
app.app_mode = crate::AppMode::Editor;
}
}
});
}

View File

@ -18,6 +18,7 @@ use crate::panes::NodePath;
use crate::RenderContext;
pub mod icons;
pub mod intent;
mod stack;
mod surface;
mod transport;