use std::sync::Arc; use iced_wgpu::core::keyboard; use iced_wgpu::core::keyboard::key; use iced_wgpu::core::text::{Highlight, Wrapping}; use iced_wgpu::core::{ border, padding, Background, Border, Color, Element, Font, Length, Padding, Shadow, Theme, }; use iced_widget::container; use iced_widget::markdown; use iced_widget::text_editor::{self, Binding, KeyPress, Motion, Status, Style}; use iced_wgpu::core::text::highlighter::Format; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings}; #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { EditorAction(text_editor::Action), TogglePreview, MarkdownLink(markdown::Uri), InsertTable, ToggleBold, ToggleItalic, Evaluate, SmartEval, ZoomIn, ZoomOut, ZoomReset, } pub struct EditorState { pub content: text_editor::Content, pub font_size: f32, pub preview: bool, pub parsed: Vec, pub eval_results: Vec<(usize, String)>, pub eval_errors: Vec<(usize, String)>, pub lang: Option, } fn md_style() -> markdown::Style { markdown::Style { font: Font::default(), inline_code_highlight: Highlight { background: Color::from_rgb(0.188, 0.188, 0.259).into(), border: border::rounded(4), }, inline_code_padding: padding::left(2).right(2), inline_code_color: Color::from_rgb(0.651, 0.890, 0.631), inline_code_font: Font::MONOSPACE, code_block_font: Font::MONOSPACE, link_color: Color::from_rgb(0.537, 0.706, 0.980), } } impl EditorState { pub fn new() -> Self { let sample = concat!( "use std::collections::HashMap;\n\n", "/// A simple key-value store.\n", "pub struct Store {\n", " data: HashMap,\n", "}\n\n", "impl Store {\n", " pub fn new() -> Self {\n", " Self { data: HashMap::new() }\n", " }\n\n", " pub fn insert(&mut self, key: &str, value: i64) {\n", " self.data.insert(key.to_string(), value);\n", " }\n\n", " pub fn get(&self, key: &str) -> Option<&i64> {\n", " self.data.get(key)\n", " }\n", "}\n\n", "fn main() {\n", " let mut store = Store::new();\n", " store.insert(\"count\", 42);\n", " if let Some(val) = store.get(\"count\") {\n", " println!(\"value: {val}\");\n", " }\n", "}\n", ); Self { content: text_editor::Content::with_text(sample), font_size: 14.0, preview: false, parsed: Vec::new(), eval_results: Vec::new(), eval_errors: Vec::new(), lang: Some("rust".into()), } } pub fn set_text(&mut self, text: &str) { self.content = text_editor::Content::with_text(text); self.reparse(); } pub fn set_lang_from_ext(&mut self, ext: &str) { self.lang = lang_from_extension(ext); } fn reparse(&mut self) { let text = self.content.text(); self.parsed = markdown::parse(&text).collect(); } fn toggle_wrap(&mut self, marker: &str) { let mlen = marker.len(); match self.content.selection() { Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => { let inner = &sel[mlen..sel.len() - mlen]; self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(inner.to_string())), )); } Some(sel) => { let wrapped = format!("{marker}{sel}{marker}"); self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(wrapped)), )); } None => { let empty = format!("{marker}{marker}"); self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(empty)), )); for _ in 0..mlen { self.content.perform(text_editor::Action::Move(Motion::Left)); } } } self.reparse(); } fn run_eval(&mut self) { let text = self.content.text(); let doc = crate::eval::evaluate_document(&text); self.eval_results = doc.results.into_iter().map(|r| (r.line, r.result)).collect(); self.eval_errors = doc.errors.into_iter().map(|e| (e.line, e.error)).collect(); } pub fn update(&mut self, message: Message) { match message { Message::EditorAction(action) => { let is_edit = action.is_edit(); let auto_indent = if let text_editor::Action::Edit(text_editor::Edit::Enter) = &action { let cursor = self.content.cursor(); let line_text = self.content.line(cursor.position.line) .map(|l| l.text.to_string()) .unwrap_or_default(); let base = leading_whitespace(&line_text).to_string(); let trimmed = line_text.trim_end(); let extra = matches!(trimmed.as_bytes().last(), Some(b'{' | b'[' | b'(')); if extra { Some(format!("{base} ")) } else { Some(base) } } else { None }; let dedent = if let text_editor::Action::Edit(text_editor::Edit::Insert(ch)) = &action { matches!(ch, '}' | ']' | ')').then(|| { let cursor = self.content.cursor(); let line_text = self.content.line(cursor.position.line) .map(|l| l.text.to_string()) .unwrap_or_default(); let prefix = &line_text[..cursor.position.column]; if prefix.chars().all(|c| c == ' ' || c == '\t') && prefix.len() >= 4 { Some(prefix.len()) } else { None } }).flatten() } else { None }; self.content.perform(action); if let Some(indent) = auto_indent { if !indent.is_empty() { self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(indent)), )); } } if let Some(col) = dedent { let remove = col.min(4); self.content.perform(text_editor::Action::Move(Motion::Left)); for _ in 0..remove { self.content.perform(text_editor::Action::Edit( text_editor::Edit::Backspace, )); } self.content.perform(text_editor::Action::Move(Motion::Right)); } if is_edit { if self.lang.is_none() { self.lang = detect_lang_from_content(&self.content.text()); } self.reparse(); self.run_eval(); } } Message::InsertTable => { let table = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| | | |\n| | | |\n"; self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(table.to_string())), )); self.reparse(); self.run_eval(); } Message::ToggleBold => { self.toggle_wrap("**"); } Message::ToggleItalic => { self.toggle_wrap("*"); } Message::Evaluate => { self.run_eval(); } Message::SmartEval => { let cursor = self.content.cursor(); let text = self.content.text(); let lines: Vec<&str> = text.lines().collect(); let line_idx = cursor.position.line; if line_idx < lines.len() { let line = lines[line_idx].trim(); if let Some(varname) = parse_let_binding(line) { let insert = format!("\n/= {varname}"); self.content.perform(text_editor::Action::Move(Motion::End)); self.content.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(insert)), )); self.reparse(); self.run_eval(); } } } Message::TogglePreview => { self.preview = !self.preview; if self.preview { self.reparse(); } } Message::MarkdownLink(_url) => {} Message::ZoomIn => { self.font_size = (self.font_size + 1.0).min(48.0); } Message::ZoomOut => { self.font_size = (self.font_size - 1.0).max(8.0); } Message::ZoomReset => { self.font_size = 14.0; } } } pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview { let settings = markdown::Settings::with_text_size(self.font_size, md_style()); let preview = markdown::view(&self.parsed, settings) .map(Message::MarkdownLink); iced_widget::container( iced_widget::scrollable( iced_widget::container(preview) .padding(Padding { top: 38.0, right: 16.0, bottom: 16.0, left: 16.0 }) ) .height(Length::Fill) ) .width(Length::Fill) .height(Length::Fill) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(0.08, 0.08, 0.10))), border: Border::default(), text_color: Some(Color::from_rgb(0.804, 0.839, 0.957)), shadow: Shadow::default(), snap: false, }) .into() } else { let editor = iced_widget::text_editor(&self.content) .on_action(Message::EditorAction) .font(Font::MONOSPACE) .size(self.font_size) .height(Length::Fill) .padding(Padding { top: 38.0, right: 8.0, bottom: 8.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .style(|_theme, _status| Style { background: Background::Color(Color::from_rgb(0.08, 0.08, 0.10)), border: Border::default(), placeholder: Color::from_rgb(0.4, 0.4, 0.4), value: Color::WHITE, selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4), }); if let Some(lang) = &self.lang { let settings = SyntaxSettings { lang: lang.clone(), source: self.content.text(), }; editor .highlight_with::( settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), font: None, }, ) .into() } else { editor.into() } }; let mode_label = if self.preview { "Preview" } else { "Edit" }; let cursor = self.content.cursor(); let line = cursor.position.line + 1; let col = cursor.position.column + 1; let status_bar = iced_widget::container( iced_widget::row([ iced_widget::text(format!("{mode_label} Ln {line}, Col {col}")) .font(Font::MONOSPACE) .size(11.0) .color(Color::from_rgb(0.55, 0.55, 0.55)) .into(), ]) ) .width(Length::Fill) .padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 }) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(0.12, 0.12, 0.14))), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, }); let mut col_items: Vec> = vec![main_content]; if !self.eval_results.is_empty() || !self.eval_errors.is_empty() { let mut result_items: Vec> = Vec::new(); for (ln, val) in &self.eval_results { result_items.push( iced_widget::text(format!("Ln {}: {}", ln + 1, val)) .font(Font::MONOSPACE) .size(11.0) .color(Color::from_rgb(0.651, 0.890, 0.631)) .into(), ); } for (ln, err) in &self.eval_errors { result_items.push( iced_widget::text(format!("Ln {}: {}", ln + 1, err)) .font(Font::MONOSPACE) .size(11.0) .color(Color::from_rgb(0.890, 0.400, 0.400)) .into(), ); } let eval_panel = iced_widget::container( iced_widget::column(result_items).spacing(2.0), ) .width(Length::Fill) .padding(Padding { top: 4.0, right: 10.0, bottom: 4.0, left: 10.0 }) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.12))), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, }); col_items.push(eval_panel.into()); } col_items.push(status_bar.into()); iced_widget::column(col_items) .height(Length::Fill) .into() } } fn parse_let_binding(line: &str) -> Option { let rest = line.strip_prefix("let ")?; let eq_pos = rest.find('=')?; if rest.as_bytes().get(eq_pos + 1) == Some(&b'=') { return None; } let name_part = rest[..eq_pos].trim(); let name = if let Some(colon) = name_part.find(':') { name_part[..colon].trim() } else { name_part }; if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { return None; } Some(name.to_string()) } fn macos_key_binding(key_press: KeyPress) -> Option> { let KeyPress { key, modifiers, status, .. } = &key_press; if !matches!(status, Status::Focused { .. }) { return None; } match key.as_ref() { keyboard::Key::Character("=" | "+") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomIn)) } keyboard::Key::Character("-") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomOut)) } keyboard::Key::Character("0") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomReset)) } keyboard::Key::Named(key::Named::Backspace) if modifiers.alt() => { Some(Binding::Sequence(vec![ Binding::Select(Motion::WordLeft), Binding::Backspace, ])) } keyboard::Key::Named(key::Named::Delete) if modifiers.alt() => { Some(Binding::Sequence(vec![ Binding::Select(Motion::WordRight), Binding::Delete, ])) } keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() && modifiers.shift() => { Some(Binding::Select(Motion::DocumentStart)) } keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() && modifiers.shift() => { Some(Binding::Select(Motion::DocumentEnd)) } keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() => { Some(Binding::Move(Motion::DocumentStart)) } keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() => { Some(Binding::Move(Motion::DocumentEnd)) } _ => Binding::from_key_press(key_press), } } fn lang_from_extension(ext: &str) -> Option { let lang = match ext { "rs" => "rust", "c" | "h" => "c", "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp", "js" | "mjs" | "cjs" => "javascript", "jsx" => "jsx", "ts" | "mts" | "cts" => "typescript", "tsx" => "tsx", "py" => "python", "go" => "go", "rb" => "ruby", "sh" | "bash" | "zsh" => "bash", "java" => "java", "html" | "htm" => "html", "css" => "css", "scss" => "scss", "less" => "less", "json" => "json", "lua" => "lua", "php" => "php", "toml" => "toml", "yaml" | "yml" => "yaml", "swift" => "swift", "zig" => "zig", "sql" => "sql", "mk" => "make", "cord" | "cordial" => "rust", _ => return None, }; Some(lang.to_string()) } fn detect_lang_from_content(text: &str) -> Option { let keywords = ["fn ", "let ", "if ", "else ", "while ", "for ", "/="]; let mut hits = 0; for line in text.lines().take(50) { let trimmed = line.trim(); for kw in &keywords { if trimmed.starts_with(kw) || trimmed.contains(&format!(" {kw}")) { hits += 1; } } if hits >= 2 { return Some("rust".into()); } } None } fn leading_whitespace(line: &str) -> &str { let end = line.len() - line.trim_start().len(); &line[..end] }