diff --git a/viewport/src/browser/preview.rs b/viewport/src/browser/preview.rs index d9a8428..637e83b 100644 --- a/viewport/src/browser/preview.rs +++ b/viewport/src/browser/preview.rs @@ -18,6 +18,7 @@ pub fn highlight_preview(source: &str) -> Vec { lang: "rust".to_string(), source: source.to_string(), user_idents: crate::syntax::scan_user_idents_in(source), + rules: crate::syntax::SyntaxRules::cordial(), }; let mut highlighter = SyntaxHighlighter::new(&settings); diff --git a/viewport/src/editor/content.rs b/viewport/src/editor/content.rs index 46c7922..d104e55 100644 --- a/viewport/src/editor/content.rs +++ b/viewport/src/editor/content.rs @@ -406,6 +406,16 @@ impl super::EditorState { self.content_mut().jump_to_line(target); } + /// scrolls the viewport and places the cursor at the given line. + pub(super) fn jump_to_line(&mut self, line: usize) { + let clamped = line.min(self.content().line_count().saturating_sub(1)); + self.content_mut().jump_to_line(clamped); + self.safe_move_to(crate::text_widget::Cursor { + position: crate::text_widget::Position { line: clamped, column: 0 }, + selection: None, + }); + } + /// builds the clipboard payload from the focused table pub(super) fn copy_focused_table_selection(&self) -> Option { let block = self.block_at(self.focused_block)?; diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs index 33ec4b6..c04d627 100644 --- a/viewport/src/editor/mod.rs +++ b/viewport/src/editor/mod.rs @@ -246,6 +246,7 @@ impl EditorState { lang: lang_for_block.clone(), source: tb.content.text(), user_idents: self.cached_user_idents.clone(), + rules: self.syntax_rules.clone(), }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( @@ -381,32 +382,24 @@ impl EditorState { } } - /// builds the right-edge minimap as a hover-aware overlay. + /// builds the right-edge minimap as a clickable AST declaration list. fn minimap_overlay(&self) -> Option> { if !self.minimap_enabled { return None; } if self.render_mode != RenderMode::Editor { return None; } if self.cached_minimap_lines.is_empty() { return None; } - let lines = self.cached_minimap_lines.clone(); - - let scroll = self.content().scroll_line(); - let line_h = self.line_height().max(1.0); - let visible_lines = (self.viewport_size.1 / line_h).max(1.0) as usize; - let suppressed = self.minimap_hover_only && !self.minimap_hovered; let data = crate::minimap::MinimapData { - lines, - viewport_first: scroll, - viewport_last: scroll.saturating_add(visible_lines), + entries: self.cached_minimap_lines.clone(), hovered: self.minimap_hovered, suppressed, }; - let strip_w = self.font_size * 6.0; - let canvas = crate::minimap::minimap(data, strip_w, Message::MinimapJump); + let strip_w = self.font_size * 12.0; + let map = crate::minimap::minimap(data, strip_w, self.font_size, Message::MinimapJump); - let hover_zone = iced_widget::mouse_area(canvas) + let hover_zone = iced_widget::mouse_area(map) .on_enter(Message::MinimapHover(true)) .on_exit(Message::MinimapHover(false)); @@ -720,7 +713,8 @@ impl EditorState { let settings = SyntaxSettings { lang: lang_for_block, source: tb.content.text(), - user_idents: syntax::scan_user_idents_in(&self.full_text()), + user_idents: self.cached_user_idents.clone(), + rules: self.syntax_rules.clone(), }; editor .highlight_with::( diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs index 22675ee..b4fe1b5 100644 --- a/viewport/src/editor/state.rs +++ b/viewport/src/editor/state.rs @@ -109,6 +109,8 @@ pub struct EditorState { pub(super) cached_user_idents: HashMap, /// cached minimap line data, recomputed on text change only. pub(super) cached_minimap_lines: Vec, + /// custom keyword/builtin/type table layered on top of Cordial. + pub syntax_rules: crate::syntax::SyntaxRules, } impl EditorState { @@ -175,9 +177,15 @@ impl EditorState { snapping: false, cached_user_idents: HashMap::new(), cached_minimap_lines: Vec::new(), + syntax_rules: crate::syntax::SyntaxRules::cordial(), } } + /// replaces the active syntax rule set. + pub fn set_syntax_rules(&mut self, rules: crate::syntax::SyntaxRules) { + self.syntax_rules = rules; + } + /// recomputes cached syntax idents and minimap lines from current document text. pub(super) fn refresh_text_caches(&mut self) { let text = self.full_text(); diff --git a/viewport/src/editor/types.rs b/viewport/src/editor/types.rs index 4504b66..166e61a 100644 --- a/viewport/src/editor/types.rs +++ b/viewport/src/editor/types.rs @@ -160,7 +160,7 @@ pub enum Message { /// reports pointer hover state over the minimap. MinimapHover(bool), /// minimap click at y-fraction 0.0..1.0. - MinimapJump(f32), + MinimapJump(usize), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/viewport/src/editor/update.rs b/viewport/src/editor/update.rs index 083f7f3..39423a0 100644 --- a/viewport/src/editor/update.rs +++ b/viewport/src/editor/update.rs @@ -929,8 +929,8 @@ impl super::EditorState { Message::MinimapHover(over) => { self.minimap_hovered = over; } - Message::MinimapJump(frac) => { - self.jump_to_fraction(frac); + Message::MinimapJump(line) => { + self.jump_to_line(line); } } } diff --git a/viewport/src/embed.rs b/viewport/src/embed.rs new file mode 100644 index 0000000..abb9396 --- /dev/null +++ b/viewport/src/embed.rs @@ -0,0 +1,71 @@ +//! helpers for embedding EditorState inside an external iced application. +//! +//! the editor produces a fully-functional iced widget via EditorState::view(). +//! callers also need to drive periodic state work (eval debounce, autosave hints) +//! by calling EditorState::tick() once per frame, and drain pending clipboard or +//! shell-action output. these helpers wrap the common patterns. +//! +//! example: +//! +//! ```ignore +//! use acord_viewport::{EditorState, embed}; +//! +//! struct App { editor: EditorState } +//! +//! enum Msg { Acord(acord_viewport::Message), Tick } +//! +//! fn update(&mut self, msg: Msg) { +//! match msg { +//! Msg::Acord(m) => self.editor.update(m), +//! Msg::Tick => { +//! self.editor.tick(); +//! let pending = self.editor.drain_pending(); +//! if let Some(text) = pending.clipboard { +//! // copy to host clipboard +//! } +//! } +//! } +//! } +//! +//! fn view(&self) -> Element<'_, Msg> { +//! self.editor.view().map(Msg::Acord) +//! } +//! +//! fn subscription(&self) -> Subscription { +//! iced::time::every(embed::TICK_INTERVAL).map(|_| Msg::Tick) +//! } +//! ``` + +use std::time::Duration; + +use crate::editor::EditorState; + +/// recommended tick interval for embedded use (60 fps cadence). +pub const TICK_INTERVAL: Duration = Duration::from_millis(16); + +/// snapshot of host-handled state the editor produced this frame. +pub struct Pending { + /// text the editor wants written to the host clipboard, if any. + pub clipboard: Option, + /// numeric command the host shell should act on, if any. + pub shell_action: Option, + /// widget that should receive iced focus this frame, if any. + pub focus: Option, +} + +impl EditorState { + /// pulls every host-handled output for this frame and clears it from the editor. + pub fn drain_pending(&mut self) -> Pending { + Pending { + clipboard: self.pending_clipboard.take(), + shell_action: self.take_pending_shell_action(), + focus: self.take_pending_focus(), + } + } + + /// records the surface size so the minimap, scrollable math, and free-layer + /// placement can size themselves correctly. + pub fn set_viewport_size(&mut self, width: f32, height: f32) { + self.viewport_size = (width, height); + } +} diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 0923e55..9aeeb4e 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -35,6 +35,7 @@ pub mod text_block; pub mod text_widget; pub mod tree_block; pub mod widgets; +pub mod embed; pub use acord_core::*; @@ -43,7 +44,7 @@ pub use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; pub use crate::editor::{EditorState, Message, RenderMode, ShellAction}; pub use crate::palette::{Palette, current as current_palette, set_theme as set_palette_theme}; pub use crate::selection::{BlockId, InnerPath, NodePath, Selection, TextPos}; -pub use crate::syntax::{SyntaxHighlight, SyntaxHighlighter, SyntaxSettings, EDITOR_FONT}; +pub use crate::syntax::{SyntaxHighlight, SyntaxHighlighter, SyntaxRules, SyntaxSettings, EDITOR_FONT}; pub use crate::text_widget::{Content, TextEditor}; use iced_graphics::Viewport; diff --git a/viewport/src/minimap.rs b/viewport/src/minimap.rs index 897f8e4..8adede5 100644 --- a/viewport/src/minimap.rs +++ b/viewport/src/minimap.rs @@ -1,188 +1,277 @@ use iced_wgpu::core::{ - mouse, Color, Element, Length, Point, Rectangle, Size, Theme, + alignment, mouse, Color, Element, Font, Length, Theme, }; -use iced_widget::canvas::{self, Frame}; +use iced_widget::{column, container, mouse_area, text}; use crate::palette; - -#[derive(Clone, Copy, Debug)] -pub enum LineKind { - Empty, - Plain, - Heading, - Code, - List, - Quote, -} +use crate::syntax; #[derive(Clone, Debug)] -pub struct MinimapLine { - pub width_chars: u16, - pub kind: LineKind, +pub struct MapEntry { + pub kind: DeclKind, + pub name: String, + pub trait_group: Option, + pub line: usize, } -/// classifies a single source line by minimap colour category. -pub fn classify(line: &str) -> MinimapLine { - let trimmed = line.trim_start(); - let kind = if trimmed.is_empty() { - LineKind::Empty - } else if trimmed.starts_with('#') { - LineKind::Heading - } else if trimmed.starts_with("```") || trimmed.starts_with(" ") || trimmed.starts_with('\t') { - LineKind::Code - } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { - LineKind::List - } else if trimmed.starts_with("> ") { - LineKind::Quote +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DeclKind { + Fn, + Impl, + Trait, + Struct, + Let, + Heading, + Mod, + Enum, +} + +impl DeclKind { + pub fn label(&self) -> &'static str { + match self { + DeclKind::Fn => "fn", + DeclKind::Impl => "impl", + DeclKind::Trait => "trait", + DeclKind::Struct => "struct", + DeclKind::Let => "let", + DeclKind::Heading => "#", + DeclKind::Mod => "mod", + DeclKind::Enum => "enum", + } + } +} + +/// extracts named declarations from cordial/markdown source. +pub fn extract_declarations(source: &str) -> Vec { + let mut entries = Vec::new(); + for (line_idx, line) in source.lines().enumerate() { + let trimmed = line.trim_start(); + if let Some(entry) = classify_decl(trimmed, line_idx) { + entries.push(entry); + } + } + entries +} + +fn classify_decl(trimmed: &str, line: usize) -> Option { + let stripped = strip_visibility(trimmed); + + if let Some(rest) = stripped.strip_prefix("fn ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Fn, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("async fn ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Fn, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("unsafe fn ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Fn, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("impl ") { + return Some(parse_impl_entry(rest, line)); + } + if let Some(rest) = stripped.strip_prefix("trait ") { + let name = ident_at_start(rest)?; + let group = name.clone(); + return Some(MapEntry { kind: DeclKind::Trait, name, trait_group: Some(group), line }); + } + if let Some(rest) = stripped.strip_prefix("struct ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Struct, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("enum ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Enum, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("mod ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Mod, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("type ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Struct, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("const ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Let, name, trait_group: None, line }); + } + if let Some(rest) = stripped.strip_prefix("static ") { + let name = ident_at_start(rest)?; + return Some(MapEntry { kind: DeclKind::Let, name, trait_group: None, line }); + } + if let Some(rest) = trimmed.strip_prefix("let ") { + let name = ident_at_start(rest)?; + if rest.contains('=') { + return Some(MapEntry { kind: DeclKind::Let, name, trait_group: None, line }); + } + } + if trimmed.starts_with('#') && !trimmed.starts_with("#!") { + let level = trimmed.chars().take_while(|c| *c == '#').count(); + let text = trimmed[level..].trim(); + if !text.is_empty() { + return Some(MapEntry { + kind: DeclKind::Heading, + name: text.to_string(), + trait_group: None, + line, + }); + } + } + None +} + +fn parse_impl_entry(rest: &str, line: usize) -> MapEntry { + let words: Vec<&str> = rest.split_whitespace().collect(); + if words.len() >= 3 && words[1] == "for" { + let trait_name = words[0].to_string(); + let type_name = words[2].trim_matches('{').to_string(); + MapEntry { + kind: DeclKind::Impl, + name: format!("{} for {}", trait_name, type_name), + trait_group: Some(trait_name), + line, + } } else { - LineKind::Plain - }; - let width = line.chars().count().min(u16::MAX as usize) as u16; - MinimapLine { width_chars: width, kind } + let type_name = words.first().map(|w| w.trim_matches('{')).unwrap_or("").to_string(); + MapEntry { + kind: DeclKind::Impl, + name: type_name, + trait_group: None, + line, + } + } } -/// turns the document text into per-line minimap data in one pass. -pub fn classify_text(text: &str) -> Vec { - text.lines().map(classify).collect() +/// strips `pub`, `pub(crate)`, `pub(super)`, `pub(in path)` from the front. +fn strip_visibility(s: &str) -> &str { + if let Some(rest) = s.strip_prefix("pub(") { + if let Some(end) = rest.find(')') { + let after = &rest[end + 1..]; + return after.trim_start(); + } + } + if let Some(rest) = s.strip_prefix("pub ") { + return rest.trim_start(); + } + s +} + +fn ident_at_start(s: &str) -> Option { + let s = s.trim_start(); + let end = s.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(s.len()); + if end == 0 { return None; } + Some(s[..end].to_string()) +} + +/// assigns rainbow slots to trait groups. +fn trait_color_map(entries: &[MapEntry]) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let mut next: u32 = 0; + for e in entries { + if let Some(ref group) = e.trait_group { + if !map.contains_key(group) { + let slot = ((next * syntax::USER_IDENT_HOP as u32) + % syntax::USER_IDENT_PALETTE_SIZE as u32) as u8; + map.insert(group.clone(), slot); + next += 1; + } + } + } + map +} + +fn entry_color(entry: &MapEntry, color_map: &std::collections::HashMap) -> Color { + let p = palette::current(); + if let Some(ref group) = entry.trait_group { + if let Some(&slot) = color_map.get(group) { + return syntax::rainbow_color(slot as u32); + } + } + match entry.kind { + DeclKind::Fn => p.blue, + DeclKind::Impl => p.mauve, + DeclKind::Trait => p.green, + DeclKind::Struct => p.peach, + DeclKind::Enum => p.yellow, + DeclKind::Let => p.overlay1, + DeclKind::Heading => p.text, + DeclKind::Mod => p.teal, + } } #[derive(Clone)] pub struct MinimapData { - pub lines: Vec, - pub viewport_first: usize, - pub viewport_last: usize, + pub entries: Vec, pub hovered: bool, pub suppressed: bool, } -struct MinimapProgram -where - M: Clone + 'static, - F: Fn(f32) -> M, -{ - data: MinimapData, - on_jump: F, - _marker: std::marker::PhantomData M>, -} - -impl canvas::Program for MinimapProgram -where - M: Clone + 'static, - F: Fn(f32) -> M, -{ - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced_wgpu::Renderer, - _theme: &Theme, - bounds: Rectangle, - _cursor: mouse::Cursor, - ) -> Vec> { - let mut frame = Frame::new(renderer, bounds.size()); - let p = palette::current(); - - let total = self.data.lines.len().max(1) as f32; - let h = bounds.height; - let pixels_per_line = (h / total).max(0.5); - let bar_h = pixels_per_line.max(1.0); - - let alpha = if self.data.suppressed { - 0.0 - } else if self.data.hovered { - 0.55 - } else { - 0.18 - }; - - if alpha == 0.0 { - return vec![frame.into_geometry()]; - } - - let max_chars = self.data.lines.iter().map(|l| l.width_chars as f32).fold(1.0, f32::max); - - for (i, line) in self.data.lines.iter().enumerate() { - let y = (i as f32 / total) * h; - let bar_w = (line.width_chars as f32 / max_chars) * bounds.width * 0.85; - let color = match line.kind { - LineKind::Empty => continue, - LineKind::Heading => Color { a: alpha + 0.20, ..p.mauve }, - LineKind::Code => Color { a: alpha + 0.05, ..p.peach }, - LineKind::List => Color { a: alpha + 0.05, ..p.green }, - LineKind::Quote => Color { a: alpha + 0.05, ..p.teal }, - LineKind::Plain => Color { a: alpha, ..p.text }, - }; - frame.fill_rectangle( - Point::new(bounds.width * 0.075, y), - Size::new(bar_w, bar_h), - color, - ); - } - - if self.data.viewport_last > self.data.viewport_first { - let top = (self.data.viewport_first as f32 / total) * h; - let bot = ((self.data.viewport_last as f32) / total) * h; - let height = (bot - top).max(8.0); - let indicator_alpha = if self.data.hovered { 0.22 } else { 0.12 }; - frame.fill_rectangle( - Point::new(0.0, top), - Size::new(bounds.width, height), - Color { a: indicator_alpha, ..p.text }, - ); - } - - vec![frame.into_geometry()] - } - - fn update( - &self, - _state: &mut (), - event: &canvas::Event, - bounds: Rectangle, - cursor: mouse::Cursor, - ) -> Option> { - if self.data.suppressed { return None; } - let pos = cursor.position_in(bounds)?; - match event { - canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { - let frac = (pos.y / bounds.height).clamp(0.0, 1.0); - Some(canvas::Action::publish((self.on_jump)(frac)).and_capture()) - } - _ => None, - } - } - - fn mouse_interaction( - &self, - _state: &(), - bounds: Rectangle, - cursor: mouse::Cursor, - ) -> mouse::Interaction { - if !self.data.suppressed && cursor.is_over(bounds) { - mouse::Interaction::Pointer - } else { - mouse::Interaction::default() - } - } -} - -/// builds a minimap canvas pinned at a fixed width. -pub fn minimap<'a, M, F>( +/// builds the minimap as a clickable declaration list. +pub fn minimap<'a, M>( data: MinimapData, width: f32, - on_jump: F, + font_size: f32, + on_jump: impl Fn(usize) -> M + 'a, ) -> Element<'a, M, Theme, iced_wgpu::Renderer> where M: Clone + 'static, - F: Fn(f32) -> M + 'a, { - canvas::Canvas::new(MinimapProgram:: { - data, - on_jump, - _marker: std::marker::PhantomData, - }) + let p = palette::current(); + let color_map = trait_color_map(&data.entries); + let alpha = if data.suppressed { 0.0 } else if data.hovered { 1.0 } else { 0.35 }; + + if alpha == 0.0 || data.entries.is_empty() { + return container(text("")) + .width(Length::Fixed(width)) + .height(Length::Fill) + .into(); + } + + let sz = (font_size * 0.7).max(8.0); + let mut rows: Vec> = Vec::new(); + + let _ = p; + for entry in &data.entries { + let color = entry_color(entry, &color_map); + let dimmed = Color { a: alpha, ..color }; + + let kind_label = entry.kind.label(); + let display = format!("{} {}", kind_label, entry.name); + let display = if display.len() > 24 { + format!("{}...", &display[..21]) + } else { + display + }; + + let line = entry.line; + let row_el: Element<'a, M, Theme, iced_wgpu::Renderer> = mouse_area( + text(display) + .font(Font::MONOSPACE) + .size(sz) + .color(dimmed) + ) + .on_press(on_jump(line)) + .into(); + + rows.push(row_el); + } + + let col = column(rows) + .spacing(1.0) + .width(Length::Fixed(width)); + + container( + iced_widget::scrollable(col).height(Length::Fill) + ) .width(Length::Fixed(width)) .height(Length::Fill) .into() } + +// keep for backward compat with the caching system in EditorState +pub type MinimapLine = MapEntry; + +pub fn classify_text(text: &str) -> Vec { + extract_declarations(text) +} diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index bb9356f..6615c0d 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -76,6 +76,73 @@ pub struct SyntaxSettings { pub source: String, /// doc-wide user-ident to rainbow-slot map, computed across all text blocks. pub user_idents: HashMap, + /// optional extra keywords/builtins layered on top of Cordial. + pub rules: SyntaxRules, +} + +/// extensible token table for languages built on top of Cordial. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxRules { + pub extra_keywords: std::collections::BTreeSet, + pub extra_builtins: std::collections::BTreeSet, + pub extra_types: std::collections::BTreeSet, + /// when true, the hardcoded Cordial keywords/builtins remain active. + pub include_cordial: bool, +} + +impl SyntaxRules { + /// fresh table with the Cordial defaults active. + pub fn cordial() -> Self { + Self { + extra_keywords: Default::default(), + extra_builtins: Default::default(), + extra_types: Default::default(), + include_cordial: true, + } + } + + /// fresh table with no defaults at all. + pub fn empty() -> Self { + Self { + extra_keywords: Default::default(), + extra_builtins: Default::default(), + extra_types: Default::default(), + include_cordial: false, + } + } + + pub fn keyword(mut self, w: impl Into) -> Self { + self.extra_keywords.insert(w.into()); + self + } + + pub fn builtin(mut self, w: impl Into) -> Self { + self.extra_builtins.insert(w.into()); + self + } + + pub fn ty(mut self, w: impl Into) -> Self { + self.extra_types.insert(w.into()); + self + } + + pub fn keywords(mut self, items: I) -> Self + where I: IntoIterator, S: Into + { + self.extra_keywords.extend(items.into_iter().map(Into::into)); + self + } + + pub fn builtins(mut self, items: I) -> Self + where I: IntoIterator, S: Into + { + self.extra_builtins.extend(items.into_iter().map(Into::into)); + self + } +} + +impl Default for SyntaxRules { + fn default() -> Self { Self::cordial() } } #[derive(Clone, Copy, Debug)] @@ -103,57 +170,49 @@ pub struct SyntaxHighlighter { user_idents: HashMap, /// per-line tree-sitter spans for fenced code body lines, keyed by absolute line index. code_block_spans: HashMap, SyntaxHighlight)>>, - /// line count at last full rebuild, drives incremental skip. - prev_line_count: usize, + rules: SyntaxRules, } impl SyntaxHighlighter { fn rebuild(&mut self, source: &str) { - let new_line_count = source.split('\n').count(); - let structure_changed = new_line_count != self.prev_line_count; - + 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; } + let classified = classify_document(source); + self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); - if structure_changed { - self.spans = highlight_source(source, &self.lang); - let classified = classify_document(source); - self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); - - self.line_decors.clear(); - let mut in_fence = false; - for (i, raw_line) in source.split('\n').enumerate() { - let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; - if is_md { - let trimmed = raw_line.trim_start(); - if trimmed.starts_with("```") { - in_fence = !in_fence; - self.line_decors.push(LineDecor::FenceMarker); - } else if in_fence { - self.line_decors.push(LineDecor::CodeBlock); - } else if is_horizontal_rule(trimmed) { - self.line_decors.push(LineDecor::HorizontalRule); - } else if trimmed.starts_with("> ") || trimmed == ">" { - self.line_decors.push(LineDecor::Blockquote); - } else { - self.line_decors.push(LineDecor::None); - } + self.line_decors.clear(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + self.line_decors.push(LineDecor::FenceMarker); + } else if in_fence { + self.line_decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + self.line_decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + self.line_decors.push(LineDecor::Blockquote); } else { - if in_fence { in_fence = false; } self.line_decors.push(LineDecor::None); } + } else { + if in_fence { in_fence = false; } + self.line_decors.push(LineDecor::None); } - - self.scan_fenced_code_blocks(source); - self.prev_line_count = new_line_count; } self.in_fenced_code = false; self.current_line = 0; + + self.scan_fenced_code_blocks(source); } /// highlights language-tagged fenced blocks via tree-sitter, stashing per-line spans. @@ -437,7 +496,7 @@ fn extract_paren_idents(s: &str, map: &mut HashMap, slot: &mut u32) } } -fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Range, SyntaxHighlight)> { +fn highlight_cordial(line: &str, user_idents: &HashMap, rules: &SyntaxRules) -> Vec<(Range, SyntaxHighlight)> { let bytes = line.as_bytes(); let len = bytes.len(); let mut spans: Vec<(Range, SyntaxHighlight)> = Vec::new(); @@ -552,11 +611,17 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang let start = i; while i < len && is_ident_byte(bytes[i]) { i += 1; } let word = &line[start..i]; - if is_cordial_keyword(word) { + let is_kw = (rules.include_cordial && is_cordial_keyword(word)) + || rules.extra_keywords.contains(word); + let is_bi = (rules.include_cordial && is_cordial_builtin(word)) + || rules.extra_builtins.contains(word); + let is_ty = (rules.include_cordial && is_cordial_type_annotation(word)) + || rules.extra_types.contains(word); + if is_kw { spans.push((start..i, SyntaxHighlight { kind: COR_KEYWORD })); - } else if is_cordial_builtin(word) { + } else if is_bi { spans.push((start..i, SyntaxHighlight { kind: COR_BUILTIN_FN })); - } else if is_cordial_type_annotation(word) && last_token_is_colon(&spans) { + } else if is_ty && last_token_is_colon(&spans) { spans.push((start..i, SyntaxHighlight { kind: COR_TYPE_ANN })); } else if let Some(&slot) = user_idents.get(word) { spans.push((start..i, SyntaxHighlight { kind: USER_IDENT_BASE + slot })); @@ -903,7 +968,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { line_decors: Vec::new(), user_idents: settings.user_idents.clone(), code_block_spans: HashMap::new(), - prev_line_count: 0, + rules: settings.rules.clone(), }; h.rebuild(&settings.source); h @@ -912,6 +977,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { fn update(&mut self, new_settings: &Self::Settings) { self.lang = new_settings.lang.clone(); self.user_idents = new_settings.user_idents.clone(); + self.rules = new_settings.rules.clone(); self.rebuild(&new_settings.source); } @@ -942,7 +1008,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) { if self.in_fenced_code { self.in_fenced_code = false; } - return highlight_cordial(line, &self.user_idents).into_iter(); + return highlight_cordial(line, &self.user_idents, &self.rules).into_iter(); } let is_markdown = !is_pure_code diff --git a/viewport/src/text_block.rs b/viewport/src/text_block.rs index b2b6014..b2edf36 100644 --- a/viewport/src/text_block.rs +++ b/viewport/src/text_block.rs @@ -92,6 +92,7 @@ impl Block for TextBlock { let settings = SyntaxSettings { lang: self.lang.clone(), user_idents: syntax::scan_user_idents_in(&source), + rules: syntax::SyntaxRules::cordial(), source, }; let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor