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:
Skyler Lehmkuhl 2025-11-12 06:12:56 -05:00
parent f28791c2c9
commit bf007e774e
9 changed files with 4966 additions and 0 deletions

17
lightningbeam-ui/.gitignore vendored Normal file
View File

@ -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

4345
lightningbeam-ui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -0,0 +1,8 @@
[package]
name = "lightningbeam-core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -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,
]
}
}

View File

@ -0,0 +1,4 @@
// Lightningbeam Core Library
// Shared data structures and types
pub mod layout;

View File

@ -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 }

View File

@ -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" }
]
}
]
}
]
}
}
]

View File

@ -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),
}
}