This commit is contained in:
Skyler Lehmkuhl 2025-11-13 18:12:21 -05:00
parent bf007e774e
commit 48da21e062
25 changed files with 6812 additions and 137 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ members = [
[workspace.dependencies]
# UI Framework (using eframe for simplified integration)
eframe = { version = "0.29", default-features = true, features = ["wgpu"] }
egui_extras = { version = "0.29", features = ["image", "svg"] }
# GPU Rendering
vello = "0.3"
@ -18,9 +19,16 @@ peniko = "0.5"
# Windowing
winit = "0.30"
# Native menus
muda = "0.15"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Image loading
image = "0.25"
resvg = "0.42"
# Utilities
pollster = "0.3"

View File

@ -2,3 +2,5 @@
// Shared data structures and types
pub mod layout;
pub mod pane;
pub mod tool;

View File

@ -0,0 +1,137 @@
/// Pane system for the layout manager
///
/// Each pane has:
/// - An icon button (top-left) for pane type selection
/// - Optional header with controls (e.g., Timeline playback controls)
/// - Content area (main pane body)
use serde::{Deserialize, Serialize};
/// Pane type enum matching the layout system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaneType {
/// Main animation canvas
Stage,
/// Frame-based timeline (matches timelineV2 from JS, but just "timeline" in Rust)
#[serde(rename = "timelineV2")]
Timeline,
/// Tool selection bar
Toolbar,
/// Property/info panel
Infopanel,
/// Layer hierarchy
#[serde(rename = "outlineer")]
Outliner,
/// MIDI piano roll editor
PianoRoll,
/// Node-based editor
NodeEditor,
/// Preset/asset browser
PresetBrowser,
}
impl PaneType {
/// Get display name for the pane type
pub fn display_name(self) -> &'static str {
match self {
PaneType::Stage => "Stage",
PaneType::Timeline => "Timeline",
PaneType::Toolbar => "Toolbar",
PaneType::Infopanel => "Info Panel",
PaneType::Outliner => "Outliner",
PaneType::PianoRoll => "Piano Roll",
PaneType::NodeEditor => "Node Editor",
PaneType::PresetBrowser => "Preset Browser",
}
}
/// Get SVG icon file name for the pane type
/// Path is relative to ~/Dev/Lightningbeam-2/src/assets/
/// TODO: Move assets to lightningbeam-editor/assets/icons/ before release
pub fn icon_file(self) -> &'static str {
match self {
PaneType::Stage => "stage.svg",
PaneType::Timeline => "timeline.svg",
PaneType::Toolbar => "toolbar.svg",
PaneType::Infopanel => "infopanel.svg",
PaneType::Outliner => "stage.svg", // TODO: needs own icon
PaneType::PianoRoll => "piano-roll.svg",
PaneType::NodeEditor => "node-editor.svg",
PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon
}
}
/// Parse pane type from string name (case-insensitive)
/// Accepts both JS names (timelineV2) and Rust names (timeline)
pub fn from_name(name: &str) -> Option<Self> {
match name.to_lowercase().as_str() {
"stage" => Some(PaneType::Stage),
"timeline" | "timelinev2" => Some(PaneType::Timeline),
"toolbar" => Some(PaneType::Toolbar),
"infopanel" => Some(PaneType::Infopanel),
"outlineer" | "outliner" => Some(PaneType::Outliner),
"pianoroll" => Some(PaneType::PianoRoll),
"nodeeditor" => Some(PaneType::NodeEditor),
"presetbrowser" => Some(PaneType::PresetBrowser),
_ => None,
}
}
/// Get all available pane types
pub fn all() -> &'static [PaneType] {
&[
PaneType::Stage,
PaneType::Timeline,
PaneType::Toolbar,
PaneType::Infopanel,
PaneType::Outliner,
PaneType::NodeEditor,
PaneType::PianoRoll,
PaneType::PresetBrowser,
]
}
/// Get the string name for this pane type (used in JSON)
pub fn to_name(self) -> &'static str {
match self {
PaneType::Stage => "stage",
PaneType::Timeline => "timelineV2", // JSON uses timelineV2
PaneType::Toolbar => "toolbar",
PaneType::Infopanel => "infopanel",
PaneType::Outliner => "outlineer", // JSON uses outlineer
PaneType::PianoRoll => "pianoRoll",
PaneType::NodeEditor => "nodeEditor",
PaneType::PresetBrowser => "presetBrowser",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pane_type_from_name() {
assert_eq!(PaneType::from_name("stage"), Some(PaneType::Stage));
assert_eq!(PaneType::from_name("Stage"), Some(PaneType::Stage));
assert_eq!(PaneType::from_name("STAGE"), Some(PaneType::Stage));
// Accept both JS name (timelineV2) and Rust name (timeline)
assert_eq!(PaneType::from_name("timelineV2"), Some(PaneType::Timeline));
assert_eq!(PaneType::from_name("timeline"), Some(PaneType::Timeline));
assert_eq!(PaneType::from_name("invalid"), None);
}
#[test]
fn test_pane_type_display() {
assert_eq!(PaneType::Stage.display_name(), "Stage");
assert_eq!(PaneType::Timeline.display_name(), "Timeline");
}
#[test]
fn test_pane_type_icons() {
assert_eq!(PaneType::Stage.icon_file(), "stage.svg");
assert_eq!(PaneType::Timeline.icon_file(), "timeline.svg");
assert_eq!(PaneType::NodeEditor.icon_file(), "node-editor.svg");
}
}

View File

@ -0,0 +1,79 @@
/// Tool system for the toolbar
///
/// Defines the available drawing/editing tools
use serde::{Deserialize, Serialize};
/// Drawing and editing tools
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Tool {
/// Selection tool - select and move objects
Select,
/// Draw/Pen tool - freehand drawing
Draw,
/// Transform tool - scale, rotate, skew
Transform,
/// Rectangle shape tool
Rectangle,
/// Ellipse/Circle shape tool
Ellipse,
/// Paint bucket - fill areas with color
PaintBucket,
/// Eyedropper - pick colors from the canvas
Eyedropper,
}
impl Tool {
/// Get display name for the tool
pub fn display_name(self) -> &'static str {
match self {
Tool::Select => "Select",
Tool::Draw => "Draw",
Tool::Transform => "Transform",
Tool::Rectangle => "Rectangle",
Tool::Ellipse => "Ellipse",
Tool::PaintBucket => "Paint Bucket",
Tool::Eyedropper => "Eyedropper",
}
}
/// Get SVG icon file name for the tool
pub fn icon_file(self) -> &'static str {
match self {
Tool::Select => "select.svg",
Tool::Draw => "draw.svg",
Tool::Transform => "transform.svg",
Tool::Rectangle => "rectangle.svg",
Tool::Ellipse => "ellipse.svg",
Tool::PaintBucket => "paint_bucket.svg",
Tool::Eyedropper => "eyedropper.svg",
}
}
/// Get all available tools
pub fn all() -> &'static [Tool] {
&[
Tool::Select,
Tool::Draw,
Tool::Transform,
Tool::Rectangle,
Tool::Ellipse,
Tool::PaintBucket,
Tool::Eyedropper,
]
}
/// Get keyboard shortcut hint
pub fn shortcut_hint(self) -> &'static str {
match self {
Tool::Select => "V",
Tool::Draw => "P",
Tool::Transform => "Q",
Tool::Rectangle => "R",
Tool::Ellipse => "E",
Tool::PaintBucket => "B",
Tool::Eyedropper => "I",
}
}
}

View File

@ -8,6 +8,7 @@ lightningbeam-core = { path = "../lightningbeam-core" }
# UI Framework
eframe = { workspace = true }
egui_extras = { workspace = true }
# GPU
wgpu = { workspace = true }
@ -18,9 +19,16 @@ peniko = { workspace = true }
# Windowing
winit = { workspace = true }
# Native menus
muda = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
# Image loading
image = { workspace = true }
resvg = { workspace = true }
# Utilities
pollster = { workspace = true }

View File

@ -0,0 +1,461 @@
# Lightningbeam Rust UI - Implementation Plan
## Project Overview
**Goal**: Complete migration from JavaScript/Tauri to Rust/egui
- **Scope**: ~10,000+ lines of code migration
- **Source**: `~/Dev/Lightningbeam-2/src/` (JS models, actions, main.js)
- **Target**: Full-featured Rust animation editor with native performance
- **Motivation**: IPC overhead between Rust↔JS too slow for real-time performance
## UI Boundaries
**In Scope (This UI)**:
- Layout system (panes, splits, resize)
- Stage rendering (layer compositing with Vello)
- Timeline (frame scrubbing, keyframes)
- Tools (pen, select, transform)
- Property panels
- User interaction & editing
**Out of Scope (External Systems)**:
- **Video import/export**: Handled by separate video processing module
- **Audio playback/processing**: Handled by `daw-backend`
- **File I/O**: Coordinated with backend systems
- **Plugin architecture**: TBD in separate crate
## Technology Stack
### UI Framework
- **Primary**: `eframe` 0.29 + `egui` (immediate-mode GUI)
- **Theming**: `egui-aesthetix` for professional appearance
- **Windowing**: `winit` 0.30
- **Native Menus**: `muda` for OS-integrated menus (File, Edit, etc.)
### Rendering
- **GPU**: `wgpu` 22 for low-level GPU access
- **2D Graphics**: `Vello` 0.3 for high-performance vector rendering
- 84% faster than iced in benchmarks
- Used for: Stage, Timeline, Node Editor, Virtual Piano
- **Architecture**: Layer-based rendering
- Each layer (2D animation, video, etc.) renders to texture
- Textures composited together on Stage canvas
### Serialization
- **Format**: JSON (serde_json)
- **Compatibility**: Match existing JS JSON schema for layouts
---
## Implementation Phases
### ✅ Phase 1: Layout System (COMPLETE)
**Status**: Fully implemented and tested
**Features**:
- [x] Workspace structure (lightningbeam-core + lightningbeam-editor)
- [x] JSON layout loading (8 predefined layouts)
- [x] Recursive pane tree rendering
- [x] Layout switching via menu
- [x] Drag-to-resize dividers with visual feedback
- [x] Split operations with live preview
- [x] Join operations (remove splits)
- [x] Context menus on dividers
- [x] ESC/click-outside cancellation
- [x] Pane selection and type switching
**Files**:
- `lightningbeam-core/src/layout.rs` - Core data structures
- `lightningbeam-editor/src/main.rs` - Rendering and interaction
- `assets/layouts.json` - Layout definitions
---
### 🔄 Phase 2: Pane Architecture (CURRENT)
**Goal**: Define proper pane abstraction with header + content sections
**Requirements**:
1. **Pane Trait/Struct**:
```rust
trait Pane {
fn header(&mut self, ui: &mut egui::Ui) -> Option<Response>;
fn content(&mut self, ui: &mut egui::Ui, rect: Rect);
fn name(&self) -> &str;
}
```
2. **Header Section**:
- Optional controls (used by Timeline pane)
- Play/pause, zoom, frame counter
- Collapsible/expandable
3. **Content Section**:
- Main pane body
- Custom rendering per pane type
- Can use egui widgets or custom GPU rendering
4. **Integration**:
- Update `render_pane()` to use new trait
- Support dynamic pane instantiation
- Maintain layout tree structure
**Deliverables**:
- [ ] Pane trait in `lightningbeam-core/src/pane.rs`
- [ ] Example placeholder pane implementation
- [ ] Update main.rs to use pane trait
- [ ] Document pane interface
---
### Phase 3: Native Menu Integration
**Goal**: OS-integrated menu bar using `muda`
**Features**:
- [ ] File menu (New, Open, Save, Export)
- [ ] Edit menu (Undo, Redo, Cut, Copy, Paste)
- [ ] View menu (Layouts, Zoom, Panels)
- [ ] Help menu (Documentation, About)
- [ ] Platform-specific integration (macOS menu bar, Windows menu bar)
- [ ] Keyboard shortcuts
**Dependencies**:
```toml
muda = "*" # Native menu system (what Tauri uses)
```
---
### Phase 4: Stage Pane Implementation
**Goal**: Main canvas with Vello-based layer compositing
**Architecture**:
```
Stage Pane
├── wgpu Surface
├── Vello Scene
└── Layer Renderer
├── Layer 1 → Texture
├── Layer 2 → Texture
├── Layer 3 → Texture (video from external)
└── Composite → Final render
```
**Features**:
- [ ] wgpu surface integration with egui
- [ ] Vello scene management
- [ ] Layer texture rendering
- [ ] Compositing pipeline
- [ ] Camera/viewport controls (pan, zoom)
- [ ] Selection visualization
- [ ] Tool integration (later phase)
**Performance Targets**:
- 60 FPS at 1920x1080
- Sub-16ms frame time
- Smooth layer compositing
---
### Phase 5: Timeline Pane Implementation
**Goal**: Frame-based animation timeline with Vello rendering
**Features**:
- [ ] Frame scrubber with Vello
- [ ] Layer tracks
- [ ] Keyframe visualization
- [ ] Playback controls in header
- [ ] Zoom/pan timeline
- [ ] Frame selection
- [ ] Drag keyframes
- [ ] Multi-selection
**Header Controls**:
- Play/pause button
- Current frame indicator
- FPS selector
- Zoom slider
**Audio Integration**:
- Display audio waveforms (data from `daw-backend`)
- Sync playback with audio system
- No direct audio processing in UI
---
### Phase 6: Additional Panes
**Priority Order**:
1. **Toolbar** (simple, egui widgets)
2. **Info Panel** (property editor, egui widgets)
3. **Outliner** (layer hierarchy, egui tree)
4. **Node Editor** (Vello-based graph)
5. **Piano Roll** (MIDI editor, Vello)
6. **Preset Browser** (file list, egui)
---
### Phase 7: Core Class Migration
**Source**: `~/Dev/Lightningbeam-2/src/models/`
#### Models to Migrate
**From `~/Dev/Lightningbeam-2/src/models/`**:
1. **root.js** (34 lines)
- Document root structure
- → `lightningbeam-core/src/document.rs`
2. **layer.js**
- Layer types (2D, video, audio, etc.)
- Transform properties
- Visibility, opacity
- → `lightningbeam-core/src/layer.rs`
3. **shapes.js** (752 lines)
- Path, Rectangle, Ellipse, Star, Polygon
- Stroke/fill properties
- Bezier curves
- → `lightningbeam-core/src/shape.rs`
4. **animation.js**
- Keyframe data structures
- Interpolation curves
- → `lightningbeam-core/src/animation.rs`
5. **graphics-object.js**
- Base graphics properties
- Transform matrices
- → `lightningbeam-core/src/graphics.rs`
#### Actions to Migrate
**From `~/Dev/Lightningbeam-2/src/actions/`**:
1. **index.js** (2,615 lines)
- Drawing tools
- Transform operations
- Layer operations
- → `lightningbeam-core/src/actions/`
2. **selection-actions.js** (166 lines)
- Selection state management
- → `lightningbeam-core/src/selection.rs`
#### Migration Strategy
1. **Rust-first design**: Leverage Rust's type system, don't just transliterate JS
2. **Serde compatibility**: Ensure classes can serialize/deserialize from existing JSON
3. **Performance**: Use `Arc<RwLock<T>>` for shared mutable state where needed
4. **Memory safety**: Eliminate runtime errors through compile-time checks
---
### Phase 8: Tools & Interaction
**After core classes are migrated**:
- [ ] Pen tool (Bezier curves)
- [ ] Select tool (bounding box, direct selection)
- [ ] Transform tool (rotate, scale, skew)
- [ ] Shape tools (rectangle, ellipse, star, polygon)
- [ ] Text tool
- [ ] Eyedropper
- [ ] Zoom/pan
---
### Phase 9: Feature Parity
**Remaining features from JS version**:
- [ ] Onion skinning
- [ ] Frame export (PNG sequence)
- [ ] Project save/load
- [ ] Undo/redo system
- [ ] Preferences/settings
- [ ] Keyboard shortcuts
- [ ] Help/documentation
- [ ] Clipboard operations (copy/paste)
---
## Architecture Decisions
### Why egui over iced?
- **Vello compatibility**: iced has issues with Vello integration
- **Immediate mode**: Simpler state management for complex UI
- **Maturity**: More stable and well-documented
- **Performance**: Good enough for our needs
### Layer Rendering Strategy
```rust
// Conceptual pipeline
for layer in document.layers {
let texture = layer.render_to_texture(vello_renderer);
composite_textures.push(texture);
}
let final_image = compositor.blend(composite_textures);
stage.display(final_image);
```
### State Management
- **Document state**: `Arc<RwLock<Document>>` - shared across panes
- **Selection state**: Event-based updates
- **UI state**: Local to each pane (egui handles this)
### External System Integration
**Video Layers**:
- UI requests frame texture from video processing module
- Module returns GPU texture handle
- UI composites texture with other layers
**Audio Playback**:
- UI sends playback commands to `daw-backend`
- Backend handles audio processing/mixing
- UI displays waveforms (data from backend)
---
## File Structure (Target)
```
lightningbeam-ui/
├── lightningbeam-core/ # Pure Rust library
│ ├── src/
│ │ ├── lib.rs
│ │ ├── layout.rs # ✅ Layout system
│ │ ├── pane.rs # ⏳ Pane trait
│ │ ├── document.rs # ❌ Document root
│ │ ├── layer.rs # ❌ Layer types
│ │ ├── shape.rs # ❌ Shape primitives
│ │ ├── animation.rs # ❌ Keyframes
│ │ ├── graphics.rs # ❌ Graphics base
│ │ ├── selection.rs # ❌ Selection state
│ │ └── actions/ # ❌ Action system
│ └── Cargo.toml
├── lightningbeam-editor/ # egui application
│ ├── src/
│ │ ├── main.rs # ✅ App + layout rendering
│ │ ├── menu.rs # ❌ Native menu integration (muda)
│ │ ├── panes/ # ⏳ Pane implementations
│ │ │ ├── stage.rs # ❌ Stage pane
│ │ │ ├── timeline.rs # ❌ Timeline pane
│ │ │ ├── toolbar.rs # ❌ Toolbar
│ │ │ ├── infopanel.rs # ❌ Info panel
│ │ │ └── outliner.rs # ❌ Outliner
│ │ ├── rendering/ # ❌ Vello integration
│ │ └── tools/ # ❌ Drawing tools
│ ├── assets/
│ │ └── layouts.json # ✅ Layout definitions
│ └── Cargo.toml
└── Cargo.toml # Workspace
```
Legend: ✅ Complete | ⏳ In Progress | ❌ Not Started
---
## Dependencies Roadmap
### Current (Phase 1)
```toml
eframe = "0.29" # UI framework
wgpu = "22" # GPU
vello = "0.3" # 2D rendering
kurbo = "0.11" # 2D geometry
peniko = "0.5" # 2D primitives
serde = "1.0" # Serialization
serde_json = "1.0" # JSON
```
### Phase 2-3 Additions
```toml
muda = "*" # Native OS menus
```
### Phase 4+ Additions
```toml
image = "*" # Image loading
egui-aesthetix = "*" # Theming
clipboard = "*" # Copy/paste
```
---
## Performance Goals
### Rendering
- **Stage**: 60 FPS @ 1080p, 100+ layers
- **Timeline**: Smooth scrolling with 1000+ frames
- **Memory**: < 500MB for typical project
### Benchmarks to Track
- Layer render time
- Composite time
- UI frame time
- Memory usage per layer
---
## Testing Strategy
1. **Unit tests**: Core classes (Layer, Shape, Animation)
2. **Integration tests**: Pane rendering
3. **Visual tests**: Screenshot comparison for rendering
4. **Performance tests**: Benchmark critical paths
5. **Manual testing**: UI interaction, edge cases
---
## Migration Checklist
### Before Deprecating JS Version
- [ ] All panes functional
- [ ] Core classes migrated
- [ ] Project save/load working
- [ ] Performance meets/exceeds JS version
- [ ] No critical bugs
- [ ] User testing complete
- [ ] Native menus integrated
- [ ] External system integration verified (video, audio)
---
## Assets Management
**Current Approach**:
- SVG icons referenced from `~/Dev/Lightningbeam-2/src/assets/`
- Allows pulling in new icons/changes from upstream during rewrite
- Using `egui_extras::RetainedImage` for cached SVG rendering
**TODO (Before Release)**:
- [ ] Move assets to `lightningbeam-editor/assets/icons/`
- [ ] Update asset paths in code
- [ ] Set up asset bundling/embedding
---
## Open Questions
1. **Plugin system**: Support for extensions?
2. **Scripting**: Embed Lua/JavaScript for automation?
3. **Collaborative editing**: Future consideration?
---
## References
- **JS Source**: `~/Dev/Lightningbeam-2/src/`
- **egui docs**: https://docs.rs/egui
- **Vello docs**: https://docs.rs/vello
- **wgpu docs**: https://docs.rs/wgpu
- **muda docs**: https://docs.rs/muda

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,563 @@
/// Native menu implementation using muda
///
/// This module creates the native menu bar with all menu items matching
/// the JavaScript version's menu structure.
use muda::{
accelerator::{Accelerator, Code, Modifiers},
Menu, MenuItem, PredefinedMenuItem, Submenu,
};
/// All possible menu actions that can be triggered
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
// File menu
NewFile,
NewWindow,
Save,
SaveAs,
OpenFile,
Revert,
Import,
Export,
Quit,
// Edit menu
Undo,
Redo,
Cut,
Copy,
Paste,
Delete,
SelectAll,
SelectNone,
Preferences,
// Modify menu
Group,
SendToBack,
BringToFront,
// Layer menu
AddLayer,
AddVideoLayer,
AddAudioTrack,
AddMidiTrack,
DeleteLayer,
ToggleLayerVisibility,
// Timeline menu
NewKeyframe,
NewBlankKeyframe,
DeleteFrame,
DuplicateKeyframe,
AddKeyframeAtPlayhead,
AddMotionTween,
AddShapeTween,
ReturnToStart,
Play,
// View menu
ZoomIn,
ZoomOut,
ActualSize,
RecenterView,
NextLayout,
PreviousLayout,
SwitchLayout(usize),
// Help menu
About,
// Lightningbeam menu (macOS)
Settings,
CloseWindow,
}
/// Menu system that holds all menu items and can dispatch actions
pub struct MenuSystem {
#[allow(dead_code)]
menu: Menu,
items: Vec<(MenuItem, MenuAction)>,
}
impl MenuSystem {
/// Create a new menu system with all menus and items
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let menu = Menu::new();
let mut items = Vec::new();
// Platform-specific: Add "Lightningbeam" menu on macOS
#[cfg(target_os = "macos")]
{
let app_menu = Submenu::new("Lightningbeam", true);
let about_item = MenuItem::new("About Lightningbeam", true, None);
items.push((about_item.clone(), MenuAction::About));
app_menu.append(&about_item)?;
app_menu.append(&PredefinedMenuItem::separator())?;
let settings_item = MenuItem::new(
"Settings",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::Comma)),
);
items.push((settings_item.clone(), MenuAction::Settings));
app_menu.append(&settings_item)?;
app_menu.append(&PredefinedMenuItem::separator())?;
let close_item = MenuItem::new(
"Close Window",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::KeyW)),
);
items.push((close_item.clone(), MenuAction::CloseWindow));
app_menu.append(&close_item)?;
let quit_item = MenuItem::new(
"Quit Lightningbeam",
true,
Some(Accelerator::new(Some(Modifiers::META), Code::KeyQ)),
);
items.push((quit_item.clone(), MenuAction::Quit));
app_menu.append(&quit_item)?;
menu.append(&app_menu)?;
}
// File menu
let file_menu = Submenu::new("File", true);
let new_file = MenuItem::new(
"New file...",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)),
);
items.push((new_file.clone(), MenuAction::NewFile));
file_menu.append(&new_file)?;
let new_window = MenuItem::new(
"New Window",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyN,
)),
);
items.push((new_window.clone(), MenuAction::NewWindow));
file_menu.append(&new_window)?;
file_menu.append(&PredefinedMenuItem::separator())?;
let save = MenuItem::new(
"Save",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)),
);
items.push((save.clone(), MenuAction::Save));
file_menu.append(&save)?;
let save_as = MenuItem::new(
"Save As...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyS,
)),
);
items.push((save_as.clone(), MenuAction::SaveAs));
file_menu.append(&save_as)?;
file_menu.append(&PredefinedMenuItem::separator())?;
// Open Recent submenu (placeholder for now)
let open_recent = Submenu::new("Open Recent", true);
file_menu.append(&open_recent)?;
let open_file = MenuItem::new(
"Open File...",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)),
);
items.push((open_file.clone(), MenuAction::OpenFile));
file_menu.append(&open_file)?;
let revert = MenuItem::new("Revert", true, None);
items.push((revert.clone(), MenuAction::Revert));
file_menu.append(&revert)?;
file_menu.append(&PredefinedMenuItem::separator())?;
let import = MenuItem::new(
"Import...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyI,
)),
);
items.push((import.clone(), MenuAction::Import));
file_menu.append(&import)?;
let export = MenuItem::new(
"Export...",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyE,
)),
);
items.push((export.clone(), MenuAction::Export));
file_menu.append(&export)?;
// On non-macOS, add Quit to File menu
#[cfg(not(target_os = "macos"))]
{
file_menu.append(&PredefinedMenuItem::separator())?;
let quit = MenuItem::new(
"Quit",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyQ)),
);
items.push((quit.clone(), MenuAction::Quit));
file_menu.append(&quit)?;
}
menu.append(&file_menu)?;
// Edit menu
let edit_menu = Submenu::new("Edit", true);
let undo = MenuItem::new(
"Undo",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyZ)),
);
items.push((undo.clone(), MenuAction::Undo));
edit_menu.append(&undo)?;
let redo = MenuItem::new(
"Redo",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyZ,
)),
);
items.push((redo.clone(), MenuAction::Redo));
edit_menu.append(&redo)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let cut = MenuItem::new(
"Cut",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyX)),
);
items.push((cut.clone(), MenuAction::Cut));
edit_menu.append(&cut)?;
let copy = MenuItem::new(
"Copy",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyC)),
);
items.push((copy.clone(), MenuAction::Copy));
edit_menu.append(&copy)?;
let paste = MenuItem::new(
"Paste",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyV)),
);
items.push((paste.clone(), MenuAction::Paste));
edit_menu.append(&paste)?;
let delete = MenuItem::new(
"Delete",
true,
Some(Accelerator::new(None, Code::Delete)),
);
items.push((delete.clone(), MenuAction::Delete));
edit_menu.append(&delete)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let select_all = MenuItem::new(
"Select All",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyA)),
);
items.push((select_all.clone(), MenuAction::SelectAll));
edit_menu.append(&select_all)?;
let select_none = MenuItem::new(
"Select None",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyA,
)),
);
items.push((select_none.clone(), MenuAction::SelectNone));
edit_menu.append(&select_none)?;
edit_menu.append(&PredefinedMenuItem::separator())?;
let preferences = MenuItem::new("Preferences", true, None);
items.push((preferences.clone(), MenuAction::Preferences));
edit_menu.append(&preferences)?;
menu.append(&edit_menu)?;
// Modify menu
let modify_menu = Submenu::new("Modify", true);
let group = MenuItem::new(
"Group",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyG)),
);
items.push((group.clone(), MenuAction::Group));
modify_menu.append(&group)?;
modify_menu.append(&PredefinedMenuItem::separator())?;
let send_to_back = MenuItem::new("Send to back", true, None);
items.push((send_to_back.clone(), MenuAction::SendToBack));
modify_menu.append(&send_to_back)?;
let bring_to_front = MenuItem::new("Bring to front", true, None);
items.push((bring_to_front.clone(), MenuAction::BringToFront));
modify_menu.append(&bring_to_front)?;
menu.append(&modify_menu)?;
// Layer menu
let layer_menu = Submenu::new("Layer", true);
let add_layer = MenuItem::new(
"Add Layer",
true,
Some(Accelerator::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT),
Code::KeyL,
)),
);
items.push((add_layer.clone(), MenuAction::AddLayer));
layer_menu.append(&add_layer)?;
let add_video_layer = MenuItem::new("Add Video Layer", true, None);
items.push((add_video_layer.clone(), MenuAction::AddVideoLayer));
layer_menu.append(&add_video_layer)?;
let add_audio_track = MenuItem::new("Add Audio Track", true, None);
items.push((add_audio_track.clone(), MenuAction::AddAudioTrack));
layer_menu.append(&add_audio_track)?;
let add_midi_track = MenuItem::new("Add MIDI Track", true, None);
items.push((add_midi_track.clone(), MenuAction::AddMidiTrack));
layer_menu.append(&add_midi_track)?;
layer_menu.append(&PredefinedMenuItem::separator())?;
let delete_layer = MenuItem::new("Delete Layer", true, None);
items.push((delete_layer.clone(), MenuAction::DeleteLayer));
layer_menu.append(&delete_layer)?;
let toggle_layer = MenuItem::new("Hide/Show Layer", true, None);
items.push((toggle_layer.clone(), MenuAction::ToggleLayerVisibility));
layer_menu.append(&toggle_layer)?;
menu.append(&layer_menu)?;
// Timeline menu
let timeline_menu = Submenu::new("Timeline", true);
let new_keyframe = MenuItem::new(
"New Keyframe",
true,
Some(Accelerator::new(None, Code::KeyK)),
);
items.push((new_keyframe.clone(), MenuAction::NewKeyframe));
timeline_menu.append(&new_keyframe)?;
let new_blank_keyframe = MenuItem::new("New Blank Keyframe", true, None);
items.push((new_blank_keyframe.clone(), MenuAction::NewBlankKeyframe));
timeline_menu.append(&new_blank_keyframe)?;
let delete_frame = MenuItem::new("Delete Frame", true, None);
items.push((delete_frame.clone(), MenuAction::DeleteFrame));
timeline_menu.append(&delete_frame)?;
let duplicate_keyframe = MenuItem::new("Duplicate Keyframe", true, None);
items.push((duplicate_keyframe.clone(), MenuAction::DuplicateKeyframe));
timeline_menu.append(&duplicate_keyframe)?;
let add_keyframe_playhead = MenuItem::new("Add Keyframe at Playhead", true, None);
items.push((add_keyframe_playhead.clone(), MenuAction::AddKeyframeAtPlayhead));
timeline_menu.append(&add_keyframe_playhead)?;
timeline_menu.append(&PredefinedMenuItem::separator())?;
let motion_tween = MenuItem::new("Add Motion Tween", true, None);
items.push((motion_tween.clone(), MenuAction::AddMotionTween));
timeline_menu.append(&motion_tween)?;
let shape_tween = MenuItem::new("Add Shape Tween", true, None);
items.push((shape_tween.clone(), MenuAction::AddShapeTween));
timeline_menu.append(&shape_tween)?;
timeline_menu.append(&PredefinedMenuItem::separator())?;
let return_to_start = MenuItem::new("Return to start", true, None);
items.push((return_to_start.clone(), MenuAction::ReturnToStart));
timeline_menu.append(&return_to_start)?;
let play = MenuItem::new("Play", true, None);
items.push((play.clone(), MenuAction::Play));
timeline_menu.append(&play)?;
menu.append(&timeline_menu)?;
// View menu
let view_menu = Submenu::new("View", true);
let zoom_in = MenuItem::new(
"Zoom In",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Equal)),
);
items.push((zoom_in.clone(), MenuAction::ZoomIn));
view_menu.append(&zoom_in)?;
let zoom_out = MenuItem::new(
"Zoom Out",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Minus)),
);
items.push((zoom_out.clone(), MenuAction::ZoomOut));
view_menu.append(&zoom_out)?;
let actual_size = MenuItem::new(
"Actual Size",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Digit0)),
);
items.push((actual_size.clone(), MenuAction::ActualSize));
view_menu.append(&actual_size)?;
let recenter = MenuItem::new("Recenter View", true, None);
items.push((recenter.clone(), MenuAction::RecenterView));
view_menu.append(&recenter)?;
view_menu.append(&PredefinedMenuItem::separator())?;
// Layout submenu
let layout_submenu = Submenu::new("Layout", true);
let next_layout = MenuItem::new(
"Next Layout",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::BracketRight)),
);
items.push((next_layout.clone(), MenuAction::NextLayout));
layout_submenu.append(&next_layout)?;
let prev_layout = MenuItem::new(
"Previous Layout",
true,
Some(Accelerator::new(Some(Modifiers::CONTROL), Code::BracketLeft)),
);
items.push((prev_layout.clone(), MenuAction::PreviousLayout));
layout_submenu.append(&prev_layout)?;
// TODO: Add dynamic layout list with checkmarks for current layout
// This will need to be updated when layouts change
view_menu.append(&layout_submenu)?;
menu.append(&view_menu)?;
// Help menu
let help_menu = Submenu::new("Help", true);
let about = MenuItem::new("About...", true, None);
items.push((about.clone(), MenuAction::About));
help_menu.append(&about)?;
menu.append(&help_menu)?;
Ok(Self { menu, items })
}
/// Initialize the menu for the application window
#[cfg(target_os = "linux")]
pub fn init_for_gtk(&self, window: &gtk::ApplicationWindow, container: Option<&gtk::Box>) -> Result<(), Box<dyn std::error::Error>> {
self.menu.init_for_gtk_window(window, container)?;
Ok(())
}
/// Initialize the menu for macOS (app-wide)
#[cfg(target_os = "macos")]
pub fn init_for_macos(&self) {
self.menu.init_for_nsapp();
}
/// Check if any menu item was triggered and return the action
pub fn check_events(&self) -> Option<MenuAction> {
for (item, action) in &self.items {
if let Ok(event) = muda::MenuEvent::receiver().try_recv() {
if event.id == item.id() {
return Some(*action);
}
}
}
None
}
/// Update menu item text dynamically (e.g., for Undo/Redo with action names)
#[allow(dead_code)]
pub fn update_undo_text(&self, action_name: Option<&str>) {
// Find the Undo menu item and update its text
for (item, action) in &self.items {
if *action == MenuAction::Undo {
let text = if let Some(name) = action_name {
format!("Undo {}", name)
} else {
"Undo".to_string()
};
let _ = item.set_text(text);
break;
}
}
}
/// Update menu item text dynamically for Redo
#[allow(dead_code)]
pub fn update_redo_text(&self, action_name: Option<&str>) {
for (item, action) in &self.items {
if *action == MenuAction::Redo {
let text = if let Some(name) = action_name {
format!("Redo {}", name)
} else {
"Redo".to_string()
};
let _ = item.set_text(text);
break;
}
}
}
/// Enable or disable a menu item
#[allow(dead_code)]
pub fn set_enabled(&self, action: MenuAction, enabled: bool) {
for (item, item_action) in &self.items {
if *item_action == action {
let _ = item.set_enabled(enabled);
break;
}
}
}
}

View File

@ -0,0 +1,45 @@
/// Info Panel pane - displays properties of selected objects
///
/// This will eventually show editable properties.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct InfopanelPane {}
impl InfopanelPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for InfopanelPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(30, 50, 40),
);
let text = "Info Panel\n(TODO: Implement property editor)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Info Panel"
}
}

View File

@ -0,0 +1,145 @@
/// Pane implementations for the editor
///
/// Each pane type has its own module with implementation details.
/// Panes can hold local state and access shared state through SharedPaneState.
use eframe::egui;
use lightningbeam_core::{pane::PaneType, tool::Tool};
// Type alias for node paths (matches main.rs)
pub type NodePath = Vec<usize>;
pub mod toolbar;
pub mod stage;
pub mod timeline;
pub mod infopanel;
pub mod outliner;
pub mod piano_roll;
pub mod node_editor;
pub mod preset_browser;
/// Shared state that all panes can access
pub struct SharedPaneState<'a> {
pub tool_icon_cache: &'a mut crate::ToolIconCache,
pub icon_cache: &'a mut crate::IconCache,
pub selected_tool: &'a mut Tool,
pub fill_color: &'a mut egui::Color32,
pub stroke_color: &'a mut egui::Color32,
}
/// Trait for pane rendering
///
/// Panes implement this trait to provide custom rendering logic.
/// The header is optional and typically used for controls (e.g., Timeline playback).
/// The content area is the main body of the pane.
pub trait PaneRenderer {
/// Render the optional header section with controls
///
/// Returns true if a header was rendered, false if no header
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
false // Default: no header
}
/// Render the main content area
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
path: &NodePath,
shared: &mut SharedPaneState,
);
/// Get the display name of this pane
fn name(&self) -> &str;
}
/// Enum wrapper for all pane implementations (enum dispatch pattern)
pub enum PaneInstance {
Stage(stage::StagePane),
Timeline(timeline::TimelinePane),
Toolbar(toolbar::ToolbarPane),
Infopanel(infopanel::InfopanelPane),
Outliner(outliner::OutlinerPane),
PianoRoll(piano_roll::PianoRollPane),
NodeEditor(node_editor::NodeEditorPane),
PresetBrowser(preset_browser::PresetBrowserPane),
}
impl PaneInstance {
/// Create a new pane instance for the given type
pub fn new(pane_type: PaneType) -> Self {
match pane_type {
PaneType::Stage => PaneInstance::Stage(stage::StagePane::new()),
PaneType::Timeline => PaneInstance::Timeline(timeline::TimelinePane::new()),
PaneType::Toolbar => PaneInstance::Toolbar(toolbar::ToolbarPane::new()),
PaneType::Infopanel => PaneInstance::Infopanel(infopanel::InfopanelPane::new()),
PaneType::Outliner => PaneInstance::Outliner(outliner::OutlinerPane::new()),
PaneType::PianoRoll => PaneInstance::PianoRoll(piano_roll::PianoRollPane::new()),
PaneType::NodeEditor => PaneInstance::NodeEditor(node_editor::NodeEditorPane::new()),
PaneType::PresetBrowser => {
PaneInstance::PresetBrowser(preset_browser::PresetBrowserPane::new())
}
}
}
/// Get the pane type of this instance
pub fn pane_type(&self) -> PaneType {
match self {
PaneInstance::Stage(_) => PaneType::Stage,
PaneInstance::Timeline(_) => PaneType::Timeline,
PaneInstance::Toolbar(_) => PaneType::Toolbar,
PaneInstance::Infopanel(_) => PaneType::Infopanel,
PaneInstance::Outliner(_) => PaneType::Outliner,
PaneInstance::PianoRoll(_) => PaneType::PianoRoll,
PaneInstance::NodeEditor(_) => PaneType::NodeEditor,
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
}
}
}
impl PaneRenderer for PaneInstance {
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
match self {
PaneInstance::Stage(p) => p.render_header(ui, shared),
PaneInstance::Timeline(p) => p.render_header(ui, shared),
PaneInstance::Toolbar(p) => p.render_header(ui, shared),
PaneInstance::Infopanel(p) => p.render_header(ui, shared),
PaneInstance::Outliner(p) => p.render_header(ui, shared),
PaneInstance::PianoRoll(p) => p.render_header(ui, shared),
PaneInstance::NodeEditor(p) => p.render_header(ui, shared),
PaneInstance::PresetBrowser(p) => p.render_header(ui, shared),
}
}
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
path: &NodePath,
shared: &mut SharedPaneState,
) {
match self {
PaneInstance::Stage(p) => p.render_content(ui, rect, path, shared),
PaneInstance::Timeline(p) => p.render_content(ui, rect, path, shared),
PaneInstance::Toolbar(p) => p.render_content(ui, rect, path, shared),
PaneInstance::Infopanel(p) => p.render_content(ui, rect, path, shared),
PaneInstance::Outliner(p) => p.render_content(ui, rect, path, shared),
PaneInstance::PianoRoll(p) => p.render_content(ui, rect, path, shared),
PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared),
PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared),
}
}
fn name(&self) -> &str {
match self {
PaneInstance::Stage(p) => p.name(),
PaneInstance::Timeline(p) => p.name(),
PaneInstance::Toolbar(p) => p.name(),
PaneInstance::Infopanel(p) => p.name(),
PaneInstance::Outliner(p) => p.name(),
PaneInstance::PianoRoll(p) => p.name(),
PaneInstance::NodeEditor(p) => p.name(),
PaneInstance::PresetBrowser(p) => p.name(),
}
}
}

View File

@ -0,0 +1,45 @@
/// Node Editor pane - node-based visual programming
///
/// This will eventually render a node graph with Vello.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct NodeEditorPane {}
impl NodeEditorPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for NodeEditorPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(30, 45, 50),
);
let text = "Node Editor\n(TODO: Implement node graph)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Node Editor"
}
}

View File

@ -0,0 +1,47 @@
/// Outliner pane - layer hierarchy view
///
/// This will eventually show a tree view of layers.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct OutlinerPane {
// TODO: Add tree expansion state
}
impl OutlinerPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for OutlinerPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(40, 50, 30),
);
let text = "Outliner\n(TODO: Implement layer tree)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Outliner"
}
}

View File

@ -0,0 +1,45 @@
/// Piano Roll pane - MIDI editor
///
/// This will eventually render a piano roll with Vello.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct PianoRollPane {}
impl PianoRollPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for PianoRollPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(55, 35, 45),
);
let text = "Piano Roll\n(TODO: Implement MIDI editor)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Piano Roll"
}
}

View File

@ -0,0 +1,45 @@
/// Preset Browser pane - asset and preset library
///
/// This will eventually show a file browser for presets.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct PresetBrowserPane {}
impl PresetBrowserPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for PresetBrowserPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(50, 45, 30),
);
let text = "Preset Browser\n(TODO: Implement file browser)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Preset Browser"
}
}

View File

@ -0,0 +1,47 @@
/// Stage pane - main animation canvas
///
/// This will eventually render the composited layers using Vello.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct StagePane {
// TODO: Add state for camera, selection, etc.
}
impl StagePane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for StagePane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(30, 40, 50),
);
let text = "Stage Pane\n(TODO: Implement Vello rendering)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Stage"
}
}

View File

@ -0,0 +1,57 @@
/// Timeline pane - frame-based animation timeline
///
/// This will eventually render keyframes, layers, and playback controls.
/// For now, it's a placeholder.
use eframe::egui;
use super::{NodePath, PaneRenderer, SharedPaneState};
pub struct TimelinePane {
// TODO: Add state for zoom, scroll, playback, etc.
}
impl TimelinePane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for TimelinePane {
fn render_header(&mut self, ui: &mut egui::Ui, _shared: &mut SharedPaneState) -> bool {
// TODO: Add playback controls (play/pause, frame counter, zoom)
ui.horizontal(|ui| {
ui.label("");
ui.label("Frame: 0");
ui.label("FPS: 24");
});
true // Header was rendered
}
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
_path: &NodePath,
_shared: &mut SharedPaneState,
) {
// Placeholder rendering
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgb(40, 30, 50),
);
let text = "Timeline Pane\n(TODO: Implement frame scrubbing)";
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(16.0),
egui::Color32::from_gray(150),
);
}
fn name(&self) -> &str {
"Timeline"
}
}

View File

@ -0,0 +1,235 @@
/// Toolbar pane - displays drawing tool buttons
///
/// The toolbar shows all available drawing tools in a responsive grid layout.
/// Users can click to select tools, which updates the global selected_tool state.
use eframe::egui;
use lightningbeam_core::tool::Tool;
use super::{NodePath, PaneRenderer, SharedPaneState};
/// Toolbar pane state
pub struct ToolbarPane {
// No local state needed for toolbar
}
impl ToolbarPane {
pub fn new() -> Self {
Self {}
}
}
impl PaneRenderer for ToolbarPane {
fn render_content(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
path: &NodePath,
shared: &mut SharedPaneState,
) {
let button_size = 60.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
// Calculate total number of tools and rows
let tools = Tool::all();
let total_tools = tools.len();
let total_rows = (total_tools + columns - 1) / columns;
let mut y = rect.top() + button_padding;
// Process tools row by row for centered layout
for row in 0..total_rows {
let start_idx = row * columns;
let end_idx = (start_idx + columns).min(total_tools);
let buttons_in_row = end_idx - start_idx;
// Calculate the total width of buttons in this row
let row_width = (buttons_in_row as f32 * button_size)
+ ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing);
// Center the row
let mut x = rect.left() + (rect.width() - row_width) / 2.0;
for tool_idx in start_idx..end_idx {
let tool = &tools[tool_idx];
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 = *shared.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) = shared.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() {
*shared.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 column in this row
x += button_size + button_spacing;
}
// Move to next row
y += button_size + button_spacing;
}
// Add color pickers below the tool buttons
y += button_spacing * 2.0; // Extra spacing
// Fill Color
let fill_label_width = 40.0;
let color_button_size = 50.0;
let color_row_width = fill_label_width + color_button_size + button_spacing;
let color_x = rect.left() + (rect.width() - color_row_width) / 2.0;
// Fill color label
ui.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
egui::Align2::CENTER_CENTER,
"Fill",
egui::FontId::proportional(14.0),
egui::Color32::from_gray(200),
);
// Fill color button
let fill_button_rect = egui::Rect::from_min_size(
egui::pos2(color_x + fill_label_width + button_spacing, y),
egui::vec2(color_button_size, color_button_size),
);
let fill_button_id = ui.id().with(("fill_color_button", path));
let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click());
// Draw fill color button with checkerboard for alpha
draw_color_button(ui, fill_button_rect, *shared.fill_color);
if fill_response.clicked() {
// Open color picker popup
ui.memory_mut(|mem| mem.toggle_popup(fill_button_id));
}
// Show fill color picker popup
egui::popup::popup_below_widget(ui, fill_button_id, &fill_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| {
egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend);
});
y += color_button_size + button_spacing;
// Stroke color label
ui.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
egui::Align2::CENTER_CENTER,
"Stroke",
egui::FontId::proportional(14.0),
egui::Color32::from_gray(200),
);
// Stroke color button
let stroke_button_rect = egui::Rect::from_min_size(
egui::pos2(color_x + fill_label_width + button_spacing, y),
egui::vec2(color_button_size, color_button_size),
);
let stroke_button_id = ui.id().with(("stroke_color_button", path));
let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click());
// Draw stroke color button with checkerboard for alpha
draw_color_button(ui, stroke_button_rect, *shared.stroke_color);
if stroke_response.clicked() {
// Open color picker popup
ui.memory_mut(|mem| mem.toggle_popup(stroke_button_id));
}
// Show stroke color picker popup
egui::popup::popup_below_widget(ui, stroke_button_id, &stroke_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| {
egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend);
});
}
fn name(&self) -> &str {
"Toolbar"
}
}
/// Draw a color button with checkerboard background for alpha channel
fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) {
// Draw checkerboard background
let checker_size = 5.0;
let cols = (rect.width() / checker_size).ceil() as usize;
let rows = (rect.height() / checker_size).ceil() as usize;
for row in 0..rows {
for col in 0..cols {
let is_light = (row + col) % 2 == 0;
let checker_color = if is_light {
egui::Color32::from_gray(180)
} else {
egui::Color32::from_gray(120)
};
let checker_rect = egui::Rect::from_min_size(
egui::pos2(
rect.min.x + col as f32 * checker_size,
rect.min.y + row as f32 * checker_size,
),
egui::vec2(checker_size, checker_size),
).intersect(rect);
ui.painter().rect_filled(checker_rect, 0.0, checker_color);
}
}
// Draw color on top
ui.painter().rect_filled(rect, 2.0, color);
// Draw border
ui.painter().rect_stroke(
rect,
2.0,
egui::Stroke::new(1.0, egui::Color32::from_gray(80)),
);
}

1281
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,14 @@ lru = "0.12"
# WebSocket for frame streaming (disable default features to remove tracing, but keep handshake)
tungstenite = { version = "0.20", default-features = false, features = ["handshake"] }
# Native rendering with wgpu
wgpu = "0.19"
winit = "0.29"
pollster = "0.3"
bytemuck = { version = "1.14", features = ["derive"] }
raw-window-handle = "0.6"
image = "0.24"
[profile.dev]
opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance

View File

@ -0,0 +1,230 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::get,
Router,
};
use flume::{Sender, unbounded};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::Duration;
/// Playback state for a video pool
#[derive(Clone, Debug)]
pub struct PlaybackState {
pub is_playing: bool,
pub target_fps: f64,
pub current_time: f64,
}
/// Shared server state
pub struct ServerState {
/// Connected clients
clients: RwLock<Vec<Sender<Vec<u8>>>>,
/// Playback state per pool index
playback_state: RwLock<HashMap<usize, PlaybackState>>,
}
impl ServerState {
pub fn new() -> Self {
Self {
clients: RwLock::new(Vec::new()),
playback_state: RwLock::new(HashMap::new()),
}
}
/// Register a new client
pub async fn add_client(&self, sender: Sender<Vec<u8>>) {
let mut clients = self.clients.write().await;
clients.push(sender);
eprintln!("[Async Frame Streamer] Client registered, total: {}", clients.len());
}
/// Broadcast a frame to all connected clients
pub async fn broadcast_frame(&self, frame_data: Vec<u8>) {
let clients = self.clients.read().await;
// Send to all clients
for client in clients.iter() {
// Non-blocking send, drop frame if client is slow
let _ = client.try_send(frame_data.clone());
}
}
/// Remove disconnected clients
pub async fn cleanup_clients(&self) {
let mut clients = self.clients.write().await;
clients.retain(|client| !client.is_disconnected());
eprintln!("[Async Frame Streamer] Cleaned up clients, remaining: {}", clients.len());
}
/// Update playback state for a pool
pub async fn set_playback_state(&self, pool_index: usize, state: PlaybackState) {
let mut states = self.playback_state.write().await;
states.insert(pool_index, state);
}
/// Get playback state for a pool
pub async fn get_playback_state(&self, pool_index: usize) -> Option<PlaybackState> {
let states = self.playback_state.read().await;
states.get(&pool_index).cloned()
}
}
pub struct AsyncFrameStreamer {
port: u16,
state: Arc<ServerState>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl AsyncFrameStreamer {
pub async fn new() -> Result<Self, String> {
let state = Arc::new(ServerState::new());
// Create router with WebSocket upgrade handler
let app_state = state.clone();
let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(app_state);
// Bind to localhost on a random port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind: {}", e))?;
let port = listener
.local_addr()
.map_err(|e| format!("Failed to get address: {}", e))?
.port();
eprintln!("[Async Frame Streamer] WebSocket server starting on port {}", port);
// Spawn server task
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await
.expect("Server error");
});
eprintln!("[Async Frame Streamer] Server started");
Ok(Self {
port,
state,
shutdown_tx: Some(shutdown_tx),
})
}
pub fn port(&self) -> u16 {
self.port
}
/// Send a frame to all connected clients for a specific pool
/// Frame format: [pool_index: u32][timestamp_ms: u32][width: u32][height: u32][rgba_data...]
pub async fn send_frame(&self, pool_index: usize, timestamp: f64, width: u32, height: u32, rgba_data: &[u8]) {
// Build frame message
let mut frame_msg = Vec::with_capacity(16 + rgba_data.len());
frame_msg.extend_from_slice(&(pool_index as u32).to_le_bytes());
frame_msg.extend_from_slice(&((timestamp * 1000.0) as u32).to_le_bytes());
frame_msg.extend_from_slice(&width.to_le_bytes());
frame_msg.extend_from_slice(&height.to_le_bytes());
frame_msg.extend_from_slice(rgba_data);
// Broadcast to all connected clients
self.state.broadcast_frame(frame_msg).await;
}
/// Start streaming frames for a pool at a target FPS
pub async fn start_stream(&self, pool_index: usize, fps: f64) {
let state = PlaybackState {
is_playing: true,
target_fps: fps,
current_time: 0.0,
};
self.state.set_playback_state(pool_index, state).await;
eprintln!("[Async Frame Streamer] Started streaming pool {} at {} FPS", pool_index, fps);
}
/// Stop streaming frames for a pool
pub async fn stop_stream(&self, pool_index: usize) {
if let Some(mut state) = self.state.get_playback_state(pool_index).await {
state.is_playing = false;
self.state.set_playback_state(pool_index, state).await;
eprintln!("[Async Frame Streamer] Stopped streaming pool {}", pool_index);
}
}
/// Seek to a specific time in a pool
pub async fn seek(&self, pool_index: usize, timestamp: f64) {
if let Some(mut state) = self.state.get_playback_state(pool_index).await {
state.current_time = timestamp;
self.state.set_playback_state(pool_index, state).await;
}
}
}
impl Drop for AsyncFrameStreamer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
/// WebSocket handler
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<ServerState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
/// Handle individual WebSocket connection
async fn handle_socket(mut socket: WebSocket, state: Arc<ServerState>) {
eprintln!("[Async Frame Streamer] New WebSocket connection");
// Create a channel for this client
let (tx, rx) = unbounded::<Vec<u8>>();
// Register this client
state.add_client(tx).await;
// Spawn task to send frames to this client
let mut rx = rx;
let mut send_task = tokio::spawn(async move {
while let Ok(frame) = rx.recv_async().await {
if socket.send(Message::Binary(frame)).await.is_err() {
eprintln!("[Async Frame Streamer] Failed to send frame to client");
break;
}
}
eprintln!("[Async Frame Streamer] Send task ended");
});
// Keep connection alive with ping/pong
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
// Connection alive, no need to ping in this simple implementation
}
_ = &mut send_task => {
eprintln!("[Async Frame Streamer] Send task completed, closing connection");
break;
}
}
}
// Cleanup
state.cleanup_clients().await;
}

View File

@ -6,6 +6,8 @@ use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder};
mod audio;
mod video;
mod frame_streamer;
mod renderer;
mod render_window;
#[derive(Default)]
@ -13,6 +15,12 @@ struct AppState {
counter: u32,
}
struct RenderWindowState {
handle: Option<render_window::RenderWindowHandle>,
canvas_offset: (i32, i32), // Canvas position relative to window
canvas_size: (u32, u32),
}
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
@ -48,6 +56,165 @@ fn get_frame_streamer_port(
streamer.port()
}
// Render window commands
#[tauri::command]
fn render_window_create(
x: i32,
y: i32,
width: u32,
height: u32,
canvas_offset_x: i32,
canvas_offset_y: i32,
app: tauri::AppHandle,
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let mut render_state = state.lock().unwrap();
if render_state.handle.is_some() {
return Err("Render window already exists".to_string());
}
let handle = render_window::spawn_render_window(x, y, width, height)?;
render_state.handle = Some(handle);
render_state.canvas_offset = (canvas_offset_x, canvas_offset_y);
render_state.canvas_size = (width, height);
// Start a background thread to poll main window position
let state_clone = state.inner().clone();
let app_clone = app.clone();
std::thread::spawn(move || {
let mut last_pos: Option<(i32, i32)> = None;
loop {
std::thread::sleep(std::time::Duration::from_millis(50));
if let Some(main_window) = app_clone.get_webview_window("main") {
if let Ok(pos) = main_window.outer_position() {
let current_pos = (pos.x, pos.y);
// Only update if position actually changed
if last_pos != Some(current_pos) {
eprintln!("[WindowSync] Main window position: {:?}", current_pos);
let render_state = state_clone.lock().unwrap();
if let Some(handle) = &render_state.handle {
let new_x = pos.x + render_state.canvas_offset.0;
let new_y = pos.y + render_state.canvas_offset.1;
handle.set_position(new_x, new_y);
last_pos = Some(current_pos);
} else {
break; // No handle, exit thread
}
}
} else {
break; // Window closed, exit thread
}
} else {
break; // Main window gone, exit thread
}
}
});
Ok(())
}
#[tauri::command]
fn render_window_update_gradient(
top_r: f32,
top_g: f32,
top_b: f32,
top_a: f32,
bottom_r: f32,
bottom_g: f32,
bottom_b: f32,
bottom_a: f32,
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let render_state = state.lock().unwrap();
if let Some(handle) = &render_state.handle {
handle.update_gradient(
[top_r, top_g, top_b, top_a],
[bottom_r, bottom_g, bottom_b, bottom_a],
);
Ok(())
} else {
Err("Render window not created".to_string())
}
}
#[tauri::command]
fn render_window_set_position(
x: i32,
y: i32,
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let render_state = state.lock().unwrap();
if let Some(handle) = &render_state.handle {
handle.set_position(x, y);
Ok(())
} else {
Err("Render window not created".to_string())
}
}
#[tauri::command]
fn render_window_sync_position(
app: tauri::AppHandle,
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let render_state = state.lock().unwrap();
if let Some(main_window) = app.get_webview_window("main") {
if let Ok(pos) = main_window.outer_position() {
if let Some(handle) = &render_state.handle {
let new_x = pos.x + render_state.canvas_offset.0;
let new_y = pos.y + render_state.canvas_offset.1;
eprintln!("[Manual Sync] Updating to ({}, {})", new_x, new_y);
handle.set_position(new_x, new_y);
Ok(())
} else {
Err("Render window not created".to_string())
}
} else {
Err("Could not get window position".to_string())
}
} else {
Err("Main window not found".to_string())
}
}
#[tauri::command]
fn render_window_set_size(
width: u32,
height: u32,
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let render_state = state.lock().unwrap();
if let Some(handle) = &render_state.handle {
handle.set_size(width, height);
Ok(())
} else {
Err("Render window not created".to_string())
}
}
#[tauri::command]
fn render_window_close(
state: tauri::State<'_, Arc<Mutex<RenderWindowState>>>,
) -> Result<(), String> {
let mut render_state = state.lock().unwrap();
if let Some(handle) = render_state.handle.take() {
handle.close();
Ok(())
} else {
Err("Render window not created".to_string())
}
}
use tauri::PhysicalSize;
#[tauri::command]
@ -148,6 +315,11 @@ pub fn run() {
.manage(Arc::new(Mutex::new(audio::AudioState::default())))
.manage(Arc::new(Mutex::new(video::VideoState::default())))
.manage(Arc::new(Mutex::new(frame_streamer)))
.manage(Arc::new(Mutex::new(RenderWindowState {
handle: None,
canvas_offset: (0, 0),
canvas_size: (0, 0),
})))
.setup(|app| {
#[cfg(any(windows, target_os = "linux"))] // Windows/Linux needs different handling from macOS
{
@ -215,6 +387,12 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
greet, trace, debug, info, warn, error, create_window, get_frame_streamer_port,
render_window_create,
render_window_update_gradient,
render_window_set_position,
render_window_set_size,
render_window_sync_position,
render_window_close,
audio::audio_init,
audio::audio_reset,
audio::audio_play,

View File

@ -0,0 +1,161 @@
use std::sync::Arc;
use std::thread;
use winit::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy},
window::WindowBuilder,
};
#[cfg(target_os = "linux")]
use winit::platform::x11::EventLoopBuilderExtX11;
use crate::renderer::Renderer;
/// Events that can be sent to the render window thread
#[derive(Debug, Clone)]
pub enum RenderEvent {
UpdateGradient { top: [f32; 4], bottom: [f32; 4] },
SetPosition { x: i32, y: i32 },
SetSize { width: u32, height: u32 },
RequestRedraw,
Close,
}
/// Handle to control the render window from other threads
pub struct RenderWindowHandle {
proxy: EventLoopProxy<RenderEvent>,
}
impl RenderWindowHandle {
/// Update the gradient colors
pub fn update_gradient(&self, top: [f32; 4], bottom: [f32; 4]) {
let _ = self.proxy.send_event(RenderEvent::UpdateGradient { top, bottom });
}
/// Set window position
pub fn set_position(&self, x: i32, y: i32) {
let _ = self.proxy.send_event(RenderEvent::SetPosition { x, y });
}
/// Set window size
pub fn set_size(&self, width: u32, height: u32) {
let _ = self.proxy.send_event(RenderEvent::SetSize { width, height });
}
/// Request a redraw
pub fn request_redraw(&self) {
let _ = self.proxy.send_event(RenderEvent::RequestRedraw);
}
/// Close the render window
pub fn close(&self) {
let _ = self.proxy.send_event(RenderEvent::Close);
}
}
/// Spawn the render window in a separate thread
pub fn spawn_render_window(
x: i32,
y: i32,
width: u32,
height: u32,
) -> Result<RenderWindowHandle, String> {
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let mut event_loop_builder = EventLoopBuilder::with_user_event();
// On Linux, allow event loop on any thread (not just main thread)
#[cfg(target_os = "linux")]
{
event_loop_builder.with_any_thread(true);
}
let event_loop: EventLoop<RenderEvent> = event_loop_builder.build().unwrap();
let proxy = event_loop.create_proxy();
// Send the proxy back to the main thread
tx.send(proxy.clone()).unwrap();
let window = WindowBuilder::new()
.with_title("Lightningbeam Renderer")
.with_inner_size(winit::dpi::PhysicalSize::new(width, height))
.with_position(winit::dpi::PhysicalPosition::new(x, y))
.with_decorations(false) // No title bar
.with_transparent(false) // Opaque background
.with_resizable(false)
.build(&event_loop)
.unwrap();
let window = Arc::new(window);
// Initialize renderer (async operation)
let mut renderer = pollster::block_on(Renderer::new(window.clone()));
event_loop.run(move |event, elwt| {
elwt.set_control_flow(ControlFlow::Wait);
match event {
Event::UserEvent(render_event) => match render_event {
RenderEvent::UpdateGradient { top, bottom } => {
eprintln!("[RenderWindow] Updating gradient: {:?} -> {:?}", top, bottom);
renderer.update_gradient(top, bottom);
window.request_redraw();
}
RenderEvent::SetPosition { x, y } => {
eprintln!("[RenderWindow] Setting position: ({}, {})", x, y);
let _ = window.set_outer_position(winit::dpi::PhysicalPosition::new(x, y));
}
RenderEvent::SetSize { width, height } => {
eprintln!("[RenderWindow] Setting size: {}x{}", width, height);
let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(width, height));
}
RenderEvent::RequestRedraw => {
window.request_redraw();
}
RenderEvent::Close => {
eprintln!("[RenderWindow] Closing render window");
elwt.exit();
}
},
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
elwt.exit();
}
Event::WindowEvent {
event: WindowEvent::Resized(physical_size),
..
} => {
renderer.resize(physical_size);
window.request_redraw();
}
Event::WindowEvent {
event: WindowEvent::RedrawRequested,
..
} => {
match renderer.render() {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost) => renderer.resize(window.inner_size()),
Err(wgpu::SurfaceError::OutOfMemory) => {
eprintln!("Out of memory!");
elwt.exit();
}
Err(e) => eprintln!("Render error: {:?}", e),
}
}
_ => {}
}
}).expect("Event loop error");
});
// Wait for the proxy to be sent back
let proxy = rx.recv().map_err(|e| format!("Failed to receive proxy: {}", e))?;
Ok(RenderWindowHandle { proxy })
}

293
src-tauri/src/renderer.rs Normal file
View File

@ -0,0 +1,293 @@
use std::sync::Arc;
use wgpu::util::DeviceExt;
/// Vertex data for rendering
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 2],
color: [f32; 4],
}
impl Vertex {
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
// Position
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
},
// Color
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
],
}
}
}
/// Main renderer state that manages the wgpu rendering pipeline
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
size: winit::dpi::PhysicalSize<u32>,
render_pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
num_vertices: u32,
}
impl Renderer {
/// Create a new renderer for the given window
pub async fn new(window: Arc<winit::window::Window>) -> Self {
let size = window.inner_size();
// Create wgpu instance
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
// Create surface from window
let surface = instance.create_surface(window.clone()).unwrap();
// Request adapter
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.unwrap();
// Request device and queue
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("Lightningbeam Render Device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
},
None,
)
.await
.unwrap();
// Configure surface
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width,
height: size.height,
present_mode: wgpu::PresentMode::Fifo, // VSync
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
// Create shader module
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Gradient Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
});
// Create render pipeline
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Render Pipeline Layout"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Render Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
});
// Create initial gradient vertices (two triangles forming a quad)
let vertices = Self::create_gradient_vertices();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Vertex Buffer"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
Self {
surface,
device,
queue,
config,
size,
render_pipeline,
vertex_buffer,
num_vertices: vertices.len() as u32,
}
}
/// Create vertices for a gradient quad covering the entire viewport
fn create_gradient_vertices() -> Vec<Vertex> {
vec![
// First triangle
Vertex {
position: [-1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
Vertex {
position: [-1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
Vertex {
position: [1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
// Second triangle
Vertex {
position: [-1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
Vertex {
position: [1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
Vertex {
position: [1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
]
}
/// Resize the renderer (call when window is resized)
pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
}
}
/// Render a frame
pub fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
let output = self.surface.get_current_texture()?;
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Render Encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.1,
g: 0.1,
b: 0.1,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.draw(0..self.num_vertices, 0..1);
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
/// Update gradient colors (for future customization)
pub fn update_gradient(&mut self, color_top: [f32; 4], color_bottom: [f32; 4]) {
let vertices = vec![
Vertex {
position: [-1.0, 1.0],
color: color_top,
},
Vertex {
position: [-1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [-1.0, 1.0],
color: color_top,
},
Vertex {
position: [1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [1.0, 1.0],
color: color_top,
},
];
self.queue
.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices));
}
}

View File

@ -0,0 +1,26 @@
// Vertex shader
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.clip_position = vec4<f32>(input.position, 0.0, 1.0);
output.color = input.color;
return output;
}
// Fragment shader
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return input.color;
}