release: Storybook v0.2.0 - Major syntax and features update

BREAKING CHANGES:
- Relationship syntax now requires blocks for all participants
- Removed self/other perspective blocks from relationships
- Replaced 'guard' keyword with 'if' for behavior tree decorators

Language Features:
- Add tree-sitter grammar with improved if/condition disambiguation
- Add comprehensive tutorial and reference documentation
- Add SBIR v0.2.0 binary format specification
- Add resource linking system for behaviors and schedules
- Add year-long schedule patterns (day, season, recurrence)
- Add behavior tree enhancements (named nodes, decorators)

Documentation:
- Complete tutorial series (9 chapters) with baker family examples
- Complete reference documentation for all language features
- SBIR v0.2.0 specification with binary format details
- Added locations and institutions documentation

Examples:
- Convert all examples to baker family scenario
- Add comprehensive working examples

Tooling:
- Zed extension with LSP integration
- Tree-sitter grammar for syntax highlighting
- Build scripts and development tools

Version Updates:
- Main package: 0.1.0 → 0.2.0
- Tree-sitter grammar: 0.1.0 → 0.2.0
- Zed extension: 0.1.0 → 0.2.0
- Storybook editor: 0.1.0 → 0.2.0
This commit is contained in:
2026-02-13 21:52:03 +00:00
parent 80332971b8
commit 16deb5d237
290 changed files with 90316 additions and 5827 deletions

277
storybook-editor/src/app.rs Normal file
View File

@@ -0,0 +1,277 @@
use std::path::PathBuf;
use iced::mouse;
/// Main application state and logic
use iced::{
Element,
Event,
Subscription,
Task,
};
use iced_code_editor::Message as EditorMessage;
use crate::{
file_editor_state::FileEditorState,
recent_projects::RecentProjects,
ui,
};
/// Represents a tab in the editor
#[derive(Debug)]
pub enum Tab {
Character(String), // Character name
File(FileEditorState), // File editor state
}
impl Tab {
pub fn title(&self) -> String {
match self {
| Tab::Character(name) => name.clone(),
| Tab::File(state) => state.filename(),
}
}
}
pub struct Editor {
pub project_path: Option<PathBuf>,
pub project: Option<storybook::Project>,
pub error_message: Option<String>,
pub tabs: Vec<Tab>,
pub active_tab: Option<usize>,
pub hovered_tab: Option<usize>,
pub expanded_folders: std::collections::HashSet<String>, // Track which folders are expanded
pub recent_projects: RecentProjects,
}
impl Default for Editor {
fn default() -> Self {
Self {
project_path: None,
project: None,
error_message: None,
tabs: Vec::new(),
active_tab: None,
hovered_tab: None,
expanded_folders: std::collections::HashSet::new(),
recent_projects: RecentProjects::load(),
}
}
}
#[derive(Debug, Clone)]
pub enum Message {
// Menu actions
OpenProjectDialog,
ProjectSelected(Option<PathBuf>),
ProjectLoaded(Result<storybook::Project, String>),
CloseProject,
SaveProject,
Exit,
// Entity selection
SelectCharacter(String),
// Quick actions
ValidateProject,
NewCharacter,
// Tab management
OpenCharacterTab(String),
OpenFileTab(PathBuf),
SwitchTab(usize),
CloseTab(usize),
TabHovered(Option<usize>),
// File editing
EditorAction(usize, EditorMessage),
// File tree
ToggleFolder(String),
EventOccurred(Event),
}
impl Editor {
pub fn new() -> (Self, Task<Message>) {
(Self::default(), Task::none())
}
pub fn title(&self) -> String {
"Storybook Editor".to_string()
}
pub fn theme(&self) -> iced::Theme {
crate::theme::storybook_theme()
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
| Message::OpenProjectDialog => {
// Open file picker dialog
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Select Storybook Project Directory")
.pick_folder()
.await
.map(|handle| handle.path().to_path_buf())
},
Message::ProjectSelected,
)
},
| Message::ProjectSelected(Some(path)) => {
self.project_path = Some(path.clone());
self.error_message = None;
// Load the project asynchronously
Task::perform(
async move {
match storybook::Project::load(&path) {
| Ok(project) => Ok(project),
| Err(e) => Err(format!("Failed to load project: {}", e)),
}
},
Message::ProjectLoaded,
)
},
| Message::ProjectSelected(None) => {
// User cancelled the dialog
Task::none()
},
| Message::ProjectLoaded(Ok(project)) => {
self.project = Some(project);
self.error_message = None;
// Add to recent projects
if let Some(ref path) = self.project_path {
self.recent_projects.add(path.clone());
}
Task::none()
},
| Message::ProjectLoaded(Err(error)) => {
self.error_message = Some(error);
self.project = None;
Task::none()
},
| Message::CloseProject => {
self.project_path = None;
self.project = None;
self.error_message = None;
Task::none()
},
| Message::SaveProject => {
// TODO: Implement save functionality
Task::none()
},
| Message::Exit => iced::exit(),
| Message::SelectCharacter(name) => {
// Open character in a new tab (or switch to existing tab)
Task::done(Message::OpenCharacterTab(name))
},
| Message::OpenCharacterTab(name) => {
// Check if tab already exists
if let Some(idx) = self
.tabs
.iter()
.position(|t| matches!(t, Tab::Character(n) if n == &name))
{
self.active_tab = Some(idx);
} else {
// For now, just create a character tab
// TODO: In the future, find the .sb file and open it as a File tab
self.tabs.push(Tab::Character(name));
self.active_tab = Some(self.tabs.len() - 1);
}
Task::none()
},
| Message::OpenFileTab(path) => {
// Check if tab already exists
if let Some(idx) = self
.tabs
.iter()
.position(|t| matches!(t, Tab::File(state) if state.path == path))
{
self.active_tab = Some(idx);
} else {
// Load file content
if let Ok(content) = std::fs::read_to_string(&path) {
let state = FileEditorState::new(path, content);
self.tabs.push(Tab::File(state));
self.active_tab = Some(self.tabs.len() - 1);
}
}
Task::none()
},
| Message::EditorAction(tab_idx, editor_msg) => {
// Update the code editor
if let Some(Tab::File(state)) = self.tabs.get_mut(tab_idx) {
return state
.editor
.update(&editor_msg)
.map(move |msg| Message::EditorAction(tab_idx, msg));
}
Task::none()
},
| Message::ToggleFolder(folder_path) => {
// Toggle folder expanded/collapsed state
if self.expanded_folders.contains(&folder_path) {
self.expanded_folders.remove(&folder_path);
} else {
self.expanded_folders.insert(folder_path);
}
Task::none()
},
| Message::SwitchTab(idx) => {
if idx < self.tabs.len() {
self.active_tab = Some(idx);
}
Task::none()
},
| Message::CloseTab(idx) => {
if idx < self.tabs.len() {
self.tabs.remove(idx);
// Adjust active tab
if self.tabs.is_empty() {
self.active_tab = None;
} else if let Some(active) = self.active_tab {
if active >= self.tabs.len() {
self.active_tab = Some(self.tabs.len() - 1);
} else if active > idx {
self.active_tab = Some(active - 1);
}
}
}
Task::none()
},
| Message::ValidateProject => {
// TODO: Run validation and show results
// For now, just clear any error
if self.project.is_some() {
self.error_message = Some("✓ Project validated successfully!".to_string());
}
Task::none()
},
| Message::NewCharacter => {
// TODO: Open character creation dialog
self.error_message = Some("New character creation coming soon!".to_string());
Task::none()
},
| Message::TabHovered(idx) => {
self.hovered_tab = idx;
Task::none()
},
| Message::EventOccurred(event) => {
// Handle middle-click to close tab
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) = event {
if let Some(idx) = self.hovered_tab {
return Task::done(Message::CloseTab(idx));
}
}
Task::none()
},
}
}
pub fn view(&self) -> Element<'_, Message> {
ui::view(self)
}
pub fn subscription(&self) -> Subscription<Message> {
iced::event::listen().map(Message::EventOccurred)
}
}

View File

@@ -0,0 +1,86 @@
//! File editor state management
//!
//! Manages the state for editable file tabs using iced-code-editor.
use std::path::PathBuf;
use iced_code_editor::CodeEditor;
/// Create a custom theme matching our Aubergine/Gold color scheme
fn create_storybook_theme() -> iced_code_editor::Style {
use crate::theme::*;
iced_code_editor::Style {
background: Aubergine::_900,
text_color: Neutral::CREAM,
gutter_background: Aubergine::_800,
gutter_border: Aubergine::_700,
line_number_color: Neutral::WARM_GRAY_400,
scrollbar_background: Aubergine::_700,
scroller_color: Gold::_500,
current_line_highlight: Aubergine::_700,
}
}
/// State for an editable file tab
pub struct FileEditorState {
/// Path to the file
pub path: PathBuf,
/// Code editor instance with syntax highlighting and line numbers
pub editor: CodeEditor,
}
impl std::fmt::Debug for FileEditorState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileEditorState")
.field("path", &self.path)
.field("editor", &"<CodeEditor>")
.finish()
}
}
impl FileEditorState {
/// Create a new file editor state from a path and initial content
pub fn new(path: PathBuf, initial_content: String) -> Self {
// Detect language from file extension
let language = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
// Map .sb extension to "storybook"
if ext == "sb" || ext == "storybook" {
"storybook"
} else {
ext
}
})
.unwrap_or("txt");
// Create code editor with syntax highlighting
let mut editor = CodeEditor::new(&initial_content, language);
// Enable line numbers
editor.set_line_numbers_enabled(true);
// Set custom theme to match our Aubergine/Gold color scheme
let custom_theme = create_storybook_theme();
editor.set_theme(custom_theme);
Self { path, editor }
}
/// Get the current text content as a string
pub fn text(&self) -> String {
self.editor.content()
}
/// Get the filename for display
pub fn filename(&self) -> String {
self.path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
}

View File

@@ -0,0 +1,113 @@
//! File tree data structure for hierarchical file browser
use std::path::{
Path,
PathBuf,
};
/// A node in the file tree - either a file or a folder
#[derive(Debug, Clone)]
pub enum FileTreeNode {
File {
path: PathBuf,
name: String,
},
Folder {
name: String,
path: String,
children: Vec<FileTreeNode>,
},
}
/// Build a hierarchical file tree from a list of file paths
pub fn build_tree(root: &Path, files: &[PathBuf]) -> Vec<FileTreeNode> {
let mut root_nodes = Vec::new();
for file_path in files {
let relative_path = file_path.strip_prefix(root).unwrap_or(file_path);
let components: Vec<String> = relative_path
.iter()
.map(|s| s.to_str().unwrap_or("").to_string())
.collect();
insert_path(&mut root_nodes, &components, file_path.clone(), Vec::new());
}
// Sort nodes: folders first, then files, both alphabetically
sort_nodes(&mut root_nodes);
root_nodes
}
fn insert_path(
nodes: &mut Vec<FileTreeNode>,
components: &[String],
full_path: PathBuf,
path_so_far: Vec<String>,
) {
if components.is_empty() {
return;
}
let component = &components[0];
let is_last = components.len() == 1;
if is_last {
// It's a file
nodes.push(FileTreeNode::File {
path: full_path,
name: component.clone(),
});
} else {
// It's a folder - find or create it
let folder_path = if path_so_far.is_empty() {
component.clone()
} else {
format!("{}/{}", path_so_far.join("/"), component)
};
if let Some(folder_node) = nodes
.iter_mut()
.find(|n| matches!(n, FileTreeNode::Folder { name, .. } if name == component))
{
// Folder exists, insert into its children
if let FileTreeNode::Folder { children, .. } = folder_node {
let mut new_path = path_so_far.clone();
new_path.push(component.clone());
insert_path(children, &components[1..], full_path, new_path);
}
} else {
// Create new folder
let mut children = Vec::new();
let mut new_path = path_so_far.clone();
new_path.push(component.clone());
insert_path(&mut children, &components[1..], full_path, new_path.clone());
nodes.push(FileTreeNode::Folder {
name: component.clone(),
path: folder_path,
children,
});
}
}
}
fn sort_nodes(nodes: &mut Vec<FileTreeNode>) {
nodes.sort_by(|a, b| match (a, b) {
| (
FileTreeNode::Folder { name: a_name, .. },
FileTreeNode::Folder { name: b_name, .. },
) => a_name.cmp(b_name),
| (FileTreeNode::File { name: a_name, .. }, FileTreeNode::File { name: b_name, .. }) => {
a_name.cmp(b_name)
},
| (FileTreeNode::Folder { .. }, FileTreeNode::File { .. }) => std::cmp::Ordering::Less,
| (FileTreeNode::File { .. }, FileTreeNode::Folder { .. }) => std::cmp::Ordering::Greater,
});
// Recursively sort children
for node in nodes.iter_mut() {
if let FileTreeNode::Folder { children, .. } = node {
sort_nodes(children);
}
}
}

View File

@@ -0,0 +1,123 @@
//! Syntax highlighter for Storybook DSL
//!
//! Implements iced's Highlighter trait to provide line-by-line syntax
//! highlighting.
use std::ops::Range;
use iced::{
Color,
advanced::text::highlighter,
};
use crate::syntax_highlight::{
token_to_color_contextual,
tokenize,
};
/// Settings for the Storybook highlighter
#[derive(Debug, Clone, PartialEq)]
pub struct Settings;
/// Storybook syntax highlighter
#[derive(Debug)]
pub struct StorybookHighlighter {
current_line: usize,
in_prose_block: bool, // Track if we're inside a prose block
}
impl iced::widget::text::Highlighter for StorybookHighlighter {
type Highlight = Color;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
type Settings = Settings;
fn new(_settings: &Self::Settings) -> Self {
Self {
current_line: 0,
in_prose_block: false,
}
}
fn update(&mut self, _new_settings: &Self::Settings) {
// No settings to update
}
fn change_line(&mut self, line: usize) {
self.current_line = line;
// Reset prose block state when jumping to a different line
self.in_prose_block = false;
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
let mut highlights = Vec::new();
// Check if this line starts or ends a prose block
let trimmed = line.trim_start();
if trimmed.starts_with("---") {
// This is a prose marker line
if self.in_prose_block {
// Ending a prose block
self.in_prose_block = false;
} else {
// Starting a prose block
self.in_prose_block = true;
}
// Color the entire line as a prose marker (gray)
if !line.is_empty() {
highlights.push((0..line.len(), Color::from_rgb8(0x6d, 0x6d, 0x6d)));
}
} else if self.in_prose_block {
// Inside a prose block - render as plain text in peach color
if !line.is_empty() {
highlights.push((0..line.len(), Color::from_rgb8(0xff, 0xb8, 0x6c)));
}
} else {
// Regular code - tokenize and highlight
let tokens = tokenize(line);
// Track if we're after a colon for context-aware coloring
let mut after_colon = false;
for (token, range) in tokens {
let color = token_to_color_contextual(&token, after_colon);
highlights.push((range, color));
// Update context: set after_colon when we see a colon,
// reset it when we see an identifier (field value) or certain other tokens
use storybook::syntax::lexer::Token;
match &token {
| Token::Colon => after_colon = true,
| Token::Ident(_) |
Token::IntLit(_) |
Token::FloatLit(_) |
Token::StringLit(_) |
Token::True |
Token::False |
Token::TimeLit(_) |
Token::DurationLit(_) => {
// Reset after consuming a value
after_colon = false;
},
// Don't reset for whitespace, commas, or other punctuation
| _ => {},
}
}
}
self.current_line += 1;
highlights.into_iter()
}
fn current_line(&self) -> usize {
self.current_line
}
}
/// Convert a highlight (Color) to a Format for rendering
pub fn to_format(color: &Color, _theme: &iced::Theme) -> highlighter::Format<iced::Font> {
highlighter::Format {
color: Some(*color),
font: None,
}
}

View File

@@ -0,0 +1,27 @@
mod app;
mod file_editor_state;
mod file_tree;
mod highlighter;
mod recent_projects;
mod syntax_highlight;
mod theme;
mod ui;
use app::Editor;
use theme::storybook_theme;
// Embed Monaspace Neon fonts
const MONASPACE_NEON_REGULAR: &[u8] = include_bytes!("../assets/fonts/MonaspaceNeonNF-Regular.otf");
const MONASPACE_NEON_BOLD: &[u8] = include_bytes!("../assets/fonts/MonaspaceNeonNF-Bold.otf");
const MONASPACE_NEON_ITALIC: &[u8] = include_bytes!("../assets/fonts/MonaspaceNeonNF-Italic.otf");
fn main() -> iced::Result {
iced::application(|| Editor::default(), Editor::update, Editor::view)
.title(Editor::title)
.subscription(Editor::subscription)
.theme(Editor::theme)
.font(MONASPACE_NEON_REGULAR)
.font(MONASPACE_NEON_BOLD)
.font(MONASPACE_NEON_ITALIC)
.run()
}

View File

@@ -0,0 +1,95 @@
//! Recent projects management
//!
//! Stores and retrieves recently opened projects for quick access.
use std::path::PathBuf;
use serde::{
Deserialize,
Serialize,
};
const MAX_RECENT_PROJECTS: usize = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentProject {
pub path: PathBuf,
pub name: String,
pub last_opened: String, // ISO 8601 timestamp
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RecentProjects {
pub projects: Vec<RecentProject>,
}
impl RecentProjects {
/// Load recent projects from disk
pub fn load() -> Self {
let config_path = Self::config_path();
if let Ok(contents) = std::fs::read_to_string(&config_path) {
if let Ok(recent) = serde_json::from_str(&contents) {
return recent;
}
}
Self::default()
}
/// Save recent projects to disk
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let config_path = Self::config_path();
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&config_path, json)?;
Ok(())
}
/// Add a project to the recent list
pub fn add(&mut self, path: PathBuf) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let now = chrono::Utc::now().to_rfc3339();
// Remove if already exists
self.projects.retain(|p| p.path != path);
// Add to front
self.projects.insert(
0,
RecentProject {
path,
name,
last_opened: now,
},
);
// Limit to MAX_RECENT_PROJECTS
self.projects.truncate(MAX_RECENT_PROJECTS);
// Save to disk
let _ = self.save();
}
/// Get the config file path
fn config_path() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".config")
.join("storybook-editor")
.join("recent_projects.json")
} else {
PathBuf::from("recent_projects.json")
}
}
}

View File

@@ -0,0 +1,228 @@
//! Syntax highlighting for Storybook DSL
//!
//! This module tokenizes source code and maps tokens to theme colors for
//! rendering.
use std::ops::Range;
use iced::Color;
use storybook::syntax::lexer::{
Lexer,
Token,
};
use crate::theme::*;
/// Tokenize source code into tokens with byte positions
pub fn tokenize(source: &str) -> Vec<(Token, Range<usize>)> {
let lexer = Lexer::new(source);
lexer
.map(|(start, token, end)| (token, start..end))
.collect()
}
/// Map a token to its display color based on context
/// Field values (identifiers after colons) get a different color than field
/// names
pub fn token_to_color_contextual(token: &Token, after_colon: bool) -> Color {
match token {
// Identifiers - different colors for field names vs field values
| Token::Ident(_) => {
if after_colon {
// Field value - bright cyan/teal
Color::from_rgb8(0x66, 0xd9, 0xef)
} else {
// Field name or other identifier - bright green
Color::from_rgb8(0x50, 0xfa, 0x7b)
}
},
// All other tokens use the standard coloring
| _ => token_to_color(token),
}
}
/// Map a token to its display color based on the theme
/// Using a vibrant, professional color scheme inspired by modern editors
pub fn token_to_color(token: &Token) -> Color {
match token {
// Declaration keywords (character, template, etc.) - Bright Purple/Magenta
| Token::Character |
Token::Template |
Token::LifeArc |
Token::Schedule |
Token::Behavior |
Token::Institution |
Token::Relationship |
Token::Location |
Token::Species |
Token::Enum |
Token::State => Color::from_rgb8(0xc5, 0x86, 0xc0), // Vibrant purple
// Control flow keywords - Bright Pink/Rose
| Token::ForAll |
Token::Exists |
Token::In |
Token::Where |
Token::And |
Token::Or |
Token::Not |
Token::On |
Token::Enter => Color::from_rgb8(0xff, 0x79, 0xc6), // Hot pink
// Import/module keywords - Bright Orange
| Token::Use | Token::Include | Token::From => Color::from_rgb8(0xff, 0xb8, 0x6c), /* Bright orange */
// Special keywords - Cyan/Aqua
| Token::As |
Token::SelfKw |
Token::Other |
Token::Remove |
Token::Append |
Token::Strict |
Token::Is => Color::from_rgb8(0x8b, 0xe9, 0xfd), // Bright cyan
// Boolean literals - Bright Orange
| Token::True | Token::False => Color::from_rgb8(0xff, 0xb8, 0x6c),
// Identifiers - Bright green for field names/identifiers
| Token::Ident(_) => Color::from_rgb8(0x50, 0xfa, 0x7b), // Bright green
// Number literals - Bright Purple
| Token::IntLit(_) | Token::FloatLit(_) => Color::from_rgb8(0xbd, 0x93, 0xf9), /* Light purple */
// String literals - Vibrant Yellow/Gold
| Token::StringLit(_) => Color::from_rgb8(0xf1, 0xfa, 0x8c), // Bright yellow
// Time/duration literals - Bright Teal
| Token::TimeLit(_) | Token::DurationLit(_) => Color::from_rgb8(0x66, 0xd9, 0xef), /* Bright teal */
// Prose blocks - Soft pink/peach (to stand out as prose)
| Token::ProseBlock(_) => Color::from_rgb8(0xff, 0xb8, 0x6c), // Peach/coral
// Braces and brackets - Bright foreground
| Token::LBrace |
Token::RBrace |
Token::LParen |
Token::RParen |
Token::LBracket |
Token::RBracket => Color::from_rgb8(0xf8, 0xf8, 0xf2), // Bright white
// Other punctuation - Dimmed
| Token::Colon |
Token::ColonColon |
Token::Semicolon |
Token::Comma |
Token::Dot |
Token::DotDot |
Token::Star |
Token::Question |
Token::At => Color::from_rgb8(0x6d, 0x6d, 0x6d), // Medium gray
// Operators - Bright Pink
| Token::Gt | Token::Ge | Token::Lt | Token::Le | Token::Arrow => {
Color::from_rgb8(0xff, 0x79, 0xc6)
},
// Prose marker - Comment gray
| Token::ProseMarker => Color::from_rgb8(0x6d, 0x6d, 0x6d), // Gray
// Errors - Bright Red
| Token::Error => Color::from_rgb8(0xff, 0x55, 0x55), // Bright red
}
}
/// Build a vector of text spans with syntax highlighting
/// Returns (text_content, color) pairs for rendering
pub fn build_highlighted_spans(
source: &str,
tokens: &[(Token, Range<usize>)],
) -> Vec<(String, Color)> {
if tokens.is_empty() {
return vec![(source.to_string(), Neutral::WARM_GRAY_100)];
}
let mut spans = Vec::new();
let mut last_end = 0;
for (token, range) in tokens {
// Add any unhighlighted gap before this token (whitespace, comments)
if range.start > last_end {
let gap = &source[last_end..range.start];
if !gap.is_empty() {
spans.push((gap.to_string(), Neutral::WARM_GRAY_400));
}
}
// Add the highlighted token
let text = &source[range.clone()];
let color = token_to_color(token);
spans.push((text.to_string(), color));
last_end = range.end;
}
// Add any remaining text after the last token
if last_end < source.len() {
let remaining = &source[last_end..];
if !remaining.is_empty() {
spans.push((remaining.to_string(), Neutral::WARM_GRAY_400));
}
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize_basic() {
let source = "character Martha { age: 34 }";
let tokens = tokenize(source);
assert!(!tokens.is_empty());
assert!(matches!(tokens[0].0, Token::Character));
assert_eq!(tokens[0].1, 0..9); // "character"
}
#[test]
fn test_token_colors() {
// Keywords should be gold
assert_eq!(token_to_color(&Token::Character), Gold::_500);
// Strings should be green
assert_eq!(
token_to_color(&Token::StringLit("test".to_string())),
Semantic::SUCCESS
);
// Numbers should be gold (lighter)
assert_eq!(token_to_color(&Token::IntLit(42)), Gold::_400);
// Identifiers should be warm gray
assert_eq!(
token_to_color(&Token::Ident("Martha".to_string())),
Neutral::WARM_GRAY_100
);
}
#[test]
fn test_build_highlighted_spans() {
let source = "character Martha";
let tokens = tokenize(source);
let spans = build_highlighted_spans(source, &tokens);
// Should have at least 3 spans: "character", " " (whitespace), "Martha"
assert!(spans.len() >= 3);
// First span should be "character" in gold
assert_eq!(spans[0].0, "character");
assert_eq!(spans[0].1, Gold::_500);
// Last span should be "Martha" in warm gray
let last = spans.last().unwrap();
assert_eq!(last.0, "Martha");
assert_eq!(last.1, Neutral::WARM_GRAY_100);
}
}

View File

@@ -0,0 +1,201 @@
/// Storybook Editor Theme
/// Based on the aubergine + gold design system
///
/// Synthesis of Teenage Engineering's bold minimalism and Dieter Rams'
/// functionalist precision.
use iced::color;
use iced::{
Background,
Border,
Color,
Degrees,
Gradient,
Shadow,
Theme,
Vector,
gradient::Linear,
};
/// Aubergine color palette
#[allow(dead_code)]
pub struct Aubergine;
#[allow(dead_code)]
impl Aubergine {
// Active elements
pub const _300: Color = color!(0x9f, 0x76, 0xa7);
// Subtle highlights
pub const _400: Color = color!(0x80, 0x57, 0x93);
// Borders, dividers
pub const _500: Color = color!(0x61, 0x38, 0x6f);
// Surface hover
pub const _600: Color = color!(0x4f, 0x2e, 0x5b);
// Alternate row color
pub const _700: Color = color!(0x3d, 0x24, 0x47);
// Surface (panels, cards)
pub const _750: Color = color!(0x38, 0x24, 0x44);
// Background (darkest)
pub const _800: Color = color!(0x2b, 0x1a, 0x33);
pub const _900: Color = color!(0x1a, 0x0f, 0x1e); // Muted text, info
}
/// Gold/Orange accent palette
#[allow(dead_code)]
pub struct Gold;
#[allow(dead_code)]
impl Gold {
// Pressed/active state
pub const _200: Color = color!(0xfa, 0xd6, 0xad);
// Hover state
pub const _300: Color = color!(0xf8, 0xc5, 0x94);
// PRIMARY ACCENT ⭐
pub const _400: Color = color!(0xf6, 0xb4, 0x7a);
// Deeper accent (optional)
pub const _500: Color = color!(0xf4, 0xa2, 0x61);
pub const _600: Color = color!(0xe8, 0x93, 0x50); // Very subtle highlight
}
/// Neutral colors
#[allow(dead_code)]
pub struct Neutral;
#[allow(dead_code)]
impl Neutral {
// Disabled elements
pub const CHARCOAL: Color = color!(0x1f, 0x1f, 0x1f);
pub const CREAM: Color = color!(0xfd, 0xf8, 0xf3);
// Primary text
pub const WARM_GRAY_100: Color = color!(0xe8, 0xe3, 0xdd);
// Secondary text
pub const WARM_GRAY_200: Color = color!(0xd1, 0xcb, 0xc3);
// Tertiary text
pub const WARM_GRAY_400: Color = color!(0x8b, 0x86, 0x80);
// Muted text, placeholders
pub const WARM_GRAY_700: Color = color!(0x4a, 0x48, 0x45); // Pure dark (code bg)
}
/// Semantic colors
#[allow(dead_code)]
pub struct Semantic;
#[allow(dead_code)]
impl Semantic {
// Warning pressed
pub const ERROR: Color = color!(0xd6, 0x28, 0x28);
// Error hover
pub const ERROR_DARK: Color = color!(0xb8, 0x1e, 0x1e);
// Parse errors ✗
pub const ERROR_LIGHT: Color = color!(0xe5, 0x45, 0x45);
// Error pressed
pub const INFO: Color = Aubergine::_300;
pub const SUCCESS: Color = color!(0x6a, 0x99, 0x4e);
// Success hover
pub const SUCCESS_DARK: Color = color!(0x55, 0x80, 0x39);
// Validation passed ✓
pub const SUCCESS_LIGHT: Color = color!(0x8a, 0xb8, 0x64);
// Success pressed
pub const WARNING: Color = Gold::_500;
// Warning hover
pub const WARNING_DARK: Color = Gold::_600;
// Warning, attention ⚠
pub const WARNING_LIGHT: Color = Gold::_400; // Info, hints
}
/// Spacing constants following 8px base grid
#[allow(dead_code)]
pub struct Spacing;
#[allow(dead_code)]
impl Spacing {
// Section spacing
pub const LARGE: f32 = 24.0;
// Default gap between related elements
pub const MEDIUM: f32 = 16.0;
pub const MICRO: f32 = 4.0;
// Icon padding, tight spacing
pub const SMALL: f32 = 8.0;
// Panel padding
pub const XLARGE: f32 = 32.0;
// Major section separation
pub const XXLARGE: f32 = 48.0; // Page margins, hero spacing
}
/// Create the custom Storybook theme
pub fn storybook_theme() -> Theme {
Theme::custom(
"Storybook".to_string(),
iced::theme::Palette {
background: Aubergine::_900,
text: Neutral::CREAM,
primary: Gold::_500,
success: Semantic::SUCCESS,
danger: Semantic::ERROR,
warning: Semantic::WARNING,
},
)
}
/// Helper function for creating gold focus borders with glow
#[allow(dead_code)]
pub fn gold_focus_border() -> Border {
Border {
color: Gold::_500,
width: 2.0,
radius: 4.0.into(),
}
}
/// Helper function for creating subtle aubergine borders
pub fn subtle_border() -> Border {
Border {
color: Aubergine::_600,
width: 1.0,
radius: 4.0.into(),
}
}
/// Helper function for panel background with shadow
pub fn panel_background() -> Background {
Background::Color(Aubergine::_800)
}
/// Helper function for gold glow shadow (focus states)
#[allow(dead_code)]
pub fn gold_glow() -> Shadow {
Shadow {
color: Color::from_rgba(Gold::_500.r, Gold::_500.g, Gold::_500.b, 0.2),
offset: Vector::new(0.0, 0.0),
blur_radius: 8.0,
}
}
/// Helper function for subtle panel shadow
#[allow(dead_code)]
pub fn panel_shadow() -> Shadow {
Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.3),
offset: Vector::new(0.0, 2.0),
blur_radius: 8.0,
}
}
/// Helper function for left panel gradient background
/// Aubergine (left edge) → Cream (right edge)
pub fn left_panel_gradient() -> Background {
Background::Gradient(Gradient::Linear(
Linear::new(Degrees(90.0))
.add_stop(0.0, Aubergine::_900) // Left edge: dark aubergine
.add_stop(1.0, Neutral::CREAM),
)) // Right edge: cream
}
/// Helper function for right panel gradient background
/// Cream (left edge) → Aubergine (right edge)
pub fn right_panel_gradient() -> Background {
Background::Gradient(Gradient::Linear(
Linear::new(Degrees(90.0))
.add_stop(0.0, Neutral::CREAM) // Left edge: cream
.add_stop(1.0, Aubergine::_900),
)) // Right edge: dark aubergine
}

View File

@@ -0,0 +1,208 @@
/// Reusable UI components for consistent styling
use iced::widget::{
Column,
button,
column,
container,
text,
text_input,
};
use iced::{
Background,
Element,
Length,
};
use crate::{
app::Message,
theme::*,
};
/// Menu bar button with consistent styling
pub fn menu_button<'a>(
label: &'a str,
enabled: bool,
on_press: Option<Message>,
) -> Element<'a, Message> {
let mut btn = button(text(label).size(14).color(if enabled {
Neutral::CREAM
} else {
Neutral::WARM_GRAY_700
}))
.padding([Spacing::MICRO, Spacing::SMALL])
.style(move |_theme, status| {
let background = if enabled {
match status {
| button::Status::Hovered => Background::Color(Aubergine::_700),
| button::Status::Pressed => Background::Color(Aubergine::_600),
| _ => Background::Color(Aubergine::_800),
}
} else {
Background::Color(Aubergine::_800)
};
button::Style {
background: Some(background),
text_color: if enabled {
Neutral::CREAM
} else {
Neutral::WARM_GRAY_700
},
border: subtle_border(),
..Default::default()
}
});
if let Some(msg) = on_press {
btn = btn.on_press(msg);
}
btn.into()
}
/// Entity browser item button
pub fn entity_button<'a>(
label: &'a str,
is_selected: bool,
on_press: Message,
) -> Element<'a, Message> {
button(text(label).size(13).color(if is_selected {
Gold::_500
} else {
Neutral::WARM_GRAY_100
}))
.on_press(on_press)
.padding([Spacing::MICRO, Spacing::SMALL])
.style(move |_theme, status| button::Style {
background: if is_selected {
Some(Background::Color(Aubergine::_600))
} else {
match status {
| button::Status::Hovered => Some(Background::Color(Aubergine::_700)),
| _ => None,
}
},
text_color: if is_selected {
Gold::_500
} else {
Neutral::WARM_GRAY_100
},
..Default::default()
})
.into()
}
/// Quick action button (prominent actions)
pub fn quick_action_button<'a>(
label: &'a str,
enabled: bool,
on_press: Option<Message>,
) -> Element<'a, Message> {
let mut btn = button(text(label).size(14).color(if enabled {
Aubergine::_900
} else {
Neutral::WARM_GRAY_700
}))
.padding([Spacing::SMALL, Spacing::MEDIUM])
.style(move |_theme, status| {
let background = if enabled {
match status {
| button::Status::Hovered => Background::Color(Gold::_400),
| button::Status::Pressed => Background::Color(Gold::_600),
| _ => Background::Color(Gold::_500),
}
} else {
Background::Color(Aubergine::_800)
};
button::Style {
background: Some(background),
text_color: if enabled {
Aubergine::_900
} else {
Neutral::WARM_GRAY_700
},
border: if enabled {
subtle_border()
} else {
iced::Border {
color: Aubergine::_700,
width: 1.0,
radius: 0.0.into(),
}
},
..Default::default()
}
});
if let Some(msg) = on_press {
btn = btn.on_press(msg);
}
btn.into()
}
/// Section header for entity browser sections
pub fn section_header<'a>(label: &'a str) -> Element<'a, Message> {
text(label).size(14).color(Gold::_500).into()
}
/// Entity item text (non-clickable)
pub fn entity_item<'a>(label: &'a str) -> Element<'a, Message> {
text(label).size(13).color(Neutral::WARM_GRAY_100).into()
}
/// Metadata field with soft styling (label + input with subtle underline)
pub fn metadata_field<'a>(label: &'a str, value: &str) -> Column<'a, Message> {
column![
// Soft, subtle label
text(label).size(11).color(Neutral::WARM_GRAY_400),
// Input with soft underline only
text_input("", value)
.padding([Spacing::MICRO, 0.0])
.size(15)
.style(|_theme, status| text_input::Style {
background: Background::Color(iced::Color::TRANSPARENT),
border: iced::Border {
color: match status {
| text_input::Status::Focused { .. } => Neutral::WARM_GRAY_200,
| text_input::Status::Hovered => Neutral::WARM_GRAY_400,
| _ => Neutral::WARM_GRAY_700,
},
width: 1.0,
radius: 0.0.into(),
},
icon: iced::Color::TRANSPARENT,
placeholder: Neutral::WARM_GRAY_700,
value: Neutral::CREAM,
selection: Neutral::WARM_GRAY_400,
})
]
.spacing(Spacing::MICRO)
}
/// Side panel container (entity browser, quick actions)
pub fn side_panel<'a>(
content: impl Into<Element<'a, Message>>,
width: u16,
) -> Element<'a, Message> {
container(content)
.width(Length::Fixed(width as f32))
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(panel_background()),
border: subtle_border(),
..Default::default()
})
.into()
}
/// Main content panel (editor area)
pub fn main_panel<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_900)),
..Default::default()
})
.into()
}

View File

@@ -0,0 +1,179 @@
use iced::{
Element,
Length,
widget::{
button,
column,
container,
row,
scrollable,
text,
},
};
use crate::{
app::{
Editor,
Message,
},
file_tree::{
self,
FileTreeNode,
},
theme::*,
};
pub fn view(editor: &Editor) -> Element<'_, Message> {
let items = if let Some(ref project) = editor.project {
let mut file_list = column![].spacing(Spacing::MICRO);
// Recursively find all .sb files
let mut sb_files = Vec::new();
fn collect_sb_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("sb") {
files.push(path);
} else if path.is_dir() {
collect_sb_files(&path, files);
}
}
}
}
collect_sb_files(&project.root, &mut sb_files);
// Build hierarchical tree
let tree = file_tree::build_tree(&project.root, &sb_files);
// Render tree
for node in tree {
file_list = file_list.push(render_tree_node(node, editor, 0));
}
file_list
} else {
column![
text("No project loaded")
.size(14)
.color(Neutral::WARM_GRAY_400)
]
.spacing(Spacing::SMALL)
};
let content = column![items]
.spacing(Spacing::SMALL)
.padding([Spacing::MEDIUM, Spacing::SMALL]);
container(scrollable(content))
.width(240)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(panel_background()),
border: subtle_border(),
..Default::default()
})
.into()
}
fn render_tree_node(node: FileTreeNode, editor: &Editor, depth: usize) -> Element<'_, Message> {
let indent = (depth as f32) * 16.0; // 16px per level
match node {
| FileTreeNode::File { path, name } => {
// Check if this file has an open tab
let path_clone = path.clone();
let is_selected = editor
.active_tab
.and_then(|idx| editor.tabs.get(idx))
.map(|tab| {
if let crate::app::Tab::File(state) = tab {
&state.path == &path_clone
} else {
false
}
})
.unwrap_or(false);
container(
button(text(name).size(13).color(if is_selected {
Gold::_500
} else {
Neutral::WARM_GRAY_100
}))
.on_press(Message::OpenFileTab(path))
.padding([Spacing::MICRO, Spacing::SMALL])
.style(move |_theme, status| button::Style {
background: if is_selected {
Some(iced::Background::Color(Aubergine::_600))
} else {
match status {
| button::Status::Hovered => {
Some(iced::Background::Color(Aubergine::_700))
},
| _ => None,
}
},
text_color: if is_selected {
Gold::_500
} else {
Neutral::WARM_GRAY_100
},
..Default::default()
}),
)
.padding(iced::Padding::new(0.0).left(indent)) // Left padding for indent
.into()
},
| FileTreeNode::Folder {
name,
path,
children,
} => {
let is_expanded = editor.expanded_folders.contains(&path);
let folder_path = path.clone();
let mut col = column![].spacing(Spacing::MICRO);
// Folder header with chevron
let chevron = if is_expanded { "" } else { "" };
let folder_button = container(
button(
row![
text(chevron).size(10).color(Neutral::WARM_GRAY_400),
text(format!(" {}", name))
.size(13)
.color(Neutral::WARM_GRAY_200)
.font(iced::Font {
weight: iced::font::Weight::Bold,
..Default::default()
}),
]
.spacing(4),
)
.on_press(Message::ToggleFolder(folder_path))
.padding([Spacing::MICRO, Spacing::SMALL])
.style(|_theme, status| button::Style {
background: match status {
| button::Status::Hovered => Some(iced::Background::Color(Aubergine::_700)),
| _ => None,
},
text_color: Neutral::WARM_GRAY_200,
..Default::default()
}),
)
.padding(iced::Padding::new(0.0).left(indent)); // Left padding for indent
col = col.push(folder_button);
// Render children if expanded
if is_expanded {
for child in children {
col = col.push(render_tree_node(child, editor, depth + 1));
}
}
col.into()
},
}
}

View File

@@ -0,0 +1,39 @@
//! File editor UI component
//!
//! Renders an editable code editor with syntax highlighting and line numbers.
use iced::{
Background,
Element,
Length,
widget::container,
};
use crate::{
app::Message,
file_editor_state::FileEditorState,
theme::*,
};
/// Render the file editor view for a given file state
pub fn view(state: &FileEditorState, tab_index: usize) -> Element<'_, Message> {
// Use the CodeEditor's view, mapping its messages to our Message enum
let editor_view = state
.editor
.view()
.map(move |msg| Message::EditorAction(tab_index, msg));
container(editor_view)
.width(Length::Fill)
.height(Length::Fill)
.padding(
iced::Padding::new(Spacing::MEDIUM)
.left(Spacing::LARGE)
.right(Spacing::LARGE),
)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_900)),
..Default::default()
})
.into()
}

View File

@@ -0,0 +1,219 @@
use iced::{
Background,
Element,
Length,
widget::{
button,
column,
container,
row,
scrollable,
text,
},
};
use storybook::syntax::ast::Value;
use crate::{
app::{
Editor,
Message,
Tab,
},
theme::*,
ui::components::metadata_field,
};
pub fn view(editor: &Editor) -> Element<'_, Message> {
// If there's an active tab, show its content
if let Some(idx) = editor.active_tab {
if let Some(tab) = editor.tabs.get(idx) {
return match tab {
| Tab::Character(name) => character_editor_view(editor, name),
| Tab::File(state) => super::file_editor::view(state, idx),
};
}
}
// Otherwise show welcome/status screen
welcome_view(editor)
}
// file_editor_view is now handled by the file_editor module
fn character_editor_view<'a>(editor: &'a Editor, char_name: &str) -> Element<'a, Message> {
if let Some(ref project) = editor.project {
// Find the selected character
let character = project
.files
.iter()
.flat_map(|f| &f.declarations)
.find_map(|decl| {
if let storybook::ResolvedDeclaration::Character(c) = decl {
if &c.name == char_name {
return Some(c);
}
}
None
});
if let Some(char) = character {
// Character editor form
let header = row![
text("Character Editor").size(24).color(Gold::_500),
text(format!(" {}", char.name))
.size(24)
.color(Neutral::CREAM),
];
let mut fields_list = column![].spacing(Spacing::LARGE);
// Name field with metadata styling
fields_list = fields_list.push(metadata_field("Name", &char.name));
// Show all fields with soft metadata styling
for (field_name, field_value) in &char.fields {
let value_text = match field_value {
| Value::Int(n) => format!("{}", n),
| Value::Float(n) => format!("{}", n),
| Value::String(s) => s.clone(),
| Value::Bool(b) => format!("{}", b),
| Value::List(items) => {
format!("[{} items]", items.len())
},
| Value::Identifier(id) => id.join("::"),
| Value::ProseBlock(prose) => {
format!(
"[Prose: {}...]",
prose.content.chars().take(30).collect::<String>()
)
},
| Value::Range(start, end) => {
format!("{:?}..{:?}", start, end)
},
| _ => "[Complex value]".to_string(),
};
fields_list = fields_list.push(metadata_field(field_name, &value_text));
}
// Add right padding to fields so they don't overlap with scrollbar
let fields_with_padding = container(fields_list).padding([0.0, Spacing::LARGE]);
let content = column![header, scrollable(fields_with_padding)]
.spacing(Spacing::LARGE)
.padding(Spacing::LARGE);
return container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_900)),
..Default::default()
})
.into();
}
}
// Character not found
let content = column![text("Character not found").size(16).color(Semantic::ERROR)]
.spacing(Spacing::MEDIUM)
.padding(Spacing::LARGE);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_900)),
..Default::default()
})
.into()
}
fn welcome_view<'a>(editor: &'a Editor) -> Element<'a, Message> {
let content = if let Some(ref error) = editor.error_message {
// Show error message
column![
text("Error Loading Project")
.size(24)
.color(Semantic::ERROR),
text(error).size(14).color(Neutral::WARM_GRAY_100),
]
.spacing(Spacing::MEDIUM)
} else if editor.project.is_some() {
// Project loaded but no file selected
column![
text("Project Loaded").size(32).color(Neutral::CREAM),
text("Select a file from the file tree to edit")
.size(16)
.color(Neutral::WARM_GRAY_100),
]
.spacing(Spacing::MEDIUM)
} else if editor.project_path.is_some() {
// Loading state
column![text("Loading Project...").size(24).color(Neutral::CREAM),].spacing(Spacing::MEDIUM)
} else {
// Welcome state with recent projects
let mut welcome_col = column![
text("Welcome to Storybook Editor")
.size(32)
.color(Neutral::CREAM),
]
.spacing(Spacing::MEDIUM);
// Show recent projects if any
if !editor.recent_projects.projects.is_empty() {
welcome_col = welcome_col.push(text("Recent Projects").size(20).color(Gold::_500));
let mut projects_list = column![].spacing(Spacing::SMALL);
for recent in &editor.recent_projects.projects {
let project_button = button(
column![
text(&recent.name).size(15).color(Neutral::CREAM),
text(recent.path.to_string_lossy().to_string())
.size(12)
.color(Neutral::WARM_GRAY_400),
]
.spacing(2),
)
.on_press(Message::ProjectSelected(Some(recent.path.clone())))
.padding(Spacing::MEDIUM)
.style(|_theme, status| button::Style {
background: match status {
| button::Status::Hovered => Some(Background::Color(Aubergine::_700)),
| _ => Some(Background::Color(Aubergine::_800)),
},
text_color: Neutral::CREAM,
border: iced::Border {
color: Aubergine::_600,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
projects_list = projects_list.push(project_button);
}
welcome_col = welcome_col.push(projects_list);
} else {
welcome_col = welcome_col.push(
text("Use File → Open to open a project")
.size(16)
.color(Neutral::WARM_GRAY_100),
);
}
welcome_col.spacing(Spacing::LARGE)
};
container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding(Spacing::XXLARGE)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_900)),
..Default::default()
})
.into()
}

View File

@@ -0,0 +1,52 @@
use iced::{
Background,
Border,
Element,
widget::{
container,
row,
},
};
use crate::{
app::{
Editor,
Message,
},
theme::*,
ui::components::menu_button,
};
pub fn view(editor: &Editor) -> Element<'_, Message> {
let has_project = editor.project.is_some();
let menu_content = row![
iced::widget::text("File").size(14).color(Neutral::CREAM),
menu_button("Open", true, Some(Message::OpenProjectDialog)),
menu_button(
"Close",
has_project,
has_project.then_some(Message::CloseProject)
),
menu_button(
"Save",
has_project,
has_project.then_some(Message::SaveProject)
),
]
.spacing(Spacing::SMALL)
.padding(Spacing::SMALL);
container(menu_content)
.width(iced::Length::Fill)
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_800)),
border: Border {
color: Aubergine::_600,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}

View File

@@ -0,0 +1,55 @@
/// UI components module
mod components;
mod entity_browser;
mod file_editor;
mod main_editor;
mod menu_bar;
mod quick_actions;
mod tabs;
use iced::{
Element,
Length,
widget::{
column,
row,
},
};
use crate::app::{
Editor,
Message,
};
pub fn view(editor: &Editor) -> Element<'_, Message> {
let menu_bar = menu_bar::view(editor);
let tab_bar = tabs::tab_bar(editor);
let main_editor = main_editor::view(editor);
let editor_with_tabs = column![tab_bar, main_editor]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill);
// Zed-like layout: file tree + editor (only show file tree when project is
// open)
let main_content = if editor.project.is_some() {
let entity_browser = entity_browser::view(editor);
row![entity_browser, editor_with_tabs]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill)
} else {
// No project - just show editor area (welcome screen)
row![editor_with_tabs]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill)
};
column![menu_bar, main_content]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill)
.into()
}

View File

@@ -0,0 +1,105 @@
use iced::{
Background,
Element,
Length,
widget::{
button,
column,
container,
scrollable,
text,
},
};
use crate::{
app::{
Editor,
Message,
},
theme::*,
};
pub fn view(editor: &Editor) -> Element<'_, Message> {
let title = text("Quick Actions").size(16).color(Neutral::CREAM);
let has_project = editor.project.is_some();
let validate_btn = button(text("✓ Validate").size(14).color(if has_project {
Gold::_500
} else {
Neutral::WARM_GRAY_700
}))
.padding([Spacing::MICRO, Spacing::SMALL])
.style(move |_theme, status| button::Style {
background: match status {
| button::Status::Hovered if has_project => Some(Background::Color(Aubergine::_700)),
| _ => None,
},
text_color: if has_project {
Gold::_500
} else {
Neutral::WARM_GRAY_700
},
..Default::default()
});
let validate_btn = if has_project {
validate_btn.on_press(Message::ValidateProject)
} else {
validate_btn
};
let new_char_btn = button(text("⊕ New Character").size(14).color(Gold::_500))
.on_press(Message::NewCharacter)
.padding([Spacing::MICRO, Spacing::SMALL])
.style(|_theme, status| button::Style {
background: match status {
| button::Status::Hovered => Some(Background::Color(Aubergine::_700)),
| _ => None,
},
text_color: Gold::_500,
..Default::default()
});
let actions = column![
validate_btn,
new_char_btn,
text("⊕ New Relationship")
.size(14)
.color(Neutral::WARM_GRAY_400),
text("⊕ New Location")
.size(14)
.color(Neutral::WARM_GRAY_400),
text("🔍 Search...").size(14).color(Neutral::WARM_GRAY_400),
]
.spacing(Spacing::SMALL);
let project_info = if let Some(ref project) = editor.project {
column![
text("Project").size(16).color(Neutral::CREAM),
text(format!("{} characters", project.characters().count()))
.size(12)
.color(Neutral::WARM_GRAY_400),
text(format!("{} relationships", project.relationships().count()))
.size(12)
.color(Neutral::WARM_GRAY_400),
]
.spacing(Spacing::MICRO)
} else {
column![text("No project").size(14).color(Neutral::WARM_GRAY_400),].spacing(Spacing::MICRO)
};
let content = column![title, actions, project_info]
.spacing(Spacing::LARGE)
.padding(Spacing::LARGE);
container(scrollable(content))
.width(240) // Fixed width from design spec
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(panel_background()),
border: subtle_border(),
..Default::default()
})
.into()
}

View File

@@ -0,0 +1,99 @@
use iced::{
Background,
Element,
Length,
widget::{
button,
container,
mouse_area,
row,
text,
},
};
use crate::{
app::{
Editor,
Message,
},
theme::*,
};
pub fn tab_bar(editor: &Editor) -> Element<'_, Message> {
if editor.tabs.is_empty() {
return container(text("")).width(Length::Fill).height(0).into();
}
let mut tabs_row = row![].spacing(Spacing::MICRO);
for (idx, tab) in editor.tabs.iter().enumerate() {
let is_active = editor.active_tab == Some(idx);
let tab_content = row![
text(tab.title()).size(13).color(if is_active {
Gold::_500
} else {
Neutral::WARM_GRAY_100
}),
button(text("×").size(14).color(Neutral::WARM_GRAY_400))
.on_press(Message::CloseTab(idx))
.padding([0, 4])
.style(|_theme, status| button::Style {
background: match status {
| button::Status::Hovered => Some(Background::Color(Aubergine::_600)),
| _ => None,
},
text_color: Neutral::WARM_GRAY_400,
..Default::default()
}),
]
.spacing(Spacing::SMALL);
let tab_button = button(tab_content)
.on_press(Message::SwitchTab(idx))
.padding([Spacing::SMALL, Spacing::MEDIUM])
.style(move |_theme, status| button::Style {
background: if is_active {
Some(Background::Color(Aubergine::_900))
} else {
match status {
| button::Status::Hovered => Some(Background::Color(Aubergine::_700)),
| button::Status::Pressed => Some(Background::Color(Aubergine::_700)),
| _ => None,
}
},
text_color: if is_active {
Gold::_500
} else {
Neutral::WARM_GRAY_100
},
border: iced::Border {
color: iced::Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
// Wrap in mouse_area to track hover state for middle-click
let tab_with_hover = mouse_area(tab_button)
.on_enter(Message::TabHovered(Some(idx)))
.on_exit(Message::TabHovered(None));
tabs_row = tabs_row.push(tab_with_hover);
}
container(tabs_row)
.width(Length::Fill)
.padding([Spacing::MICRO, Spacing::SMALL])
.style(|_theme| container::Style {
background: Some(Background::Color(Aubergine::_800)),
border: iced::Border {
color: iced::Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}