Toolbar
This commit is contained in:
parent
bf007e774e
commit
48da21e062
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ members = [
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# UI Framework (using eframe for simplified integration)
|
# UI Framework (using eframe for simplified integration)
|
||||||
eframe = { version = "0.29", default-features = true, features = ["wgpu"] }
|
eframe = { version = "0.29", default-features = true, features = ["wgpu"] }
|
||||||
|
egui_extras = { version = "0.29", features = ["image", "svg"] }
|
||||||
|
|
||||||
# GPU Rendering
|
# GPU Rendering
|
||||||
vello = "0.3"
|
vello = "0.3"
|
||||||
|
|
@ -18,9 +19,16 @@ peniko = "0.5"
|
||||||
# Windowing
|
# Windowing
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
|
|
||||||
|
# Native menus
|
||||||
|
muda = "0.15"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Image loading
|
||||||
|
image = "0.25"
|
||||||
|
resvg = "0.42"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
pollster = "0.3"
|
pollster = "0.3"
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,5 @@
|
||||||
// Shared data structures and types
|
// Shared data structures and types
|
||||||
|
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod pane;
|
||||||
|
pub mod tool;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ lightningbeam-core = { path = "../lightningbeam-core" }
|
||||||
|
|
||||||
# UI Framework
|
# UI Framework
|
||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
|
egui_extras = { workspace = true }
|
||||||
|
|
||||||
# GPU
|
# GPU
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
|
|
@ -18,9 +19,16 @@ peniko = { workspace = true }
|
||||||
# Windowing
|
# Windowing
|
||||||
winit = { workspace = true }
|
winit = { workspace = true }
|
||||||
|
|
||||||
|
# Native menus
|
||||||
|
muda = { workspace = true }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
# Image loading
|
||||||
|
image = { workspace = true }
|
||||||
|
resvg = { workspace = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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(©)?;
|
||||||
|
|
||||||
|
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: >k::ApplicationWindow, container: Option<>k::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,6 +39,14 @@ lru = "0.12"
|
||||||
# WebSocket for frame streaming (disable default features to remove tracing, but keep handshake)
|
# WebSocket for frame streaming (disable default features to remove tracing, but keep handshake)
|
||||||
tungstenite = { version = "0.20", default-features = false, features = ["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]
|
[profile.dev]
|
||||||
opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance
|
opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder};
|
||||||
mod audio;
|
mod audio;
|
||||||
mod video;
|
mod video;
|
||||||
mod frame_streamer;
|
mod frame_streamer;
|
||||||
|
mod renderer;
|
||||||
|
mod render_window;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -13,6 +15,12 @@ struct AppState {
|
||||||
counter: u32,
|
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/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
|
|
@ -48,6 +56,165 @@ fn get_frame_streamer_port(
|
||||||
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;
|
use tauri::PhysicalSize;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -148,6 +315,11 @@ pub fn run() {
|
||||||
.manage(Arc::new(Mutex::new(audio::AudioState::default())))
|
.manage(Arc::new(Mutex::new(audio::AudioState::default())))
|
||||||
.manage(Arc::new(Mutex::new(video::VideoState::default())))
|
.manage(Arc::new(Mutex::new(video::VideoState::default())))
|
||||||
.manage(Arc::new(Mutex::new(frame_streamer)))
|
.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| {
|
.setup(|app| {
|
||||||
#[cfg(any(windows, target_os = "linux"))] // Windows/Linux needs different handling from macOS
|
#[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())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet, trace, debug, info, warn, error, create_window, get_frame_streamer_port,
|
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_init,
|
||||||
audio::audio_reset,
|
audio::audio_reset,
|
||||||
audio::audio_play,
|
audio::audio_play,
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue