diff --git a/Cargo.lock b/Cargo.lock index 3664fe1..3bfa951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,27 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -482,7 +503,8 @@ dependencies = [ "regex", "ropey", "serde", - "thiserror", + "syntect", + "thiserror 2.0.12", "tokio", "tokio-test", "toml", @@ -772,7 +794,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "mio", "parking_lot", @@ -1021,6 +1043,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastdivide" version = "0.4.2" @@ -1039,6 +1071,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1725,7 +1767,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "libc", ] @@ -1844,7 +1886,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libc", ] @@ -1858,6 +1900,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2140,7 +2188,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -2328,6 +2376,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64", + "indexmap 2.10.0", + "quick-xml", + "serde", + "time", +] + [[package]] name = "polling" version = "3.9.0" @@ -2499,6 +2560,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" +dependencies = [ + "memchr", +] + [[package]] name = "quickscope" version = "0.2.0" @@ -2614,7 +2684,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cassowary", "compact_str", "crossterm", @@ -2655,7 +2725,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -2666,7 +2736,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -2888,7 +2958,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2901,7 +2971,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2920,6 +2990,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2964,7 +3043,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -3069,7 +3148,7 @@ dependencies = [ "steel-decimal", "steel-derive", "tantivy", - "thiserror", + "thiserror 2.0.12", "time", "tokio", "tokio-test", @@ -3171,7 +3250,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.12", "time", ] @@ -3291,7 +3370,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.12", "time", "tokio", "tokio-stream", @@ -3346,7 +3425,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.9.1", "byteorder", "bytes", "chrono", @@ -3377,7 +3456,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "uuid", @@ -3392,7 +3471,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.9.1", "byteorder", "chrono", "crc", @@ -3418,7 +3497,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "uuid", @@ -3445,7 +3524,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "url", @@ -3527,7 +3606,7 @@ dependencies = [ "rust_decimal_macros", "steel-core", "steel-derive", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -3659,6 +3738,28 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", + "walkdir", + "yaml-rust", +] + [[package]] name = "tantivy" version = "0.24.2" @@ -3705,7 +3806,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror", + "thiserror 2.0.12", "time", "uuid", "winapi", @@ -3833,13 +3934,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] @@ -4364,6 +4485,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4812,7 +4943,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -4836,6 +4967,15 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 13b6830..23afc9d 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -25,6 +25,7 @@ async-trait.workspace = true regex = { workspace = true, optional = true } ropey = { version = "1.6.1", optional = true } once_cell = "1.21.3" +syntect = { version = "5.2.0", optional = true, default-features = false, features = ["default-fancy"] } [dev-dependencies] tokio-test = "0.4.4" @@ -37,6 +38,7 @@ cursor-style = ["crossterm"] validation = ["regex"] computed = [] textarea = ["dep:ropey","gui"] +syntect = ["dep:syntect", "gui", "textarea"] # text modes (mutually exclusive; default to vim) textmode-vim = [] @@ -50,7 +52,6 @@ all-nontextmodes = [ "computed", "textarea" ] -ropey = ["dep:ropey"] [[example]] name = "suggestions" diff --git a/canvas/examples/textarea_syntax.rs b/canvas/examples/textarea_syntax.rs new file mode 100644 index 0000000..9d41c9e --- /dev/null +++ b/canvas/examples/textarea_syntax.rs @@ -0,0 +1,360 @@ +// examples/textarea_syntax.rs +//! Demonstrates syntax highlighting with the textarea widget +//! +//! This example REQUIRES the `syntect` feature to compile. +//! +//! Run with: +//! cargo run --example textarea_syntax --features "gui,cursor-style,textarea,syntect,textmode-normal" + +#[cfg(not(feature = "syntect"))] +compile_error!( + "This example requires the 'syntect' feature. \ + Run with: cargo run --example textarea_syntax --features \"gui,cursor-style,textarea,syntect,textmode-normal\"" +); + +use std::io; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +use canvas::{ + canvas::CursorManager, + textarea::highlight::{TextAreaSyntax, TextAreaSyntaxState}, +}; + +/// Syntax highlighting TextArea demo +struct SyntaxTextAreaDemo { + textarea: TextAreaSyntaxState, + has_unsaved_changes: bool, + debug_message: String, + current_language: String, +} + +impl SyntaxTextAreaDemo { + fn new() -> Self { + let initial_text = r#"// 🎯 Syntax Highlighting Demo +fn main() { + println!("Hello, world!"); + + let numbers = vec![1, 2, 3, 4, 5]; + let doubled: Vec = numbers + .iter() + .map(|x| x * 2) + .collect(); + + for num in &doubled { + println!("Number: {}", num); + } +} + +// Try pressing F5-F8 to switch languages! +// F1/F2: Switch overflow modes +// F3/F4: Adjust wrap indent"#; + + let mut textarea = TextAreaSyntaxState::from_text(initial_text); + textarea.set_placeholder("Start typing code..."); + + // Set up syntax highlighting + let _ = textarea.set_syntax_theme("InspiredGitHub"); + let _ = textarea.set_syntax_by_extension("rs"); + + Self { + textarea, + has_unsaved_changes: false, + debug_message: "🎯 Syntax highlighting enabled - Rust".to_string(), + current_language: "Rust".to_string(), + } + } + + fn handle_textarea_input(&mut self, key: KeyEvent) { + self.textarea.input(key); + self.has_unsaved_changes = true; + } + + fn switch_to_rust(&mut self) { + let _ = self.textarea.set_syntax_by_extension("rs"); + self.current_language = "Rust".to_string(); + self.debug_message = "🦀 Switched to Rust syntax".to_string(); + + let rust_code = r#"// Rust example +fn fibonacci(n: u32) -> u32 { + match n { + 0 => 0, + 1 => 1, + _ => fibonacci(n - 1) + fibonacci(n - 2), + } +} + +fn main() { + for i in 0..10 { + println!("fib({}) = {}", i, fibonacci(i)); + } +}"#; + self.textarea.set_text(rust_code); + } + + fn switch_to_python(&mut self) { + let _ = self.textarea.set_syntax_by_extension("py"); + self.current_language = "Python".to_string(); + self.debug_message = "🐍 Switched to Python syntax".to_string(); + + let python_code = r#"# Python example +def fibonacci(n): + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + +def main(): + for i in range(10): + print(f"fib({i}) = {fibonacci(i)}") + +if __name__ == "__main__": + main()"#; + self.textarea.set_text(python_code); + } + + fn switch_to_javascript(&mut self) { + let _ = self.textarea.set_syntax_by_extension("js"); + self.current_language = "JavaScript".to_string(); + self.debug_message = "🟨 Switched to JavaScript syntax".to_string(); + + let js_code = r#"// JavaScript example +function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function main() { + for (let i = 0; i < 10; i++) { + console.log(`fib(${i}) = ${fibonacci(i)}`); + } +} + +main();"#; + self.textarea.set_text(js_code); + } + + fn switch_to_scheme(&mut self) { + let _ = self.textarea.set_syntax_by_name("Scheme"); + self.current_language = "Scheme".to_string(); + self.debug_message = "🎭 Switched to Scheme syntax".to_string(); + + let scheme_code = r#";; Scheme example +(define (fibonacci n) + (cond ((= n 0) 0) + ((= n 1) 1) + (else (+ (fibonacci (- n 1)) + (fibonacci (- n 2)))))) + +(define (main) + (do ((i 0 (+ i 1))) + ((= i 10)) + (display (format "fib(~a) = ~a~n" i (fibonacci i))))) + +(main)"#; + self.textarea.set_text(scheme_code); + } + + fn get_cursor_info(&self) -> String { + format!( + "Line {}, Col {} | Lang: {}", + self.textarea.current_field() + 1, + self.textarea.cursor_position() + 1, + self.current_language + ) + } + + fn debug_message(&self) -> &str { + &self.debug_message + } + + fn set_debug_message(&mut self, msg: String) { + self.debug_message = msg; + } + + fn has_unsaved_changes(&self) -> bool { + self.has_unsaved_changes + } +} + +fn handle_key_press( + key_event: KeyEvent, + editor: &mut SyntaxTextAreaDemo, +) -> anyhow::Result { + let KeyEvent { + code: key, + modifiers, + .. + } = key_event; + + // Quit + if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) + || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) + || key == KeyCode::F(10) + { + return Ok(false); + } + + match (key, modifiers) { + // Language switching + (KeyCode::F(5), _) => editor.switch_to_rust(), + (KeyCode::F(6), _) => editor.switch_to_python(), + (KeyCode::F(7), _) => editor.switch_to_javascript(), + (KeyCode::F(8), _) => editor.switch_to_scheme(), + + // Overflow modes + (KeyCode::F(1), _) => { + editor.textarea.use_overflow_indicator('$'); + editor.set_debug_message("Overflow: indicator '$' (wrap OFF)".to_string()); + } + (KeyCode::F(2), _) => { + editor.textarea.use_wrap(); + editor.set_debug_message("Overflow: wrap ON".to_string()); + } + + // Wrap indent + (KeyCode::F(3), _) => { + editor.textarea.set_wrap_indent_cols(4); + editor.set_debug_message("Wrap indent: 4 columns".to_string()); + } + (KeyCode::F(4), _) => { + editor.textarea.set_wrap_indent_cols(0); + editor.set_debug_message("Wrap indent: 0 columns".to_string()); + } + + // Info + (KeyCode::Char('?'), _) => { + editor.set_debug_message(format!( + "{} | Syntax highlighting enabled", + editor.get_cursor_info() + )); + } + + // Default: pass to textarea + _ => editor.handle_textarea_input(key_event), + } + + Ok(true) +} + +fn run_app(terminal: &mut Terminal, mut editor: SyntaxTextAreaDemo) -> io::Result<()> { + loop { + terminal.draw(|f| ui(f, &mut editor))?; + + if let Event::Key(key) = event::read()? { + match handle_key_press(key, &mut editor) { + Ok(should_continue) => { + if !should_continue { + break; + } + } + Err(e) => { + editor.set_debug_message(format!("Error: {e}")); + } + } + } + } + Ok(()) +} + +fn ui(f: &mut Frame, editor: &mut SyntaxTextAreaDemo) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(8)]) + .split(f.area()); + + render_textarea(f, chunks[0], editor); + render_status_and_help(f, chunks[1], editor); +} + +fn render_textarea(f: &mut Frame, area: ratatui::layout::Rect, editor: &mut SyntaxTextAreaDemo) { + let block = Block::default() + .borders(Borders::ALL) + .title("🎨 Syntax Highlighted Code Editor"); + + let textarea_widget = TextAreaSyntax::default().block(block.clone()); + f.render_stateful_widget(textarea_widget, area, &mut editor.textarea); + + // Reuse cursor calculation from the wrapped textarea + let (cx, cy) = editor.textarea.cursor(area, Some(&block)); + f.set_cursor_position((cx, cy)); +} + +fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, editor: &SyntaxTextAreaDemo) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(5)]) + .split(area); + + let status_text = if editor.has_unsaved_changes() { + format!( + "-- SYNTAX MODE (highlighting enabled) -- [Modified] {} | {}", + editor.debug_message(), + editor.get_cursor_info() + ) + } else { + format!( + "-- SYNTAX MODE (highlighting enabled) -- {} | {}", + editor.debug_message(), + editor.get_cursor_info() + ) + }; + + let status = Paragraph::new(Line::from(Span::raw(status_text))) + .block(Block::default().borders(Borders::ALL).title("🎨 Syntax Status")); + + f.render_widget(status, chunks[0]); + + let help_text = "🎨 SYNTAX HIGHLIGHTING DEMO\n\ +F5=Rust, F6=Python, F7=JavaScript, F8=Scheme\n\ +F1/F2=overflow modes, F3/F4=wrap indent\n\ +?=info, Ctrl+Q=quit"; + + let help = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("🚀 Help")) + .style(Style::default().fg(Color::Cyan)); + + f.render_widget(help, chunks[1]); +} + +fn main() -> Result<(), Box> { + println!("🎨 Canvas Textarea Syntax Highlighting Demo"); + println!("✅ cursor-style feature: ENABLED"); + println!("✅ textarea feature: ENABLED"); + println!("✅ syntect feature: ENABLED"); + println!("🎨 Syntax highlighting active"); + println!(); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let editor = SyntaxTextAreaDemo::new(); + + let res = run_app(&mut terminal, editor); + + CursorManager::reset()?; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + println!("🎨 Syntax highlighting demo complete!"); + Ok(()) +} diff --git a/canvas/src/textarea/highlight/chunks.rs b/canvas/src/textarea/highlight/chunks.rs new file mode 100644 index 0000000..e7a03c9 --- /dev/null +++ b/canvas/src/textarea/highlight/chunks.rs @@ -0,0 +1,183 @@ +// src/textarea/highlight/chunks.rs +use ratatui::text::{Line, Span}; +use ratatui::style::Style; +use unicode_width::UnicodeWidthChar; + +#[derive(Debug, Clone)] +pub struct StyledChunk { + pub text: String, + pub style: Style, +} + +pub fn display_width_chunks(chunks: &[StyledChunk]) -> u16 { + chunks + .iter() + .map(|c| { + c.text + .chars() + .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16) + .sum::() + }) + .sum() +} + +pub fn slice_chunks_by_display_cols( + chunks: &[StyledChunk], + start_cols: u16, + max_cols: u16, +) -> Vec { + if max_cols == 0 { + return Vec::new(); + } + + let mut skipped: u16 = 0; + let mut taken: u16 = 0; + let mut out: Vec = Vec::new(); + + for ch in chunks { + if taken >= max_cols { + break; + } + + let mut acc = String::new(); + + for c in ch.text.chars() { + let w = UnicodeWidthChar::width(c).unwrap_or(0) as u16; + if skipped + w <= start_cols { + skipped += w; + continue; + } + if taken + w > max_cols { + break; + } + acc.push(c); + taken = taken.saturating_add(w); + if taken >= max_cols { + break; + } + } + + if !acc.is_empty() { + out.push(StyledChunk { + text: acc, + style: ch.style, + }); + } + } + + out +} + +pub fn clip_chunks_window_with_indicator_padded( + chunks: &[StyledChunk], + view_width: u16, + indicator: char, + start_cols: u16, +) -> Line<'static> { + if view_width == 0 { + return Line::from(""); + } + + let total = display_width_chunks(chunks); + let show_left = start_cols > 0; + let left_cols: u16 = if show_left { 1 } else { 0 }; + + let cap_with_right = view_width.saturating_sub(left_cols + 1); + let remaining = total.saturating_sub(start_cols); + let show_right = remaining > cap_with_right; + + let max_visible = if show_right { + cap_with_right + } else { + view_width.saturating_sub(left_cols) + }; + + let visible = slice_chunks_by_display_cols(chunks, start_cols, max_visible); + let used_cols = left_cols + display_width_chunks(&visible); + + let mut spans: Vec = Vec::new(); + if show_left { + spans.push(Span::raw(indicator.to_string())); + } + for v in visible { + spans.push(Span::styled(v.text, v.style)); + } + if show_right { + let right_pos = view_width.saturating_sub(1); + let filler = right_pos.saturating_sub(used_cols); + if filler > 0 { + spans.push(Span::raw(" ".repeat(filler as usize))); + } + spans.push(Span::raw(indicator.to_string())); + } + + Line::from(spans) +} + +pub fn wrap_chunks_indented( + chunks: &[StyledChunk], + width: u16, + indent: u16, +) -> Vec> { + if width == 0 { + return vec![Line::from("")]; + } + let indent = indent.min(width.saturating_sub(1)); + let cont_cap = width.saturating_sub(indent); + let indent_str = " ".repeat(indent as usize); + + let mut lines: Vec = Vec::new(); + let mut current_spans: Vec = Vec::new(); + let mut used: u16 = 0; + let mut first_line = true; + + // Fixed: Restructure to avoid borrow checker issues + for chunk in chunks { + let mut buf = String::new(); + let mut buf_style = chunk.style; + + for ch in chunk.text.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let cap = if first_line { width } else { cont_cap }; + + if used > 0 && used.saturating_add(w) >= cap { + if !buf.is_empty() { + current_spans.push(Span::styled(buf.clone(), buf_style)); + buf.clear(); + } + lines.push(Line::from(current_spans)); + current_spans = Vec::new(); + first_line = false; + used = 0; + + // Add indent directly instead of using closure + if !first_line && indent > 0 { + current_spans.push(Span::raw(indent_str.clone())); + used = indent; + } + } + + if !buf.is_empty() && buf_style != chunk.style { + current_spans.push(Span::styled(buf.clone(), buf_style)); + buf.clear(); + } + buf_style = chunk.style; + + // Add indent if needed + if used == 0 && !first_line && indent > 0 { + current_spans.push(Span::raw(indent_str.clone())); + used = indent; + } + + buf.push(ch); + used = used.saturating_add(w); + } + + if !buf.is_empty() { + current_spans.push(Span::styled(buf, buf_style)); + } + } + + lines.push(Line::from(current_spans)); + lines +} diff --git a/canvas/src/textarea/highlight/engine.rs b/canvas/src/textarea/highlight/engine.rs new file mode 100644 index 0000000..b678559 --- /dev/null +++ b/canvas/src/textarea/highlight/engine.rs @@ -0,0 +1,269 @@ +// src/textarea/highlight/engine.rs +#![allow(dead_code)] + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use ratatui::style::{Modifier, Style}; +use syntect::{ + highlighting::{ + HighlightIterator, Highlighter, Style as SynStyle, Theme, ThemeSet, + }, + parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}, +}; + +use crate::data_provider::DataProvider; +use super::chunks::StyledChunk; + +#[derive(Debug)] +pub struct SyntectEngine { + ps: SyntaxSet, + ts: ThemeSet, + theme_name: String, + syntax_name: Option, + // Cached parser state (after line i) + parse_after: Vec, + // Cached scope stack (after line i) + stack_after: Vec, + // Hash of line contents to detect edits + line_hashes: Vec, +} + +impl SyntectEngine { + pub fn new() -> Self { + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + Self { + ps, + ts, + theme_name: "InspiredGitHub".to_string(), + syntax_name: None, + parse_after: Vec::new(), + stack_after: Vec::new(), + line_hashes: Vec::new(), + } + } + + pub fn clear(&mut self) { + self.parse_after.clear(); + self.stack_after.clear(); + self.line_hashes.clear(); + } + + pub fn set_theme(&mut self, theme_name: &str) -> bool { + if self.ts.themes.contains_key(theme_name) { + self.theme_name = theme_name.to_string(); + true + } else { + false + } + } + + pub fn set_syntax_by_name(&mut self, name: &str) -> bool { + if self.ps.find_syntax_by_name(name).is_some() { + self.syntax_name = Some(name.to_string()); + self.clear(); + true + } else { + false + } + } + + pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool { + if let Some(s) = self.ps.find_syntax_by_extension(ext) { + self.syntax_name = Some(s.name.clone()); + self.clear(); + true + } else { + false + } + } + + pub fn invalidate_from(&mut self, line_idx: usize) { + if line_idx < self.parse_after.len() { + self.parse_after.truncate(line_idx); + } + if line_idx < self.stack_after.len() { + self.stack_after.truncate(line_idx); + } + if line_idx < self.line_hashes.len() { + self.line_hashes.truncate(line_idx); + } + } + + pub fn on_insert_line(&mut self, at: usize) { + self.invalidate_from(at); + } + + pub fn on_delete_line(&mut self, at: usize) { + self.invalidate_from(at); + } + + fn theme(&self) -> &Theme { + self.ts + .themes + .get(&self.theme_name) + .expect("theme exists") + } + + fn syntax_ref(&self) -> &SyntaxReference { + if let Some(name) = &self.syntax_name { + if let Some(s) = self.ps.find_syntax_by_name(name) { + return s; + } + } + self.ps.find_syntax_plain_text() + } + + fn map_syntect_style(s: SynStyle) -> Style { + let fg = + ratatui::style::Color::Rgb(s.foreground.r, s.foreground.g, s.foreground.b); + let mut st = Style::default().fg(fg); + use syntect::highlighting::FontStyle; + if s.font_style.contains(FontStyle::BOLD) { + st = st.add_modifier(Modifier::BOLD); + } + if s.font_style.contains(FontStyle::UNDERLINE) { + st = st.add_modifier(Modifier::UNDERLINED); + } + if s.font_style.contains(FontStyle::ITALIC) { + st = st.add_modifier(Modifier::ITALIC); + } + st + } + + fn hash_line(s: &str) -> u64 { + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() + } + + // Verify cached chain up to the nearest trusted predecessor of line_idx, + // using the provider to fetch the current lines. + fn verify_and_truncate_before(&mut self, line_idx: usize, provider: &dyn DataProvider) { + let mut k = std::cmp::min(line_idx, self.parse_after.len()); + while k > 0 { + let j = k - 1; + let curr = Self::hash_line(provider.field_value(j)); + if self.line_hashes.get(j) == Some(&curr) { + break; + } + self.invalidate_from(j); + k = j; + } + } + + // Ensure we have parser + stack for lines [0..line_idx) + fn ensure_state_before(&mut self, line_idx: usize, provider: &dyn DataProvider) { + if line_idx == 0 || self.parse_after.len() >= line_idx { + return; + } + + let syntax = self.syntax_ref(); + let theme = self.theme(); + let highlighter = Highlighter::new(theme); + + let mut ps = if self.parse_after.is_empty() { + ParseState::new(syntax) + } else { + self.parse_after[self.parse_after.len() - 1].clone() + }; + let mut stack = if self.stack_after.is_empty() { + ScopeStack::new() + } else { + self.stack_after[self.stack_after.len() - 1].clone() + }; + + let start = self.parse_after.len(); + for i in start..line_idx { + let s = provider.field_value(i); + let ops = ps.parse_line(s); + // Advance stack by applying ops using HighlightIterator + let mut it = HighlightIterator::new(&highlighter, &ops[..], s, &mut stack); + while let Some((_style, _text)) = it.next() { + // Iterate to apply ops; we don't need the tokens here. + } + + let h = Self::hash_line(s); + + self.parse_after.push(ps.clone()); + self.stack_after.push(stack.clone()); + if i >= self.line_hashes.len() { + self.line_hashes.push(h); + } else { + self.line_hashes[i] = h; + } + } + } + + // Highlight a single line using cached state; update caches for this line. + pub fn highlight_line_cached( + &mut self, + line_idx: usize, + line: &str, + provider: &dyn DataProvider, + ) -> Vec { + // Auto-detect prior changes and truncate cache if needed + self.verify_and_truncate_before(line_idx, provider); + // Precompute states up to line_idx + self.ensure_state_before(line_idx, provider); + + let syntax = self.syntax_ref(); + let theme = self.theme(); + let highlighter = Highlighter::new(theme); + + let mut ps = if line_idx == 0 { + ParseState::new(syntax) + } else if self.parse_after.len() >= line_idx { + self.parse_after[line_idx - 1].clone() + } else { + ParseState::new(syntax) + }; + + let mut stack = if line_idx == 0 { + ScopeStack::new() + } else if self.stack_after.len() >= line_idx { + self.stack_after[line_idx - 1].clone() + } else { + ScopeStack::new() + }; + + let ops = ps.parse_line(line); + let mut iter = HighlightIterator::new(&highlighter, &ops[..], line, &mut stack); + + let mut out: Vec = Vec::new(); + while let Some((syn_style, slice)) = iter.next() { + if slice.is_empty() { + continue; + } + let text = slice.trim_end_matches('\n').to_string(); + if text.is_empty() { + continue; + } + out.push(StyledChunk { + text, + style: Self::map_syntect_style(syn_style), + }); + } + + // Update caches for this line (state after this line) + let h = Self::hash_line(line); + if line_idx >= self.parse_after.len() { + self.parse_after.push(ps); + } else { + self.parse_after[line_idx] = ps; + } + if line_idx >= self.stack_after.len() { + self.stack_after.push(stack); + } else { + self.stack_after[line_idx] = stack; + } + if line_idx >= self.line_hashes.len() { + self.line_hashes.push(h); + } else { + self.line_hashes[line_idx] = h; + } + + out + } +} diff --git a/canvas/src/textarea/highlight/mod.rs b/canvas/src/textarea/highlight/mod.rs new file mode 100644 index 0000000..9bd6b22 --- /dev/null +++ b/canvas/src/textarea/highlight/mod.rs @@ -0,0 +1,18 @@ +// src/textarea/highlight/mod.rs +#[cfg(all(feature = "syntect", feature = "gui"))] +pub mod engine; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub mod chunks; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub mod state; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub mod widget; + +#[cfg(all(feature = "syntect", feature = "gui"))] +pub use engine::SyntectEngine; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub use chunks::StyledChunk; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub use state::TextAreaSyntaxState; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub use widget::TextAreaSyntax; diff --git a/canvas/src/textarea/highlight/state.rs b/canvas/src/textarea/highlight/state.rs new file mode 100644 index 0000000..eaade3e --- /dev/null +++ b/canvas/src/textarea/highlight/state.rs @@ -0,0 +1,53 @@ +// src/textarea/highlight/state.rs +use std::ops::{Deref, DerefMut}; + +use super::engine::SyntectEngine; +use crate::textarea::state::TextAreaState; + +// Remove Debug derive since TextAreaState doesn't implement Debug +pub struct TextAreaSyntaxState { + pub textarea: TextAreaState, + pub engine: SyntectEngine, +} + +impl Default for TextAreaSyntaxState { + fn default() -> Self { + Self { + textarea: TextAreaState::default(), + engine: SyntectEngine::new(), + } + } +} + +impl TextAreaSyntaxState { + pub fn from_text>(text: S) -> Self { + let mut s = Self::default(); + s.textarea.set_text(text); + s + } + + // Optional: convenience setters + pub fn set_syntax_theme(&mut self, theme: &str) -> bool { + self.engine.set_theme(theme) + } + pub fn set_syntax_by_name(&mut self, name: &str) -> bool { + self.engine.set_syntax_by_name(name) + } + pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool { + self.engine.set_syntax_by_extension(ext) + } +} + +impl Deref for TextAreaSyntaxState { + type Target = TextAreaState; + fn deref(&self) -> &Self::Target { + &self.textarea + } +} + +impl DerefMut for TextAreaSyntaxState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.textarea + } +} + diff --git a/canvas/src/textarea/highlight/widget.rs b/canvas/src/textarea/highlight/widget.rs new file mode 100644 index 0000000..7e5a9d7 --- /dev/null +++ b/canvas/src/textarea/highlight/widget.rs @@ -0,0 +1,211 @@ +// src/textarea/highlight/widget.rs +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Paragraph, StatefulWidget, Widget}, +}; + +use unicode_width::UnicodeWidthChar; + +use super::chunks::{ + clip_chunks_window_with_indicator_padded, + wrap_chunks_indented, +}; +use super::state::TextAreaSyntaxState; + +use crate::data_provider::DataProvider; +use crate::textarea::state::{ + compute_h_scroll_with_padding, count_wrapped_rows_indented, TextOverflowMode, +}; + +#[derive(Debug, Clone)] +pub struct TextAreaSyntax<'a> { + pub block: Option>, + pub style: Style, + pub border_type: BorderType, +} + +impl<'a> Default for TextAreaSyntax<'a> { + fn default() -> Self { + Self { + block: Some( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ), + style: Style::default(), + border_type: BorderType::Rounded, + } + } +} + +impl<'a> TextAreaSyntax<'a> { + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + pub fn border_type(mut self, ty: BorderType) -> Self { + self.border_type = ty; + if let Some(b) = &mut self.block { + *b = b.clone().border_type(ty); + } + self + } +} + +fn display_width(s: &str) -> u16 { + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16) + .sum() +} + +fn display_cols_up_to(s: &str, char_count: usize) -> u16 { + let mut cols: u16 = 0; + for (i, ch) in s.chars().enumerate() { + if i >= char_count { + break; + } + cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + cols +} + +fn resolve_start_line_and_intra_indented( + state: &TextAreaSyntaxState, + inner: Rect, +) -> (usize, u16) { + let provider = state.textarea.editor.data_provider(); + let total = provider.line_count(); + + if total == 0 { + return (0, 0); + } + + let wrap = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap); + let width = inner.width; + let target_vis = state.textarea.scroll_y; + + if !wrap { + let start = (target_vis as usize).min(total); + return (start, 0); + } + + let indent = state.textarea.wrap_indent_cols; + + let mut acc: u16 = 0; + for i in 0..total { + let s = provider.field_value(i); + let rows = count_wrapped_rows_indented(s, width, indent); + if acc.saturating_add(rows) > target_vis { + let intra = target_vis.saturating_sub(acc); + return (i, intra); + } + acc = acc.saturating_add(rows); + } + + (total.saturating_sub(1), 0) +} + +impl<'a> StatefulWidget for TextAreaSyntax<'a> { + type State = TextAreaSyntaxState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // Reuse existing scroll logic + state.textarea.ensure_visible(area, self.block.as_ref()); + + let inner = if let Some(b) = &self.block { + b.clone().render(area, buf); + b.inner(area) + } else { + area + }; + + let edited_now = state.textarea.take_edited_flag(); + + let wrap_mode = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap); + let provider = state.textarea.editor.data_provider(); + let total = provider.line_count(); + + let (start, intra) = resolve_start_line_and_intra_indented(state, inner); + + let mut display_lines: Vec = Vec::new(); + + if total == 0 || start >= total { + if let Some(ph) = &state.textarea.placeholder { + display_lines.push(Line::from(Span::raw(ph.clone()))); + } + } else if wrap_mode { + let mut rows_left = inner.height; + let indent = state.textarea.wrap_indent_cols; + + let mut i = start; + while i < total && rows_left > 0 { + let s = provider.field_value(i); + + let chunks = state + .engine + .highlight_line_cached(i, s, provider); + + let lines = wrap_chunks_indented(&chunks, inner.width, indent); + let skip = if i == start { intra as usize } else { 0 }; + for l in lines.into_iter().skip(skip) { + display_lines.push(l); + rows_left = rows_left.saturating_sub(1); + if rows_left == 0 { + break; + } + } + i += 1; + } + } else { + let end = (start.saturating_add(inner.height as usize)).min(total); + + for i in start..end { + let s = provider.field_value(i); + + let chunks = state.engine.highlight_line_cached(i, s, provider); + + let fits = display_width(s) <= inner.width; + let start_cols = if i == state.textarea.current_field() { + let col_idx = state.textarea.display_cursor_position(); + let cursor_cols = display_cols_up_to(s, col_idx); + let (target_h, _left_cols) = + compute_h_scroll_with_padding(cursor_cols, inner.width); + + if fits { + if edited_now { + target_h + } else { + 0 + } + } else { + target_h.max(state.textarea.h_scroll) + } + } else { + 0 + }; + + if let TextOverflowMode::Indicator { ch } = state.textarea.overflow_mode { + display_lines.push(clip_chunks_window_with_indicator_padded( + &chunks, + inner.width, + ch, + start_cols, + )); + } + } + } + + let p = Paragraph::new(display_lines) + .alignment(Alignment::Left) + .style(self.style); + + p.render(inner, buf); + } +} diff --git a/canvas/src/textarea/mod.rs b/canvas/src/textarea/mod.rs index 58c766a..ca3c4d8 100644 --- a/canvas/src/textarea/mod.rs +++ b/canvas/src/textarea/mod.rs @@ -1,8 +1,5 @@ // src/textarea/mod.rs //! Text area convenience exports. -//! -//! Re-export the core textarea types and provider so consumers can use -//! `canvas::textarea::TextArea` / `TextAreaState` / `TextAreaProvider`. pub mod provider; pub mod state; @@ -10,6 +7,9 @@ pub mod state; #[cfg(feature = "gui")] pub mod widget; +#[cfg(all(feature = "syntect", feature = "gui"))] +pub mod highlight; + pub use provider::TextAreaProvider; pub use state::{TextAreaEditor, TextAreaState, TextOverflowMode};