From f2cb516e8ac1a887afea3cb9c98e45cc24fcceb9 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 30 Jun 2026 02:33:49 -0400 Subject: [PATCH] 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. --- .../lightningbeam-editor/src/main.rs | 6 +- .../lightningbeam-editor/src/mobile/icons.rs | 8 + .../lightningbeam-editor/src/mobile/intent.rs | 171 ++++++++++++++++++ .../lightningbeam-editor/src/mobile/mod.rs | 1 + 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a936514..90fc1a0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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 } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index db7a954..d13b6a8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -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}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs new file mode 100644 index 0000000..664a69c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -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 = 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; + } + } + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 970c367..2403af8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -18,6 +18,7 @@ use crate::panes::NodePath; use crate::RenderContext; pub mod icons; +pub mod intent; mod stack; mod surface; mod transport;