diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 38acae8..c719a09 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -211,6 +211,7 @@ fn main() -> eframe::Result { let mut viewport_builder = egui::ViewportBuilder::default() .with_inner_size(initial_size) + .with_min_inner_size([360.0, 300.0]) // keep layouts (esp. the mobile shell) above degenerate sizes .with_title("Lightningbeam Editor") .with_app_id("lightningbeam-editor"); // Set app_id for Wayland diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup b/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup deleted file mode 100644 index 3ffdea7..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup +++ /dev/null @@ -1,1568 +0,0 @@ -use eframe::egui; -use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; -use lightningbeam_core::pane::PaneType; -use lightningbeam_core::tool::Tool; -use std::collections::HashMap; -use clap::Parser; -use uuid::Uuid; - -mod panes; -use panes::{PaneInstance, PaneRenderer, SharedPaneState}; - -mod menu; -use menu::{MenuAction, MenuSystem}; - -mod theme; -use theme::{Theme, ThemeMode}; - -/// Lightningbeam Editor - Animation and video editing software -#[derive(Parser, Debug)] -#[command(name = "Lightningbeam Editor")] -#[command(author, version, about, long_about = None)] -struct Args { - /// Use light theme - #[arg(long, conflicts_with = "dark")] - light: bool, - - /// Use dark theme - #[arg(long, conflicts_with = "light")] - dark: bool, -} - -fn main() -> eframe::Result { - println!("πŸš€ Starting Lightningbeam Editor..."); - - // Parse command line arguments - let args = Args::parse(); - - // Determine theme mode from arguments - let theme_mode = if args.light { - ThemeMode::Light - } else if args.dark { - ThemeMode::Dark - } else { - ThemeMode::System - }; - - // Load theme - let mut theme = Theme::load_default().expect("Failed to load theme"); - theme.set_mode(theme_mode); - println!("βœ… Loaded theme with {} selectors (mode: {:?})", theme.len(), theme_mode); - - // Debug: print theme info - theme.debug_print(); - - // Load layouts from JSON - let layouts = load_layouts(); - println!("βœ… Loaded {} layouts", layouts.len()); - for layout in &layouts { - println!(" - {}: {}", layout.name, layout.description); - } - - // Initialize native menus for macOS (app-wide, doesn't need window) - #[cfg(target_os = "macos")] - { - if let Ok(menu_system) = MenuSystem::new() { - menu_system.init_for_macos(); - println!("βœ… Native macOS menus initialized"); - } - } - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1920.0, 1080.0]) - .with_title("Lightningbeam Editor"), - ..Default::default() - }; - - eframe::run_native( - "Lightningbeam Editor", - options, - Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))), - ) -} - -fn load_layouts() -> Vec { - let json = include_str!("../assets/layouts.json"); - serde_json::from_str(json).expect("Failed to parse layouts.json") -} - -/// Path to a node in the layout tree (indices of children) -type NodePath = Vec; - -#[derive(Default)] -struct DragState { - is_dragging: bool, - node_path: NodePath, - is_horizontal: bool, -} - -/// Action to perform on the layout tree -enum LayoutAction { - SplitHorizontal(NodePath, f32), // path, percent - SplitVertical(NodePath, f32), // path, percent - RemoveSplit(NodePath), - EnterSplitPreviewHorizontal, - EnterSplitPreviewVertical, -} - -#[derive(Default)] -enum SplitPreviewMode { - #[default] - None, - Active { - is_horizontal: bool, - hovered_pane: Option, - split_percent: f32, - }, -} - -/// Icon cache for pane type icons -struct IconCache { - icons: HashMap, - assets_path: std::path::PathBuf, -} - -impl IconCache { - fn new() -> Self { - let assets_path = std::path::PathBuf::from( - std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) - ).join("Dev/Lightningbeam-2/src/assets"); - - Self { - icons: HashMap::new(), - assets_path, - } - } - - fn get_or_load(&mut self, pane_type: PaneType) -> Option<&egui_extras::RetainedImage> { - if !self.icons.contains_key(&pane_type) { - // Load and cache the icon - let icon_path = self.assets_path.join(pane_type.icon_file()); - if let Ok(image) = egui_extras::RetainedImage::from_svg_bytes( - pane_type.icon_file(), - &std::fs::read(&icon_path).unwrap_or_default(), - ) { - self.icons.insert(pane_type, image); - } - } - self.icons.get(&pane_type) - } -} - -/// Icon cache for tool icons -struct ToolIconCache { - icons: HashMap, - assets_path: std::path::PathBuf, -} - -impl ToolIconCache { - fn new() -> Self { - let assets_path = std::path::PathBuf::from( - std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) - ).join("Dev/Lightningbeam-2/src/assets"); - - Self { - icons: HashMap::new(), - assets_path, - } - } - - fn get_or_load(&mut self, tool: Tool, ctx: &egui::Context) -> Option<&egui::TextureHandle> { - if !self.icons.contains_key(&tool) { - // Load SVG and rasterize at high resolution using resvg - let icon_path = self.assets_path.join(tool.icon_file()); - if let Ok(svg_data) = std::fs::read(&icon_path) { - // Rasterize at 3x size for crisp display (180px for 60px display) - let render_size = 180; - - if let Ok(tree) = resvg::usvg::Tree::from_data(&svg_data, &resvg::usvg::Options::default()) { - let pixmap_size = tree.size().to_int_size(); - let scale_x = render_size as f32 / pixmap_size.width() as f32; - let scale_y = render_size as f32 / pixmap_size.height() as f32; - let scale = scale_x.min(scale_y); - - let final_size = resvg::usvg::Size::from_wh( - pixmap_size.width() as f32 * scale, - pixmap_size.height() as f32 * scale, - ).unwrap_or(resvg::usvg::Size::from_wh(render_size as f32, render_size as f32).unwrap()); - - if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new( - final_size.width() as u32, - final_size.height() as u32, - ) { - let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); - resvg::render(&tree, transform, &mut pixmap.as_mut()); - - // Convert RGBA8 to egui ColorImage - let rgba_data = pixmap.data(); - let size = [pixmap.width() as usize, pixmap.height() as usize]; - let color_image = egui::ColorImage::from_rgba_unmultiplied(size, rgba_data); - - // Upload to GPU - let texture = ctx.load_texture( - tool.icon_file(), - color_image, - egui::TextureOptions::LINEAR, - ); - self.icons.insert(tool, texture); - } - } - } - } - self.icons.get(&tool) - } -} - -struct EditorApp { - layouts: Vec, - current_layout_index: usize, - current_layout: LayoutNode, // Mutable copy for editing - drag_state: DragState, - hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) - selected_pane: Option, // Currently selected pane for editing - split_preview_mode: SplitPreviewMode, - icon_cache: IconCache, - tool_icon_cache: ToolIconCache, - selected_tool: Tool, // Currently selected drawing tool - fill_color: egui::Color32, // Fill color for drawing - stroke_color: egui::Color32, // Stroke color for drawing - pane_instances: HashMap, // Pane instances per path - menu_system: Option, // Native menu system for event checking - pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane - theme: Theme, // Theme system for colors and dimensions - action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo - active_layer_id: Option, // Currently active layer for editing - selection: lightningbeam_core::selection::Selection, // Current selection state - tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state -} - -impl EditorApp { - fn new(cc: &eframe::CreationContext, layouts: Vec, theme: Theme) -> Self { - let current_layout = layouts[0].layout.clone(); - - // Initialize native menu system - let menu_system = MenuSystem::new().ok(); - - // Create default document with a simple test scene - let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) - .with_duration(10.0) - .with_framerate(60.0); - - // Add a test layer with a simple shape to visualize - use lightningbeam_core::layer::{AnyLayer, VectorLayer}; - use lightningbeam_core::object::Object; - use lightningbeam_core::shape::{Shape, ShapeColor}; - use vello::kurbo::{Circle, Shape as KurboShape}; - - let circle = Circle::new((200.0, 150.0), 50.0); - let path = circle.to_path(0.1); - let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); - let object = Object::new(shape.id); - - let mut vector_layer = VectorLayer::new("Layer 1"); - vector_layer.add_shape(shape); - vector_layer.add_object(object); - let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); - - // Wrap document in ActionExecutor - let action_executor = lightningbeam_core::action::ActionExecutor::new(document); - - Self { - layouts, - current_layout_index: 0, - current_layout, - drag_state: DragState::default(), - hovered_divider: None, - selected_pane: None, - split_preview_mode: SplitPreviewMode::default(), - icon_cache: IconCache::new(), - tool_icon_cache: ToolIconCache::new(), - selected_tool: Tool::Select, // Default tool - fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill - stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke - pane_instances: HashMap::new(), // Initialize empty, panes created on-demand - menu_system, - pending_view_action: None, - theme, - action_executor, - active_layer_id: Some(layer_id), - selection: lightningbeam_core::selection::Selection::new(), - tool_state: lightningbeam_core::tool::ToolState::default(), - } - } - - fn switch_layout(&mut self, index: usize) { - self.current_layout_index = index; - self.current_layout = self.layouts[index].layout.clone(); - } - - fn current_layout_def(&self) -> &LayoutDefinition { - &self.layouts[self.current_layout_index] - } - - fn apply_layout_action(&mut self, action: LayoutAction) { - match action { - LayoutAction::SplitHorizontal(path, percent) => { - split_node(&mut self.current_layout, &path, true, percent); - } - LayoutAction::SplitVertical(path, percent) => { - split_node(&mut self.current_layout, &path, false, percent); - } - LayoutAction::RemoveSplit(path) => { - remove_split(&mut self.current_layout, &path); - } - LayoutAction::EnterSplitPreviewHorizontal => { - self.split_preview_mode = SplitPreviewMode::Active { - is_horizontal: false, // horizontal divider = vertical grid (top/bottom) - hovered_pane: None, - split_percent: 50.0, - }; - } - LayoutAction::EnterSplitPreviewVertical => { - self.split_preview_mode = SplitPreviewMode::Active { - is_horizontal: true, // vertical divider = horizontal grid (left/right) - hovered_pane: None, - split_percent: 50.0, - }; - } - } - } - - fn handle_menu_action(&mut self, action: MenuAction) { - match action { - // File menu - MenuAction::NewFile => { - println!("Menu: New File"); - // TODO: Implement new file - } - MenuAction::NewWindow => { - println!("Menu: New Window"); - // TODO: Implement new window - } - MenuAction::Save => { - println!("Menu: Save"); - // TODO: Implement save - } - MenuAction::SaveAs => { - println!("Menu: Save As"); - // TODO: Implement save as - } - MenuAction::OpenFile => { - println!("Menu: Open File"); - // TODO: Implement open file - } - MenuAction::Revert => { - println!("Menu: Revert"); - // TODO: Implement revert - } - MenuAction::Import => { - println!("Menu: Import"); - // TODO: Implement import - } - MenuAction::Export => { - println!("Menu: Export"); - // TODO: Implement export - } - MenuAction::Quit => { - println!("Menu: Quit"); - std::process::exit(0); - } - - // Edit menu - MenuAction::Undo => { - if self.action_executor.undo() { - println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); - } else { - println!("Nothing to undo"); - } - } - MenuAction::Redo => { - if self.action_executor.redo() { - println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); - } else { - println!("Nothing to redo"); - } - } - MenuAction::Cut => { - println!("Menu: Cut"); - // TODO: Implement cut - } - MenuAction::Copy => { - println!("Menu: Copy"); - // TODO: Implement copy - } - MenuAction::Paste => { - println!("Menu: Paste"); - // TODO: Implement paste - } - MenuAction::Delete => { - println!("Menu: Delete"); - // TODO: Implement delete - } - MenuAction::SelectAll => { - println!("Menu: Select All"); - // TODO: Implement select all - } - MenuAction::SelectNone => { - println!("Menu: Select None"); - // TODO: Implement select none - } - MenuAction::Preferences => { - println!("Menu: Preferences"); - // TODO: Implement preferences dialog - } - - // Modify menu - MenuAction::Group => { - println!("Menu: Group"); - // TODO: Implement group - } - MenuAction::SendToBack => { - println!("Menu: Send to Back"); - // TODO: Implement send to back - } - MenuAction::BringToFront => { - println!("Menu: Bring to Front"); - // TODO: Implement bring to front - } - - // Layer menu - MenuAction::AddLayer => { - println!("Menu: Add Layer"); - // TODO: Implement add layer - } - MenuAction::AddVideoLayer => { - println!("Menu: Add Video Layer"); - // TODO: Implement add video layer - } - MenuAction::AddAudioTrack => { - println!("Menu: Add Audio Track"); - // TODO: Implement add audio track - } - MenuAction::AddMidiTrack => { - println!("Menu: Add MIDI Track"); - // TODO: Implement add MIDI track - } - MenuAction::DeleteLayer => { - println!("Menu: Delete Layer"); - // TODO: Implement delete layer - } - MenuAction::ToggleLayerVisibility => { - println!("Menu: Toggle Layer Visibility"); - // TODO: Implement toggle layer visibility - } - - // Timeline menu - MenuAction::NewKeyframe => { - println!("Menu: New Keyframe"); - // TODO: Implement new keyframe - } - MenuAction::NewBlankKeyframe => { - println!("Menu: New Blank Keyframe"); - // TODO: Implement new blank keyframe - } - MenuAction::DeleteFrame => { - println!("Menu: Delete Frame"); - // TODO: Implement delete frame - } - MenuAction::DuplicateKeyframe => { - println!("Menu: Duplicate Keyframe"); - // TODO: Implement duplicate keyframe - } - MenuAction::AddKeyframeAtPlayhead => { - println!("Menu: Add Keyframe at Playhead"); - // TODO: Implement add keyframe at playhead - } - MenuAction::AddMotionTween => { - println!("Menu: Add Motion Tween"); - // TODO: Implement add motion tween - } - MenuAction::AddShapeTween => { - println!("Menu: Add Shape Tween"); - // TODO: Implement add shape tween - } - MenuAction::ReturnToStart => { - println!("Menu: Return to Start"); - // TODO: Implement return to start - } - MenuAction::Play => { - println!("Menu: Play"); - // TODO: Implement play/pause - } - - // View menu - MenuAction::ZoomIn => { - self.pending_view_action = Some(MenuAction::ZoomIn); - } - MenuAction::ZoomOut => { - self.pending_view_action = Some(MenuAction::ZoomOut); - } - MenuAction::ActualSize => { - self.pending_view_action = Some(MenuAction::ActualSize); - } - MenuAction::RecenterView => { - self.pending_view_action = Some(MenuAction::RecenterView); - } - MenuAction::NextLayout => { - println!("Menu: Next Layout"); - let next_index = (self.current_layout_index + 1) % self.layouts.len(); - self.switch_layout(next_index); - } - MenuAction::PreviousLayout => { - println!("Menu: Previous Layout"); - let prev_index = if self.current_layout_index == 0 { - self.layouts.len() - 1 - } else { - self.current_layout_index - 1 - }; - self.switch_layout(prev_index); - } - MenuAction::SwitchLayout(index) => { - println!("Menu: Switch to Layout {}", index); - if index < self.layouts.len() { - self.switch_layout(index); - } - } - - // Help menu - MenuAction::About => { - println!("Menu: About"); - // TODO: Implement about dialog - } - - // Lightningbeam menu (macOS) - MenuAction::Settings => { - println!("Menu: Settings"); - // TODO: Implement settings - } - MenuAction::CloseWindow => { - println!("Menu: Close Window"); - // TODO: Implement close window - } - } - } -} - -impl eframe::App for EditorApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Disable egui's built-in Ctrl+Plus/Minus zoom behavior - // We handle zoom ourselves for the Stage pane - ctx.options_mut(|o| { - o.zoom_with_keyboard = false; - }); - - // Check for native menu events (macOS) - if let Some(menu_system) = &self.menu_system { - if let Some(action) = menu_system.check_events() { - self.handle_menu_action(action); - } - } - - // Check keyboard shortcuts (works on all platforms) - ctx.input(|i| { - if let Some(action) = MenuSystem::check_shortcuts(i) { - self.handle_menu_action(action); - } - }); - - // Top menu bar (egui-rendered on all platforms) - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { - if let Some(action) = MenuSystem::render_egui_menu_bar(ui) { - self.handle_menu_action(action); - } - }); - - // Main pane area - let mut layout_action: Option = None; - egui::CentralPanel::default().show(ctx, |ui| { - let available_rect = ui.available_rect_before_wrap(); - - // Reset hovered divider each frame - self.hovered_divider = None; - - // Track fallback pane priority for view actions (reset each frame) - let mut fallback_pane_priority: Option = None; - - // Registry for view action handlers (two-phase dispatch) - let mut pending_handlers: Vec = Vec::new(); - - render_layout_node( - ui, - &mut self.current_layout, - available_rect, - &mut self.drag_state, - &mut self.hovered_divider, - &mut self.selected_pane, - &mut layout_action, - &mut self.split_preview_mode, - &mut self.icon_cache, - &mut self.tool_icon_cache, - &mut self.selected_tool, - &mut self.fill_color, - &mut self.stroke_color, - &mut self.pane_instances, - &Vec::new(), // Root path - &mut self.pending_view_action, - &mut fallback_pane_priority, - &mut pending_handlers, - &self.theme, - self.action_executor.document(), - &mut self.selection, - &self.active_layer_id, - &mut self.tool_state, - ); - - // Execute action on the best handler (two-phase dispatch) - if let Some(action) = &self.pending_view_action { - if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { - // Look up the pane instance and execute the action - if let Some(pane_instance) = self.pane_instances.get_mut(&best_handler.pane_path) { - match pane_instance { - panes::PaneInstance::Stage(stage_pane) => { - stage_pane.execute_view_action(action, best_handler.zoom_center); - } - _ => {} // Other pane types don't handle view actions yet - } - } - } - // Clear the pending action after execution - self.pending_view_action = None; - } - - // Set cursor based on hover state - if let Some((_, is_horizontal)) = self.hovered_divider { - if is_horizontal { - ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } else { - ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - } - }); - - // Handle ESC key and click-outside to cancel split preview - if let SplitPreviewMode::Active { hovered_pane, .. } = &self.split_preview_mode { - let should_cancel = ctx.input(|i| { - // Cancel on ESC key - if i.key_pressed(egui::Key::Escape) { - return true; - } - // Cancel on click outside any pane - if i.pointer.primary_clicked() && hovered_pane.is_none() { - return true; - } - false - }); - - if should_cancel { - self.split_preview_mode = SplitPreviewMode::None; - } - } - - // Apply layout action after rendering to avoid borrow issues - if let Some(action) = layout_action { - self.apply_layout_action(action); - } - } - -} - -/// Recursively render a layout node with drag support -fn render_layout_node( - ui: &mut egui::Ui, - node: &mut LayoutNode, - rect: egui::Rect, - drag_state: &mut DragState, - hovered_divider: &mut Option<(NodePath, bool)>, - selected_pane: &mut Option, - layout_action: &mut Option, - split_preview_mode: &mut SplitPreviewMode, - icon_cache: &mut IconCache, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - fill_color: &mut egui::Color32, - stroke_color: &mut egui::Color32, - pane_instances: &mut HashMap, - path: &NodePath, - pending_view_action: &mut Option, - fallback_pane_priority: &mut Option, - pending_handlers: &mut Vec, - theme: &Theme, - document: &lightningbeam_core::document::Document, - selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, - tool_state: &mut lightningbeam_core::tool::ToolState, -) { - match node { - LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id); - } - LayoutNode::HorizontalGrid { percent, children } => { - // Handle dragging - if drag_state.is_dragging && drag_state.node_path == *path { - if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { - // Calculate new percentage based on pointer position - let new_percent = ((pointer_pos.x - rect.left()) / rect.width() * 100.0) - .clamp(10.0, 90.0); // Clamp to prevent too small panes - *percent = new_percent; - } - } - - // Split horizontally (left | right) - let split_x = rect.left() + (rect.width() * *percent / 100.0); - - let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(split_x, rect.max.y)); - - let right_rect = - egui::Rect::from_min_max(egui::pos2(split_x, rect.min.y), rect.max); - - // Render children - let mut left_path = path.clone(); - left_path.push(0); - render_layout_node( - ui, - &mut children[0], - left_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &left_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - let mut right_path = path.clone(); - right_path.push(1); - render_layout_node( - ui, - &mut children[1], - right_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &right_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - // Draw divider with interaction - let divider_width = 8.0; - let divider_rect = egui::Rect::from_min_max( - egui::pos2(split_x - divider_width / 2.0, rect.min.y), - egui::pos2(split_x + divider_width / 2.0, rect.max.y), - ); - - let divider_id = ui.id().with(("divider", path)); - let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); - - // Check if pointer is over divider - if response.hovered() { - *hovered_divider = Some((path.clone(), true)); - } - - // Handle drag start - if response.drag_started() { - drag_state.is_dragging = true; - drag_state.node_path = path.clone(); - drag_state.is_horizontal = true; - } - - // Handle drag end - if response.drag_stopped() { - drag_state.is_dragging = false; - } - - // Context menu on right-click - response.context_menu(|ui| { - ui.set_min_width(180.0); - - if ui.button("Split Horizontal ->").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); - ui.close_menu(); - } - - if ui.button("Split Vertical |").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); - ui.close_menu(); - } - - ui.separator(); - - if ui.button("< Join Left").clicked() { - let mut path_keep_right = path.clone(); - path_keep_right.push(1); // Remove left, keep right child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_right)); - ui.close_menu(); - } - - if ui.button("Join Right >").clicked() { - let mut path_keep_left = path.clone(); - path_keep_left.push(0); // Remove right, keep left child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_left)); - ui.close_menu(); - } - - }); - - // Visual feedback - let divider_color = if response.hovered() || response.dragged() { - egui::Color32::from_gray(120) - } else { - egui::Color32::from_gray(60) - }; - - ui.painter().vline( - split_x, - rect.y_range(), - egui::Stroke::new(2.0, divider_color), - ); - } - LayoutNode::VerticalGrid { percent, children } => { - // Handle dragging - if drag_state.is_dragging && drag_state.node_path == *path { - if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { - // Calculate new percentage based on pointer position - let new_percent = ((pointer_pos.y - rect.top()) / rect.height() * 100.0) - .clamp(10.0, 90.0); // Clamp to prevent too small panes - *percent = new_percent; - } - } - - // Split vertically (top / bottom) - let split_y = rect.top() + (rect.height() * *percent / 100.0); - - let top_rect = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y)); - - let bottom_rect = - egui::Rect::from_min_max(egui::pos2(rect.min.x, split_y), rect.max); - - // Render children - let mut top_path = path.clone(); - top_path.push(0); - render_layout_node( - ui, - &mut children[0], - top_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &top_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - let mut bottom_path = path.clone(); - bottom_path.push(1); - render_layout_node( - ui, - &mut children[1], - bottom_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &bottom_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - // Draw divider with interaction - let divider_height = 8.0; - let divider_rect = egui::Rect::from_min_max( - egui::pos2(rect.min.x, split_y - divider_height / 2.0), - egui::pos2(rect.max.x, split_y + divider_height / 2.0), - ); - - let divider_id = ui.id().with(("divider", path)); - let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); - - // Check if pointer is over divider - if response.hovered() { - *hovered_divider = Some((path.clone(), false)); - } - - // Handle drag start - if response.drag_started() { - drag_state.is_dragging = true; - drag_state.node_path = path.clone(); - drag_state.is_horizontal = false; - } - - // Handle drag end - if response.drag_stopped() { - drag_state.is_dragging = false; - } - - // Context menu on right-click - response.context_menu(|ui| { - ui.set_min_width(180.0); - - if ui.button("Split Horizontal ->").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); - ui.close_menu(); - } - - if ui.button("Split Vertical |").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); - ui.close_menu(); - } - - ui.separator(); - - if ui.button("^ Join Up").clicked() { - let mut path_keep_bottom = path.clone(); - path_keep_bottom.push(1); // Remove top, keep bottom child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_bottom)); - ui.close_menu(); - } - - if ui.button("Join Down v").clicked() { - let mut path_keep_top = path.clone(); - path_keep_top.push(0); // Remove bottom, keep top child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_top)); - ui.close_menu(); - } - - }); - - // Visual feedback - let divider_color = if response.hovered() || response.dragged() { - egui::Color32::from_gray(120) - } else { - egui::Color32::from_gray(60) - }; - - ui.painter().hline( - rect.x_range(), - split_y, - egui::Stroke::new(2.0, divider_color), - ); - } - } -} - -/// Render a single pane with its content -fn render_pane( - ui: &mut egui::Ui, - pane_name: &mut String, - rect: egui::Rect, - selected_pane: &mut Option, - layout_action: &mut Option, - split_preview_mode: &mut SplitPreviewMode, - icon_cache: &mut IconCache, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - fill_color: &mut egui::Color32, - stroke_color: &mut egui::Color32, - pane_instances: &mut HashMap, - path: &NodePath, - pending_view_action: &mut Option, - fallback_pane_priority: &mut Option, - pending_handlers: &mut Vec, - theme: &Theme, - document: &lightningbeam_core::document::Document, - selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, -) { - let pane_type = PaneType::from_name(pane_name); - - // Define header and content areas - let header_height = 40.0; - let header_rect = egui::Rect::from_min_size( - rect.min, - egui::vec2(rect.width(), header_height), - ); - let content_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(0.0, header_height), - egui::vec2(rect.width(), rect.height() - header_height), - ); - - // Draw header background - ui.painter().rect_filled( - header_rect, - 0.0, - egui::Color32::from_rgb(35, 35, 35), - ); - - // Draw content background - let bg_color = if let Some(pane_type) = pane_type { - pane_color(pane_type) - } else { - egui::Color32::from_rgb(40, 40, 40) - }; - ui.painter().rect_filled(content_rect, 0.0, bg_color); - - // Draw border around entire pane - let border_color = egui::Color32::from_gray(80); - let border_width = 1.0; - ui.painter().rect_stroke( - rect, - 0.0, - egui::Stroke::new(border_width, border_color), - ); - - // Draw header separator line - ui.painter().hline( - rect.x_range(), - header_rect.max.y, - egui::Stroke::new(1.0, egui::Color32::from_gray(50)), - ); - - // Render icon button in header (left side) - let icon_size = 24.0; - let icon_padding = 8.0; - let icon_button_rect = egui::Rect::from_min_size( - header_rect.min + egui::vec2(icon_padding, icon_padding), - egui::vec2(icon_size, icon_size), - ); - - // Draw icon button background - ui.painter().rect_filled( - icon_button_rect, - 4.0, - egui::Color32::from_rgba_premultiplied(50, 50, 50, 200), - ); - - // Load and render icon if available - if let Some(pane_type) = pane_type { - if let Some(icon) = icon_cache.get_or_load(pane_type) { - let icon_texture_id = icon.texture_id(ui.ctx()); - let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button - ui.painter().image( - icon_texture_id, - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - } - - // Make icon button interactive (show pane type menu on click) - let icon_button_id = ui.id().with(("icon_button", path)); - let icon_response = ui.interact(icon_button_rect, icon_button_id, egui::Sense::click()); - - if icon_response.hovered() { - ui.painter().rect_stroke( - icon_button_rect, - 4.0, - egui::Stroke::new(1.0, egui::Color32::from_gray(180)), - ); - } - - // Show pane type selector menu on left click - let menu_id = ui.id().with(("pane_type_menu", path)); - if icon_response.clicked() { - ui.memory_mut(|mem| mem.toggle_popup(menu_id)); - } - - egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| { - ui.set_min_width(200.0); - ui.label("Select Pane Type:"); - ui.separator(); - - for pane_type_option in PaneType::all() { - // Load icon for this pane type - if let Some(icon) = icon_cache.get_or_load(*pane_type_option) { - ui.horizontal(|ui| { - // Show icon - let icon_texture_id = icon.texture_id(ui.ctx()); - let icon_size = egui::vec2(16.0, 16.0); - ui.add(egui::Image::new((icon_texture_id, icon_size))); - - // Show label with selection - if ui.selectable_label( - pane_type == Some(*pane_type_option), - pane_type_option.display_name() - ).clicked() { - *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); - } - }); - } else { - // Fallback if icon fails to load - if ui.selectable_label( - pane_type == Some(*pane_type_option), - pane_type_option.display_name() - ).clicked() { - *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); - } - } - } - }); - - // Draw pane title in header - let title_text = if let Some(pane_type) = pane_type { - pane_type.display_name() - } else { - pane_name.as_str() - }; - let title_pos = header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0, header_height / 2.0); - ui.painter().text( - title_pos, - egui::Align2::LEFT_CENTER, - title_text, - egui::FontId::proportional(14.0), - egui::Color32::from_gray(220), - ); - - // Create header controls area (positioned after title) - let title_width = 150.0; // Approximate width for title - let header_controls_rect = egui::Rect::from_min_size( - header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0 + title_width, 0.0), - egui::vec2(header_rect.width() - (icon_padding * 2.0 + icon_size + 8.0 + title_width), header_height), - ); - - // Render pane-specific header controls (if pane has them) - if let Some(pane_type) = pane_type { - // Get or create pane instance for header rendering - let needs_new_instance = pane_instances - .get(path) - .map(|instance| instance.pane_type() != pane_type) - .unwrap_or(true); - - if needs_new_instance { - pane_instances.insert(path.clone(), panes::PaneInstance::new(pane_type)); - } - - if let Some(pane_instance) = pane_instances.get_mut(path) { - let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); - let mut shared = panes::SharedPaneState { - tool_icon_cache, - icon_cache, - selected_tool, - fill_color, - stroke_color, - pending_view_action, - fallback_pane_priority, - theme, - pending_handlers, - document, - selection, - active_layer_id, - }; - pane_instance.render_header(&mut header_ui, &mut shared); - } - } - - // Make pane content clickable (use full rect for split preview interaction) - let pane_id = ui.id().with(("pane", path)); - let response = ui.interact(rect, pane_id, egui::Sense::click()); - - // Render pane-specific content using trait-based system - if let Some(pane_type) = pane_type { - // Get or create pane instance for this path - // Check if we need a new instance (either doesn't exist or type changed) - let needs_new_instance = pane_instances - .get(path) - .map(|instance| instance.pane_type() != pane_type) - .unwrap_or(true); - - if needs_new_instance { - pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); - } - - // Get the pane instance and render its content - if let Some(pane_instance) = pane_instances.get_mut(path) { - // Create shared state - let mut shared = SharedPaneState { - tool_icon_cache, - icon_cache, - selected_tool, - fill_color, - stroke_color, - pending_view_action, - fallback_pane_priority, - theme, - pending_handlers, - document, - selection, - active_layer_id, - }; - - // Render pane content (header was already rendered above) - pane_instance.render_content(ui, content_rect, path, &mut shared); - } - } else { - // Unknown pane type - draw placeholder - let content_text = "Unknown pane type"; - let text_pos = content_rect.center(); - ui.painter().text( - text_pos, - egui::Align2::CENTER_CENTER, - content_text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), - ); - } - - // Handle split preview mode (rendered AFTER pane content for proper z-ordering) - if let SplitPreviewMode::Active { - is_horizontal, - hovered_pane, - split_percent, - } = split_preview_mode - { - // Check if mouse is over this pane - if let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos()) { - if rect.contains(pointer_pos) { - // Update hovered pane - *hovered_pane = Some(path.clone()); - - // Calculate split percentage based on mouse position - *split_percent = if *is_horizontal { - ((pointer_pos.x - rect.left()) / rect.width() * 100.0).clamp(10.0, 90.0) - } else { - ((pointer_pos.y - rect.top()) / rect.height() * 100.0).clamp(10.0, 90.0) - }; - - // Render split preview overlay - let grey_overlay = egui::Color32::from_rgba_premultiplied(128, 128, 128, 30); - - if *is_horizontal { - let split_x = rect.left() + (rect.width() * *split_percent / 100.0); - - // First half - let first_rect = egui::Rect::from_min_max( - rect.min, - egui::pos2(split_x, rect.max.y), - ); - ui.painter().rect_filled(first_rect, 0.0, grey_overlay); - - // Second half - let second_rect = egui::Rect::from_min_max( - egui::pos2(split_x, rect.min.y), - rect.max, - ); - ui.painter().rect_filled(second_rect, 0.0, grey_overlay); - - // Divider line - ui.painter().vline( - split_x, - rect.y_range(), - egui::Stroke::new(2.0, egui::Color32::BLACK), - ); - } else { - let split_y = rect.top() + (rect.height() * *split_percent / 100.0); - - // First half - let first_rect = egui::Rect::from_min_max( - rect.min, - egui::pos2(rect.max.x, split_y), - ); - ui.painter().rect_filled(first_rect, 0.0, grey_overlay); - - // Second half - let second_rect = egui::Rect::from_min_max( - egui::pos2(rect.min.x, split_y), - rect.max, - ); - ui.painter().rect_filled(second_rect, 0.0, grey_overlay); - - // Divider line - ui.painter().hline( - rect.x_range(), - split_y, - egui::Stroke::new(2.0, egui::Color32::BLACK), - ); - } - - // Create a high-priority interaction for split preview (rendered last = highest priority) - let split_preview_id = ui.id().with(("split_preview", path)); - let split_response = ui.interact(rect, split_preview_id, egui::Sense::click()); - - // If clicked, perform the split - if split_response.clicked() { - if *is_horizontal { - *layout_action = Some(LayoutAction::SplitHorizontal(path.clone(), *split_percent)); - } else { - *layout_action = Some(LayoutAction::SplitVertical(path.clone(), *split_percent)); - } - // Exit preview mode - *split_preview_mode = SplitPreviewMode::None; - } - } - } - } else if response.clicked() { - *selected_pane = Some(path.clone()); - } -} - -/// Render toolbar with tool buttons -fn render_toolbar( - ui: &mut egui::Ui, - rect: egui::Rect, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - path: &NodePath, -) { - let button_size = 60.0; // 50% bigger (was 40.0) - let button_padding = 8.0; - let button_spacing = 4.0; - - // Calculate how many columns we can fit - let available_width = rect.width() - (button_padding * 2.0); - let columns = ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - - let mut x = rect.left() + button_padding; - let mut y = rect.top() + button_padding; - let mut col = 0; - - for tool in Tool::all() { - let button_rect = egui::Rect::from_min_size( - egui::pos2(x, y), - egui::vec2(button_size, button_size), - ); - - // Check if this is the selected tool - let is_selected = *selected_tool == *tool; - - // Button background - let bg_color = if is_selected { - egui::Color32::from_rgb(70, 100, 150) // Highlighted blue - } else { - egui::Color32::from_rgb(50, 50, 50) - }; - ui.painter().rect_filled(button_rect, 4.0, bg_color); - - // Load and render tool icon - if let Some(icon) = tool_icon_cache.get_or_load(*tool, ui.ctx()) { - let icon_rect = button_rect.shrink(8.0); // Padding inside button - ui.painter().image( - icon.id(), - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Make button interactive (include path to ensure unique IDs across panes) - let button_id = ui.id().with(("tool_button", path, *tool as usize)); - let response = ui.interact(button_rect, button_id, egui::Sense::click()); - - // Check for click first - if response.clicked() { - *selected_tool = *tool; - } - - if response.hovered() { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_gray(180)), - ); - } - - // Show tooltip with tool name and shortcut (consumes response) - response.on_hover_text(format!("{} ({})", tool.display_name(), tool.shortcut_hint())); - - // Draw selection border - if is_selected { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)), - ); - } - - // Move to next position in grid - col += 1; - if col >= columns { - // Move to next row - col = 0; - x = rect.left() + button_padding; - y += button_size + button_spacing; - } else { - // Move to next column - x += button_size + button_spacing; - } - } -} - -/// Get a color for each pane type for visualization -fn pane_color(pane_type: PaneType) -> egui::Color32 { - match pane_type { - PaneType::Stage => egui::Color32::from_rgb(30, 40, 50), - PaneType::Timeline => egui::Color32::from_rgb(40, 30, 50), - PaneType::Toolbar => egui::Color32::from_rgb(50, 40, 30), - PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40), - PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30), - PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45), - PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), - PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), - } -} - -/// Split a pane node into a horizontal or vertical grid with two copies of the pane -fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) { - if path.is_empty() { - // Split the root node - if let LayoutNode::Pane { name } = root { - let pane_name = name.clone(); - let new_node = if is_horizontal { - LayoutNode::HorizontalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - } else { - LayoutNode::VerticalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - }; - *root = new_node; - } - } else { - // Navigate to parent and split the child - navigate_to_node(root, &path[..path.len() - 1], &mut |node| { - let child_index = path[path.len() - 1]; - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - if let LayoutNode::Pane { name } = &*children[child_index] { - let pane_name = name.clone(); - let new_node = if is_horizontal { - LayoutNode::HorizontalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - } else { - LayoutNode::VerticalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - }; - children[child_index] = Box::new(new_node); - } - } - _ => {} - } - }); - } -} - -/// Remove a split by replacing it with one of its children -/// The path includes the split node path plus which child to keep (0 or 1 as last element) -fn remove_split(root: &mut LayoutNode, path: &NodePath) { - if path.is_empty() { - return; // Can't remove if path is empty - } - - // Last element indicates which child to keep (0 or 1) - let child_to_keep = path[path.len() - 1]; - - // Path to the split node is everything except the last element - let split_path = &path[..path.len() - 1]; - - if split_path.is_empty() { - // Removing root split - replace root with the chosen child - if let LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } = root - { - *root = (*children[child_to_keep]).clone(); - } - } else { - // Navigate to parent of the split node and replace it - let parent_path = &split_path[..split_path.len() - 1]; - let split_index = split_path[split_path.len() - 1]; - - navigate_to_node(root, parent_path, &mut |node| { - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - // Get the split node's chosen child - if let LayoutNode::HorizontalGrid { children: split_children, .. } - | LayoutNode::VerticalGrid { children: split_children, .. } = - &*children[split_index] - { - // Replace the split node with the chosen child - children[split_index] = split_children[child_to_keep].clone(); - } - } - _ => {} - } - }); - } -} - -/// Navigate to a node at the given path and apply a function to it -fn navigate_to_node(node: &mut LayoutNode, path: &[usize], f: &mut F) -where - F: FnMut(&mut LayoutNode), -{ - if path.is_empty() { - f(node); - } else { - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - navigate_to_node(&mut children[path[0]], &path[1..], f); - } - _ => {} - } - } -} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs index b48e0ea..86f948a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -81,8 +81,9 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { ) }; - 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; + // Floor at 0 so a very small window can't produce negative/inverted card rects. + let col_w = ((grid_rect.width() - (cols as f32 - 1.0) * gap) / cols as f32).max(0.0); + let card_h = ((grid_rect.height() - (rows as f32 - 1.0) * gap) / rows as f32).max(0.0); for (i, intent) in intents(&pal).iter().enumerate() { let col = (i % cols) as f32; let row = (i / cols) as f32; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index e7be8a7..df1ec1f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -197,6 +197,8 @@ pub struct MobileState { pub inspector_frac: f32, /// Inspector side-panel width as a fraction of the region (landscape). pub inspector_width_frac: f32, + /// Last frame's orientation (landscape?), to reset orientation-specific cached state on rotation. + pub was_landscape: bool, /// 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, @@ -235,6 +237,7 @@ impl Default for MobileState { show_instruments: false, inspector_frac: 0.45, inspector_width_frac: 0.4, + was_landscape: false, inspector_visible: false, inspector_anchor_y: 0.0, inspector_dismissed: false, @@ -268,6 +271,13 @@ pub fn render_mobile_shell( state.window_count = 2; state.window_top = state.window_top.min(STACK.len().saturating_sub(2)); } + // On an orientation change, the cached inspector tap-anchor is in the old layout's coordinate + // space; drop it to a non-covering default so the portrait reflow doesn't mis-decide until the + // next tap re-anchors. + if landscape != state.was_landscape { + state.was_landscape = landscape; + state.inspector_anchor_y = available_rect.top(); + } // Background (device color; bands paint over most of it). ui.painter().rect_filled(available_rect, 0.0, pal.bg); @@ -317,11 +327,17 @@ pub fn render_mobile_shell( let mut inspector_shown = state.inspector_visible; // Inspector geometry: a bottom sheet in portrait, a right-side vertical column in landscape. + // Clamp defensively: on a very small region the desired minimum can exceed the available space, + // and `f32::clamp` panics if `min > max`, so keep `min <= max`. let inspector_rect = if landscape { - let w = (region.width() * state.inspector_width_frac).clamp(180.0, region.width() - 140.0); + let lo = 180.0_f32.min(region.width()); + let hi = (region.width() - 140.0).max(lo); + let w = (region.width() * state.inspector_width_frac).clamp(lo, hi); 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); + let lo = 120.0_f32.min(region.height()); + let hi = (region.height() - 60.0).max(lo); + let h = (region.height() * state.inspector_frac).clamp(lo, hi); egui::Rect::from_min_max(egui::pos2(region.left(), region.bottom() - h), region.max) }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs index 566ba52..ab703a1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -704,13 +704,14 @@ fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, } } 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. + // Snap the two-phase 2β†’1 reveal to match the preview's phases: past the midpoint (phase 2, + // collapsing) β†’ revealed pane fullscreen; a middling drag (phase 1, sliding) β†’ 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 { + if frac >= 0.5 { 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 { @@ -718,7 +719,7 @@ fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, 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 { + } else if frac >= 0.5 { begin_anim(state, top, 2, even2, top + 1, 1, even_arr(1), frac, now); } } @@ -728,7 +729,7 @@ fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, let frac = (d.offset / h).clamp(0.0, 1.0); let even2 = even_arr(2); if top > 0 { - if frac >= COLLAPSE_HI { + if frac >= 0.5 { 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 { @@ -736,7 +737,7 @@ fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, 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 { + } else if frac >= 0.5 { begin_anim(state, top, 2, even2, top, 1, even_arr(1), frac, now); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs index 50f4858..5fd2815 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -118,15 +118,31 @@ pub fn render_inline( .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; + // Reserve gutters so the cluster never overlaps the pane header's own grip/label (left) or its + // fullscreen / node-toggle buttons (right). + let left_gutter = 150.0; + let right_gutter = 2.0 * BTN + 16.0; + let span_left = header.left() + left_gutter; + let span_right = (header.right() - right_gutter).max(span_left); + let span_w = span_right - span_left; + + // Filename elided to fit whatever's left after the two buttons. + let max_name_w = (span_w - gap - 2.0 * btn).max(0.0); + let galley = { + let mut job = + egui::text::LayoutJob::simple_singleline(name, egui::FontId::proportional(14.0), pal.text); + job.wrap.max_width = max_name_w; + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + ui.painter().layout_job(job) + }; + let name_w = galley.rect.width(); + let name_h = galley.rect.height(); let cluster_w = name_w + gap + 2.0 * btn; - let x0 = (header.center().x - cluster_w / 2.0).max(header.left() + 8.0); + // Center within the reserved span, then clamp so the right edge stays inside it. + let x0 = (span_left + (span_w - cluster_w) / 2.0).clamp(span_left, (span_right - cluster_w).max(span_left)); let cy = header.center().y; let bt = header.top() + (header.height() - btn) / 2.0; @@ -223,7 +239,11 @@ fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, s .hint_text("Search commands…") .desired_width(inner.width()), ); - te.request_focus(); + // Focus the field only when it isn't already focused β€” re-requesting every frame would stomp + // focus and prevent anything else in the panel from taking it. + if !te.has_focus() { + te.request_focus(); + } // Filtered list. let q = state.palette_query.to_lowercase(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs index c35a0c0..051a29d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs @@ -607,6 +607,9 @@ impl NodeGraphPane { /// Cable an output port β†’ input port: update the frontend graph + dispatch `Connect`. fn mobile_connect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + // Mobile has no subgraph navigation yet, so cables always target the track-level graph. If + // subgraph nav is added, route through `va_context()` / `graph_connect_in_template` like desktop. + debug_assert!(self.subgraph_stack.is_empty(), "mobile node connect assumes the top-level graph"); let Some(track_id) = self.track_id else { return }; let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); @@ -625,8 +628,10 @@ impl NodeGraphPane { } } - /// Remove a cable: update the frontend graph + dispatch `Disconnect`. + /// Remove a cable: update the frontend graph + dispatch `Disconnect`. Like `mobile_connect`, this + /// assumes the track-level graph (mobile has no subgraph navigation). fn mobile_disconnect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + debug_assert!(self.subgraph_stack.is_empty(), "mobile node disconnect assumes the top-level graph"); let Some(track_id) = self.track_id else { return }; let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 08296f6..ad47424 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -392,7 +392,9 @@ impl PianoRollPane { // 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)); + // Clamp the bar height so a very short pane can't invert the remaining content rect. + let bar_h = 30.0_f32.min(rect.height()); + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), rect.min.y + bar_h)); rect = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); self.render_keys_notes_toggle(ui, bar, shared); if self.landscape_keys { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index bef45be..5c2f15b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -11407,12 +11407,12 @@ impl StagePane { } } - // P5 gesture: double-tap-drag on empty space β†’ transient marquee, regardless of the active - // tool. The second tap arms it; a drag then drives ToolState::MarqueeSelecting (resolved by - // the release handler above). While armed we suppress the normal tool dispatch so a + // P5 gesture (mobile): double-tap-drag on empty space β†’ transient marquee, regardless of the + // active tool. The second tap arms it; a drag then drives ToolState::MarqueeSelecting (resolved + // by the release handler above). While armed we suppress the normal tool dispatch so a // brush/etc. doesn't paint during the gesture. A double-tap without a drag falls through - // (Select tool handles zoom-to-fit). - if !is_replaying && !alt_held { + // (Select tool handles zoom-to-fit). Mobile-only so it can't hijack a desktop Brush/Draw drag. + if shared.is_mobile && !is_replaying && !alt_held { use vello::kurbo::Point; let point = Point::new(world_pos.x as f64, world_pos.y as f64); let press_pos = mouse_canvas_pos.to_pos2(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index b139a57..793835b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -5891,6 +5891,11 @@ impl PaneRenderer for TimelinePane { } else if now - t0 >= 0.4 { self.lp_time = None; long_press = Some(self.lp_pos); + } else { + // Still counting down β€” request a repaint so the threshold is re-evaluated even if + // the finger is perfectly still and egui would otherwise go idle (no input events). + let remaining = (0.4 - (now - t0)).max(0.0); + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(remaining)); } } }