From 08232454a7a74fce7f3b8452f4dad1811d1cfb2a Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 16 Nov 2025 00:01:07 -0500 Subject: [PATCH] Add stage pane with scrolling --- lightningbeam-ui/Cargo.lock | 1 + lightningbeam-ui/Cargo.toml | 1 + .../lightningbeam-editor/Cargo.toml | 1 + .../lightningbeam-editor/src/main.rs | 70 ++- .../lightningbeam-editor/src/panes/mod.rs | 17 + .../src/panes/shaders/blit.wgsl | 31 ++ .../lightningbeam-editor/src/panes/stage.rs | 527 +++++++++++++++++- 7 files changed, 623 insertions(+), 25 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/shaders/blit.wgsl diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index e26325e..e04de2d 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2447,6 +2447,7 @@ name = "lightningbeam-editor" version = "0.1.0" dependencies = [ "eframe", + "egui-wgpu", "egui_extras", "image", "kurbo 0.11.3", diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index e9a8aee..40d60a5 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -9,6 +9,7 @@ members = [ # UI Framework (using eframe for simplified integration) eframe = { version = "0.29", default-features = true, features = ["wgpu"] } egui_extras = { version = "0.29", features = ["image", "svg"] } +egui-wgpu = "0.29" # GPU Rendering vello = "0.3" diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 37a1b27..61004b8 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -9,6 +9,7 @@ lightningbeam-core = { path = "../lightningbeam-core" } # UI Framework eframe = { workspace = true } egui_extras = { workspace = true } +egui-wgpu = { workspace = true } # GPU wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 77373b3..11cc91b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -190,6 +190,7 @@ struct EditorApp { 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 } impl EditorApp { @@ -214,6 +215,7 @@ impl EditorApp { 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, } } @@ -412,20 +414,16 @@ impl EditorApp { // View menu MenuAction::ZoomIn => { - println!("Menu: Zoom In"); - // TODO: Implement zoom in + self.pending_view_action = Some(MenuAction::ZoomIn); } MenuAction::ZoomOut => { - println!("Menu: Zoom Out"); - // TODO: Implement zoom out + self.pending_view_action = Some(MenuAction::ZoomOut); } MenuAction::ActualSize => { - println!("Menu: Actual Size"); - // TODO: Implement actual size (100% zoom) + self.pending_view_action = Some(MenuAction::ActualSize); } MenuAction::RecenterView => { - println!("Menu: Recenter View"); - // TODO: Implement recenter view + self.pending_view_action = Some(MenuAction::RecenterView); } MenuAction::NextLayout => { println!("Menu: Next Layout"); @@ -469,6 +467,12 @@ impl EditorApp { 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() { @@ -498,6 +502,12 @@ impl eframe::App for EditorApp { // 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, @@ -514,8 +524,28 @@ impl eframe::App for EditorApp { &mut self.stroke_color, &mut self.pane_instances, &Vec::new(), // Root path + &mut self.pending_view_action, + &mut fallback_pane_priority, + &mut pending_handlers, ); + // 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 { @@ -550,6 +580,7 @@ impl eframe::App for EditorApp { self.apply_layout_action(action); } } + } /// Recursively render a layout node with drag support @@ -569,10 +600,13 @@ fn render_layout_node( 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, ) { 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); + 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); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging @@ -612,6 +646,9 @@ fn render_layout_node( stroke_color, pane_instances, &left_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, ); let mut right_path = path.clone(); @@ -632,6 +669,9 @@ fn render_layout_node( stroke_color, pane_instances, &right_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, ); // Draw divider with interaction @@ -744,6 +784,9 @@ fn render_layout_node( stroke_color, pane_instances, &top_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, ); let mut bottom_path = path.clone(); @@ -764,6 +807,9 @@ fn render_layout_node( stroke_color, pane_instances, &bottom_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, ); // Draw divider with interaction @@ -856,6 +902,9 @@ fn render_pane( 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, ) { let pane_type = PaneType::from_name(pane_name); @@ -1115,6 +1164,9 @@ fn render_pane( selected_tool, fill_color, stroke_color, + pending_view_action, + fallback_pane_priority, + pending_handlers, }; // Render pane header (if it has one) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 1baa163..4306309 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -9,6 +9,15 @@ use lightningbeam_core::{pane::PaneType, tool::Tool}; // Type alias for node paths (matches main.rs) pub type NodePath = Vec; +/// Handler information for view actions (zoom, pan, etc.) +/// Used for two-phase dispatch: register during render, execute after +#[derive(Clone)] +pub struct ViewActionHandler { + pub priority: u32, + pub pane_path: NodePath, + pub zoom_center: egui::Vec2, +} + pub mod toolbar; pub mod stage; pub mod timeline; @@ -25,6 +34,14 @@ pub struct SharedPaneState<'a> { pub selected_tool: &'a mut Tool, pub fill_color: &'a mut egui::Color32, pub stroke_color: &'a mut egui::Color32, + pub pending_view_action: &'a mut Option, + /// Tracks the priority of the best fallback pane for view actions + /// Lower number = higher priority. None = no fallback pane seen yet + /// Priority order: Stage(0) > Timeline(1) > PianoRoll(2) > NodeEditor(3) + pub fallback_pane_priority: &'a mut Option, + /// Registry of handlers for the current pending action + /// Panes register themselves here during render, execution happens after + pub pending_handlers: &'a mut Vec, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/blit.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/blit.wgsl new file mode 100644 index 0000000..b41371f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/blit.wgsl @@ -0,0 +1,31 @@ +// Simple fullscreen blit shader for rendering Vello texture to screen + +@group(0) @binding(0) var tex: texture_2d; +@group(0) @binding(1) var tex_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +// Vertex shader - generates fullscreen triangle strip +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + + // Triangle strip covering the screen + // Vertices: (0,0), (2,0), (0,2), (2,2) in UV space + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + + return out; +} + +// Fragment shader - sample and display texture +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return textureSample(tex, tex_sampler, in.uv); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 5bd88cb..1076741 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1,18 +1,452 @@ -/// Stage pane - main animation canvas +/// Stage pane - main animation canvas with Vello rendering /// -/// This will eventually render the composited layers using Vello. -/// For now, it's a placeholder. +/// Renders composited layers using Vello GPU renderer via egui callbacks. use eframe::egui; use super::{NodePath, PaneRenderer, SharedPaneState}; +use std::sync::{Arc, Mutex}; + +/// Resources for a single Vello instance +struct VelloResources { + renderer: Arc>, + texture: Option, + texture_view: Option, + // Blit pipeline for rendering texture to screen + blit_pipeline: wgpu::RenderPipeline, + blit_bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + blit_bind_group: Option, +} + +/// Container for all Vello instances, stored in egui's CallbackResources +pub struct VelloResourcesMap { + instances: std::collections::HashMap, +} + +impl VelloResources { + pub fn new(device: &wgpu::Device) -> Result { + let renderer = vello::Renderer::new( + device, + vello::RendererOptions { + surface_format: None, + use_cpu: false, + antialiasing_support: vello::AaSupport::all(), + num_init_threads: std::num::NonZeroUsize::new(1), + }, + ).map_err(|e| format!("Failed to create Vello renderer: {}", e))?; + + // Create blit shader for rendering texture to screen + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("vello_blit_shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/blit.wgsl").into()), + }); + + // Create bind group layout for texture + sampler + let blit_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("vello_blit_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + // Create pipeline layout + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("vello_blit_pipeline_layout"), + bind_group_layouts: &[&blit_bind_group_layout], + push_constant_ranges: &[], + }); + + // Create render pipeline for blitting + let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("vello_blit_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_main", + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, // egui's target format + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + // Create sampler + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("vello_blit_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + println!("✅ Vello renderer and blit pipeline initialized"); + + Ok(Self { + renderer: Arc::new(Mutex::new(renderer)), + texture: None, + texture_view: None, + blit_pipeline, + blit_bind_group_layout, + sampler, + blit_bind_group: None, + }) + } + + fn ensure_texture(&mut self, device: &wgpu::Device, width: u32, height: u32) { + // Clamp to GPU limits (most GPUs support up to 8192) + let max_texture_size = 8192; + let width = width.min(max_texture_size); + let height = height.min(max_texture_size); + + // Only recreate if size changed + if let Some(tex) = &self.texture { + if tex.width() == width && tex.height() == height { + return; + } + } + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("vello_output"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create bind group for blit pipeline + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("vello_blit_bind_group"), + layout: &self.blit_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + self.texture = Some(texture); + self.texture_view = Some(texture_view); + self.blit_bind_group = Some(bind_group); + } +} + +/// Callback for Vello rendering within egui +struct VelloCallback { + rect: egui::Rect, + pan_offset: egui::Vec2, + zoom: f32, + instance_id: u64, +} + +impl VelloCallback { + fn new(rect: egui::Rect, pan_offset: egui::Vec2, zoom: f32, instance_id: u64) -> Self { + Self { rect, pan_offset, zoom, instance_id } + } +} + +impl egui_wgpu::CallbackTrait for VelloCallback { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + _screen_descriptor: &egui_wgpu::ScreenDescriptor, + _egui_encoder: &mut wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + // Get or create the resources map + if !resources.contains::() { + resources.insert(VelloResourcesMap { + instances: std::collections::HashMap::new(), + }); + } + + let map: &mut VelloResourcesMap = resources.get_mut().unwrap(); + + // Get or create resources for this specific instance + let vello_resources = map.instances.entry(self.instance_id).or_insert_with(|| { + VelloResources::new(device).expect("Failed to initialize Vello renderer") + }); + + // Ensure texture is the right size + let width = self.rect.width() as u32; + let height = self.rect.height() as u32; + + if width == 0 || height == 0 { + return Vec::new(); + } + + vello_resources.ensure_texture(device, width, height); + + // Build Vello scene with a test rectangle + let mut scene = vello::Scene::new(); + + // Draw a colored rectangle as proof of concept + use vello::kurbo::{RoundedRect, Affine}; + use vello::peniko::Color; + + let rect = RoundedRect::new( + 100.0, 100.0, + 400.0, 300.0, + 10.0, // corner radius + ); + + // Apply camera transform: translate for pan, scale for zoom + let transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64)) + * Affine::scale(self.zoom as f64); + + scene.fill( + vello::peniko::Fill::NonZero, + transform, + Color::rgb8(100, 150, 250), + None, + &rect, + ); + + // Render scene to texture + if let Some(texture_view) = &vello_resources.texture_view { + let render_params = vello::RenderParams { + base_color: vello::peniko::Color::rgb8(45, 45, 48), // Dark background + width, + height, + antialiasing_method: vello::AaConfig::Msaa16, + }; + + if let Ok(mut renderer) = vello_resources.renderer.lock() { + renderer + .render_to_texture(device, queue, &scene, texture_view, &render_params) + .ok(); + } + } + + Vec::new() + } + + fn paint( + &self, + _info: egui::PaintCallbackInfo, + render_pass: &mut wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + // Get Vello resources map + let map: &VelloResourcesMap = match resources.get() { + Some(m) => m, + None => return, // Resources not initialized yet + }; + + // Get resources for this specific instance + let vello_resources = match map.instances.get(&self.instance_id) { + Some(r) => r, + None => return, // Instance not initialized yet + }; + + // Check if we have a bind group (texture ready) + let bind_group = match &vello_resources.blit_bind_group { + Some(bg) => bg, + None => return, // Texture not ready yet + }; + + // Render fullscreen quad with our texture + render_pass.set_pipeline(&vello_resources.blit_pipeline); + render_pass.set_bind_group(0, bind_group, &[]); + render_pass.draw(0..4, 0..1); // Triangle strip: 4 vertices + } +} pub struct StagePane { - // TODO: Add state for camera, selection, etc. + // Camera state + pan_offset: egui::Vec2, + zoom: f32, + // Interaction state + is_panning: bool, + last_pan_pos: Option, + // Unique ID for this stage instance (for Vello resources) + instance_id: u64, } +// Global counter for generating unique instance IDs +static INSTANCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + impl StagePane { pub fn new() -> Self { - Self {} + let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Self { + pan_offset: egui::Vec2::ZERO, + zoom: 1.0, + is_panning: false, + last_pan_pos: None, + instance_id, + } + } + + /// Execute a view action with the given parameters + /// Called from main.rs after determining this is the best handler + pub fn execute_view_action(&mut self, action: &crate::menu::MenuAction, zoom_center: egui::Vec2) { + use crate::menu::MenuAction; + match action { + MenuAction::ZoomIn => self.zoom_in(zoom_center), + MenuAction::ZoomOut => self.zoom_out(zoom_center), + MenuAction::ActualSize => self.actual_size(), + MenuAction::RecenterView => self.recenter(), + _ => {} // Not a view action we handle + } + } + + /// Zoom in by a fixed increment (to center of viewport) + pub fn zoom_in(&mut self, center: egui::Vec2) { + self.apply_zoom_at_point(0.2, center); + } + + /// Zoom out by a fixed increment (to center of viewport) + pub fn zoom_out(&mut self, center: egui::Vec2) { + self.apply_zoom_at_point(-0.2, center); + } + + /// Reset zoom to 100% (1.0) + pub fn actual_size(&mut self) { + self.zoom = 1.0; + } + + /// Reset pan to center (0,0) and zoom to 100% + pub fn recenter(&mut self) { + self.pan_offset = egui::Vec2::ZERO; + self.zoom = 1.0; + } + + /// Apply zoom while keeping the point under the mouse cursor stationary + fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_canvas_pos: egui::Vec2) { + let old_zoom = self.zoom; + + // Calculate world position under mouse before zoom + let world_pos = (mouse_canvas_pos - self.pan_offset) / old_zoom; + + // Apply zoom + let new_zoom = (old_zoom * (1.0 + zoom_delta)).clamp(0.1, 10.0); + self.zoom = new_zoom; + + // Adjust pan so the same world point stays under the mouse + self.pan_offset = mouse_canvas_pos - (world_pos * new_zoom); + } + + fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect) { + let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); + + // Only process input if mouse is over the stage pane + if !response.hovered() { + self.is_panning = false; + self.last_pan_pos = None; + return; + } + + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); + let alt_held = ui.input(|i| i.modifiers.alt); + let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command); + + // Get mouse position for zoom-to-cursor + let mouse_pos = response.hover_pos().unwrap_or(rect.center()); + let mouse_canvas_pos = mouse_pos - rect.min; + + // Distinguish between mouse wheel (discrete) and trackpad (smooth) + let mut handled = false; + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { + match unit { + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { + // Real mouse wheel (discrete clicks) -> always zoom + let zoom_delta = if ctrl_held || modifiers.ctrl { + delta.y * 0.01 // Ctrl+wheel: faster zoom + } else { + delta.y * 0.005 // Normal zoom + }; + self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); + handled = true; + } + egui::MouseWheelUnit::Point => { + // Trackpad (smooth scrolling) -> only zoom if Ctrl held + if ctrl_held || modifiers.ctrl { + let zoom_delta = delta.y * 0.005; + self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); + handled = true; + } + // Otherwise let scroll_delta handle panning + } + } + } + } + }); + + // Handle scroll_delta for trackpad panning (when Ctrl not held) + if !handled && (scroll_delta.x.abs() > 0.0 || scroll_delta.y.abs() > 0.0) { + self.pan_offset.x += scroll_delta.x; + self.pan_offset.y += scroll_delta.y; + } + + // Handle panning with Alt+Drag + if alt_held && response.dragged() { + // Alt+Click+Drag panning + if let Some(last_pos) = self.last_pan_pos { + if let Some(current_pos) = response.interact_pointer_pos() { + let delta = current_pos - last_pos; + self.pan_offset += delta; + } + } + self.last_pan_pos = response.interact_pointer_pos(); + self.is_panning = true; + } else { + if !response.dragged() { + self.is_panning = false; + self.last_pan_pos = None; + } + } } } @@ -22,22 +456,83 @@ impl PaneRenderer for StagePane { ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, ) { - // Placeholder rendering - ui.painter().rect_filled( + // Handle input for pan/zoom controls + self.handle_input(ui, rect); + + // Register handler for pending view actions (two-phase dispatch) + // Priority: Mouse-over (0-99) > Fallback Stage(1000) > Fallback Timeline(1001) etc. + const STAGE_MOUSE_OVER_PRIORITY: u32 = 0; + const STAGE_FALLBACK_PRIORITY: u32 = 1000; + + let mouse_over = ui.rect_contains_pointer(rect); + + // Determine our priority for this action + let our_priority = if mouse_over { + STAGE_MOUSE_OVER_PRIORITY // High priority - mouse is over this pane + } else { + STAGE_FALLBACK_PRIORITY // Low priority - just a fallback option + }; + + // Check if we should register as a handler (better priority than current best) + let should_register = shared.pending_view_action.is_some() && + shared.fallback_pane_priority.map_or(true, |p| our_priority < p); + + if should_register { + // Update fallback priority tracker + *shared.fallback_pane_priority = Some(our_priority); + + // Register as a handler (don't execute yet - that happens after all panes render) + if let Some(action) = &shared.pending_view_action { + use crate::menu::MenuAction; + + // Determine zoom center point + let center = if mouse_over { + // Use mouse position for zoom-to-cursor + let mouse_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center()); + mouse_pos - rect.min + } else { + // Use center of viewport for fallback + rect.size() / 2.0 + }; + + // Only register for actions we can handle + match action { + MenuAction::ZoomIn | MenuAction::ZoomOut | + MenuAction::ActualSize | MenuAction::RecenterView => { + shared.pending_handlers.push(super::ViewActionHandler { + priority: our_priority, + pane_path: _path.clone(), + zoom_center: center, + }); + } + _ => { + // Not a view action we handle - reset priority so others can try + *shared.fallback_pane_priority = None; + } + } + } + } + + // Use egui's custom painting callback for Vello + let callback = VelloCallback::new(rect, self.pan_offset, self.zoom, self.instance_id); + + let cb = egui_wgpu::Callback::new_paint_callback( rect, - 0.0, - egui::Color32::from_rgb(30, 40, 50), + callback, ); - let text = "Stage Pane\n(TODO: Implement Vello rendering)"; + ui.painter().add(cb); + + // Show camera info overlay ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), + rect.min + egui::vec2(10.0, 10.0), + egui::Align2::LEFT_TOP, + format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})", + self.zoom, self.pan_offset.x, self.pan_offset.y), + egui::FontId::proportional(14.0), + egui::Color32::from_gray(200), ); }