diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index ac852a6..90b634a 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -26,10 +26,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! var appState: AppState! private var titleCancellable: AnyCancellable? + private var textCancellable: AnyCancellable? private var titleBarView: TitleBarView? private var focusTitleObserver: NSObjectProtocol? private var windowControllers: [WindowController] = [] + private var viewport: IcedViewportView? { + window?.contentView as? IcedViewportView + } + func applicationDidFinishLaunching(_ notification: Notification) { _ = ConfigManager.shared appState = AppState() @@ -57,6 +62,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { setupMenuBar() observeDocumentTitle() + observeDocumentText() + DocumentBrowserController.shared = DocumentBrowserController(appState: appState) NotificationCenter.default.addObserver( @@ -86,6 +93,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + syncTextFromViewport() appState.saveNote() } @@ -301,6 +309,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func saveNote() { + syncTextFromViewport() if appState.currentFileURL != nil { appState.saveNote() } else { @@ -309,6 +318,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func saveNoteAs() { + syncTextFromViewport() let panel = NSSavePanel() panel.allowedContentTypes = Self.supportedContentTypes panel.nameFieldStringValue = defaultFilename() @@ -466,6 +476,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + private func observeDocumentText() { + textCancellable = appState.$documentText + .receive(on: RunLoop.main) + .sink { [weak self] text in + self?.viewport?.setText(text) + } + } + + private func syncTextFromViewport() { + guard let vp = viewport else { return } + let text = vp.getText() + if !text.isEmpty || appState.documentText.isEmpty { + appState.documentText = text + } + } + private func observeDocumentTitle() { titleCancellable = appState.$documentText .receive(on: RunLoop.main) diff --git a/src/IcedViewportView.swift b/src/IcedViewportView.swift index 47bb010..5703435 100644 --- a/src/IcedViewportView.swift +++ b/src/IcedViewportView.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI class IcedViewportView: NSView { - private var viewportHandle: OpaquePointer? + private(set) var viewportHandle: OpaquePointer? private var displayLink: CVDisplayLink? override init(frame frameRect: NSRect) { @@ -159,7 +159,7 @@ class IcedViewportView: NSView { if cmd && !shift { switch chars { - case "a", "c", "v", "x", "z", "p", "t", + case "a", "b", "c", "i", "v", "x", "z", "p", "t", "=", "+", "-", "0": keyDown(with: event) return true @@ -197,4 +197,21 @@ class IcedViewportView: NSView { guard let h = viewportHandle else { return } viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil) } + + // MARK: - Text Bridge + + func setText(_ text: String) { + guard let h = viewportHandle else { return } + text.withCString { cstr in + viewport_set_text(h, cstr) + } + } + + func getText() -> String { + guard let h = viewportHandle else { return "" } + guard let cstr = viewport_get_text(h) else { return "" } + let result = String(cString: cstr) + viewport_free_string(cstr) + return result + } } diff --git a/viewport/include/swiftly.h b/viewport/include/swiftly.h index 9c9007b..9c0a654 100644 --- a/viewport/include/swiftly.h +++ b/viewport/include/swiftly.h @@ -41,4 +41,12 @@ void viewport_scroll_event(struct ViewportHandle *handle, float delta_x, float delta_y); +void viewport_set_text(struct ViewportHandle *handle, const char *text); + +void viewport_set_lang(struct ViewportHandle *handle, const char *ext); + +char *viewport_get_text(struct ViewportHandle *handle); + +void viewport_free_string(char *s); + #endif /* SWIFTLY_VIEWPORT_H */ diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index d45618d..7289c3d 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -9,13 +9,20 @@ use iced_wgpu::core::{ 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, @@ -26,6 +33,9 @@ pub struct EditorState { 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 { @@ -46,41 +56,90 @@ fn md_style() -> markdown::Style { impl EditorState { pub fn new() -> Self { let sample = concat!( - "# Heading 1\n\n", - "## Heading 2\n\n", - "### Heading 3\n\n", - "Regular text with **bold** and *italic* and `inline code`.\n\n", - "- Bullet one\n", - "- Bullet two\n", - "- Bullet three\n\n", - "1. First\n", - "2. Second\n", - "3. Third\n\n", - "> This is a blockquote\n\n", - "```python\n", - "def hello():\n", - " print(\"Hello world\")\n", - "```\n\n", - "| Name | Age | City |\n", - "|------|-----|------|\n", - "| Alice | 30 | NYC |\n", - "| Bob | 25 | LA |\n\n", - "---\n\n", - "[Link text](https://example.com)\n", + "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) => { @@ -88,6 +147,7 @@ impl EditorState { self.content.perform(action); if is_edit { self.reparse(); + self.run_eval(); } } Message::InsertTable => { @@ -96,6 +156,34 @@ impl EditorState { 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; @@ -140,7 +228,7 @@ impl EditorState { }) .into() } else { - iced_widget::text_editor(&self.content) + let editor = iced_widget::text_editor(&self.content) .on_action(Message::EditorAction) .font(Font::MONOSPACE) .size(self.font_size) @@ -154,8 +242,25 @@ impl EditorState { 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), - }) - .into() + }); + + 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" }; @@ -182,12 +287,73 @@ impl EditorState { snap: false, }); - iced_widget::column([main_content, status_bar.into()]) + 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; @@ -232,3 +398,35 @@ fn macos_key_binding(key_press: KeyPress) -> Option> { _ => 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", + _ => return None, + }; + Some(lang.to_string()) +} diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 45a70d0..15778c5 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -168,6 +168,9 @@ pub fn render(handle: &mut ViewportHandle) { match c.as_str() { "p" => messages.push(Message::TogglePreview), "t" => messages.push(Message::InsertTable), + "b" => messages.push(Message::ToggleBold), + "i" => messages.push(Message::ToggleItalic), + "e" => messages.push(Message::SmartEval), _ => {} } } diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 12ba428..15b44f8 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -1,8 +1,9 @@ -use std::ffi::{c_char, c_void}; +use std::ffi::{c_char, c_void, CStr, CString}; mod bridge; mod editor; mod handle; +mod syntax; pub use swiftly_core::*; @@ -127,3 +128,47 @@ pub extern "C" fn viewport_scroll_event( }; bridge::push_scroll_event(h, x, y, delta_x, delta_y); } + +#[no_mangle] +pub extern "C" fn viewport_set_text(handle: *mut ViewportHandle, text: *const c_char) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + let s = if text.is_null() { + "" + } else { + unsafe { CStr::from_ptr(text) }.to_str().unwrap_or("") + }; + h.state.set_text(s); +} + +#[no_mangle] +pub extern "C" fn viewport_set_lang(handle: *mut ViewportHandle, ext: *const c_char) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + if ext.is_null() { + h.state.lang = None; + } else { + let s = unsafe { CStr::from_ptr(ext) }.to_str().unwrap_or(""); + h.state.set_lang_from_ext(s); + } +} + +#[no_mangle] +pub extern "C" fn viewport_get_text(handle: *mut ViewportHandle) -> *mut c_char { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return std::ptr::null_mut(), + }; + let text = h.state.content.text(); + CString::new(text).unwrap_or_default().into_raw() +} + +#[no_mangle] +pub extern "C" fn viewport_free_string(s: *mut c_char) { + if s.is_null() { return; } + unsafe { drop(CString::from_raw(s)); } +} diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs new file mode 100644 index 0000000..69fb975 --- /dev/null +++ b/viewport/src/syntax.rs @@ -0,0 +1,125 @@ +use std::ops::Range; + +use iced_wgpu::core::text::highlighter; +use iced_wgpu::core::Color; +use swiftly_core::highlight::{highlight_source, HighlightSpan}; + +#[derive(Clone, PartialEq)] +pub struct SyntaxSettings { + pub lang: String, + pub source: String, +} + +#[derive(Clone, Copy, Debug)] +pub struct SyntaxHighlight { + pub kind: u8, +} + +pub struct SyntaxHighlighter { + lang: String, + spans: Vec, + line_offsets: Vec, + current_line: usize, +} + +impl SyntaxHighlighter { + fn rebuild(&mut self, source: &str) { + self.spans = highlight_source(source, &self.lang); + self.line_offsets.clear(); + let mut offset = 0; + for line in source.split('\n') { + self.line_offsets.push(offset); + offset += line.len() + 1; + } + self.current_line = 0; + } +} + +impl highlighter::Highlighter for SyntaxHighlighter { + type Settings = SyntaxSettings; + type Highlight = SyntaxHighlight; + type Iterator<'a> = std::vec::IntoIter<(Range, SyntaxHighlight)>; + + fn new(settings: &Self::Settings) -> Self { + let mut h = SyntaxHighlighter { + lang: settings.lang.clone(), + spans: Vec::new(), + line_offsets: Vec::new(), + current_line: 0, + }; + h.rebuild(&settings.source); + h + } + + fn update(&mut self, new_settings: &Self::Settings) { + self.lang = new_settings.lang.clone(); + self.rebuild(&new_settings.source); + } + + fn change_line(&mut self, line: usize) { + self.current_line = self.current_line.min(line); + } + + fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> { + let ln = self.current_line; + self.current_line += 1; + + if ln >= self.line_offsets.len() { + return Vec::new().into_iter(); + } + + let line_start = self.line_offsets[ln]; + let line_end = if ln + 1 < self.line_offsets.len() { + self.line_offsets[ln + 1] - 1 + } else { + line_start + _line.len() + }; + + let mut result = Vec::new(); + for span in &self.spans { + if span.end <= line_start || span.start >= line_end { + continue; + } + let start = span.start.max(line_start) - line_start; + let end = span.end.min(line_end) - line_start; + if start < end { + result.push((start..end, SyntaxHighlight { kind: span.kind })); + } + } + result.into_iter() + } + + fn current_line(&self) -> usize { + self.current_line + } +} + +pub fn highlight_color(kind: u8) -> Color { + match kind { + 0 => Color::from_rgb(0.804, 0.569, 0.945), // keyword - mauve + 1 => Color::from_rgb(0.537, 0.706, 0.980), // function - blue + 2 => Color::from_rgb(0.604, 0.831, 0.898), // function.builtin - teal + 3 => Color::from_rgb(0.976, 0.827, 0.522), // type - yellow + 4 => Color::from_rgb(0.976, 0.827, 0.522), // type.builtin - yellow + 5 => Color::from_rgb(0.569, 0.878, 0.800), // constructor - teal + 6 => Color::from_rgb(0.988, 0.702, 0.529), // constant - peach + 7 => Color::from_rgb(0.988, 0.702, 0.529), // constant.builtin - peach + 8 => Color::from_rgb(0.651, 0.890, 0.631), // string - green + 9 => Color::from_rgb(0.988, 0.702, 0.529), // number - peach + 10 => Color::from_rgb(0.424, 0.443, 0.537), // comment - overlay0 + 11 => Color::from_rgb(0.804, 0.839, 0.957), // variable - text + 12 => Color::from_rgb(0.949, 0.604, 0.584), // variable.builtin - red + 13 => Color::from_rgb(0.949, 0.773, 0.584), // variable.parameter - flamingo + 14 => Color::from_rgb(0.604, 0.831, 0.898), // operator - sky + 15 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation - overlay2 + 16 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.bracket - overlay2 + 17 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.delimiter - overlay2 + 18 => Color::from_rgb(0.537, 0.706, 0.980), // property - blue + 19 => Color::from_rgb(0.804, 0.569, 0.945), // tag - mauve + 20 => Color::from_rgb(0.976, 0.827, 0.522), // attribute - yellow + 21 => Color::from_rgb(0.569, 0.878, 0.800), // label - teal + 22 => Color::from_rgb(0.949, 0.604, 0.584), // escape - red + 23 => Color::from_rgb(0.804, 0.839, 0.957), // embedded - text + _ => Color::from_rgb(0.804, 0.839, 0.957), // default text + } +}