From 95cacdcd0ed1625c47dd01754f73ebdf2bfd7058 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 5 Apr 2026 18:06:57 +0200 Subject: [PATCH] fix another round of fixes of M3 --- crates/bds-editor/src/highlight.rs | 9 ++ crates/bds-editor/src/lib.rs | 2 +- crates/bds-editor/src/widget.rs | 168 +++++++++++---------- crates/bds-ui/src/app.rs | 82 +++++++--- crates/bds-ui/src/views/post_editor.rs | 56 +++---- crates/bds-ui/src/views/script_editor.rs | 86 ++++++++--- crates/bds-ui/src/views/tags_view.rs | 2 +- crates/bds-ui/src/views/template_editor.rs | 85 ++++++++--- 8 files changed, 318 insertions(+), 172 deletions(-) diff --git a/crates/bds-editor/src/highlight.rs b/crates/bds-editor/src/highlight.rs index 1200800..6f22b73 100644 --- a/crates/bds-editor/src/highlight.rs +++ b/crates/bds-editor/src/highlight.rs @@ -1,8 +1,17 @@ use std::collections::HashMap; +use std::sync::LazyLock; use syntect::highlighting::{Style, ThemeSet}; use syntect::parsing::{SyntaxReference, SyntaxSet}; +/// Global highlighter singleton (expensive to create, safe to share). +static GLOBAL_HIGHLIGHTER: LazyLock = LazyLock::new(Highlighter::new); + +/// Return a reference to the global syntax highlighter. +pub fn highlighter() -> &'static Highlighter { + &GLOBAL_HIGHLIGHTER +} + /// Syntax highlighter using syntect with line-level caching. pub struct Highlighter { syntax_set: SyntaxSet, diff --git a/crates/bds-editor/src/lib.rs b/crates/bds-editor/src/lib.rs index 0398bc0..11968cc 100644 --- a/crates/bds-editor/src/lib.rs +++ b/crates/bds-editor/src/lib.rs @@ -4,5 +4,5 @@ pub mod history; mod widget; pub use buffer::{EditorBuffer, Selection}; -pub use highlight::Highlighter; +pub use highlight::{highlighter, Highlighter}; pub use widget::{CodeEditor, EditorMessage, mono_metrics}; diff --git a/crates/bds-editor/src/widget.rs b/crates/bds-editor/src/widget.rs index bd6207e..0f17261 100644 --- a/crates/bds-editor/src/widget.rs +++ b/crates/bds-editor/src/widget.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::sync::OnceLock; use cosmic_text::{Attrs, Buffer as CosmicBuffer, Family, FontSystem, Metrics, Shaping}; @@ -96,7 +97,7 @@ fn syntect_to_iced(c: syntect::highlighting::Color) -> Color { /// A syntax-highlighting code editor widget for Iced. pub struct CodeEditor<'a, Message> { - buffer: &'a mut EditorBuffer, + buffer: &'a RefCell, highlighter: &'a Highlighter, extension: &'a str, on_change: Option Message + 'a>>, @@ -104,7 +105,7 @@ pub struct CodeEditor<'a, Message> { impl<'a, Message> CodeEditor<'a, Message> { pub fn new( - buffer: &'a mut EditorBuffer, + buffer: &'a RefCell, highlighter: &'a Highlighter, extension: &'a str, ) -> Self { @@ -161,6 +162,7 @@ where ) { let bounds = layout.bounds(); let state = tree.state.downcast_ref::(); + let buf = self.buffer.borrow(); // Background renderer.fill_quad( @@ -187,14 +189,14 @@ where ); let metrics = mono_metrics(); - let (cursor_line, cursor_col) = self.buffer.cursor(); - let scroll = self.buffer.scroll_offset(); + let (cursor_line, cursor_col) = buf.cursor(); + let scroll = buf.scroll_offset(); let visible_lines = (bounds.height / metrics.line_height) as usize + 1; - let selection = self.buffer.selection(); + let selection = buf.selection(); // Pre-compute highlighted lines for visible range let syntax = self.highlighter.syntax_for_extension(self.extension); - let full_text = self.buffer.text(); + let full_text = buf.text(); let highlighted = self.highlighter.highlight_lines(&full_text, syntax); let font = iced::Font::MONOSPACE; @@ -202,7 +204,7 @@ where // Render visible lines for vis_idx in 0..visible_lines { let line_idx = scroll + vis_idx; - if line_idx >= self.buffer.line_count() { + if line_idx >= buf.line_count() { break; } @@ -217,8 +219,7 @@ where if let Some(sel) = selection { if !sel.is_empty() { let (start, end) = sel.ordered(); - let line_len = self - .buffer + let line_len = buf .line(line_idx) .map(|l| { let len = l.len_chars(); @@ -319,7 +320,7 @@ where ); x_off += span_width; } - } else if let Some(line) = self.buffer.line(line_idx) { + } else if let Some(line) = buf.line(line_idx) { // Fallback: plain text rendering let mut line_text: String = line.chars().collect(); if line_text.ends_with('\n') { @@ -390,8 +391,9 @@ where state.is_dragging = true; if let Some(pos) = cursor.position_in(bounds) { + let mut buf = self.buffer.borrow_mut(); let line = (pos.y / metrics.line_height) as usize - + self.buffer.scroll_offset(); + + buf.scroll_offset(); let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; @@ -405,11 +407,11 @@ where && (state.last_click_col as isize - col as isize).unsigned_abs() < 3; if is_double_click { - self.buffer.select_word_at(line, col); + buf.select_word_at(line, col); state.last_click_time = None; // reset } else { - self.buffer.clear_selection(); - self.buffer.set_cursor(line, col); + buf.clear_selection(); + buf.set_cursor(line, col); state.last_click_time = Some(now); state.last_click_line = line; state.last_click_col = col; @@ -426,15 +428,16 @@ where Event::Mouse(mouse::Event::CursorMoved { .. }) => { if state.is_dragging && state.is_focused { if let Some(pos) = cursor.position_in(bounds) { + let mut buf = self.buffer.borrow_mut(); let line = (pos.y / metrics.line_height) as usize - + self.buffer.scroll_offset(); + + buf.scroll_offset(); let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; // Extend selection by simulating shift+movement - let clamped_line = line.min(self.buffer.line_count().saturating_sub(1)); - let clamped_col = if clamped_line < self.buffer.line_count() { + let clamped_line = line.min(buf.line_count().saturating_sub(1)); + let clamped_col = if clamped_line < buf.line_count() { col.min( - self.buffer + buf .line(clamped_line) .map(|l| { let len = l.len_chars(); @@ -449,20 +452,21 @@ where } else { 0 }; - self.buffer - .set_selection( - self.buffer - .selection() - .map(|s| s.anchor_line) - .unwrap_or(self.buffer.cursor().0), - self.buffer - .selection() - .map(|s| s.anchor_col) - .unwrap_or(self.buffer.cursor().1), + let anchor_line = buf + .selection() + .map(|s| s.anchor_line) + .unwrap_or(buf.cursor().0); + let anchor_col = buf + .selection() + .map(|s| s.anchor_col) + .unwrap_or(buf.cursor().1); + buf.set_selection( + anchor_line, + anchor_col, clamped_line, clamped_col, ); - self.buffer.set_cursor(clamped_line, clamped_col); + buf.set_cursor(clamped_line, clamped_col); } return Status::Captured; } @@ -474,7 +478,7 @@ where -(y / metrics.line_height) as isize } }; - self.buffer.scroll_by(lines); + self.buffer.borrow_mut().scroll_by(lines); return Status::Captured; } Event::Keyboard(keyboard::Event::KeyPressed { @@ -488,35 +492,34 @@ where match key { // Cmd+Z = undo, Cmd+Shift+Z = redo keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "z" => { - if is_shift { - self.buffer.redo(); - } else { - self.buffer.undo(); + { + let mut buf = self.buffer.borrow_mut(); + if is_shift { buf.redo(); } else { buf.undo(); } } self.emit_change(shell); } // Cmd+Y = redo (Windows convention) keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "y" => { - self.buffer.redo(); + self.buffer.borrow_mut().redo(); self.emit_change(shell); } // Cmd+A = select all keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "a" => { - self.buffer.select_all(); + self.buffer.borrow_mut().select_all(); } // Cmd+C = copy keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "c" => { - let text = self.buffer.selected_text(); + let text = self.buffer.borrow().selected_text(); if !text.is_empty() { clipboard.write(iced::advanced::clipboard::Kind::Standard, text); } } // Cmd+X = cut keyboard::Key::Character(ref c) if is_cmd && c.as_str() == "x" => { - let text = self.buffer.selected_text(); + let text = self.buffer.borrow().selected_text(); if !text.is_empty() { clipboard.write(iced::advanced::clipboard::Kind::Standard, text); - self.buffer.delete_selection(); + self.buffer.borrow_mut().delete_selection(); self.emit_change(shell); } } @@ -525,7 +528,7 @@ where if let Some(text) = clipboard.read(iced::advanced::clipboard::Kind::Standard) { - self.buffer.insert(&text); + self.buffer.borrow_mut().insert(&text); self.emit_change(shell); } } @@ -537,99 +540,110 @@ where } // Arrow keys with modifiers keyboard::Key::Named(keyboard::key::Named::ArrowUp) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_up(); + buf.select_up(); } else { - self.buffer.move_up(); + buf.move_up(); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_down(); + buf.select_down(); } else { - self.buffer.move_down(); + buf.move_down(); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => { + let mut buf = self.buffer.borrow_mut(); if is_shift && is_alt { - self.buffer.select_word_left(); + buf.select_word_left(); } else if is_shift { - self.buffer.select_left(); + buf.select_left(); } else if is_alt { - self.buffer.move_word_left(); + buf.move_word_left(); } else { - self.buffer.move_left(); + buf.move_left(); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::ArrowRight) => { + let mut buf = self.buffer.borrow_mut(); if is_shift && is_alt { - self.buffer.select_word_right(); + buf.select_word_right(); } else if is_shift { - self.buffer.select_right(); + buf.select_right(); } else if is_alt { - self.buffer.move_word_right(); + buf.move_word_right(); } else { - self.buffer.move_right(); + buf.move_right(); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::Home) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_home(); + buf.select_home(); } else { - self.buffer.move_home(); + buf.move_home(); } } keyboard::Key::Named(keyboard::key::Named::End) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_end(); + buf.select_end(); } else { - self.buffer.move_end(); + buf.move_end(); } } keyboard::Key::Named(keyboard::key::Named::PageUp) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_page_up(vis); + buf.select_page_up(vis); } else { - self.buffer.move_page_up(vis); + buf.move_page_up(vis); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::PageDown) => { + let mut buf = self.buffer.borrow_mut(); if is_shift { - self.buffer.select_page_down(vis); + buf.select_page_down(vis); } else { - self.buffer.move_page_down(vis); + buf.move_page_down(vis); } - self.buffer.ensure_cursor_visible(vis); + buf.ensure_cursor_visible(vis); } keyboard::Key::Named(keyboard::key::Named::Backspace) => { - if is_alt { - // Option+Backspace = delete word left - self.buffer.select_word_left(); - self.buffer.delete_selection(); - } else { - self.buffer.backspace(); + { + let mut buf = self.buffer.borrow_mut(); + if is_alt { + // Option+Backspace = delete word left + buf.select_word_left(); + buf.delete_selection(); + } else { + buf.backspace(); + } } self.emit_change(shell); } keyboard::Key::Named(keyboard::key::Named::Delete) => { - self.buffer.delete_forward(); + self.buffer.borrow_mut().delete_forward(); self.emit_change(shell); } keyboard::Key::Named(keyboard::key::Named::Enter) => { - self.buffer.insert("\n"); + self.buffer.borrow_mut().insert("\n"); self.emit_change(shell); } keyboard::Key::Named(keyboard::key::Named::Tab) => { - self.buffer.insert(" "); + self.buffer.borrow_mut().insert(" "); self.emit_change(shell); } keyboard::Key::Character(ref c) if !is_cmd => { - self.buffer.insert(c); + self.buffer.borrow_mut().insert(c); self.emit_change(shell); } _ => return Status::Ignored, @@ -645,7 +659,7 @@ where impl<'a, Message> CodeEditor<'a, Message> { fn emit_change(&self, shell: &mut Shell<'_, Message>) { if let Some(ref on_change) = self.on_change { - let text = self.buffer.text(); + let text = self.buffer.borrow().text(); shell.publish((on_change)(EditorMessage::ContentChanged(text))); } } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index f7e55bf..d43848a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use iced::widget::text_editor; use iced::{Element, Subscription, Task}; use bds_core::db::Database; @@ -464,8 +463,12 @@ impl BdsApp { } // Extract content language from project metadata if let Ok(meta) = engine::meta::read_project_json(&data_dir) { - self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string()); + let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string()); + self.content_language = main_lang.clone(); self.blog_languages = meta.blog_languages; + if !self.blog_languages.contains(&main_lang) { + self.blog_languages.insert(0, main_lang); + } } } self.refresh_counts(); @@ -487,8 +490,12 @@ impl BdsApp { if let Some(data_dir) = self.data_dir.clone() { let _ = engine::meta::startup_sync(&data_dir); if let Ok(meta) = engine::meta::read_project_json(&data_dir) { - self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string()); + let main_lang = meta.main_language.unwrap_or_else(|| "en".to_string()); + self.content_language = main_lang.clone(); self.blog_languages = meta.blog_languages; + if !self.blog_languages.contains(&main_lang) { + self.blog_languages.insert(0, main_lang); + } } } let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(); @@ -928,13 +935,9 @@ impl BdsApp { PostEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; } PostEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; } PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.is_dirty = true; } - PostEditorMsg::ContentAction(action) => { - let is_edit = matches!(action, text_editor::Action::Edit(_)); - state.editor_content.perform(action); - if is_edit { - state.content = state.editor_content.text(); - state.is_dirty = true; - } + PostEditorMsg::ContentChanged(new_text) => { + state.content = new_text; + state.is_dirty = true; } PostEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.is_dirty = true; } @@ -1026,7 +1029,10 @@ impl BdsApp { TemplateEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; } TemplateEditorMsg::KindChanged(k) => { state.kind = k.0; state.is_dirty = true; } TemplateEditorMsg::EnabledChanged(b) => { state.enabled = b; state.is_dirty = true; } - TemplateEditorMsg::ContentChanged(s) => { state.content = s; state.is_dirty = true; } + TemplateEditorMsg::ContentChanged(new_text) => { + state.content = new_text; + state.is_dirty = true; + } TemplateEditorMsg::Save => { return self.save_template_editor(&tab_id); } @@ -1065,9 +1071,9 @@ impl BdsApp { ScriptEditorMsg::KindChanged(k) => { state.kind = k.0; state.is_dirty = true; } ScriptEditorMsg::EntrypointChanged(s) => { state.entrypoint = s; state.is_dirty = true; } ScriptEditorMsg::EnabledChanged(b) => { state.enabled = b; state.is_dirty = true; } - ScriptEditorMsg::ContentChanged(s) => { - state.discovered_entrypoints = engine::script::discover_entrypoints(&s); - state.content = s; + ScriptEditorMsg::ContentChanged(new_text) => { + state.discovered_entrypoints = engine::script::discover_entrypoints(&new_text); + state.content = new_text; state.is_dirty = true; } ScriptEditorMsg::Save => { @@ -2065,7 +2071,17 @@ impl BdsApp { TabType::Templates => { if !self.template_editors.contains_key(&tab.id) { match bds_core::db::queries::template::get_template_by_id(db.conn(), &tab.id) { - Ok(template) => { + Ok(mut template) => { + // Published templates: read content from file + if template.content.is_none() { + if let Some(ref data_dir) = self.data_dir { + let rel = bds_core::util::paths::template_file_path(&template.slug); + let path = data_dir.join(&rel); + if let Ok(body) = std::fs::read_to_string(&path) { + template.content = Some(body); + } + } + } self.template_editors.insert(template.id.clone(), TemplateEditorState::from_template(&template)); } Err(e) => { @@ -2077,7 +2093,17 @@ impl BdsApp { TabType::Scripts => { if !self.script_editors.contains_key(&tab.id) { match bds_core::db::queries::script::get_script_by_id(db.conn(), &tab.id) { - Ok(script) => { + Ok(mut script) => { + // Published scripts: read content from file + if script.content.is_none() { + if let Some(ref data_dir) = self.data_dir { + let rel = bds_core::util::paths::script_file_path(&script.slug); + let path = data_dir.join(&rel); + if let Ok(body) = std::fs::read_to_string(&path) { + script.content = Some(body); + } + } + } self.script_editors.insert(script.id.clone(), ScriptEditorState::from_script(&script)); } Err(e) => { @@ -2096,7 +2122,29 @@ impl BdsApp { } } TabType::Settings => { - // Settings state will be initialized lazily + if self.settings_state.is_none() { + let mut state = SettingsViewState::default(); + if let Some(ref project) = self.active_project { + state.project_name = project.name.clone(); + state.project_description = project.description.clone().unwrap_or_default(); + state.data_path = project.data_path.clone().unwrap_or_default(); + } + if let Some(ref data_dir) = self.data_dir { + if let Ok(meta) = engine::meta::read_project_json(data_dir) { + state.public_url = meta.public_url.unwrap_or_default(); + state.default_author = meta.default_author.unwrap_or_default(); + state.max_posts_per_page = meta.max_posts_per_page.to_string(); + } + if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { + state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); + state.ssh_username = pub_prefs.ssh_user.unwrap_or_default(); + state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default(); + state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase(); + } + } + state.offline_mode = self.offline_mode; + self.settings_state = Some(state); + } } _ => {} } diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 8fa8f46..67aa6fd 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1,11 +1,13 @@ +use std::cell::RefCell; use std::collections::HashMap; -use iced::widget::{button, column, container, row, scrollable, text, text_input, text_editor, Space}; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::widget::text::{Shaping, Wrapping}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::{self, UiLocale}; use bds_core::model::{Post, PostStatus, PostTranslation}; +use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter}; use crate::app::Message; use crate::components::inputs; @@ -37,7 +39,7 @@ pub struct PostEditorState { pub slug: String, pub excerpt: String, pub content: String, - pub editor_content: text_editor::Content, + pub editor_buffer: RefCell, pub tags: Vec, pub categories: Vec, pub author: String, @@ -83,7 +85,7 @@ impl Clone for PostEditorState { slug: self.slug.clone(), excerpt: self.excerpt.clone(), content: self.content.clone(), - editor_content: text_editor::Content::with_text(&self.content), + editor_buffer: RefCell::new(EditorBuffer::new(&self.content)), tags: self.tags.clone(), categories: self.categories.clone(), author: self.author.clone(), @@ -133,7 +135,7 @@ impl PostEditorState { slug: post.slug.clone(), excerpt: post.excerpt.clone().unwrap_or_default(), content: content.clone(), - editor_content: text_editor::Content::with_text(&content), + editor_buffer: RefCell::new(EditorBuffer::new(&content)), tags: post.tags.clone(), categories: post.categories.clone(), author: post.author.clone().unwrap_or_default(), @@ -192,7 +194,7 @@ impl PostEditorState { self.title = saved.title; self.excerpt = saved.excerpt; self.content = saved.content.clone(); - self.editor_content = text_editor::Content::with_text(&saved.content); + self.editor_buffer = RefCell::new(EditorBuffer::new(&saved.content)); self.status = saved.status; self.is_dirty = saved.is_dirty; } @@ -201,14 +203,14 @@ impl PostEditorState { self.title = draft.title.clone(); self.excerpt = draft.excerpt.clone(); self.content = draft.content.clone(); - self.editor_content = text_editor::Content::with_text(&draft.content); + self.editor_buffer = RefCell::new(EditorBuffer::new(&draft.content)); self.is_dirty = draft.is_dirty; } else { // No translation yet — blank fields self.title = String::new(); self.excerpt = String::new(); self.content = String::new(); - self.editor_content = text_editor::Content::with_text(""); + self.editor_buffer = RefCell::new(EditorBuffer::new("")); self.is_dirty = false; } @@ -262,7 +264,7 @@ pub enum PostEditorMsg { TitleChanged(String), SlugChanged(String), ExcerptChanged(String), - ContentAction(text_editor::Action), + ContentChanged(String), AuthorChanged(String), TemplateSlugChanged(String), ToggleDoNotTranslate(bool), @@ -461,13 +463,17 @@ pub fn view<'a>( }; // ── Content section (fills remaining space) ── - let content_placeholder = t(locale, "editor.contentPlaceholder"); let content_label = inputs::section_header(&t(locale, "editor.content")); - let editor_widget = text_editor(&state.editor_content) - .placeholder(content_placeholder) - .on_action(|action| Message::PostEditor(PostEditorMsg::ContentAction(action))) - .height(Length::Fill) - .style(editor_style); + let editor_widget: Element<'a, Message> = CodeEditor::new( + &state.editor_buffer, + highlighter(), + "md", + ) + .on_change(|msg| match msg { + EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)), + EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), + }) + .into(); // ── Footer ── let footer = row![ @@ -505,28 +511,6 @@ pub fn view<'a>( .into() } -/// Dark editor style for the text_editor widget. -fn editor_style(_theme: &Theme, status: text_editor::Status) -> text_editor::Style { - let bg = Color::from_rgb(0.10, 0.11, 0.14); - let border_color = match status { - text_editor::Status::Focused => Color::from_rgb(0.30, 0.50, 0.80), - text_editor::Status::Hovered => Color::from_rgb(0.30, 0.32, 0.40), - _ => Color::from_rgb(0.22, 0.24, 0.30), - }; - text_editor::Style { - background: iced::Background::Color(bg), - border: iced::Border { - radius: 4.0.into(), - width: 1.0, - color: border_color, - }, - icon: Color::from_rgb(0.50, 0.52, 0.58), - placeholder: Color::from_rgb(0.40, 0.42, 0.48), - value: Color::from_rgb(0.85, 0.87, 0.92), - selection: Color::from_rgba(0.30, 0.50, 0.80, 0.40), - } -} - /// Chip input: shows existing chips as removable buttons + a text input to add new ones. fn chip_input_field<'a>( label: &str, diff --git a/crates/bds-ui/src/views/script_editor.rs b/crates/bds-ui/src/views/script_editor.rs index 9b3f5ab..d3227c3 100644 --- a/crates/bds-ui/src/views/script_editor.rs +++ b/crates/bds-ui/src/views/script_editor.rs @@ -1,15 +1,17 @@ -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use std::cell::RefCell; + +use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; use bds_core::model::{Script, ScriptKind, ScriptStatus}; +use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter}; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; /// State for an open script editor. -#[derive(Debug, Clone)] pub struct ScriptEditorState { pub script_id: String, pub title: String, @@ -18,6 +20,7 @@ pub struct ScriptEditorState { pub entrypoint: String, pub enabled: bool, pub content: String, + pub editor_buffer: RefCell, pub status: ScriptStatus, pub version: i32, pub created_at: i64, @@ -27,6 +30,37 @@ pub struct ScriptEditorState { pub is_dirty: bool, } +impl std::fmt::Debug for ScriptEditorState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScriptEditorState") + .field("script_id", &self.script_id) + .field("title", &self.title) + .finish_non_exhaustive() + } +} + +impl Clone for ScriptEditorState { + fn clone(&self) -> Self { + Self { + script_id: self.script_id.clone(), + title: self.title.clone(), + slug: self.slug.clone(), + kind: self.kind.clone(), + entrypoint: self.entrypoint.clone(), + enabled: self.enabled, + content: self.content.clone(), + editor_buffer: RefCell::new(EditorBuffer::new(&self.content)), + status: self.status.clone(), + version: self.version, + created_at: self.created_at, + updated_at: self.updated_at, + discovered_entrypoints: self.discovered_entrypoints.clone(), + validation_error: self.validation_error.clone(), + is_dirty: self.is_dirty, + } + } +} + impl ScriptEditorState { pub fn from_script(script: &Script) -> Self { let content = script.content.clone().unwrap_or_default(); @@ -38,7 +72,8 @@ impl ScriptEditorState { kind: script.kind.clone(), entrypoint: script.entrypoint.clone(), enabled: script.enabled, - content, + content: content.clone(), + editor_buffer: RefCell::new(EditorBuffer::new(&content)), status: script.status.clone(), version: script.version, created_at: script.created_at, @@ -157,16 +192,20 @@ pub fn view<'a>( .spacing(16) .width(Length::Fill); - // Content editor (placeholder — will use CodeEditor with Lua syntax) + // Content editor (CodeEditor with Lua syntax highlighting) let content_section: Element<'a, Message> = column![ inputs::section_header(&t(locale, "editor.content")), - container( - text_input("", &state.content) - .on_input(|s| Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))) - .size(14) - ) - .width(Length::Fill) - .height(Length::Fixed(300.0)), + Element::from( + CodeEditor::new( + &state.editor_buffer, + highlighter(), + "lua", + ) + .on_change(|msg| match msg { + EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)), + EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save), + }) + ), ] .spacing(8) .width(Length::Fill) @@ -193,24 +232,31 @@ pub fn view<'a>( ] .padding(8); - let body = scrollable( + // Top pane: header + metadata (scrollable for overflow) + let top_pane = scrollable( column![ header, meta_row1, meta_row2, - content_section, - validation, - footer, ] .spacing(12) .padding(16) .width(Length::Fill) - ); + ) + .height(Length::Shrink); - container(body) - .width(Length::Fill) - .height(Length::Fill) - .into() + // Full layout: top pane (shrink), content (fill), validation + footer (shrink) + column![ + top_pane, + content_section, + validation, + footer, + ] + .spacing(4) + .padding([0, 16]) + .width(Length::Fill) + .height(Length::Fill) + .into() } fn status_badge<'a>(status: &ScriptStatus) -> Element<'a, Message> { diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index 3cc47c9..64bb284 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -154,7 +154,7 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes }) .collect(); - let cloud = iced::widget::Column::with_children(chips).spacing(6); + let cloud = row(chips).spacing(6).wrap(); scrollable( container(cloud) diff --git a/crates/bds-ui/src/views/template_editor.rs b/crates/bds-ui/src/views/template_editor.rs index 868662a..28646a4 100644 --- a/crates/bds-ui/src/views/template_editor.rs +++ b/crates/bds-ui/src/views/template_editor.rs @@ -1,15 +1,17 @@ -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use std::cell::RefCell; + +use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; use bds_core::model::{Template, TemplateKind, TemplateStatus}; +use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter}; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; /// State for an open template editor. -#[derive(Debug, Clone)] pub struct TemplateEditorState { pub template_id: String, pub title: String, @@ -17,6 +19,7 @@ pub struct TemplateEditorState { pub kind: TemplateKind, pub enabled: bool, pub content: String, + pub editor_buffer: RefCell, pub status: TemplateStatus, pub version: i32, pub created_at: i64, @@ -25,15 +28,46 @@ pub struct TemplateEditorState { pub is_dirty: bool, } +impl std::fmt::Debug for TemplateEditorState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TemplateEditorState") + .field("template_id", &self.template_id) + .field("title", &self.title) + .finish_non_exhaustive() + } +} + +impl Clone for TemplateEditorState { + fn clone(&self) -> Self { + Self { + template_id: self.template_id.clone(), + title: self.title.clone(), + slug: self.slug.clone(), + kind: self.kind.clone(), + enabled: self.enabled, + content: self.content.clone(), + editor_buffer: RefCell::new(EditorBuffer::new(&self.content)), + status: self.status.clone(), + version: self.version, + created_at: self.created_at, + updated_at: self.updated_at, + validation_error: self.validation_error.clone(), + is_dirty: self.is_dirty, + } + } +} + impl TemplateEditorState { pub fn from_template(tpl: &Template) -> Self { + let content = tpl.content.clone().unwrap_or_default(); Self { template_id: tpl.id.clone(), title: tpl.title.clone(), slug: tpl.slug.clone(), kind: tpl.kind.clone(), enabled: tpl.enabled, - content: tpl.content.clone().unwrap_or_default(), + content: content.clone(), + editor_buffer: RefCell::new(EditorBuffer::new(&content)), status: tpl.status.clone(), version: tpl.version, created_at: tpl.created_at, @@ -136,16 +170,20 @@ pub fn view<'a>( ); let meta_row2 = row![kind_select, enabled_check].spacing(16).width(Length::Fill); - // Content editor (text area placeholder — will use CodeEditor widget for liquid) + // Content editor (CodeEditor with Liquid/HTML syntax highlighting) let content_section: Element<'a, Message> = column![ inputs::section_header(&t(locale, "editor.content")), - container( - text_input("", &state.content) - .on_input(|s| Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s))) - .size(14) - ) - .width(Length::Fill) - .height(Length::Fixed(300.0)), + Element::from( + CodeEditor::new( + &state.editor_buffer, + highlighter(), + "liquid", + ) + .on_change(|msg| match msg { + EditorMessage::ContentChanged(s) => Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s)), + EditorMessage::SaveRequested => Message::TemplateEditor(TemplateEditorMsg::Save), + }) + ), ] .spacing(8) .width(Length::Fill) @@ -172,24 +210,31 @@ pub fn view<'a>( ] .padding(8); - let body = scrollable( + // Top pane: header + metadata (scrollable for overflow) + let top_pane = scrollable( column![ header, meta_row1, meta_row2, - content_section, - validation, - footer, ] .spacing(12) .padding(16) .width(Length::Fill) - ); + ) + .height(Length::Shrink); - container(body) - .width(Length::Fill) - .height(Length::Fill) - .into() + // Full layout: top pane (shrink), content (fill), validation + footer (shrink) + column![ + top_pane, + content_section, + validation, + footer, + ] + .spacing(4) + .padding([0, 16]) + .width(Length::Fill) + .height(Length::Fill) + .into() } fn status_badge<'a>(status: &TemplateStatus) -> Element<'a, Message> {