Add Rust desktop UI with Blender-style pane system
Implemented foundational pane system using eframe/egui: - Workspace structure with lightningbeam-core and lightningbeam-editor - Layout data structures matching existing JSON schema - All 8 predefined layouts (Animation, Video Editing, Audio/DAW, etc.) - Recursive pane rendering with visual dividers - Layout switcher menu - Color-coded pane types for visualization Foundation complete for interactive pane operations (resize, split, join).
This commit is contained in:
parent
f28791c2c9
commit
bf007e774e
|
|
@ -0,0 +1,17 @@
|
|||
# Rust build artifacts
|
||||
/target/
|
||||
**/target/
|
||||
|
||||
# Cargo.lock for applications (keep for libraries)
|
||||
# We'll keep it since this is an application
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"lightningbeam-editor",
|
||||
"lightningbeam-core",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# UI Framework (using eframe for simplified integration)
|
||||
eframe = { version = "0.29", default-features = true, features = ["wgpu"] }
|
||||
|
||||
# GPU Rendering
|
||||
vello = "0.3"
|
||||
wgpu = "22"
|
||||
kurbo = "0.11"
|
||||
peniko = "0.5"
|
||||
|
||||
# Windowing
|
||||
winit = "0.30"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Utilities
|
||||
pollster = "0.3"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "lightningbeam-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Complete layout definition matching JS schema
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LayoutDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub layout: LayoutNode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<bool>,
|
||||
}
|
||||
|
||||
/// Recursive layout tree node
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum LayoutNode {
|
||||
Pane {
|
||||
name: String,
|
||||
},
|
||||
#[serde(rename = "horizontal-grid")]
|
||||
HorizontalGrid {
|
||||
percent: f32,
|
||||
children: [Box<LayoutNode>; 2],
|
||||
},
|
||||
#[serde(rename = "vertical-grid")]
|
||||
VerticalGrid {
|
||||
percent: f32,
|
||||
children: [Box<LayoutNode>; 2],
|
||||
},
|
||||
}
|
||||
|
||||
/// Pane types available in the editor
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneType {
|
||||
Stage,
|
||||
Timeline,
|
||||
TimelineV2,
|
||||
Toolbar,
|
||||
Infopanel,
|
||||
Outliner,
|
||||
Piano,
|
||||
PianoRoll,
|
||||
NodeEditor,
|
||||
PresetBrowser,
|
||||
}
|
||||
|
||||
impl PaneType {
|
||||
/// Convert from camelCase name (from JSON)
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"stage" => Some(Self::Stage),
|
||||
"timeline" => Some(Self::Timeline),
|
||||
"timelineV2" => Some(Self::TimelineV2),
|
||||
"toolbar" => Some(Self::Toolbar),
|
||||
"infopanel" => Some(Self::Infopanel),
|
||||
"outliner" | "outlineer" => Some(Self::Outliner), // Handle typo in JS
|
||||
"piano" => Some(Self::Piano),
|
||||
"pianoRoll" => Some(Self::PianoRoll),
|
||||
"nodeEditor" => Some(Self::NodeEditor),
|
||||
"presetBrowser" => Some(Self::PresetBrowser),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to camelCase name (for JSON)
|
||||
pub fn to_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stage => "stage",
|
||||
Self::Timeline => "timeline",
|
||||
Self::TimelineV2 => "timelineV2",
|
||||
Self::Toolbar => "toolbar",
|
||||
Self::Infopanel => "infopanel",
|
||||
Self::Outliner => "outliner",
|
||||
Self::Piano => "piano",
|
||||
Self::PianoRoll => "pianoRoll",
|
||||
Self::NodeEditor => "nodeEditor",
|
||||
Self::PresetBrowser => "presetBrowser",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to kebab-case for display
|
||||
pub fn to_kebab_case(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stage => "stage",
|
||||
Self::Timeline => "timeline",
|
||||
Self::TimelineV2 => "timeline-v2",
|
||||
Self::Toolbar => "toolbar",
|
||||
Self::Infopanel => "infopanel",
|
||||
Self::Outliner => "outliner",
|
||||
Self::Piano => "piano",
|
||||
Self::PianoRoll => "piano-roll",
|
||||
Self::NodeEditor => "node-editor",
|
||||
Self::PresetBrowser => "preset-browser",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display name for UI
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stage => "Stage",
|
||||
Self::Timeline => "Timeline",
|
||||
Self::TimelineV2 => "Timeline V2",
|
||||
Self::Toolbar => "Toolbar",
|
||||
Self::Infopanel => "Info Panel",
|
||||
Self::Outliner => "Outliner",
|
||||
Self::Piano => "Piano",
|
||||
Self::PianoRoll => "Piano Roll",
|
||||
Self::NodeEditor => "Node Editor",
|
||||
Self::PresetBrowser => "Preset Browser",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pane types
|
||||
pub fn all() -> &'static [Self] {
|
||||
&[
|
||||
Self::Stage,
|
||||
Self::Timeline,
|
||||
Self::TimelineV2,
|
||||
Self::Toolbar,
|
||||
Self::Infopanel,
|
||||
Self::Outliner,
|
||||
Self::Piano,
|
||||
Self::PianoRoll,
|
||||
Self::NodeEditor,
|
||||
Self::PresetBrowser,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// Lightningbeam Core Library
|
||||
// Shared data structures and types
|
||||
|
||||
pub mod layout;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "lightningbeam-editor"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
lightningbeam-core = { path = "../lightningbeam-core" }
|
||||
|
||||
# UI Framework
|
||||
eframe = { workspace = true }
|
||||
|
||||
# GPU
|
||||
wgpu = { workspace = true }
|
||||
vello = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
peniko = { workspace = true }
|
||||
|
||||
# Windowing
|
||||
winit = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Utilities
|
||||
pollster = { workspace = true }
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
[
|
||||
{
|
||||
"name": "Animation",
|
||||
"description": "Drawing tools, timeline, and layers front and center",
|
||||
"layout": {
|
||||
"type": "horizontal-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 30,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "timelineV2" },
|
||||
{ "type": "pane", "name": "stage" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Video Editing",
|
||||
"description": "Clip timeline, source monitor, and effects panel",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 65,
|
||||
"children": [
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Audio/DAW",
|
||||
"description": "Audio tracks prominent with mixer, node editor, and preset browser",
|
||||
"layout": {
|
||||
"type": "horizontal-grid",
|
||||
"percent": 75,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "timelineV2" },
|
||||
{ "type": "pane", "name": "nodeEditor" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "presetBrowser" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Scripting",
|
||||
"description": "Code editor, object hierarchy, and console",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "outlineer" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Rigging",
|
||||
"description": "Viewport focused with bone controls and weight painting",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 75,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3D",
|
||||
"description": "3D viewport, camera controls, and lighting panel",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Drawing/Painting",
|
||||
"description": "Minimal UI - just canvas and drawing tools",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 8,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 85,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Shader Editor",
|
||||
"description": "Split between viewport preview and code editor",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 60,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
use eframe::egui;
|
||||
use lightningbeam_core::layout::{LayoutDefinition, LayoutNode, PaneType};
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
println!("🚀 Starting Lightningbeam Editor...");
|
||||
|
||||
// Load layouts from JSON
|
||||
let layouts = load_layouts();
|
||||
println!("✅ Loaded {} layouts", layouts.len());
|
||||
for layout in &layouts {
|
||||
println!(" - {}: {}", layout.name, layout.description);
|
||||
}
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1920.0, 1080.0])
|
||||
.with_title("Lightningbeam Editor"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Lightningbeam Editor",
|
||||
options,
|
||||
Box::new(move |_cc| Ok(Box::new(EditorApp::new(layouts)))),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_layouts() -> Vec<LayoutDefinition> {
|
||||
let json = include_str!("../assets/layouts.json");
|
||||
serde_json::from_str(json).expect("Failed to parse layouts.json")
|
||||
}
|
||||
|
||||
struct EditorApp {
|
||||
layouts: Vec<LayoutDefinition>,
|
||||
current_layout_index: usize,
|
||||
}
|
||||
|
||||
impl EditorApp {
|
||||
fn new(layouts: Vec<LayoutDefinition>) -> Self {
|
||||
Self {
|
||||
layouts,
|
||||
current_layout_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_layout(&self) -> &LayoutDefinition {
|
||||
&self.layouts[self.current_layout_index]
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for EditorApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Top menu bar
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("Layout", |ui| {
|
||||
for (i, layout) in self.layouts.iter().enumerate() {
|
||||
if ui
|
||||
.selectable_label(i == self.current_layout_index, &layout.name)
|
||||
.clicked()
|
||||
{
|
||||
self.current_layout_index = i;
|
||||
ui.close_menu();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.label(format!("Current: {}", self.current_layout().name));
|
||||
});
|
||||
});
|
||||
|
||||
// Main pane area
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let available_rect = ui.available_rect_before_wrap();
|
||||
render_layout_node(ui, &self.current_layout().layout, available_rect);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively render a layout node
|
||||
fn render_layout_node(ui: &mut egui::Ui, node: &LayoutNode, rect: egui::Rect) {
|
||||
match node {
|
||||
LayoutNode::Pane { name } => {
|
||||
render_pane(ui, name, rect);
|
||||
}
|
||||
LayoutNode::HorizontalGrid { percent, children } => {
|
||||
// Split horizontally (left | right)
|
||||
let split_x = rect.left() + (rect.width() * percent / 100.0);
|
||||
|
||||
let left_rect = egui::Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(split_x, rect.max.y),
|
||||
);
|
||||
|
||||
let right_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(split_x, rect.min.y),
|
||||
rect.max,
|
||||
);
|
||||
|
||||
// Render children
|
||||
render_layout_node(ui, &children[0], left_rect);
|
||||
render_layout_node(ui, &children[1], right_rect);
|
||||
|
||||
// Draw divider
|
||||
ui.painter().vline(
|
||||
split_x,
|
||||
rect.y_range(),
|
||||
egui::Stroke::new(2.0, egui::Color32::from_gray(60)),
|
||||
);
|
||||
}
|
||||
LayoutNode::VerticalGrid { percent, children } => {
|
||||
// Split vertically (top / bottom)
|
||||
let split_y = rect.top() + (rect.height() * percent / 100.0);
|
||||
|
||||
let top_rect = egui::Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.max.x, split_y),
|
||||
);
|
||||
|
||||
let bottom_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(rect.min.x, split_y),
|
||||
rect.max,
|
||||
);
|
||||
|
||||
// Render children
|
||||
render_layout_node(ui, &children[0], top_rect);
|
||||
render_layout_node(ui, &children[1], bottom_rect);
|
||||
|
||||
// Draw divider
|
||||
ui.painter().hline(
|
||||
rect.x_range(),
|
||||
split_y,
|
||||
egui::Stroke::new(2.0, egui::Color32::from_gray(60)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single pane with its content
|
||||
fn render_pane(ui: &mut egui::Ui, pane_name: &str, rect: egui::Rect) {
|
||||
let pane_type = PaneType::from_name(pane_name);
|
||||
|
||||
// Get color for pane type
|
||||
let bg_color = if let Some(pane_type) = pane_type {
|
||||
pane_color(pane_type)
|
||||
} else {
|
||||
egui::Color32::from_rgb(40, 40, 40)
|
||||
};
|
||||
|
||||
// Draw background
|
||||
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||
|
||||
// Draw border
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
0.0,
|
||||
egui::Stroke::new(1.0, egui::Color32::from_gray(80)),
|
||||
);
|
||||
|
||||
// Draw pane label
|
||||
let text = if let Some(pane_type) = pane_type {
|
||||
pane_type.display_name()
|
||||
} else {
|
||||
pane_name
|
||||
};
|
||||
|
||||
let text_pos = rect.center() - egui::vec2(40.0, 10.0);
|
||||
ui.painter().text(
|
||||
text_pos,
|
||||
egui::Align2::LEFT_CENTER,
|
||||
text,
|
||||
egui::FontId::proportional(16.0),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
|
||||
// Draw pane name in corner
|
||||
let corner_pos = rect.min + egui::vec2(8.0, 8.0);
|
||||
ui.painter().text(
|
||||
corner_pos,
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("[{}]", pane_name),
|
||||
egui::FontId::monospace(10.0),
|
||||
egui::Color32::from_gray(150),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a color for each pane type for visualization
|
||||
fn pane_color(pane_type: PaneType) -> egui::Color32 {
|
||||
match pane_type {
|
||||
PaneType::Stage => egui::Color32::from_rgb(30, 40, 50),
|
||||
PaneType::Timeline => egui::Color32::from_rgb(40, 30, 50),
|
||||
PaneType::TimelineV2 => egui::Color32::from_rgb(45, 35, 55),
|
||||
PaneType::Toolbar => egui::Color32::from_rgb(50, 40, 30),
|
||||
PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40),
|
||||
PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30),
|
||||
PaneType::Piano => egui::Color32::from_rgb(50, 30, 40),
|
||||
PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45),
|
||||
PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
|
||||
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue