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:
277
storybook-editor/src/app.rs
Normal file
277
storybook-editor/src/app.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
86
storybook-editor/src/file_editor_state.rs
Normal file
86
storybook-editor/src/file_editor_state.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
113
storybook-editor/src/file_tree.rs
Normal file
113
storybook-editor/src/file_tree.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
storybook-editor/src/highlighter.rs
Normal file
123
storybook-editor/src/highlighter.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
27
storybook-editor/src/main.rs
Normal file
27
storybook-editor/src/main.rs
Normal 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()
|
||||
}
|
||||
95
storybook-editor/src/recent_projects.rs
Normal file
95
storybook-editor/src/recent_projects.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
228
storybook-editor/src/syntax_highlight.rs
Normal file
228
storybook-editor/src/syntax_highlight.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
201
storybook-editor/src/theme.rs
Normal file
201
storybook-editor/src/theme.rs
Normal 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
|
||||
}
|
||||
208
storybook-editor/src/ui/components.rs
Normal file
208
storybook-editor/src/ui/components.rs
Normal 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()
|
||||
}
|
||||
179
storybook-editor/src/ui/entity_browser.rs
Normal file
179
storybook-editor/src/ui/entity_browser.rs
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
39
storybook-editor/src/ui/file_editor.rs
Normal file
39
storybook-editor/src/ui/file_editor.rs
Normal 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()
|
||||
}
|
||||
219
storybook-editor/src/ui/main_editor.rs
Normal file
219
storybook-editor/src/ui/main_editor.rs
Normal 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()
|
||||
}
|
||||
52
storybook-editor/src/ui/menu_bar.rs
Normal file
52
storybook-editor/src/ui/menu_bar.rs
Normal 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()
|
||||
}
|
||||
55
storybook-editor/src/ui/mod.rs
Normal file
55
storybook-editor/src/ui/mod.rs
Normal 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()
|
||||
}
|
||||
105
storybook-editor/src/ui/quick_actions.rs
Normal file
105
storybook-editor/src/ui/quick_actions.rs
Normal 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()
|
||||
}
|
||||
99
storybook-editor/src/ui/tabs.rs
Normal file
99
storybook-editor/src/ui/tabs.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user