use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; use iced_wgpu::core::keyboard::{self, Modifiers}; use iced_wgpu::core::keyboard::key; use iced_wgpu::core::text::{Highlight, Wrapping}; use iced_wgpu::core::{ border, padding, alignment, Background, Border, Color, Element, Font, Length, Padding, Point, Shadow, Theme, }; use iced_widget::container; use iced_widget::markdown; use iced_widget::MouseArea; use crate::text_widget::{self, Action, AnchoredItem, Binding, Cursor, KeyPress, Motion, Position, Status}; use iced_widget::text_input; use iced_wgpu::core::text::highlighter::Format; use iced_wgpu::core::widget::Id as WidgetId; use crate::block::{Block as BlockTrait, ViewCtx}; use crate::blocks::{self, BoxedBlock}; use crate::heading_block::HeadingBlock; use crate::hr_block::HrBlock; use crate::oklab; use crate::palette; use crate::sidecar::{self, Sidecar, TableSidecar}; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, compute_line_decors}; use crate::table_block::{self, TableBlock, TableMessage}; use crate::text_block::TextBlock; use crate::tree_block::TreeBlock; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderMode { /// Blocks rendered, eval runs, tables interactive. Live, /// Raw markdown in a single text_editor, no eval, no block splitting. Editor, /// Read-only rendered view. Press `i` for Editor, `/` for Live. View, } /// User-facing line-number gutter / cursorline behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineIndicator { /// Absolute line numbers, full-row cursorline band. On, /// Hidden — no line numbers, no cursorline band. The gutter strip /// stays at its layout width so the editor doesn't reflow. Off, /// Vim-style: relative line numbers (cursor line shows its absolute /// number, others show signed distance), cursorline band on. Vim, } impl LineIndicator { pub fn from_str(s: &str) -> Self { match s { "off" => LineIndicator::Off, "vim" => LineIndicator::Vim, _ => LineIndicator::On, } } } #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { BlockAction(usize, text_widget::Action), FocusBlock(usize), EditorAction(text_widget::Action), TogglePreview, MarkdownLink(markdown::Uri), InsertTable, ToggleBold, ToggleItalic, ToggleStrike, ToggleUnderline, ToggleBlockquote, /// Wrap the selection in matching delimiters; if the selection is /// already wrapped (markers immediately surround it, with or without /// being included in the selection), unwrap it. WrapWith(&'static str, &'static str), /// Insert a paired `[]` / `{}` and place the cursor between them. /// Only applied to `[` and `{`; quotes/parens deliberately do NOT pair /// on type — use Cmd+"/'/9 to wrap a selection. AutoPair(&'static str, &'static str), /// Cmd+0: incremental scope exit. Each press closes the innermost /// unclosed pair within the current block; once everything is closed, /// jumps the cursor past the next outer scope's closing delimiter; once /// fully at block scope, ensures a "newline sandwich" (cursor on a /// blank line with one blank line of padding above and below). FixUp, Evaluate, /// Full-document ordered eval: every module evaluated in sequence. EvalAll, SmartEval, ZoomIn, ZoomOut, ZoomReset, Undo, Redo, ToggleFind, HideFind, FindQueryChanged(String), FindNext, FindPrev, ReplaceQueryChanged(String), ReplaceOne, ReplaceAll, TableMsg(usize, TableMessage), DeleteCurrentTable, FocusedTableOp(TableMessage), TableTab, TableShiftTab, TableEnter, /// Up arrow on the top row of a table. Find the text block immediately /// above and focus its end; synthesize a fresh text block if none exists. EscapeTableUp(usize), /// Down arrow on the last row of a table. Mirror of `EscapeTableUp`. EscapeTableDown(usize), /// Move the focused cell up by one row, staying inside the same table. TableMoveUp, /// Move the focused cell down by one row, staying inside the same table. TableMoveDown, /// Move the focused cell left by one column. TableMoveLeft, /// Move the focused cell right by one column. TableMoveRight, /// Backspace / Delete on a selected (not editing) cell. Empties the cell /// without removing the row — Excel/Numbers semantics. ClearSelectedCell, /// Second Cmd+A press — escalate to whole-document selection. Every block /// renders highlighted; Backspace clears all content; Cmd+Backspace wipes /// the document down to a single empty text block. SelectAllBlocks, /// Plain Backspace/Delete with `all_blocks_selected == true`. Empties /// every block's content but keeps the structure (block count, block /// types, table row/col counts). ClearAllBlocks, /// Cmd+Backspace with `all_blocks_selected == true`. Wipes the document /// down to a single empty text block. DeleteAllBlocks, /// Right-click on a table cell. Opens the context menu anchored at the /// current cursor position. Only block_idx is needed — the menu acts on /// the existing selection, not on the right-clicked cell. ShowContextMenu { block_idx: usize }, /// Explicitly close the context menu (Escape key, etc.). Most other /// messages auto-close it via `update()`'s top-of-loop drop logic. HideContextMenu, /// Push a literal string into the clipboard out-channel. Used by the /// table spillover popup's copy button and by Cmd+C-on-selected-cell /// where the value is already in hand at dispatch time. CopyLiteral(String), /// Cmd+C while the focused block is a table — copy the current selection /// (or the spillover cell, if open) as TSV. Dispatched from handle.rs /// when the keyboard event would otherwise reach a non-cell-edit context. CopyFocusedTableSelection, /// Escape from cell edit mode. The cell stays selected (highlighted) but /// goes back to the static-text rendering — same as the Excel/Numbers /// gesture for "stop editing this cell". ExitCellEdit, /// User pressed a printable character with a cell selected but not yet /// editing. Replace the cell's content with that single character and /// enter edit mode — Excel/Numbers "start typing into the selection". EnterCellEditWithChar(char), /// Tab key inside a text block. iced's default `Binding::from_key_press` /// returns None for Tab, so without our own binding the key does nothing. IndentTab, OutdentTab, SetRenderMode(RenderMode), /// Mouse pressed on an inline `/=` result. Starts the long-press timer. InlineResultPress { block_id: crate::selection::BlockId, after_line: usize }, /// Mouse released anywhere after pressing on an inline result. Cancels /// any pending long-press that hasn't fired yet. InlineResultRelease, /// Double-clicked an inline `/=` result. Copies the source line + result /// to clipboard AND drops a `let = result` template two lines down. InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, } pub const RESULT_PREFIX: &str = "→ "; /// Long-press / double-click state for the click-and-hold-on-result gesture. #[derive(Debug, Clone)] pub struct InlinePressState { pub block_id: crate::selection::BlockId, pub after_line: usize, pub started_at: Instant, pub fired_long_press: bool, } const LONG_PRESS_MS: u128 = 300; pub const ERROR_PREFIX: &str = "⚠ "; const EVAL_DEBOUNCE_MS: u128 = 300; // ── Document layers ───────────────────────────────────────────────── // Layer 0 = registry + layout (user-authored structure). // Layers 1-3 hold computed eval artifacts, independently invalidated. /// Attachment point linking a computed item to a layer-0 text block. #[derive(Debug, Clone)] pub struct Anchor { pub block_id: crate::selection::BlockId, pub after_line: usize, } /// Layer 1: inline eval result (→ value / ⚠ error). #[derive(Debug, Clone)] pub struct InlineResult { pub anchor: Anchor, pub text: String, pub is_error: bool, } impl InlineResult { pub fn element_height(&self, line_h: f32) -> f32 { line_h } } /// Layer 2: computed table from `/=|` evaluation. #[derive(Debug, Clone)] pub struct ComputedTable { pub anchor: Anchor, pub rows: Vec>, pub col_widths: Vec, } impl ComputedTable { pub fn element_height(&self, line_h: f32) -> f32 { let row_h = line_h + 4.0; let outer_pad = 4.0 + 4.0; self.rows.len().max(1) as f32 * row_h + outer_pad } } /// Layer 3: computed tree from `/=\` evaluation. #[derive(Debug, Clone)] pub struct ComputedTree { pub anchor: Anchor, pub data: serde_json::Value, } impl ComputedTree { pub fn element_height(&self, font_size: f32) -> f32 { crate::tree_block::element_height(&self.data, font_size) } } /// Layer 4: embedded image from `![alt](src)`. #[derive(Debug, Clone)] pub struct ComputedImage { pub anchor: Anchor, pub src: String, pub alt: String, /// Pre-computed display height based on image aspect ratio and editor /// width. Falls back to a placeholder height while loading. pub display_height: f32, } /// Cached image data keyed by source path/URL. Handle must be built once /// and reused — `Handle::from_bytes` mints a fresh Id every call. pub struct ImageCacheEntry { pub handle: iced_widget::image::Handle, pub width: u32, pub height: u32, } const IMAGE_PLACEHOLDER_H: f32 = 24.0; const IMAGE_MAX_H: f32 = 600.0; const IMAGE_PADDING: f32 = 48.0; const IMAGE_VPAD: f32 = 4.0; /// Ref to a layer item for interleaved rendering. enum LayerItem<'a> { Inline(&'a InlineResult), Table(&'a ComputedTable), Tree(&'a ComputedTree), Image(&'a ComputedImage), } impl LayerItem<'_> { fn element_height(&self, line_h: f32, font_size: f32) -> f32 { match self { Self::Inline(r) => r.element_height(line_h), Self::Table(t) => t.element_height(line_h), Self::Tree(t) => t.element_height(font_size), Self::Image(img) => img.display_height + IMAGE_VPAD * 2.0, } } } pub const FIND_INPUT_ID: &str = "find_input"; pub const REPLACE_INPUT_ID: &str = "replace_input"; /// Stable id for the multi-block document scrollable. handle.rs targets this /// via `iced_core::widget::operation::scrollable::scroll_by` to forward /// wheel-scroll deltas captured by an inner `text_editor` (which would /// otherwise swallow them when the cursor is over the editor's bounds). pub const DOC_SCROLLABLE_ID: &str = "doc_scrollable"; const UNDO_MAX: usize = 200; const COALESCE_MS: u128 = 500; struct UndoSnapshot { text: String, cursor_line: usize, cursor_col: usize, } #[derive(PartialEq, Eq, Clone, Copy)] enum EditKind { Insert, Backspace, Delete, Enter, Paste, Other, } pub struct FindState { pub visible: bool, pub query: String, pub replacement: String, pub matches: Vec<(usize, usize)>, pub current: usize, } impl FindState { fn new() -> Self { Self { visible: false, query: String::new(), replacement: String::new(), matches: Vec::new(), current: 0, } } } pub struct EditorState { pub registry: HashMap, pub layout: Vec, pub modules: Vec, pub focused_block: usize, pub font_size: f32, pub preview: bool, pub render_mode: RenderMode, pub parsed: Vec, pub lang: Option, scroll_offset: f32, eval_dirty: bool, last_edit: Instant, undo_stack: Vec, redo_stack: Vec, last_edit_kind: EditKind, last_edit_time: Instant, pub find: FindState, pub pending_focus: Option, /// Stand-in `Content` returned by `content()` when the focused block isn't /// text-bearing AND there are no text blocks anywhere in the document /// (e.g. a heading-only file). Always empty; never written to. fallback_text: text_widget::Content, /// Live keyboard modifier state. Updated by handle.rs from /// `Event::Keyboard(ModifiersChanged)`. Drives modifier-aware click /// translation (Cmd → Toggle, Shift → Extend, etc.). pub mods: Modifiers, /// Single source of truth for selection. Mirrored from `focused_block` /// changes via `set_focused_block`; the compositor reads this for /// cursorline / cell tint / cross-block range visuals. pub(crate) selection: crate::selection::Selection, /// The path keys are routed to. A single point even when `selection` is a /// range or set. pub(crate) focus: Option, /// Path currently in text-input edit mode (cell static-vs-edit). #[allow(dead_code)] pub(crate) editing: Option, /// Cmd+A escalation flag. Set after a first Cmd+A (block-local select); /// a second Cmd+A while still armed escalates to whole-document /// selection. Cleared on any other input. handle.rs owns the /// arm/disarm logic; the editor only reads it. pub cmd_a_armed: bool, /// Whole-document selection mode — every block renders highlighted, /// plain Backspace clears all block content, Cmd+Backspace wipes the /// document. Set by `Message::SelectAllBlocks`, cleared by any click /// or any single-block selection change. pub all_blocks_selected: bool, /// Latest mouse cursor position in viewport coordinates. handle.rs /// updates this from `handle.cursor` BEFORE draining messages, so the /// `Message::TableMsg(_, ContextMenu)` handler can read the position /// to anchor the context menu overlay. pub cursor_pos: Point, /// Pending pixel scroll delta to apply to the document scrollable on /// the next render frame. Captured here when iced's `text_editor` /// swallows a wheel-scroll event (it captures `Action::Scroll` when /// the cursor is over the editor's bounds), and forwarded to the outer /// scrollable via `iced_core::widget::operation::scrollable::scroll_by` /// in handle.rs::render. Accumulates if multiple scroll events land /// in the same frame. pub pending_scroll: f32, /// Active context menu, if any. Set by right-clicking a cell; /// auto-cleared by `update()` whenever a message arrives that isn't /// itself a context-menu operation. So clicking a menu button /// dispatches the action AND clears the menu in one shot, and clicking /// anywhere outside the menu also dismisses it. pub context_menu: Option, // ── Document layers (computed eval artifacts) ── pub eval_results: Vec, pub computed_tables: Vec, pub computed_trees: Vec, /// Per-cell evaluated formula results. Keyed by (table block id, col, row). /// Cells whose raw text starts with `/=` and are not being edited render /// the computed value instead; anything not in this map renders raw. pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, /// Active long-press / pending-result-gesture state. Set by /// `InlineResultPress`, cleared by `InlineResultRelease` / /// `InlineResultDoubleClick`. `tick()` checks the elapsed time to fire /// the copy when it crosses `LONG_PRESS_MS`. pub inline_press: Option, /// Line-indicator preference: controls cursorline band + relative-vs- /// absolute line numbers. Pushed in from Swift via FFI. pub line_indicator: LineIndicator, /// Whether the gutter line numbers cycle through the rainbow palette /// based on distance from the cursor. Independent of `line_indicator`. pub gutter_rainbow: bool, /// Cross-platform clipboard out-channel. Editor logic writes here; /// the shell drains it after each frame via `viewport_take_clipboard` /// and pushes the text to the system clipboard. pub pending_clipboard: Option, // ── Images ── pub computed_images: Vec, pub image_cache: HashMap, /// Previous global cursor line (block start_line + intra-block line). /// Used by `tick()` to detect cursor-line changes and trigger eval. prev_cursor_line: usize, } /// Per-eval table name→id bookkeeping. `keys` is every alias a table is /// reachable by (heading name, positional `table_N`, qualified `mod::name`); /// `canonical` is the preferred key for each BlockId, used as the /// `current_table` anchor when evaluating formulas inside that table. pub struct TableIndex { pub keys: HashMap, pub canonical: HashMap, } /// Mirror of `Interpreter::resolve_table_key` for use during dep-graph /// building, when we don't have the live interpreter handy but do have /// the full alias→id map from `register_visible_tables`. fn resolve_ref_key( r: &acord_core::interp::FormulaRef, table_index: &TableIndex, ) -> Option { match &r.block { Some(b) => { let k = format!("{}::{}", b.to_lowercase(), r.table.to_lowercase()); if table_index.keys.contains_key(&k) { Some(k) } else { None } } None => { let bare = r.table.to_lowercase(); if table_index.keys.contains_key(&bare) { Some(bare) } else { None } } } } /// State for the on-screen context menu overlay. Anchored at viewport /// (x, y) — the position the user right-clicked. Carries the table's /// block index so menu items targeting "this table" know which one. /// Notably does NOT carry the right-clicked row/col — right-click is /// purely a menu trigger and doesn't make the clicked cell "current." #[derive(Debug, Clone)] pub struct ContextMenuState { pub block_idx: usize, pub x: f32, pub y: f32, } fn md_style() -> markdown::Style { let p = palette::current(); markdown::Style { font: Font::default(), inline_code_highlight: Highlight { background: p.surface0.into(), border: border::rounded(4), }, inline_code_padding: padding::left(2).right(2), inline_code_color: p.green, inline_code_font: Font::MONOSPACE, code_block_font: Font::MONOSPACE, link_color: p.blue, } } impl EditorState { pub fn new() -> Self { let sample = concat!( "# Block Compositor\n", "Acord renders structured documents with mixed content.\n\n", "## Data Table\n", "| Name | Age | Role |\n", "|-------|-----|----------|\n", "| Alice | 30 | Engineer |\n", "| Bob | 25 | Designer |\n", "| Carol | 35 | Manager |\n\n", "---\n\n", "### Code Section\n", "let x = 42\n", "/= x * 2\n", ); let block_vec = blocks::parse_blocks(sample, "rust"); let (registry, layout) = Self::vec_to_registry(block_vec); Self { registry, layout, modules: Vec::new(), focused_block: 0, font_size: 14.0, preview: false, render_mode: RenderMode::Live, parsed: Vec::new(), lang: Some("rust".into()), scroll_offset: 0.0, eval_dirty: false, last_edit: Instant::now(), undo_stack: Vec::new(), redo_stack: Vec::new(), last_edit_kind: EditKind::Other, last_edit_time: Instant::now(), find: FindState::new(), pending_focus: None, fallback_text: text_widget::Content::with_text(""), mods: Modifiers::default(), selection: crate::selection::Selection::None, focus: None, editing: None, cmd_a_armed: false, all_blocks_selected: false, cursor_pos: Point::ORIGIN, context_menu: None, pending_scroll: 0.0, eval_results: Vec::new(), computed_tables: Vec::new(), computed_trees: Vec::new(), computed_cells: HashMap::new(), inline_press: None, line_indicator: LineIndicator::On, gutter_rainbow: true, pending_clipboard: None, computed_images: Vec::new(), image_cache: HashMap::new(), prev_cursor_line: 0, } } // ── registry + layout helpers ────────────────────────────────── fn vec_to_registry(blocks: Vec) -> (HashMap, Vec) { let mut registry = HashMap::with_capacity(blocks.len()); let mut layout = Vec::with_capacity(blocks.len()); for block in blocks { let id = block.id(); layout.push(id); registry.insert(id, block); } (registry, layout) } fn registry_to_vec(&mut self) -> Vec { self.layout.iter().filter_map(|id| self.registry.remove(id)).collect() } fn replace_blocks(&mut self, blocks: Vec) { self.registry.clear(); self.layout.clear(); for block in blocks { let id = block.id(); self.layout.push(id); self.registry.insert(id, block); } } fn block_at(&self, idx: usize) -> Option<&BoxedBlock> { self.layout.get(idx).and_then(|id| self.registry.get(id)) } fn block_at_mut(&mut self, idx: usize) -> Option<&mut BoxedBlock> { self.layout.get(idx).copied().and_then(move |id| self.registry.get_mut(&id)) } fn insert_block(&mut self, idx: usize, block: BoxedBlock) { let id = block.id(); self.layout.insert(idx, id); self.registry.insert(id, block); } fn remove_block(&mut self, idx: usize) -> Option { if idx < self.layout.len() { let id = self.layout.remove(idx); self.registry.remove(&id) } else { None } } fn push_block(&mut self, block: BoxedBlock) { let id = block.id(); self.layout.push(id); self.registry.insert(id, block); } fn clear_blocks(&mut self) { self.layout.clear(); self.registry.clear(); } fn block_count(&self) -> usize { self.layout.len() } fn recount_block_lines(&mut self) { let mut line = 0; for &id in &self.layout { if let Some(block) = self.registry.get_mut(&id) { block.set_start_line(line); line += block.line_count(); } } } // ── Layer helpers ───────────────────────────────────────────── fn clear_layers_for_blocks(&mut self, ids: &[crate::selection::BlockId]) { self.eval_results.retain(|r| !ids.contains(&r.anchor.block_id)); self.computed_tables.retain(|t| !ids.contains(&t.anchor.block_id)); self.computed_trees.retain(|t| !ids.contains(&t.anchor.block_id)); } /// Map a line number in concatenated module source back to a per-block anchor. /// `boundaries` is a sorted vec of (cumulative_line_start, block_id). fn map_line_to_anchor( boundaries: &[(usize, crate::selection::BlockId)], global_line: usize, ) -> Anchor { let mut best_id = boundaries.first().map(|b| b.1).unwrap_or(0); let mut best_start = 0; for &(start, id) in boundaries { if start <= global_line { best_id = id; best_start = start; } else { break; } } Anchor { block_id: best_id, after_line: global_line - best_start, } } /// Scan text blocks for `![alt](src)` image references and populate /// `computed_images`. Loads image bytes into `image_cache` on first /// encounter (sync for local files). Replaces previous images for the /// given block set — unchanged sources keep their cache entry. fn scan_images( &mut self, boundaries: &[(usize, crate::selection::BlockId)], block_ids: &[crate::selection::BlockId], ) { self.computed_images.retain(|img| !block_ids.contains(&img.anchor.block_id)); let mut new_srcs: Vec<(Anchor, String, String)> = Vec::new(); for &(_start, block_id) in boundaries { let block = match self.registry.get(&block_id) { Some(b) => b, None => continue, }; let text = if let Some(tb) = block.as_any().downcast_ref::() { tb.content.text() } else { continue; }; for (line_idx, line) in text.lines().enumerate() { if let Some((alt, src)) = parse_image_ref(line) { let anchor = Anchor { block_id, after_line: line_idx }; new_srcs.push((anchor, src, alt)); } } } // Editor width estimate for aspect-ratio scaling. let editor_w = 800.0f32; // approximate; TODO: pass actual width for (anchor, src, alt) in new_srcs { // Load into cache if absent. if !self.image_cache.contains_key(&src) { if let Some(entry) = load_image_from_path(&src) { self.image_cache.insert(src.clone(), entry); } } let display_height = if let Some(entry) = self.image_cache.get(&src) { let max_w = (editor_w - IMAGE_PADDING).max(1.0); let scale_w = max_w.min(entry.width as f32); let aspect = entry.height as f32 / entry.width.max(1) as f32; (scale_w * aspect).min(IMAGE_MAX_H) } else { IMAGE_PLACEHOLDER_H }; self.computed_images.push(ComputedImage { anchor, src, alt, display_height, }); } } fn block_index_at_line(&self, global_line: usize) -> Option { for (i, &id) in self.layout.iter().enumerate() { if let Some(block) = self.registry.get(&id) { let start = block.start_line(); let end = start + block.line_count(); if global_line >= start && global_line < end { return Some(i); } } } None } /// Update the focused block index AND mirror it into the central /// `selection` / `focus` fields. Clears any active cell edit mode — /// changing the focused block always exits whatever cell was being edited. /// Also drops any per-table whole-table selection, since focus is moving. fn set_focused_block(&mut self, idx: usize) { self.focused_block = idx; self.editing = None; for block in self.registry.values_mut() { if let Some(tb) = block.as_any_mut().downcast_mut::() { tb.table_selected = false; } } if let Some(block) = self.block_at(idx) { let path = crate::selection::NodePath::block(block.id()); self.selection = crate::selection::Selection::Caret(path.clone()); self.focus = Some(path); } } /// Mark a specific cell of a table block as selected (highlighted but not /// in edit mode). Clears any active edit. Used by single-click and by /// Escape-from-edit. fn set_selected_cell(&mut self, idx: usize, row: usize, col: usize) { self.focused_block = idx; self.editing = None; if let Some(block) = self.block_at(idx) { let path = crate::selection::NodePath::cell(block.id(), row, col); self.selection = crate::selection::Selection::Caret(path.clone()); self.focus = Some(path); } } /// Mark a specific cell as in edit mode (renders as text_input + takes /// iced focus). Used by double-click, by printable-key entry from /// selection, and by Tab/Enter navigation that wants to keep editing /// rolling forward into the next cell. fn set_editing_cell(&mut self, idx: usize, row: usize, col: usize) { self.focused_block = idx; let bid = self.block_at(idx).map(|b| b.id()); if let Some(bid) = bid { let path = crate::selection::NodePath::cell(bid, row, col); self.editing = Some(path.clone()); self.selection = crate::selection::Selection::Caret(path.clone()); self.focus = Some(path); self.pending_focus = Some(table_block::cell_id(bid, row, col)); } } /// Up arrow on the top row of a table at `table_idx`. If the immediately- /// previous block is a text block, focus its end. Otherwise insert a fresh /// empty text block just before the table and focus it. fn escape_table_up(&mut self, table_idx: usize) { if table_idx > 0 { if let Some(tb) = self.text_block_at(table_idx - 1) { let block_id = tb.id; let last_line = tb.content.line_count().saturating_sub(1); let last_col = tb .content .line(last_line) .map(|l| l.text.len()) .unwrap_or(0); self.set_focused_block(table_idx - 1); self.pending_focus = Some(block_editor_id(block_id)); self.safe_move_to(Cursor { position: Position { line: last_line, column: last_col }, selection: None, }); return; } } // No text block immediately above — synthesize one. self.push_undo_snapshot(); let lang = self.lang_str(); let new_id = blocks::next_id(); let new_block: BoxedBlock = Box::new(TextBlock::new(new_id, "", 0, lang)); let insert_at = table_idx.min(self.block_count()); self.insert_block(insert_at, new_block); self.recount_block_lines(); self.set_focused_block(insert_at); self.pending_focus = Some(block_editor_id(new_id)); self.reparse(); } /// Down arrow on the last row of a table at `table_idx`. Mirror of /// `escape_table_up`: focus the immediately-following text block if any, /// otherwise synthesize a fresh empty text block right after the table. fn escape_table_down(&mut self, table_idx: usize) { let next_idx = table_idx + 1; if next_idx < self.block_count() { if let Some(tb) = self.text_block_at(next_idx) { let block_id = tb.id; self.set_focused_block(next_idx); self.pending_focus = Some(block_editor_id(block_id)); self.safe_move_to(Cursor { position: Position { line: 0, column: 0 }, selection: None, }); return; } } self.push_undo_snapshot(); let lang = self.lang_str(); let new_id = blocks::next_id(); let new_block: BoxedBlock = Box::new(TextBlock::new(new_id, "", 0, lang)); let insert_at = next_idx.min(self.block_count()); self.insert_block(insert_at, new_block); self.recount_block_lines(); self.set_focused_block(insert_at); self.pending_focus = Some(block_editor_id(new_id)); self.reparse(); } fn lang_str(&self) -> String { self.lang.clone().unwrap_or_default() } /// Tab width in spaces. Per-language for now defaults to 4 (matches Python /// and most house-styles); a lookup table can be added when other languages /// want narrower indents. fn tab_width(&self) -> usize { 4 } fn text_block_at(&self, idx: usize) -> Option<&TextBlock> { self.block_at(idx).and_then(|b| b.as_any().downcast_ref::()) } fn text_block_at_mut(&mut self, idx: usize) -> Option<&mut TextBlock> { self.block_at_mut(idx) .and_then(|b| b.as_any_mut().downcast_mut::()) } fn table_block_at(&self, idx: usize) -> Option<&TableBlock> { self.block_at(idx).and_then(|b| b.as_any().downcast_ref::()) } fn table_block_at_mut(&mut self, idx: usize) -> Option<&mut TableBlock> { self.block_at_mut(idx) .and_then(|b| b.as_any_mut().downcast_mut::()) } fn first_text_block_index(&self) -> Option { self.layout.iter().enumerate().find_map(|(i, id)| { self.registry.get(id).and_then(|b| { if b.as_any().is::() { Some(i) } else { None } }) }) } fn content(&self) -> &text_widget::Content { if let Some(tb) = self.text_block_at(self.focused_block) { return &tb.content; } if let Some(idx) = self.first_text_block_index() { if let Some(tb) = self.text_block_at(idx) { return &tb.content; } } &self.fallback_text } fn content_mut(&mut self) -> &mut text_widget::Content { let target = if self .block_at(self.focused_block) .map(|b| b.as_any().is::()) .unwrap_or(false) { Some(self.focused_block) } else { self.first_text_block_index() }; if let Some(idx) = target { let id = self.layout[idx]; return &mut self .registry.get_mut(&id).unwrap() .as_any_mut() .downcast_mut::() .unwrap() .content; } &mut self.fallback_text } fn full_text(&self) -> String { let mut parts = Vec::new(); for &id in &self.layout { if let Some(block) = self.registry.get(&id) { let md = block.to_md(); if block.kind_tag() == "text" || !md.is_empty() { parts.push(md); } } } parts.join("\n") } fn line_height(&self) -> f32 { self.font_size * 1.3 } /// Move the focused content's cursor to `target`, clamping line and column /// into the current text so we never hand cosmic-text an out-of-bounds index. /// Defends against an iced text_editor bug where `Content::move_to` passes /// the caller's `column` straight to cosmic-text without validation. fn safe_move_to(&mut self, mut cursor: Cursor) { { let content = self.content(); let line_count = content.line_count(); if line_count == 0 { cursor.position.line = 0; cursor.position.column = 0; } else { if cursor.position.line >= line_count { cursor.position.line = line_count - 1; } let line_len = content .line(cursor.position.line) .map(|l| l.text.len()) .unwrap_or(0); if cursor.position.column > line_len { cursor.position.column = line_len; } } if let Some(sel) = cursor.selection.as_mut() { if line_count == 0 { sel.line = 0; sel.column = 0; } else { if sel.line >= line_count { sel.line = line_count - 1; } let sel_line_len = content .line(sel.line) .map(|l| l.text.len()) .unwrap_or(0); if sel.column > sel_line_len { sel.column = sel_line_len; } } } } self.content_mut().move_to(cursor); } /// Handle arrow/backspace/delete at block boundaries. /// Returns true if the action was consumed (focus change or merge). fn handle_block_boundary(&mut self, action: &text_widget::Action) -> bool { let idx = self.focused_block; if !self.text_block_at(idx).is_some() { return false; } match action { Action::Move(Motion::Up) | Action::Select(Motion::Up) => { let cursor = self.content().cursor(); if cursor.position.line == 0 && idx > 0 { self.set_focused_block(idx - 1); return true; } } Action::Move(Motion::Down) | Action::Select(Motion::Down) => { let cursor = self.content().cursor(); let line_count = self.content().line_count(); if cursor.position.line + 1 >= line_count && idx + 1 < self.block_count() { self.set_focused_block(idx + 1); return true; } } Action::Edit(text_widget::Edit::Backspace) => { let cursor = self.content().cursor(); if cursor.position.line == 0 && cursor.position.column == 0 && cursor.selection.is_none() { if idx > 0 { return self.merge_with_previous(idx); } } } Action::Edit(text_widget::Edit::Delete) => { let cursor = self.content().cursor(); let line_count = self.content().line_count(); let last_line = line_count.saturating_sub(1); let last_line_text = self.content().line(last_line) .map(|l| l.text.len()) .unwrap_or(0); if cursor.position.line == last_line && cursor.position.column >= last_line_text && cursor.selection.is_none() { if idx + 1 < self.block_count() { return self.merge_with_next(idx); } } } _ => {} } false } fn merge_text_pair(first: &str, second: &str) -> String { if first.is_empty() { second.to_string() } else if second.is_empty() { first.to_string() } else { format!("{}\n{}", first, second) } } fn merge_with_previous(&mut self, idx: usize) -> bool { if idx == 0 { return false; } let prev_idx = idx - 1; if !self.text_block_at(prev_idx).is_some() { // Previous is non-text (HR, heading) -- remove it instead self.remove_block(prev_idx); let new_focus = prev_idx.min(self.block_count().saturating_sub(1)); self.set_focused_block(new_focus); self.recount_block_lines(); return true; } let prev_text = self .text_block_at(prev_idx) .map(|tb| tb.content.text()) .unwrap_or_default(); let cur_text = self .text_block_at(idx) .map(|tb| tb.content.text()) .unwrap_or_default(); let merged = Self::merge_text_pair(&prev_text, &cur_text); let prev_line_count = self .text_block_at(prev_idx) .map(|tb| tb.content.line_count()) .unwrap_or(1); if let Some(tb) = self.text_block_at_mut(prev_idx) { tb.content = text_widget::Content::with_text(&merged); } self.remove_block(idx); self.set_focused_block(prev_idx); self.safe_move_to(Cursor { position: Position { line: prev_line_count.saturating_sub(1), column: 0 }, selection: None, }); self.recount_block_lines(); true } fn merge_with_next(&mut self, idx: usize) -> bool { let next_idx = idx + 1; if next_idx >= self.block_count() { return false; } if !self.text_block_at(next_idx).is_some() { self.remove_block(next_idx); self.recount_block_lines(); return true; } let cur_text = self .text_block_at(idx) .map(|tb| tb.content.text()) .unwrap_or_default(); let next_text = self .text_block_at(next_idx) .map(|tb| tb.content.text()) .unwrap_or_default(); let merged = Self::merge_text_pair(&cur_text, &next_text); if let Some(tb) = self.text_block_at_mut(idx) { tb.content = text_widget::Content::with_text(&merged); } self.remove_block(next_idx); self.recount_block_lines(); true } /// Load a document from raw file bytes. Pulls any embedded sidecar /// archive out of the markdown, sets the text body, then applies the /// sidecar metadata to the parsed table blocks. Used by the FFI /// `viewport_set_text` entrypoint so callers don't have to know the /// archive format exists. pub fn load_doc(&mut self, text: &str) { // In editor mode, loading text should preserve the single-block state. // The save→observe→setText round-trip calls load_doc; if we reparse // here we'd silently exit editor mode and corrupt the block structure. if self.render_mode == RenderMode::Editor { let loaded = sidecar::extract_archive(text); let clean = strip_result_lines(&loaded.markdown); self.set_text(&clean); return; } let loaded = sidecar::extract_archive(text); let clean = strip_result_lines(&loaded.markdown); self.set_text(&clean); self.eval_results.clear(); self.computed_tables.clear(); self.computed_trees.clear(); if let Some(sc) = loaded.sidecar { self.apply_sidecar(&sc); } // Trigger full eval when loading into Live or View mode. if self.render_mode == RenderMode::Live || self.render_mode == RenderMode::View { self.run_eval_all(); } } /// Save the document to raw file bytes: assign sidecar ids to any tables /// that need them, build the sidecar from current block metadata, and /// embed it as a base64 zip in an HTML comment at the end of the file. /// Tables with no rich metadata produce no archive — the file stays a /// pure plain `.md`. Used by the FFI `viewport_get_text` entrypoint. pub fn save_doc(&mut self) -> String { let body = self.get_clean_text(); // build_block_files reads self.modules; make sure it reflects the // current document. rebuild_modules is idempotent and cheap. self.rebuild_modules(); let sidecar = self.build_sidecar(); let block_files = self.build_block_files(); sidecar::embed_archive(&body, &sidecar, &block_files) } /// Build the per-block source files for the archive. One `.cord` file /// per logical block — a logical block is a group of parser spans /// bounded by H1/H2 headings or `---` (the same grouping `self.modules` /// already maintains for `use`-resolution). Tables and prose are not /// boundaries; they live inside whichever block contains them. /// /// Filename: heading-led blocks get `.cord`; HR-led /// (anonymous) blocks get `block_N.cord` where N is the positional /// index across all logical blocks (0-based). Collisions get `_2`, /// `_3`, … suffixes. pub fn build_block_files(&self) -> Vec { use std::collections::HashSet; let mut files = Vec::with_capacity(self.modules.len()); let mut used: HashSet = HashSet::new(); for (index, module) in self.modules.iter().enumerate() { let mut source_parts: Vec = Vec::with_capacity(module.block_ids.len()); let mut title = String::new(); for &bid in &module.block_ids { let Some(block) = self.registry.get(&bid) else { continue }; if title.is_empty() { if let Some(hb) = block.as_any().downcast_ref::() { title = hb.text.clone(); } } source_parts.push(block.to_md()); } let source = source_parts.join("\n"); let kind = if module.heading_block.is_some() { "section" } else { "anonymous" }; let filename = self.unique_cord_filename(&module.name, index, &mut used); let content = format!( "---\nkind = \"{}\"\nindex = {}\ntitle = \"{}\"\n---\n{}", kind, index, title.replace('\\', "\\\\").replace('"', "\\\""), source ); files.push(sidecar::BlockFile { filename, content }); } files } fn unique_cord_filename( &self, module_name: &str, index: usize, used: &mut std::collections::HashSet, ) -> String { let base = if module_name.is_empty() { format!("block_{}", index) } else { module_name.to_string() }; let mut candidate = format!("{}.cord", base); let mut n = 2; while used.contains(&candidate) { candidate = format!("{}_{}.cord", base, n); n += 1; } used.insert(candidate.clone()); candidate } /// Build a `Sidecar` snapshot from the current block tree, keyed by the /// positional index of each non-eval table in layout order ("0", "1", ...). /// Only tables with persistent metadata produce entries. fn build_sidecar(&self) -> Sidecar { let mut sc = Sidecar::default(); sc.version = 1; let mut position: usize = 0; for block_id in &self.layout { let Some(block) = self.registry.get(block_id) else { continue }; let Some(tb) = block.as_any().downcast_ref::() else { continue }; if tb.is_eval_result { continue; } let entry = if tb.has_persistent_metadata() { let mut entry = TableSidecar::default(); entry.col_widths = tb.col_widths.clone(); for (row_idx, h) in tb.row_heights.iter().enumerate() { if let Some(height) = h { entry.row_heights.insert(row_idx.to_string(), *height); } } for (r, row) in tb.rows.iter().enumerate() { for (c, cell) in row.iter().enumerate() { if cell.trim_start().starts_with("/=") { let addr = acord_core::interp::display_addr(c as u32, r as u32); entry.formulas.insert(addr, cell.clone()); } } } Some(entry) } else { None }; if let Some(entry) = entry { sc.tables.insert(position.to_string(), entry); } position += 1; } sc } /// Apply a previously-loaded `Sidecar` to the current block tree, matching /// entries to tables by positional index in layout order. Non-eval tables /// count; eval-result tables are skipped. Missing entries leave tables /// unchanged. fn apply_sidecar(&mut self, sc: &Sidecar) { let mut position: usize = 0; let layout = self.layout.clone(); for block_id in &layout { let Some(block) = self.registry.get_mut(block_id) else { continue }; let Some(tb) = block.as_any_mut().downcast_mut::() else { continue }; if tb.is_eval_result { continue; } if let Some(entry) = sc.tables.get(&position.to_string()) { for (i, w) in entry.col_widths.iter().enumerate() { if i < tb.col_widths.len() { tb.col_widths[i] = *w; } } for (key, height) in &entry.row_heights { if let Ok(row_idx) = key.parse::() { if tb.row_heights.len() <= row_idx { tb.row_heights.resize(row_idx + 1, None); } tb.row_heights[row_idx] = Some(*height); } } for (addr, raw) in &entry.formulas { if let Some((col, row)) = acord_core::interp::parse_cell_address(addr) { let (r, c) = (row as usize, col as usize); while tb.rows.len() <= r { tb.rows.push(Vec::new()); } let target_cols = (c + 1).max(tb.col_widths.len()); while tb.col_widths.len() < target_cols { tb.col_widths.push(120.0); } while tb.row_heights.len() < tb.rows.len() { tb.row_heights.push(None); } while tb.rows[r].len() <= c { tb.rows[r].push(String::new()); } tb.rows[r][c] = raw.clone(); } } } position += 1; } } pub fn set_text(&mut self, text: &str) { // Snapshot undo before any wholesale text replacement so undo can // recover the prior state. Identity-skip when nothing actually // changes — Swift's observe loop can call set_text with the text we // just emitted, and we don't want round-trips piling up phantom undos. let current = self.get_clean_text(); if current != text { self.push_undo_snapshot(); self.last_edit_kind = EditKind::Other; } self.replace_text_no_undo(text); } /// Wholesale text replacement WITHOUT pushing an undo snapshot. Used by /// `restore_snapshot` (the undo/redo path) where touching the undo stack /// would loop. fn replace_text_no_undo(&mut self, text: &str) { // In editor mode, the document is a single raw text block. Don't // reparse into structured blocks — that would silently exit editor // mode while the render_mode flag still says Editor. if self.render_mode == RenderMode::Editor { let lang = self.lang_str(); self.clear_blocks(); self.push_block(Box::new(TextBlock::new(blocks::next_id(), text, 0, lang))); self.recount_block_lines(); self.set_focused_block(0); return; } let lang = self.lang_str(); if self.layout.is_empty() { self.replace_blocks(blocks::parse_blocks(text, &lang)); } else { let mut block_vec = self.registry_to_vec(); blocks::reparse_incremental(&mut block_vec, text, &lang); self.replace_blocks(block_vec); } if self.focused_block >= self.block_count() { self.set_focused_block(0); } self.scroll_offset = 0.0; self.reparse(); } /// Per-frame focus sync. Walks tables and sets `is_active`/`focused_cell` /// based on iced's currently-focused widget id. `focused_cell` is preserved /// across blur (keyboard shortcuts need it); `is_active` flips off every /// frame and only flips on for the table whose cell matches. pub fn sync_focused_cell(&mut self, focused_id: Option<&WidgetId>) { for block in self.registry.values_mut() { if let Some(tb) = block.as_any_mut().downcast_mut::() { tb.is_active = false; } } let Some(target_id) = focused_id else { return }; for block in self.registry.values_mut() { let Some(tb) = block.as_any_mut().downcast_mut::() else { continue }; if tb.is_eval_result { continue; } let bid = tb.id; let rows = tb.rows.len(); let cols = tb.col_widths.len(); let mut hit: Option<(usize, usize)> = None; for r in 0..rows { for c in 0..cols { let candidate = table_block::cell_id(bid, r, c); if candidate == *target_id { hit = Some((r, c)); break; } } if hit.is_some() { break; } } if let Some(rc) = hit { tb.focused_cell = Some(rc); tb.is_active = true; return; } } } /// A non-eval table currently has a selected cell. Used by handle.rs to /// gate keyboard interception of arrow keys, Tab, Enter, Backspace etc. /// Keys off `focused_cell` (logical selection, preserved across blur), /// NOT `is_active` (which only tracks whether iced widget focus is in the /// cell text_input — true only during edit mode). pub(crate) fn active_table_index(&self) -> Option { self.focused_table_index() } /// True iff the editor's *currently focused block* is a non-eval table /// that has a selected cell. This is the right gate for any table-specific /// keybinding — `focused_table_index()` would also return Some for a /// table whose selection was set on a previous click but where focus has /// since moved to a text block, which would cause the table to silently /// steal arrow keys / Backspace / Cmd+Backspace from the text block. pub(crate) fn table_is_focused_block(&self) -> bool { if let Some(block) = self.block_at(self.focused_block) { if let Some(tb) = block.as_any().downcast_ref::() { return !tb.is_eval_result && tb.focused_cell.is_some(); } } false } /// True iff the focused block is a table currently in whole-table /// select-all mode. handle.rs uses this to route plain Backspace to /// "clear all cells" and Cmd+Backspace to "delete the entire table." pub(crate) fn focused_table_is_select_all(&self) -> bool { if let Some(block) = self.block_at(self.focused_block) { if let Some(tb) = block.as_any().downcast_ref::() { return !tb.is_eval_result && tb.table_selected; } } false } /// Returns (block_idx, row, total_rows) for the currently active table's /// focused cell, or None if no table is active. handle.rs uses the /// total_rows to detect "Down arrow on the last row" for edge-escape. pub(crate) fn active_table_focused_row(&self) -> Option<(usize, usize, usize)> { let idx = self.active_table_index()?; let tb = self.table_block_at(idx)?; let (r, _c) = tb.focused_cell?; Some((idx, r, tb.rows.len())) } /// Returns the index of the editor's currently focused block IF it's a /// table with a selected cell. Returns None if focus is on a text block, /// heading, etc. — even if some other table somewhere in the document /// has `focused_cell` set (which is common since `focused_cell` is /// preserved across blur so users can click back into a prior table and /// have their selection restored). /// /// All table-targeted operations (DeleteCurrentTable, FocusedTableOp, /// TableTab/Enter/Move*, EnterCellEditWithChar, ClearSelectedCell) use /// this — so they all consistently mean "the table the user is in right /// now," not "the topmost table that has ever been touched." pub(crate) fn focused_table_index(&self) -> Option { let block = self.block_at(self.focused_block)?; let tb = block.as_any().downcast_ref::()?; if !tb.is_eval_result && tb.focused_cell.is_some() { Some(self.focused_block) } else { None } } /// True iff the editor's *currently focused block* is a table with a /// selected cell that's not in edit mode. handle.rs uses this to decide /// whether to intercept printable keys for "type to enter edit mode." /// /// MUST check `focused_block` rather than "any table has focused_cell" — /// `focused_cell` is intentionally preserved across blur so clicking /// back into a table restores selection, which means it can't double as /// a "currently active" signal. pub(crate) fn has_selected_cell_not_editing(&self) -> bool { if self.editing.is_some() { return false; } let Some(block) = self.block_at(self.focused_block) else { return false; }; let Some(tb) = block.as_any().downcast_ref::() else { return false; }; !tb.is_eval_result && tb.focused_cell.is_some() } /// True when handle.rs should intercept Cmd+C and route it to the /// table-cell copy path instead of letting iced's text widget handle it. /// Conditions: focused block is a table; not currently editing a cell /// (cell-edit mode delegates to text_input's own copy); and either a /// selection is non-empty or a spillover popup is open. pub(crate) fn should_intercept_table_copy(&self) -> bool { if self.editing.is_some() { return false; } let Some(block) = self.block_at(self.focused_block) else { return false; }; let Some(tb) = block.as_any().downcast_ref::() else { return false; }; !tb.selection.is_empty() || tb.spillover.is_some() } /// Build the clipboard payload from the focused table — selection takes /// precedence over spillover; spillover provides the single-cell payload /// when no explicit selection exists. None if neither applies. fn copy_focused_table_selection(&self) -> Option { let block = self.block_at(self.focused_block)?; let tb = block.as_any().downcast_ref::()?; if !tb.selection.is_empty() { return tb.copy_selection_payload(); } let (r, c) = tb.spillover?; tb.rows.get(r).and_then(|row| row.get(c)).cloned() } pub fn set_lang_from_ext(&mut self, ext: &str) { self.lang = lang_from_extension(ext); } pub fn tick(&mut self) { if self.render_mode != RenderMode::Live { return; } if self.eval_dirty && self.last_edit.elapsed().as_millis() >= EVAL_DEBOUNCE_MS { self.eval_dirty = false; self.run_eval(); } // Cursor-line-change trigger: when the cursor moves to a different // line (arrow keys, click, etc.) without an edit, re-evaluate. { let block_start = self.layout.get(self.focused_block) .and_then(|id| self.registry.get(id)) .map(|b| b.start_line()) .unwrap_or(0); let intra = self.content().cursor().position.line; let global_line = block_start + intra; if global_line != self.prev_cursor_line { self.prev_cursor_line = global_line; if !self.eval_dirty { self.run_eval(); } } } // Fire the long-press copy at the threshold — if the user is still // holding past LONG_PRESS_MS without having released, double-clicked, // or moved off, drop the result onto the clipboard. let due = self.inline_press.as_ref().is_some_and(|s| { !s.fired_long_press && s.started_at.elapsed().as_millis() >= LONG_PRESS_MS }); if due { if let Some(s) = self.inline_press.as_mut() { s.fired_long_press = true; let bid = s.block_id; let line = s.after_line; self.copy_inline_result(bid, line); } } // Table hover-to-spillover dwell: each table polls its own armed // timer and opens the popup once the 3s threshold passes. let block_ids: Vec = self.layout.clone(); for id in block_ids { if let Some(block) = self.registry.get_mut(&id) { if let Some(tb) = block.as_any_mut().downcast_mut::() { tb.check_hover_spillover(); } } } } /// True if an eval debounce is still pending. Used by handle::render to keep /// the vsync loop ticking through the debounce window even when no new input /// is arriving, so tick() eventually fires run_eval. pub fn has_pending_eval(&self) -> bool { self.eval_dirty || self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press) || self.layout.iter().any(|id| { self.registry.get(id) .and_then(|b| b.as_any().downcast_ref::()) .is_some_and(|tb| tb.has_pending_hover()) }) } fn reparse(&mut self) { let text = self.get_clean_text(); self.parsed = markdown::parse(&text).collect(); self.rebuild_modules(); } /// Build the BlockInfo slice used by module/table detection. /// Shared between `rebuild_modules` and `register_visible_tables`. fn build_block_infos(&self) -> Vec { use crate::heading_block::HeadingBlock; use crate::module::BlockInfo; self.layout.iter().filter_map(|&id| { let block = self.registry.get(&id)?; let tag = block.kind_tag(); let (heading_level, heading_text) = if let Some(hb) = block.as_any().downcast_ref::() { (hb.level.as_u8(), hb.text.clone()) } else { (0, String::new()) }; let text_content = if tag == "text" { block.to_md() } else { String::new() }; Some(BlockInfo { id, kind_tag: tag, heading_level, heading_text, text_content }) }).collect() } /// Rebuild the module list and apply table naming from headings. fn rebuild_modules(&mut self) { use crate::module::{compute_modules, detect_table_names}; let infos = self.build_block_infos(); self.modules = compute_modules(&infos); let names = detect_table_names(&infos); for assignment in names { if let Some(block) = self.registry.get_mut(&assignment.table_id) { if let Some(tb) = block.as_any_mut().downcast_mut::() { tb.table_name = Some(assignment.name); } } } } /// Register every non-eval-result table in the document on the /// interpreter under all names it's reachable by from the focused /// block's module. Also sets `current_block` on the interp so bare /// H4 refs resolve correctly. fn register_visible_tables( &self, interp: &mut acord_core::interp::Interpreter, focused_block_idx: usize, ) -> TableIndex { use crate::module::{ compute_positional_ids, detect_table_names, normalize_name, TableNameScope, }; let infos = self.build_block_infos(); let table_names = detect_table_names(&infos); let pos_ids = compute_positional_ids(&infos); let mut block_to_module: HashMap = HashMap::new(); for m in &self.modules { for &bid in &m.block_ids { block_to_module.insert(bid, m.name.clone()); } } let focused_id = self.layout.get(focused_block_idx).copied(); let focused_module_name = focused_id.and_then(|id| block_to_module.get(&id).cloned()); interp.set_current_block(focused_module_name.as_deref()); let mut keys_map: HashMap = HashMap::new(); let mut canonical: HashMap = HashMap::new(); for (table_id, pos_name, _pos_block_pos) in &pos_ids.tables { let Some(block) = self.registry.get(table_id) else { continue }; let Some(tb) = block.as_any().downcast_ref::() else { continue }; if tb.is_eval_result { continue; } let rows = tb.rows.clone(); let heading = table_names.iter().find(|a| a.table_id == *table_id); let module_name = block_to_module.get(table_id).cloned(); // Canonical key (used as `current_table` anchor when evaluating // formulas inside this table): heading name when global/present, // `module::heading` for H4, positional as final fallback. let canonical_key = match heading { Some(h) => { let hname = normalize_name(&h.name); match h.scope { TableNameScope::Global => hname, TableNameScope::BlockScoped => { if let Some(ref m) = module_name { format!("{}::{}", m, hname) } else { hname } } } } None => pos_name.to_lowercase(), }; canonical.insert(*table_id, canonical_key.clone()); // Build the full set of keys this table is reachable by. let mut keys: Vec = vec![pos_name.to_lowercase(), canonical_key.clone()]; if let Some(h) = heading { let hname = normalize_name(&h.name); if h.scope == TableNameScope::BlockScoped { // Also expose bare heading for refs FROM inside the // owning module (resolve_table_key_fallback also handles // this, but registering explicitly avoids the fallback // hop and disambiguates collisions between modules). if module_name.as_deref() == focused_module_name.as_deref() { keys.push(hname); } } } if let Some(ref m) = module_name { keys.push(format!("{}::{}", m, pos_name.to_lowercase())); } keys.sort(); keys.dedup(); for k in &keys { interp.register_table(k, rows.clone()); keys_map.insert(k.clone(), *table_id); } } TableIndex { keys: keys_map, canonical } } /// True if any non-eval-result table in the document has at least one /// cell whose text starts with `/=`. Used to early-out `run_eval` when /// neither text blocks nor tables have anything to evaluate. fn any_visible_cell_formulas(&self) -> bool { for block in self.registry.values() { if let Some(tb) = block.as_any().downcast_ref::() { if tb.is_eval_result { continue; } if tb.rows.iter().any(|row| row.iter().any(|c| c.trim_start().starts_with("/="))) { return true; } } } false } /// Parse, topo-sort, and evaluate every cell formula across visible /// tables. Results land in `self.computed_cells`; cycles yield /// `Value::Error("cycle")`. Also threads computed values back into /// `interp`'s table registry so subsequent text-block reads see the /// formula result rather than the raw `/=...` string. fn evaluate_cell_formulas( &mut self, interp: &mut acord_core::interp::Interpreter, table_index: &TableIndex, ) { use acord_core::interp::{parse_formula_with_spice, ParsedFormula, Value}; struct Cell { table_key: String, col: u32, row: u32, block_id: crate::selection::BlockId, ast: ParsedFormula, } let mut formulas: Vec = Vec::new(); let mut parse_errors: Vec<(crate::selection::BlockId, u32, u32, String)> = Vec::new(); let mut seen_blocks: std::collections::HashSet = std::collections::HashSet::new(); for (_, &block_id) in &table_index.keys { if !seen_blocks.insert(block_id) { continue; } let Some(block) = self.registry.get(&block_id) else { continue }; let Some(tb) = block.as_any().downcast_ref::() else { continue }; let canonical = match table_index.canonical.get(&block_id) { Some(k) => k.clone(), None => continue, }; for (r, row) in tb.rows.iter().enumerate() { for (c, cell) in row.iter().enumerate() { let trimmed = cell.trim_start(); let Some(body) = trimmed.strip_prefix("/=") else { continue }; // The interpreter's spice flag reflects any `use spice` // already executed in the code blocks for this module. // Formulas inside tables inherit that flag. match parse_formula_with_spice(body, interp.spice_enabled()) { Ok(ast) => formulas.push(Cell { table_key: canonical.clone(), col: c as u32, row: r as u32, block_id, ast, }), Err(e) => parse_errors.push((block_id, c as u32, r as u32, e)), } } } } // Clear prior computed values for visible tables only — tables // outside the focused module's scope keep their stale results so // their cells don't flash blank between cross-module evals. self.computed_cells.retain(|k, _| !seen_blocks.contains(&k.0)); for (bid, c, r, e) in parse_errors { self.computed_cells.insert((bid, c, r), Value::Error(format!("parse: {}", e))); } if formulas.is_empty() { return; } // Build dep graph. Node i is formulas[i]. Edge dep_idx → i means // formula i reads the cell that formula dep_idx computes — so // dep_idx must evaluate first. let node_key: HashMap<(String, u32, u32), usize> = formulas.iter().enumerate() .map(|(i, f)| ((f.table_key.clone(), f.col, f.row), i)) .collect(); let mut edges: Vec> = vec![Vec::new(); formulas.len()]; let mut in_degree: Vec = vec![0; formulas.len()]; for (i, f) in formulas.iter().enumerate() { let refs = f.ast.refs(&f.table_key); for r in refs { let resolved = resolve_ref_key(&r, table_index); if let Some(key) = resolved { if let Some(&dep) = node_key.get(&(key, r.cell.0, r.cell.1)) { if dep != i { edges[dep].push(i); in_degree[i] += 1; } } } } } let mut queue: std::collections::VecDeque = in_degree.iter().enumerate() .filter_map(|(i, &d)| if d == 0 { Some(i) } else { None }) .collect(); let mut order: Vec = Vec::new(); while let Some(i) = queue.pop_front() { order.push(i); let next = std::mem::take(&mut edges[i]); for j in next { in_degree[j] -= 1; if in_degree[j] == 0 { queue.push_back(j); } } } let ordered: std::collections::HashSet = order.iter().copied().collect(); for i in &order { let f = &formulas[*i]; interp.set_current_table(Some(&f.table_key)); let result = match interp.eval_formula(&f.ast) { Ok(v) => v, Err(e) => Value::Error(e), }; interp.set_current_table(None); // Thread the computed value back into the interpreter's table // registry so subsequent formulas AND text-block reads see it // instead of the raw `/=...` string. if !result.is_error() { let display = result.display(); // Write into every alias of this table. for (alias_key, &bid) in &table_index.keys { if bid == f.block_id { interp.write_cell_raw(alias_key, f.col, f.row, &display); } } } self.computed_cells.insert((f.block_id, f.col, f.row), result); } for i in 0..formulas.len() { if ordered.contains(&i) { continue; } let f = &formulas[i]; self.computed_cells.insert((f.block_id, f.col, f.row), Value::Error("cycle".into())); } } /// Apply cell writes logged by the interpreter to the live TableBlocks. /// Writes land in `rows[r][c]` and grow the table as needed (strict /// bounds would discourage using formulas to populate empty cells). fn apply_table_writes( &mut self, writes: Vec, table_index: &TableIndex, ) { for w in writes { let Some(&block_id) = table_index.keys.get(&w.table_key) else { continue }; let Some(block) = self.registry.get_mut(&block_id) else { continue }; let Some(tb) = block.as_any_mut().downcast_mut::() else { continue }; let (c, r) = (w.cell.0 as usize, w.cell.1 as usize); while tb.rows.len() <= r { tb.rows.push(Vec::new()); } let target_cols = (c + 1).max(tb.col_widths.len()); while tb.col_widths.len() < target_cols { tb.col_widths.push(120.0); } while tb.row_heights.len() < tb.rows.len() { tb.row_heights.push(None); } while tb.rows[r].len() <= c { tb.rows[r].push(String::new()); } tb.rows[r][c] = w.value; } } /// Check if block structure changed after an edit. Serializes current /// blocks, re-parses, applies an incremental diff, then re-seats the /// focused block index against the post-reparse layout. fn check_block_structure(&mut self) { let cursor = self.content().cursor(); let full = self.full_text(); let lang = self.lang_str(); let old_count = self.block_count(); { let mut block_vec = self.registry_to_vec(); blocks::reparse_incremental(&mut block_vec, &full, &lang); self.replace_blocks(block_vec); } if self.focused_block >= self.block_count() { self.set_focused_block(self.block_count().saturating_sub(1)); } if self.block_count() != old_count { if let Some(bi) = self.block_index_at_line(cursor.position.line) { self.set_focused_block(bi); } } self.rebuild_modules(); } /// Wrap a selection in matching delimiters or unwrap an existing pair. /// Used by Cmd+B (`**`), Cmd+I (`*`), Cmd+~ (`~~`), Cmd+", etc. /// /// Unwrap detection looks at characters IMMEDIATELY outside the /// selection, not just inside it — so the selection can be the inner /// text (without markers) and Cmd+B still toggles off. /// /// Star-marker parity rule: bold (`**`) unwraps when the surrounding /// star count on each side is >= 2 AND even (2 → 0, 4 → 2, …). /// Italic (`*`) unwraps when the count is odd (1, 3, 5 …). This /// keeps `**bold**` + Cmd+I → wraps to `***bold***` (bold-italic), /// not destructive; and `***both***` + Cmd+B → `*both*` (strips bold). fn toggle_wrap(&mut self, open: &str, close: &str) { let text = self.content().text(); let cursor = self.content().cursor(); let pos = byte_offset_for_cursor(&text, &cursor.position); let (start, end) = match self.selection_byte_range(&text, pos) { Some(range) => range, None => { // No selection: insert paired markers and park cursor between. let s = format!("{open}{close}"); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(s)), )); for _ in 0..close.chars().count() { self.content_mut().perform(text_widget::Action::Move(Motion::Left)); } self.reparse(); return; } }; let selected = &text[start..end]; let before = &text[..start]; let after = &text[end..]; let star_marker = open.chars().all(|c| c == '*') && close == open; if star_marker { let mlen = open.len(); // Sym-strip when markers are inside the selection itself. if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= mlen * 2 { let inner = &selected[mlen..selected.len() - mlen]; self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(inner.to_string())), )); self.reparse(); return; } let outer = count_trailing_char(before, '*').min(count_leading_char(after, '*')); let should_unwrap = match mlen { 2 => outer >= 2 && outer % 2 == 0, // bold 1 => outer >= 1 && outer % 2 == 1, // italic _ => outer >= mlen, }; if should_unwrap { self.replace_range(start - mlen, end + mlen, selected); return; } } else { // Non-star markers: simple symmetric strip. let olen = open.len(); let clen = close.len(); if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= olen + clen { let inner = &selected[olen..selected.len() - clen]; self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(inner.to_string())), )); self.reparse(); return; } if before.ends_with(open) && after.starts_with(close) { self.replace_range(start - olen, end + clen, selected); return; } } // Default: wrap. let wrapped = format!("{open}{selected}{close}"); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(wrapped)), )); self.reparse(); } /// Replace a byte range in the current content with `replacement`. Used /// by toggle_wrap's unwrap path so we can rewrite text that sits OUTSIDE /// the selection (the surrounding markers). fn replace_range(&mut self, start: usize, end: usize, replacement: &str) { let text = self.content().text(); if start > end || end > text.len() { return; } let mut new_text = String::with_capacity(text.len() - (end - start) + replacement.len()); new_text.push_str(&text[..start]); new_text.push_str(replacement); new_text.push_str(&text[end..]); // Rebuild the content with the new text and place cursor at end of // replacement so successive toggles continue to operate at the // same logical spot. let cursor_byte = start + replacement.len(); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(new_text.clone())), )); // Position cursor at byte offset cursor_byte by walking from start. let target = line_col_for_byte(&new_text, cursor_byte); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); for _ in 0..target.0 { self.content_mut().perform(text_widget::Action::Move(Motion::Down)); } self.content_mut().perform(text_widget::Action::Move(Motion::Home)); for _ in 0..target.1 { self.content_mut().perform(text_widget::Action::Move(Motion::Right)); } self.reparse(); } /// Compute the byte range of the current selection (start, end) or None /// when no selection is active. fn selection_byte_range(&self, text: &str, _cursor_pos: usize) -> Option<(usize, usize)> { let sel = self.content().selection()?; // We need the start position; use cursor + selection length to // bracket. Selection is the text between selection-start and cursor; // search both directions in the buffer to find a unique location. // For toggle_wrap's purposes, we use the cursor position as the END // and walk back by sel.len() to find start. This is correct when // selection extends backward from the cursor; otherwise we fall // back to a forward search. let cursor = self.content().cursor(); let cursor_byte = byte_offset_for_cursor(text, &cursor.position); let len = sel.len(); // Try cursor at end of selection. if cursor_byte >= len && &text[cursor_byte - len..cursor_byte] == sel.as_str() { return Some((cursor_byte - len, cursor_byte)); } // Try cursor at start of selection. if cursor_byte + len <= text.len() && &text[cursor_byte..cursor_byte + len] == sel.as_str() { return Some((cursor_byte, cursor_byte + len)); } // Fall back to searching the doc. text.find(sel.as_str()).map(|s| (s, s + len)) } /// Insert paired delimiters at the cursor and place the caret between /// them. Used for `[` → `[|]` and `{` → `{|}`. Quotes/parens are /// deliberately NOT auto-paired. fn auto_pair(&mut self, open: &str, close: &str) { let combined = format!("{open}{close}"); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(combined)), )); for _ in 0..close.chars().count() { self.content_mut().perform(text_widget::Action::Move(Motion::Left)); } } /// Toggle blockquote prefix on the current line(s). With a selection /// spanning multiple lines, prefix `> ` to each; if every line already /// has `> `, strip it. fn toggle_blockquote(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); let lines: Vec<&str> = text.lines().collect(); let cur_line = cursor.position.line.min(lines.len().saturating_sub(1)); // Single-line toggle: simplest meaningful form. if cur_line >= lines.len() { return; } let line = lines[cur_line]; let mut new_lines: Vec = lines.iter().map(|l| l.to_string()).collect(); if let Some(rest) = line.strip_prefix("> ") { new_lines[cur_line] = rest.to_string(); } else { new_lines[cur_line] = format!("> {line}"); } let new_text = new_lines.join("\n"); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(new_text)), )); self.reparse(); } /// Cmd+0 catch-all. See `Message::FixUp` for the spec. fn fix_up(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); let pos = byte_offset_for_cursor(&text, &cursor.position); // 1. Innermost unclosed delimiter? Close it. if let Some(close) = innermost_unclosed_delim(&text[..pos]) { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(close.to_string())), )); return; } // 2. Forward to the next outer scope's closing delimiter and step past it. if let Some(jump_to) = next_closing_delim_after(&text, pos) { let target = line_col_for_byte(&text, jump_to + 1); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); for _ in 0..target.0 { self.content_mut().perform(text_widget::Action::Move(Motion::Down)); } self.content_mut().perform(text_widget::Action::Move(Motion::Home)); for _ in 0..target.1 { self.content_mut().perform(text_widget::Action::Move(Motion::Right)); } return; } // 3. At block scope: ensure newline sandwich. self.ensure_newline_sandwich(); } /// Move the cursor onto its own line with exactly one blank line of /// padding above and below (3 newlines total around the caret), or up /// to EOF on either side. fn ensure_newline_sandwich(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); let pos = byte_offset_for_cursor(&text, &cursor.position); // Walk back: collapse trailing whitespace/newlines before pos to "\n\n". let mut left = pos; while left > 0 { let c = text[..left].chars().rev().next().unwrap(); if c == '\n' || c.is_whitespace() { left -= c.len_utf8(); } else { break; } } // Walk forward: collapse leading whitespace/newlines after pos to "\n\n". let mut right = pos; while right < text.len() { let c = text[right..].chars().next().unwrap(); if c == '\n' || c.is_whitespace() { right += c.len_utf8(); } else { break; } } let prefix = if left == 0 { String::new() } else { "\n\n".to_string() }; let suffix = if right == text.len() { String::new() } else { "\n\n".to_string() }; let middle = "\n"; let new_text = format!("{}{}{}{}{}", &text[..left], prefix, middle, suffix, &text[right..]); let cursor_byte = left + prefix.len() + middle.len(); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(new_text.clone())), )); let target = line_col_for_byte(&new_text, cursor_byte); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); for _ in 0..target.0 { self.content_mut().perform(text_widget::Action::Move(Motion::Down)); } self.content_mut().perform(text_widget::Action::Move(Motion::Home)); for _ in 0..target.1 { self.content_mut().perform(text_widget::Action::Move(Motion::Right)); } self.reparse(); } pub fn get_clean_text(&self) -> String { self.full_text() } /// Switch to editor mode: collapse all blocks into a single text block /// containing the raw markdown. The single-block view path renders it /// as a full-page text editor. Cmd+A then selects all text naturally. pub fn enter_editor_mode(&mut self) { if self.render_mode == RenderMode::Editor { return; } self.push_undo_snapshot(); let full = self.full_text(); self.clear_blocks(); let lang = self.lang_str(); self.push_block(Box::new(TextBlock::new(blocks::next_id(), &full, 0, lang))); self.recount_block_lines(); self.set_focused_block(0); self.render_mode = RenderMode::Editor; self.all_blocks_selected = false; self.editing = None; self.eval_results.clear(); self.computed_tables.clear(); self.computed_trees.clear(); self.computed_cells.clear(); // Select all text in the single editor so the user can immediately // delete or type over it. self.content_mut().perform(Action::Move(Motion::DocumentStart)); self.content_mut().perform(Action::Select(Motion::DocumentEnd)); if let Some(tb) = self.text_block_at(0) { self.pending_focus = Some(block_editor_id(tb.id)); } } /// Switch back to live mode: reparse the single text block into /// structured blocks (headings, tables, HRs, etc.). pub fn exit_editor_mode(&mut self) { if self.render_mode != RenderMode::Editor { return; } let text = self.content().text(); let lang = self.lang_str(); self.replace_blocks(blocks::parse_blocks(&text, &lang)); self.recount_block_lines(); if self.focused_block >= self.block_count() { self.set_focused_block(0); } self.render_mode = RenderMode::Live; self.reparse(); } /// Switch to view mode: read-only rendered view. Press `i` for Editor, /// `/` for Live. pub fn enter_view_mode(&mut self) { if self.render_mode == RenderMode::View { return; } // If coming from editor mode, reparse back to blocks first if self.render_mode == RenderMode::Editor { let text = self.content().text(); let lang = self.lang_str(); self.replace_blocks(blocks::parse_blocks(&text, &lang)); self.recount_block_lines(); if self.focused_block >= self.block_count() { self.set_focused_block(0); } self.reparse(); } self.render_mode = RenderMode::View; } /// Collect the concatenated text of all text blocks within a module. fn module_source_text(&self, module: &crate::module::Module) -> String { let mut parts = Vec::new(); for &bid in &module.block_ids { if let Some(block) = self.registry.get(&bid) { if block.kind_tag() == "text" { parts.push(block.to_md()); } } } parts.join("\n") } /// Build an interpreter pre-populated with root module exports and /// any `use`'d module exports for the block at `block_idx`. fn build_eval_interpreter(&self, block_idx: usize) -> acord_core::interp::Interpreter { use acord_core::interp; let mut eval_interp = interp::Interpreter::new(); let block_id = match self.layout.get(block_idx) { Some(&id) => id, None => return eval_interp, }; // Find which module this block belongs to let my_module = self.modules.iter().find(|m| m.block_ids.contains(&block_id)); // Evaluate and import root module exports (unless this IS the root) let is_root = my_module.map(|m| m.is_root).unwrap_or(false); if !is_root { if let Some(root) = self.modules.iter().find(|m| m.is_root) { let root_text = self.module_source_text(root); let mut root_interp = interp::Interpreter::new(); crate::eval::evaluate_document_with_interp(&mut root_interp, &root_text); eval_interp.import_all(&root_interp.exports()); } } // Find use declarations in all text blocks of this module and import those modules let use_block_ids: Vec = my_module .map(|m| m.block_ids.clone()) .unwrap_or_default(); for &bid in &use_block_ids { if let Some(block) = self.registry.get(&bid) { if let Some(tb) = block.as_any().downcast_ref::() { let text = tb.content.text(); let use_decls = interp::extract_use_declarations(&text); for decl in &use_decls { if let Some(dep_module) = self.modules.iter().find(|m| m.name == decl.module) { let dep_text = self.module_source_text(dep_module); let mut dep_interp = interp::Interpreter::new(); if let Some(root) = self.modules.iter().find(|m| m.is_root) { if !dep_module.is_root { let root_text = self.module_source_text(root); let mut root_interp = interp::Interpreter::new(); crate::eval::evaluate_document_with_interp(&mut root_interp, &root_text); dep_interp.import_all(&root_interp.exports()); } } crate::eval::evaluate_document_with_interp(&mut dep_interp, &dep_text); let dep_exports = dep_interp.exports(); match &decl.item { None => eval_interp.import_all(&dep_exports), Some(s) if s == "*" => eval_interp.import_all(&dep_exports), Some(item) => { eval_interp.import_item(&dep_exports, item); } } } } } } } eval_interp } fn run_eval(&mut self) { self.rebuild_modules(); // Find which module the focused block belongs to. let focused_id = match self.layout.get(self.focused_block) { Some(&id) => id, None => return, }; let module = match self.modules.iter().find(|m| m.block_ids.contains(&focused_id)) { Some(m) => m.clone(), None => return, }; // Collect source text from the module's text blocks, tracking block // boundaries so eval result line numbers can be mapped back to anchors. let mut source_parts: Vec = Vec::new(); let mut boundaries: Vec<(usize, crate::selection::BlockId)> = Vec::new(); let mut cumulative = 0usize; let mut block_ids: Vec = Vec::new(); for &bid in &module.block_ids { if let Some(block) = self.registry.get(&bid) { if block.kind_tag() == "text" { boundaries.push((cumulative, bid)); block_ids.push(bid); let text = block.to_md(); let lc = text.lines().count().max(1); source_parts.push(text); cumulative += lc; } } } let source = source_parts.join("\n"); // Image scan runs regardless of eval content. self.scan_images(&boundaries, &block_ids); let has_text_eval = source.lines().any(|l| l.trim_start().starts_with("/=")); let has_cell_formulas = self.any_visible_cell_formulas(); if !has_text_eval && !has_cell_formulas { self.clear_layers_for_blocks(&block_ids); self.computed_cells.clear(); return; } let mut interp = self.build_eval_interpreter(self.focused_block); let table_keys = self.register_visible_tables(&mut interp, self.focused_block); // Phase 1: evaluate cell formulas (reads current raw cell values). // Formulas override their cell's registered value in the interpreter // so phase 2 text-block reads see computed values, not /=... strings. self.evaluate_cell_formulas(&mut interp, &table_keys); // Phase 2: evaluate text-block document. let doc = crate::eval::evaluate_document_with_interp(&mut interp, &source); // Phase 3: apply text-block cell writes back to live TableBlocks. let writes = interp.drain_table_writes(); self.apply_table_writes(writes, &table_keys); // Clear previous results for this module's blocks. self.clear_layers_for_blocks(&block_ids); // Distribute results to the appropriate layers. for r in &doc.results { let anchor = Self::map_line_to_anchor(&boundaries, r.line); if r.format == "table" { match serde_json::from_str::>>(&r.result) { Ok(rows) if !rows.is_empty() => { let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); let mut col_widths = vec![120.0f32; col_count]; for row in &rows { for (ci, cell) in row.iter().enumerate() { let w = cell.len() as f32 * 8.0 + 16.0; if ci < col_widths.len() && w > col_widths[ci] { col_widths[ci] = w; } } } self.computed_tables.push(ComputedTable { anchor, rows, col_widths, }); continue; } _ => {} } // Table parse failed — fall through to inline result self.eval_results.push(InlineResult { anchor, text: format!("{}{}", RESULT_PREFIX, r.result), is_error: false, }); } else if r.format == "tree" { match serde_json::from_str::(&r.result) { Ok(data) => { self.computed_trees.push(ComputedTree { anchor, data }); } Err(_) => { self.eval_results.push(InlineResult { anchor, text: format!("{}{}", RESULT_PREFIX, r.result), is_error: false, }); } } } else { self.eval_results.push(InlineResult { anchor, text: format!("{}{}", RESULT_PREFIX, r.result), is_error: false, }); } } for e in &doc.errors { let anchor = Self::map_line_to_anchor(&boundaries, e.line); self.eval_results.push(InlineResult { anchor, text: format!("{}{}", ERROR_PREFIX, e.error), is_error: true, }); } } /// Evaluate every module in document order. Each module gets a fresh /// interpreter seeded with root exports (and its own `use` imports). /// Used by Cmd+R, mode switches to Live/View, and file loads. fn run_eval_all(&mut self) { self.rebuild_modules(); // Clear all computed layers up front. self.eval_results.clear(); self.computed_tables.clear(); self.computed_trees.clear(); self.computed_cells.clear(); // Evaluate each module in order by temporarily pointing focused_block // at a text block within it, then calling run_eval() which already // handles cross-module imports (root exports + `use` declarations). let saved = self.focused_block; let modules: Vec = self.modules.clone(); for module in &modules { let anchor_idx = module.block_ids.iter() .find_map(|bid| self.layout.iter().position(|id| id == bid)); if let Some(idx) = anchor_idx { self.focused_block = idx; self.run_eval(); } } self.focused_block = saved; } pub fn take_pending_focus(&mut self) -> Option { self.pending_focus.take() } /// Drain the accumulated wheel-scroll delta. handle.rs::render calls /// this each frame and, if non-zero, runs a `scroll_by` operation /// against the document scrollable. Returns None when no scroll has /// been queued (so handle.rs can skip the operation entirely on idle /// frames). pub fn take_pending_scroll(&mut self) -> Option { if self.pending_scroll.abs() < f32::EPSILON { self.pending_scroll = 0.0; return None; } let v = self.pending_scroll; self.pending_scroll = 0.0; Some(v) } fn snapshot(&self) -> UndoSnapshot { let cursor = self.content().cursor(); UndoSnapshot { text: self.get_clean_text(), cursor_line: cursor.position.line, cursor_col: cursor.position.column, } } fn push_undo_snapshot(&mut self) { let snap = self.snapshot(); self.undo_stack.push(snap); if self.undo_stack.len() > UNDO_MAX { self.undo_stack.remove(0); } } fn maybe_snapshot(&mut self, kind: EditKind) { let now = Instant::now(); let elapsed = now.duration_since(self.last_edit_time).as_millis(); let should_snap = kind != self.last_edit_kind || elapsed > COALESCE_MS || kind == EditKind::Enter || kind == EditKind::Paste; if should_snap { self.push_undo_snapshot(); } self.last_edit_kind = kind; self.last_edit_time = now; self.redo_stack.clear(); } fn classify_edit(action: &text_widget::Action) -> Option { match action { Action::Edit(edit) => match edit { text_widget::Edit::Insert(_) => Some(EditKind::Insert), text_widget::Edit::Enter => Some(EditKind::Enter), text_widget::Edit::Backspace => Some(EditKind::Backspace), text_widget::Edit::Delete => Some(EditKind::Delete), text_widget::Edit::Paste(_) => Some(EditKind::Paste), _ => Some(EditKind::Other), }, _ => None, } } fn restore_snapshot(&mut self, snap: &UndoSnapshot) { // Bypass the undo-recording branch in set_text — we're in the middle of // an undo/redo operation and don't want to pile new entries onto the stack. self.replace_text_no_undo(&snap.text); self.run_eval(); self.safe_move_to(Cursor { position: Position { line: snap.cursor_line, column: snap.cursor_col }, selection: None, }); } fn perform_undo(&mut self) { if self.undo_stack.is_empty() { return; } let current = self.snapshot(); self.redo_stack.push(current); let snap = self.undo_stack.pop().unwrap(); self.restore_snapshot(&snap); self.last_edit_kind = EditKind::Other; } fn perform_redo(&mut self) { if self.redo_stack.is_empty() { return; } let current = self.snapshot(); self.undo_stack.push(current); let snap = self.redo_stack.pop().unwrap(); self.restore_snapshot(&snap); self.last_edit_kind = EditKind::Other; } fn update_find_matches(&mut self) { self.find.matches.clear(); self.find.current = 0; if self.find.query.is_empty() { return; } let text = self.get_clean_text(); let query_lower = self.find.query.to_lowercase(); let text_lower = text.to_lowercase(); let mut line = 0usize; let mut col = 0usize; let mut byte = 0usize; for (i, ch) in text_lower.char_indices() { while byte < i { byte += 1; } if ch == '\n' { line += 1; col = 0; continue; } if text_lower[i..].starts_with(&query_lower) { self.find.matches.push((line, col)); } col += 1; } } fn navigate_to_match(&mut self) { if self.find.matches.is_empty() { return; } let idx = self.find.current.min(self.find.matches.len() - 1); let (line, col) = self.find.matches[idx]; self.safe_move_to(Cursor { position: Position { line, column: col }, selection: None, }); } /// Whether `message` is safe to dispatch while the editor is in /// `RenderMode::View`. Allowlist: scroll, click/drag selection, /// find, copy, zoom, focus, navigation — anything that doesn't /// touch document content. Edit-shaped `text_widget::Action`s and /// every mutating top-level `Message` get dropped at the gate. fn message_is_view_safe(message: &Message) -> bool { match message { Message::SetRenderMode(_) => true, Message::FocusBlock(_) => true, Message::TogglePreview => true, Message::MarkdownLink(_) => true, Message::ZoomIn | Message::ZoomOut | Message::ZoomReset => true, Message::ToggleFind | Message::HideFind => true, Message::FindQueryChanged(_) | Message::FindNext | Message::FindPrev => true, Message::ReplaceQueryChanged(_) => true, Message::TableMoveUp | Message::TableMoveDown | Message::TableMoveLeft | Message::TableMoveRight => true, Message::SelectAllBlocks => true, Message::ShowContextMenu { .. } | Message::HideContextMenu => true, Message::CopyLiteral(_) | Message::CopyFocusedTableSelection => true, Message::InlineResultPress { .. } | Message::InlineResultRelease => true, Message::EvalAll => true, Message::EditorAction(action) | Message::BlockAction(_, action) => { !action.is_edit() } _ => false, } } pub fn update(&mut self, message: Message) { // View mode: drop anything that would change the document. Mode // switches, focus, scroll, click/drag selection, find, copy, // navigation — all pass through. Allowlist; new mutating // messages should fall through to the default `false` arm and // get dropped. if self.render_mode == RenderMode::View && !Self::message_is_view_safe(&message) { return; } // Drop whole-document selection on any message that isn't itself an // operation on that selection. Click, key press, table action — all // collapse the doc-wide selection back to single-block / single-cell. let preserve_doc_selection = matches!( &message, Message::SelectAllBlocks | Message::ClearAllBlocks | Message::DeleteAllBlocks ); if !preserve_doc_selection && self.all_blocks_selected { self.all_blocks_selected = false; } // Drop the context menu on any message that isn't itself a context // menu operation. Includes button clicks INSIDE the menu — they // dispatch the action AND auto-clear in one shot. Clicking outside // the menu (which generates a SelectCell or BlockAction) also // dismisses for free. let preserve_context_menu = matches!( &message, Message::ShowContextMenu { .. } ); if !preserve_context_menu && self.context_menu.is_some() { self.context_menu = None; } match message { Message::EditorAction(action) => { let is_edit = action.is_edit(); let is_enter = matches!(&action, Action::Edit(text_widget::Edit::Enter)); let is_paste = matches!(&action, Action::Edit(text_widget::Edit::Paste(_))); if let Some(kind) = Self::classify_edit(&action) { self.maybe_snapshot(kind); } if let Action::Scroll { lines } = &action { let lh = self.line_height(); // Single-block-mode gutter still uses scroll_offset for // its own scroll tracking. Keep this update so the gutter // doesn't desync. The multi-block path ignores this and // uses `pending_scroll` (forwarded to the outer // scrollable in handle.rs::render). self.scroll_offset += *lines as f32 * lh; self.scroll_offset = self.scroll_offset.max(0.0); let focused_id = self.layout.get(self.focused_block).copied(); let items_h: f32 = focused_id .map(|id| self.item_offsets(id).iter().map(|(_, h)| h).sum()) .unwrap_or(0.0); let max = (self.content().line_count() as f32 - 1.0) * lh + items_h; self.scroll_offset = self.scroll_offset.min(max.max(0.0)); // Accumulate the pixel delta for the outer scrollable. // text_editor's `Action::Scroll` carries lines, not pixels — // multiply by line height. Multiple scroll events in one // frame stack up here. self.pending_scroll += *lines as f32 * lh; } // Smart-backspace inside leading whitespace: with no // selection, delete back to the previous tab stop in a // single user-visible step. Mutually exclusive with // handle_block_boundary's col-0 merge case (col > 0 here). let smart_backspace_count: Option = if matches!(&action, Action::Edit(text_widget::Edit::Backspace)) { let cursor = self.content().cursor(); if cursor.selection.is_none() && cursor.position.column > 0 { let line_text = self .content() .line(cursor.position.line) .map(|l| l.text.to_string()) .unwrap_or_default(); let col = cursor.position.column.min(line_text.len()); let prefix = &line_text[..col]; if !prefix.is_empty() && prefix.chars().all(|c| c == ' ') { let tab = self.tab_width(); let n = (col - 1) % tab + 1; if n > 1 { Some(n) } else { None } } else { None } } else { None } } else { None }; let dedent = if let text_widget::Action::Edit(text_widget::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 }; let handled_boundary = self.handle_block_boundary(&action); if !handled_boundary { if let Some(n) = smart_backspace_count { for _ in 0..n { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Backspace, )); } } else { self.content_mut().perform(action); } } // Auto-indent on Enter. Compute AFTER perform(Enter), reading // the line that was just split — that's the line whose // indentation we want to inherit on the new line below it. if is_enter && !handled_boundary { let cursor = self.content().cursor(); if cursor.position.line > 0 { let prev_line = self .content() .line(cursor.position.line - 1) .map(|l| l.text.to_string()) .unwrap_or_default(); let base = leading_whitespace(&prev_line).to_string(); let trimmed = prev_line.trim_end(); let opens_block = matches!( trimmed.as_bytes().last(), Some(b'{' | b'[' | b'(') ); let indent = if opens_block { format!("{base}{}", " ".repeat(self.tab_width())) } else { base }; if !indent.is_empty() { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(indent)), )); } } } if let Some(col) = dedent { let remove = col.min(4); self.content_mut().perform(text_widget::Action::Move(Motion::Left)); for _ in 0..remove { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Backspace, )); } self.content_mut().perform(text_widget::Action::Move(Motion::Right)); } if is_edit { self.last_edit = Instant::now(); if self.lang.is_none() { self.lang = detect_lang_from_content(&self.content().text()); } self.reparse(); if self.render_mode == RenderMode::Live { if is_enter || is_paste { self.check_block_structure(); } self.eval_dirty = true; } } } Message::InsertTable => { self.push_undo_snapshot(); let rows: Vec> = vec![ vec!["Header 1".into(), "Header 2".into(), "Header 3".into()], vec!["".into(), "".into(), "".into()], vec!["".into(), "".into(), "".into()], ]; let new_id = blocks::next_id(); let mut new_table = TableBlock::new(new_id, rows, 0); // Park focus on the first data cell (skip the header row) so // the user lands ready to type values, not to edit headers. new_table.focused_cell = Some((1, 0)); let new_block: BoxedBlock = Box::new(new_table); let insert_at = (self.focused_block + 1).min(self.block_count()); self.insert_block(insert_at, new_block); self.recount_block_lines(); // Land in edit mode on the first data cell so the user can // type immediately. set_editing_cell handles index, central // selection, and pending_focus in one shot. self.set_editing_cell(insert_at, 1, 0); self.reparse(); // Intentionally NOT calling run_eval() — see eval_segment_range // for the destruction-class bug this avoids. } Message::ToggleBold => self.toggle_wrap("**", "**"), Message::ToggleItalic => self.toggle_wrap("*", "*"), Message::ToggleStrike => self.toggle_wrap("~~", "~~"), Message::ToggleUnderline => self.toggle_wrap("", ""), Message::WrapWith(open, close) => self.toggle_wrap(open, close), Message::ToggleBlockquote => self.toggle_blockquote(), Message::AutoPair(open, close) => self.auto_pair(open, close), Message::FixUp => self.fix_up(), Message::Evaluate => { self.run_eval(); } Message::EvalAll => { self.run_eval_all(); } 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_mut().perform(text_widget::Action::Move(Motion::End)); self.content_mut().perform(text_widget::Action::Edit( text_widget::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; } Message::Undo => { self.perform_undo(); } Message::Redo => { self.perform_redo(); } Message::ToggleFind => { self.find.visible = !self.find.visible; if self.find.visible { self.pending_focus = Some(WidgetId::new(FIND_INPUT_ID)); } } Message::HideFind => { self.find.visible = false; } Message::FindQueryChanged(q) => { self.find.query = q; self.update_find_matches(); if !self.find.matches.is_empty() { self.find.current = 0; self.navigate_to_match(); } } Message::FindNext => { if !self.find.matches.is_empty() { self.find.current = (self.find.current + 1) % self.find.matches.len(); self.navigate_to_match(); } } Message::FindPrev => { if !self.find.matches.is_empty() { self.find.current = if self.find.current == 0 { self.find.matches.len() - 1 } else { self.find.current - 1 }; self.navigate_to_match(); } } Message::ReplaceQueryChanged(r) => { self.find.replacement = r; } Message::ReplaceOne => { if self.find.matches.is_empty() || self.find.query.is_empty() { return; } self.push_undo_snapshot(); self.redo_stack.clear(); let (match_line, match_col) = self.find.matches[self.find.current]; let clean = self.get_clean_text(); let query_lower = self.find.query.to_lowercase(); let query_char_count = query_lower.chars().count(); let mut lines: Vec = clean.lines().map(|l| l.to_string()).collect(); if match_line < lines.len() { let line = &lines[match_line]; // Case-fold per-substring at the match column to avoid // byte-index divergence between line and line.to_lowercase(). let chars: Vec<(usize, char)> = line.char_indices().collect(); if match_col < chars.len() { let window: String = chars[match_col..] .iter() .take(query_char_count) .map(|(_, c)| *c) .collect::() .to_lowercase(); if window == query_lower { let byte_start = chars[match_col].0; let byte_end = if match_col + query_char_count < chars.len() { chars[match_col + query_char_count].0 } else { line.len() }; let before = &line[..byte_start]; let after = &line[byte_end..]; lines[match_line] = format!("{before}{}{after}", self.find.replacement); } } } let new_text = lines.join("\n"); self.set_text(&new_text); self.run_eval(); self.update_find_matches(); if !self.find.matches.is_empty() { self.find.current = self.find.current.min(self.find.matches.len() - 1); self.navigate_to_match(); } } Message::ReplaceAll => { if self.find.matches.is_empty() || self.find.query.is_empty() { return; } self.push_undo_snapshot(); self.redo_stack.clear(); // Case-fold per-substring via char iteration. Never index // into a pre-lowercased copy — the byte layout can diverge // for characters whose lowercase changes byte length (Turkish // İ → "i\u{307}", German ß → "ss", etc.). let clean = self.get_clean_text(); let query_lower = self.find.query.to_lowercase(); let query_char_count = query_lower.chars().count(); let chars: Vec<(usize, char)> = clean.char_indices().collect(); let mut result = String::with_capacity(clean.len()); let mut ci = 0; while ci < chars.len() { let remaining = chars.len() - ci; if remaining >= query_char_count { let window: String = chars[ci..ci + query_char_count] .iter() .map(|(_, c)| *c) .collect::() .to_lowercase(); if window == query_lower { result.push_str(&self.find.replacement); ci += query_char_count; continue; } } result.push(chars[ci].1); ci += 1; } self.set_text(&result); self.run_eval(); self.update_find_matches(); } Message::TableMsg(idx, tmsg) => { let structural = matches!( &tmsg, TableMessage::InsertRowAbove | TableMessage::InsertRowBelow | TableMessage::DeleteRow | TableMessage::InsertColLeft | TableMessage::InsertColRight | TableMessage::DeleteCol | TableMessage::AddRow | TableMessage::AddColumn ); // DeleteCol on a single-column table collapses to DeleteTable. if matches!(&tmsg, TableMessage::DeleteCol) { if let Some(tb) = self.table_block_at(idx) { if !tb.is_eval_result && tb.rows.first().map(|r| r.len()).unwrap_or(0) <= 1 { self.update(Message::DeleteCurrentTable); return; } } } // The corner-cell delete affordance promotes straight to the // top-level DeleteCurrentTable handler. We need to ensure the // target table is the one focused before that runs — the // click on the affordance counts as touching the table. if matches!(&tmsg, TableMessage::DeleteTable) { if let Some(tb) = self.table_block_at_mut(idx) { if tb.focused_cell.is_none() { tb.focused_cell = Some((0, 0)); } tb.is_active = true; } self.update(Message::DeleteCurrentTable); return; } // Right-click → ShowContextMenu. Before opening the menu, // hoist focus to the right-clicked table+cell so subsequent // `FocusedTableOp` actions (Insert/Delete row/col) target // this table and so iced's focus machinery doesn't snap the // scroll position back to whatever the prior focused block // was. Pre-existing multi-cell selection IS preserved — // only `focused_block` and the right-clicked cell's // `focused_cell` are updated, not the selection HashSet. if let TableMessage::ContextMenu(r, c) = &tmsg { let (r, c) = (*r, *c); if let Some(tb) = self.table_block_at_mut(idx) { tb.focused_cell = Some((r, c)); tb.is_active = true; } self.focused_block = idx; self.update(Message::ShowContextMenu { block_idx: idx }); return; } // SelectAll/ClearAll need editor-level housekeeping: SelectAll // also marks the table as the focused block so the keyboard // gates pick it up; ClearAll snapshots undo and re-runs eval. let select_all = matches!(&tmsg, TableMessage::SelectAll); let clear_all = matches!(&tmsg, TableMessage::ClearAll); if clear_all { self.push_undo_snapshot(); self.redo_stack.clear(); } if structural { self.push_undo_snapshot(); } // SelectCell / EditCell are click-driven and need to also // mutate the editor-level `editing` / `selection` / focus. // Capture before tb.handle so we can call the helpers after. let select_target = if let TableMessage::SelectCell(r, c) = &tmsg { Some((*r, *c)) } else { None }; let edit_target = if let TableMessage::EditCell(r, c) = &tmsg { Some((*r, *c)) } else { None }; // Capture mods BEFORE the borrow so the click can be // resolved into a SelectionMode without aliasing. let mods = self.mods; if let Some(tb) = self.table_block_at_mut(idx) { tb.handle(tmsg); } if let Some((r, c)) = select_target { // Resolve the modifier-aware selection mode and apply it // to the table's HashSet selection. Click affects ONE // cell — rectangular range comes from drag, not click. // no mod → Replace (selection becomes just this cell) // Cmd → Toggle (invert this cell's membership) // Shift → Add (add this cell, never remove) // Cmd+Shift → Remove (remove this cell, never add) let mode = if mods.logo() && mods.shift() { crate::table_block::SelectionMode::Subtract } else if mods.logo() { crate::table_block::SelectionMode::Toggle } else if mods.shift() { crate::table_block::SelectionMode::Extend } else { crate::table_block::SelectionMode::Replace }; if let Some(tb) = self.table_block_at_mut(idx) { tb.apply_click_selection(r, c, mode); } self.set_selected_cell(idx, r, c); } if let Some((r, c)) = edit_target { self.set_editing_cell(idx, r, c); } if select_all { // Whole-table selection — focused block is this table, // editing is cleared. The table_selected flag was already // set by tb.handle above. self.focused_block = idx; self.editing = None; if let Some(block) = self.block_at(idx) { let path = crate::selection::NodePath::block(block.id()); self.selection = crate::selection::Selection::Caret(path.clone()); self.focus = Some(path); } } if clear_all { self.eval_dirty = true; self.last_edit = Instant::now(); self.reparse(); } if structural { self.recount_block_lines(); if let Some(tb) = self.table_block_at(idx) { if let Some((r, c)) = tb.focused_cell { self.pending_focus = Some(table_block::cell_id(tb.id, r, c)); } } self.reparse(); } } Message::DeleteCurrentTable => { if let Some(target) = self.focused_table_index() { self.push_undo_snapshot(); self.redo_stack.clear(); self.remove_block(target); if self.layout.is_empty() { let lang = self.lang_str(); self.push_block(Box::new(TextBlock::new(blocks::next_id(), "", 0, lang))); } self.recount_block_lines(); let new_focus = target.min(self.block_count().saturating_sub(1)); self.set_focused_block(new_focus); if let Some(tb) = self.text_block_at(new_focus) { self.pending_focus = Some(block_editor_id(tb.id)); } self.reparse(); } } Message::FocusedTableOp(tmsg) => { if let Some(idx) = self.focused_table_index() { self.update(Message::TableMsg(idx, tmsg)); } } Message::TableTab => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; let col_count = tb.col_count(); if cur_c + 1 >= col_count { self.update(Message::TableMsg(idx, TableMessage::AddColumn)); } // Tab keeps edit mode rolling forward so the user can type // straight into the next cell without a second click. self.set_editing_cell(idx, cur_r, cur_c + 1); } Message::TableShiftTab => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; if cur_c == 0 { return; } self.set_editing_cell(idx, cur_r, cur_c - 1); } Message::TableEnter => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; let row_count = tb.row_count(); if cur_r + 1 >= row_count { self.update(Message::TableMsg(idx, TableMessage::AddRow)); } self.set_editing_cell(idx, cur_r + 1, cur_c); } Message::EscapeTableUp(table_idx) => { self.escape_table_up(table_idx); } Message::EscapeTableDown(table_idx) => { self.escape_table_down(table_idx); } Message::ExitCellEdit => { // Exit edit mode but keep the cell selected — same as // Excel/Numbers' Escape behavior. The cell flips back to // its static-text rendering on the next frame. if let Some(path) = self.editing.clone() { self.editing = None; if let crate::selection::InnerPath::Cell { row, col } = path.inner { // Locate the table by id and re-park focus on the // (now selected, not editing) cell. for i in 0..self.block_count() { if let Some(tb) = self.block_at(i).and_then(|b| b.as_any().downcast_ref::()) { if tb.id == path.block_id { self.set_selected_cell(i, row, col); break; } } } } } } Message::EnterCellEditWithChar(c) => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let Some((r, col)) = tb.focused_cell else { return }; // Replace the cell content with just the typed character — // Excel/Numbers "start typing into the selection" semantics. if let Some(tb) = self.table_block_at_mut(idx) { if r < tb.rows.len() && col < tb.rows[r].len() { tb.rows[r][col] = c.to_string(); } } self.set_editing_cell(idx, r, col); } Message::TableMoveUp => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let block_id = tb.id; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; if cur_r == 0 { return; } if let Some(tb) = self.table_block_at_mut(idx) { tb.apply_click_selection( cur_r - 1, cur_c, crate::table_block::SelectionMode::Replace, ); } self.pending_focus = Some(table_block::cell_id(block_id, cur_r - 1, cur_c)); } Message::TableMoveDown => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let block_id = tb.id; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; let row_count = tb.row_count(); if cur_r + 1 >= row_count { return; } if let Some(tb) = self.table_block_at_mut(idx) { tb.apply_click_selection( cur_r + 1, cur_c, crate::table_block::SelectionMode::Replace, ); } self.pending_focus = Some(table_block::cell_id(block_id, cur_r + 1, cur_c)); } Message::TableMoveLeft => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let block_id = tb.id; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; if cur_c == 0 { return; } if let Some(tb) = self.table_block_at_mut(idx) { tb.apply_click_selection( cur_r, cur_c - 1, crate::table_block::SelectionMode::Replace, ); } self.pending_focus = Some(table_block::cell_id(block_id, cur_r, cur_c - 1)); } Message::TableMoveRight => { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let block_id = tb.id; let Some((cur_r, cur_c)) = tb.focused_cell else { return }; let col_count = tb.col_count(); if cur_c + 1 >= col_count { return; } if let Some(tb) = self.table_block_at_mut(idx) { tb.apply_click_selection( cur_r, cur_c + 1, crate::table_block::SelectionMode::Replace, ); } self.pending_focus = Some(table_block::cell_id(block_id, cur_r, cur_c + 1)); } Message::ClearSelectedCell => { // Empty every selected cell. Does nothing if there's no // selection or if a cell is currently being edited (the // text_input handles its own backspace). Honors the multi-cell // selection HashSet first; falls back to single focused_cell // if the HashSet is empty. if self.editing.is_some() { return; } let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; if tb.is_eval_result { return; } let targets: Vec<(usize, usize)> = if !tb.selection.is_empty() { tb.selection.iter().copied().collect() } else if let Some(rc) = tb.focused_cell { vec![rc] } else { return; }; self.push_undo_snapshot(); self.redo_stack.clear(); if let Some(tb) = self.table_block_at_mut(idx) { for (r, c) in targets { if r < tb.rows.len() && c < tb.rows[r].len() { tb.rows[r][c].clear(); } } } self.eval_dirty = true; self.last_edit = Instant::now(); self.reparse(); } Message::SelectAllBlocks => { self.enter_editor_mode(); } Message::SetRenderMode(mode) => { match mode { RenderMode::Live => { if self.render_mode == RenderMode::Editor { self.exit_editor_mode(); } else if self.render_mode == RenderMode::View { self.render_mode = RenderMode::Live; self.reparse(); // Restore keyboard focus to the focused text block. if let Some(tb) = self.text_block_at(self.focused_block) { self.pending_focus = Some(block_editor_id(tb.id)); } } self.run_eval_all(); } RenderMode::Editor => self.enter_editor_mode(), RenderMode::View => { self.enter_view_mode(); self.run_eval_all(); } } } Message::ClearAllBlocks => { // Plain Backspace/Delete with the whole document selected — // wipe to a single empty text block, matching standard editor // select-all + delete behavior. self.push_undo_snapshot(); self.redo_stack.clear(); self.clear_blocks(); let lang = self.lang_str(); self.push_block(Box::new(TextBlock::new( blocks::next_id(), "", 0, lang, ))); self.recount_block_lines(); self.set_focused_block(0); self.all_blocks_selected = false; self.eval_dirty = true; self.last_edit = Instant::now(); self.reparse(); } Message::ShowContextMenu { block_idx } => { // Anchor at the current cursor position. handle.rs writes // self.cursor_pos before draining messages so this read is // current. The position is in viewport coordinates — view_blocks // uses it directly via container padding inside an iced stack. self.context_menu = Some(ContextMenuState { block_idx, x: self.cursor_pos.x, y: self.cursor_pos.y, }); } Message::HideContextMenu => { self.context_menu = None; } Message::CopyLiteral(text) => { self.pending_clipboard = Some(text); } Message::CopyFocusedTableSelection => { if let Some(text) = self.copy_focused_table_selection() { self.pending_clipboard = Some(text); } } Message::DeleteAllBlocks => { // Cmd+Backspace with the whole document selected — wipe to a // single empty text block. Same destructive scope as // selecting all in a regular editor and hitting Delete. self.push_undo_snapshot(); self.redo_stack.clear(); self.clear_blocks(); let lang = self.lang_str(); self.push_block(Box::new(TextBlock::new( blocks::next_id(), "", 0, lang, ))); self.recount_block_lines(); self.all_blocks_selected = false; self.set_focused_block(0); if let Some(tb) = self.text_block_at(0) { self.pending_focus = Some(block_editor_id(tb.id)); } self.eval_dirty = true; self.last_edit = Instant::now(); self.reparse(); } Message::IndentTab => { let tab = self.tab_width(); let col = self.content().cursor().position.column; let to_next_stop = tab - (col % tab); let spaces = " ".repeat(to_next_stop); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(spaces)), )); self.last_edit = Instant::now(); self.eval_dirty = true; self.reparse(); } Message::OutdentTab => { let tab = self.tab_width(); let cursor = self.content().cursor(); let line_text = self .content() .line(cursor.position.line) .map(|l| l.text.to_string()) .unwrap_or_default(); let leading: usize = line_text .chars() .take_while(|c| *c == ' ' || *c == '\t') .count(); if leading == 0 { return; } let remove = leading.min(tab); self.content_mut().perform(text_widget::Action::Move(Motion::Home)); for _ in 0..remove { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Delete, )); } let new_col = cursor.position.column.saturating_sub(remove); self.safe_move_to(Cursor { position: Position { line: cursor.position.line, column: new_col, }, selection: None, }); self.last_edit = Instant::now(); self.eval_dirty = true; self.reparse(); } Message::BlockAction(idx, action) => { if idx < self.block_count() { self.set_focused_block(idx); } self.update(Message::EditorAction(action)); } Message::FocusBlock(idx) => { if idx < self.block_count() { self.set_focused_block(idx); } } Message::InlineResultPress { block_id, after_line } => { self.inline_press = Some(InlinePressState { block_id, after_line, started_at: Instant::now(), fired_long_press: false, }); } Message::InlineResultRelease => { self.inline_press = None; } Message::InlineResultDoubleClick { block_id, after_line } => { self.inline_press = None; self.handle_result_extract(block_id, after_line); } } } /// Look up the inline result for `(block_id, after_line)` and return its /// raw value text (the part after the `→ ` prefix). `None` if no result /// is attached or the result is an error. fn inline_result_value(&self, block_id: crate::selection::BlockId, after_line: usize) -> Option { let r = self.eval_results.iter().find(|r| { r.anchor.block_id == block_id && r.anchor.after_line == after_line && !r.is_error })?; Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string()) } /// Read line `line_idx` from the TextBlock with the given id, if any. fn read_line_at(&self, block_id: crate::selection::BlockId, line_idx: usize) -> Option { let block = self.registry.get(&block_id)?; let tb = block.as_any().downcast_ref::()?; tb.content.line(line_idx).map(|l| l.text.to_string()) } /// Copy `{line} → {value}` to clipboard. Used by both long-press (just /// copy) and double-click (copy then insert template). fn copy_inline_result(&mut self, block_id: crate::selection::BlockId, after_line: usize) { let value = match self.inline_result_value(block_id, after_line) { Some(v) => v, None => return, }; let line = self.read_line_at(block_id, after_line).unwrap_or_default(); let trimmed = line.trim_end(); self.pending_clipboard = Some(format!("{trimmed} {RESULT_PREFIX}{value}")); } /// Double-click on a result: copy + drop a `let = value` line two lines /// below the source `/=`. Cursor lands right after `let ` so the user can /// type the variable name. fn handle_result_extract(&mut self, block_id: crate::selection::BlockId, after_line: usize) { let value = match self.inline_result_value(block_id, after_line) { Some(v) => v, None => return, }; self.copy_inline_result(block_id, after_line); let block_idx = match self.layout.iter().position(|id| *id == block_id) { Some(i) => i, None => return, }; // Only TextBlocks accept text-buffer mutations through this path. if self.text_block_at(block_idx).is_none() { return; } self.push_undo_snapshot(); self.redo_stack.clear(); self.set_focused_block(block_idx); // Move cursor to end of the source `/=` line. let content = self.content_mut(); content.perform(Action::Move(Motion::DocumentStart)); for _ in 0..after_line { content.perform(Action::Move(Motion::Down)); } content.perform(Action::Move(Motion::End)); // Drop a blank line then `let = value`. Two spaces between `let` and // `=` — the user types the variable name into the gap. let paste = format!("\n\nlet = {value}"); content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste)))); // Cursor is at the end of `value`. Walk back past `value`, the `=`, // and the two flanking spaces — landing right after `let `. let back = 3 + value.chars().count(); for _ in 0..back { content.perform(Action::Move(Motion::Left)); } self.last_edit = Instant::now(); self.eval_dirty = true; self.reparse(); } 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| { let p = palette::current(); container::Style { background: Some(Background::Color(p.base)), border: Border::default(), text_color: Some(p.text), shadow: Shadow::default(), snap: false, } }) .into() } else { self.view_blocks() }; let mode_label = match self.render_mode { RenderMode::Live => "Live", RenderMode::Editor => "Editor", RenderMode::View => "View", }; let cursor = self.content().cursor(); let line = cursor.position.line + 1; let col = cursor.position.column + 1; let render_mode = self.render_mode; 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(oklab::lighten_for_size(Color::WHITE, 11.0)) .into(), ]) ) .width(Length::Fill) .padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 }) .style(move |_theme: &Theme| { let p = palette::current(); let darken = |c: Color| Color { r: c.r * 0.45, g: c.g * 0.45, b: c.b * 0.45, a: c.a }; let bg = match render_mode { RenderMode::Live => darken(p.mauve), RenderMode::Editor => darken(p.blue), RenderMode::View => darken(p.pink), }; container::Style { background: Some(Background::Color(bg)), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, } }); let mut col_items: Vec> = Vec::new(); col_items.push(main_content); if self.find.visible { col_items.push(self.find_bar()); } col_items.push(status_bar.into()); iced_widget::column(col_items) .width(Length::Fill) .height(Length::Fill) .into() } fn view_blocks(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let has_computed_layers = !self.eval_results.is_empty() || !self.computed_tables.is_empty() || !self.computed_trees.is_empty(); let single_text_block = self.block_count() == 1 && self.block_at(0).map(|b| b.as_any().is::()).unwrap_or(false) && !has_computed_layers; let title_bar_h = 38.0_f32; let mut block_elements: Vec> = Vec::new(); if !single_text_block && !self.layout.is_empty() { if !self.block_at(0).map(|b| b.as_any().is::()).unwrap_or(true) { block_elements.push( iced_widget::container(iced_widget::text("")) .height(Length::Fixed(title_bar_h)) .width(Length::Fill) .into() ); } } let lang_for_block = self.lang_str(); let mut global_line = 0usize; for (bi, &block_id) in self.layout.iter().enumerate() { let block = self.registry.get(&block_id).unwrap(); let any = block.as_any(); if let Some(tb) = any.downcast_ref::() { let block_idx = bi; let line_h = self.font_size * 1.3; if single_text_block { let is_focused = bi == self.focused_block; let cursor_line = tb.content.cursor().position.line; let text = tb.content.text(); let decors = compute_line_decors(&text); let anchored_items = self.build_anchored_items(tb.id); let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) .font(syntax::EDITOR_FONT) .size(self.font_size) .height(Length::Fill) .padding(Padding { top: title_bar_h, right: 8.0, bottom: 8.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) .show_gutter(true) .gutter_offset(0) .focused(is_focused) .cursor_line(if is_focused { Some(cursor_line) } else { None }) .line_indicator(self.line_indicator) .gutter_rainbow(self.gutter_rainbow) .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } }); let settings = SyntaxSettings { lang: lang_for_block.clone(), source: tb.content.text(), }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), font: syntax::highlight_font(highlight.kind), }, ) .into(); block_elements.push(editor_el); } else { let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; let is_focused = bi == self.focused_block; let anchored_items = self.build_anchored_items(tb.id); let cursor_line = tb.content.cursor().position.line; let line_count = tb.content.line_count(); let text = tb.content.text(); let decors = compute_line_decors(&text); let this_global_line = global_line; global_line += line_count; let _ = line_h; // text_widget::layout owns the height now // Length::Shrink lets text_widget::layout publish the // actual rendered height (visual_rows × line_h + items // + padding). Computing it here from logical line count // undercounts when wrap fires, which leaves the next // block sitting on top of this block's tail. let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) .font(syntax::EDITOR_FONT) .size(self.font_size) .height(Length::Shrink) .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) .show_gutter(true) .gutter_offset(this_global_line) .focused(is_focused) .cursor_line(if is_focused { Some(cursor_line) } else { None }) .line_indicator(self.line_indicator) .gutter_rainbow(self.gutter_rainbow) .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } }); let settings = SyntaxSettings { lang: lang_for_block.clone(), source: tb.content.text(), }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( settings, |highlight, _theme| Format { color: Some(syntax::highlight_color(highlight.kind)), font: syntax::highlight_font(highlight.kind), }, ) .into(); block_elements.push(editor_el); } continue; } if let Some(tab) = any.downcast_ref::() { let block_idx = bi; // Translate the central `editing` path into a (row, col) for // this specific table, so the renderer can branch each cell. let editing_cell = match self.editing.as_ref() { Some(path) if path.block_id == tab.id => match &path.inner { crate::selection::InnerPath::Cell { row, col } => Some((*row, *col)), _ => None, }, _ => None, }; block_elements.push( table_block::table_view( tab, editing_cell, self.font_size, &self.computed_cells, move |tmsg| Message::TableMsg(block_idx, tmsg), ) ); global_line += if tab.rows.is_empty() { 0 } else { tab.rows.len() + 1 }; continue; } // Heading / HR / Tree go through the trait `view` method via a // per-iteration ViewCtx. The new trait signature decouples the // returned LayeredView's lifetime from `ctx`, so a stack-local // ViewCtx is fine — implementations must read what they need from // ctx into Copy locals and not capture ctx into the element. let ctx: ViewCtx<'_, Message> = ViewCtx { block_index: bi, selection: &self.selection, focus: self.focus.as_ref(), editing: self.editing.as_ref(), font_size: self.font_size, is_dark: true, on_text_action: |idx, action| Message::BlockAction(idx, action), on_table_msg: |idx, tmsg| Message::TableMsg(idx, tmsg), computed_cells: &self.computed_cells, }; if let Some(hb) = any.downcast_ref::() { let layered = >::view(hb, &ctx); block_elements.push(layered.base); global_line += 1; continue; } if let Some(hr) = any.downcast_ref::() { let layered = >::view(hr, &ctx); block_elements.push(layered.base); global_line += 1; continue; } if let Some(tree) = any.downcast_ref::() { let layered = >::view(tree, &ctx); block_elements.push(layered.base); global_line += 1; continue; } } let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = if block_elements.is_empty() { iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fill) .into() } else if single_text_block { block_elements.remove(0) } else { iced_widget::scrollable( iced_widget::column(block_elements) .width(Length::Fill) ) .id(WidgetId::new(DOC_SCROLLABLE_ID)) .height(Length::Fill) .into() }; // Whole-document selection visual: tint the entire content area blue. // The container only paints background — it doesn't intercept clicks, // so the underlying blocks remain interactive (which is intentional: // a click anywhere drops the selection back to single-block scope). let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.all_blocks_selected { let p = palette::current(); iced_widget::container(inner) .width(Length::Fill) .height(Length::Fill) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(Color { a: 0.18, ..p.blue })), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into() } else { inner }; // Context menu overlay. Stacked above the main content; positioned // at the cursor anchor via container padding from top-left. Clicks // anywhere outside the menu hit the main content (still alive on // the layer below) AND auto-clear the menu via update()'s top-of-loop // drop logic. let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> = if let Some(menu_state) = &self.context_menu { iced_widget::stack![inner, self.context_menu_view(menu_state)].into() } else { inner }; // Spillover popup overlay — opens when wrap is off and the user // clicks a clipped cell. Only one is open at a time per editor. if let Some(popup) = self.spillover_view() { iced_widget::stack![with_ctx, popup].into() } else { with_ctx } } /// Find the first table block with an open spillover and render its /// popup. Returns None when no spillover is active. The popup is /// fixed-positioned at the top-center of the viewport — close enough /// for now; cell-anchored positioning is a polish pass away. fn spillover_view(&self) -> Option> { let p = palette::current(); let cell_text = self.layout.iter() .filter_map(|id| self.registry.get(id)) .find_map(|block| { let tb = block.as_any().downcast_ref::()?; let (r, c) = tb.spillover?; tb.rows.get(r).and_then(|row| row.get(c)).cloned() })?; let copy_btn = iced_widget::button( iced_widget::text("Copy") .size(11.0) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(context_menu_item_style) .on_press(Message::CopyLiteral(cell_text.clone())); let close_btn = iced_widget::button( iced_widget::text("\u{2715}") .size(11.0) .font(syntax::EDITOR_FONT) ) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(context_menu_item_style) .on_press(Message::FocusedTableOp(TableMessage::CloseSpillover)); let header = iced_widget::row![ iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), copy_btn, close_btn, ] .spacing(4.0) .align_y(iced_wgpu::core::Alignment::Center); let body = iced_widget::scrollable( iced_widget::container( iced_widget::text(cell_text) .size(self.font_size) .font(syntax::EDITOR_FONT) .color(p.text) ) .padding(Padding { top: 6.0, right: 12.0, bottom: 6.0, left: 12.0 }) .width(Length::Fill) ) .height(Length::Fixed(220.0)); let popup = iced_widget::container( iced_widget::column![header, body].spacing(2.0) ) .padding(Padding { top: 6.0, right: 6.0, bottom: 6.0, left: 6.0 }) .width(Length::Fixed(420.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: 4.0.into(), }, text_color: Some(p.text), shadow: iced_wgpu::core::Shadow::default(), snap: false, }); // Position via Shrink-sized leading spacers (same trick as the // context menu overlay) — Fill+padding triggers a viewport-wide // re-layout on every popup open and steals events. let popup_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = popup.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) .height(Length::Fixed(60.0)); let h_spacer = iced_widget::Space::new() .width(Length::Fixed(120.0)) .height(Length::Shrink); Some( iced_widget::column![ v_spacer, iced_widget::row![h_spacer, popup_el] ] .into() ) } /// Get (after_line, height) offset pairs for a block's anchored items. fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> { let lh = self.line_height(); self.collect_layer_items(block_id) .iter() .map(|(line, item)| (*line, item.element_height(lh, self.font_size))) .collect() } /// Collect all layer items for a block into a sorted vec of (after_line, item). fn collect_layer_items(&self, block_id: crate::selection::BlockId) -> Vec<(usize, LayerItem<'_>)> { let mut items: Vec<(usize, LayerItem<'_>)> = Vec::new(); for r in &self.eval_results { if r.anchor.block_id == block_id { items.push((r.anchor.after_line, LayerItem::Inline(r))); } } for ct in &self.computed_tables { if ct.anchor.block_id == block_id { items.push((ct.anchor.after_line, LayerItem::Table(ct))); } } for ct in &self.computed_trees { if ct.anchor.block_id == block_id { items.push((ct.anchor.after_line, LayerItem::Tree(ct))); } } for img in &self.computed_images { if img.anchor.block_id == block_id { items.push((img.anchor.after_line, LayerItem::Image(img))); } } items.sort_by_key(|(line, _)| *line); items } /// Build anchored child Elements for the text widget compositor. /// Converts layer items into AnchoredItem structs with pre-built Elements. fn build_anchored_items<'a>( &'a self, block_id: crate::selection::BlockId, ) -> Vec> { let p = palette::current(); let lh = self.line_height(); let items = self.collect_layer_items(block_id); let mut anchored = Vec::with_capacity(items.len()); for (after_line, item) in &items { match item { LayerItem::Inline(r) => { let color = if r.is_error { p.red } else { p.green }; let inner = iced_widget::container( iced_widget::text(&r.text) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(oklab::lighten_for_size(color, self.font_size)) ) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill); // Errors don't carry a copyable result value, so they // don't get the gesture wrapper. let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if r.is_error { inner.into() } else { let bid = r.anchor.block_id; let line = r.anchor.after_line; MouseArea::new(inner) .on_press(Message::InlineResultPress { block_id: bid, after_line: line }) .on_release(Message::InlineResultRelease) .on_double_click(Message::InlineResultDoubleClick { block_id: bid, after_line: line }) .into() }; anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Table(ct) => { let mut table_rows: Vec> = Vec::new(); for (ri, row) in ct.rows.iter().enumerate() { let is_header = ri == 0; let cells: Vec> = row.iter() .enumerate() .map(|(ci, cell)| { let cw = ct.col_widths.get(ci).copied().unwrap_or(80.0); let mut txt = iced_widget::text(cell) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(oklab::lighten_for_size(p.text, self.font_size)); if is_header { txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT }); } iced_widget::container(txt) .width(Length::Fixed(cw)) .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) .style(move |_theme: &Theme| { let bg_alpha = if is_header { 0.12 } else { 0.06 }; container::Style { background: Some(Background::Color(Color { a: bg_alpha, ..p.surface1 })), border: Border { color: p.surface1, width: 0.5, radius: border::Radius::default() }, text_color: None, shadow: Shadow::default(), snap: false, } }) .into() }) .collect(); table_rows.push(iced_widget::row(cells).into()); } let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = iced_widget::container(iced_widget::column(table_rows)) .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 40.0 }) .width(Length::Fill) .into(); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Tree(ct) => { let el = crate::tree_block::build(&ct.data, self.font_size); anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } LayerItem::Image(img) => { let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if let Some(entry) = self.image_cache.get(&img.src) { iced_widget::container( iced_widget::image(entry.handle.clone()) .width(Length::Fill) .height(Length::Fixed(img.display_height)) ) .padding(Padding { top: IMAGE_VPAD, right: 8.0, bottom: IMAGE_VPAD, left: 40.0 }) .width(Length::Fill) .into() } else { // Placeholder while loading or on failure. iced_widget::container( iced_widget::text(format!("[image: {}]", img.alt)) .font(syntax::EDITOR_FONT) .size(self.font_size) .color(p.overlay0) ) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill) .into() }; anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), element: el, }); } } } anchored } /// Build the context menu overlay for a right-clicked cell. Returns a /// fill container that holds the actual menu in the top-left corner of /// a padded region — padding (top: y, left: x) anchors it to the click. fn context_menu_view( &self, state: &ContextMenuState, ) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let block_idx = state.block_idx; let item = |label: &str, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { iced_widget::button( iced_widget::text(label.to_string()) .size(12.0) .font(syntax::EDITOR_FONT) ) .width(Length::Fill) .padding(Padding { top: 4.0, right: 12.0, bottom: 4.0, left: 12.0 }) .style(context_menu_item_style) .on_press(msg) .into() }; let separator: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fixed(1.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface1)), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into(); let menu_items: Vec> = vec![ item( "Insert row above", Message::FocusedTableOp(TableMessage::InsertRowAbove), ), item( "Insert row below", Message::FocusedTableOp(TableMessage::InsertRowBelow), ), item("Delete row", Message::FocusedTableOp(TableMessage::DeleteRow)), separator, item( "Insert column left", Message::FocusedTableOp(TableMessage::InsertColLeft), ), item( "Insert column right", Message::FocusedTableOp(TableMessage::InsertColRight), ), item( "Delete column", Message::FocusedTableOp(TableMessage::DeleteCol), ), iced_widget::container(iced_widget::text("")) .width(Length::Fill) .height(Length::Fixed(1.0)) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface1)), border: Border::default(), text_color: None, shadow: iced_wgpu::core::Shadow::default(), snap: false, }) .into(), item( "Select all", Message::TableMsg(block_idx, TableMessage::SelectAll), ), { let wrap_on = self.table_block_at(block_idx) .map(|tb| tb.wrap) .unwrap_or(true); item( if wrap_on { "Wrap: on" } else { "Wrap: off" }, Message::TableMsg(block_idx, TableMessage::ToggleWrap), ) }, item("Delete table", Message::DeleteCurrentTable), ]; let menu = iced_widget::container( iced_widget::column(menu_items).spacing(0.0).width(Length::Fixed(180.0)) ) .style(move |_theme: &Theme| iced_widget::container::Style { background: Some(Background::Color(p.surface0)), border: Border { color: p.surface1, width: 1.0, radius: 4.0.into(), }, text_color: Some(p.text), shadow: iced_wgpu::core::Shadow::default(), snap: false, }); // Position via shrink-sized leading spacers, NOT a Fill container with // padding. Fill+padding triggers a viewport-wide re-layout on every // menu open, which jumps the scrollable and swallows events on the // overlay layer. Shrink sizing keeps the overlay limited to its own // bounds, so clicks outside pass through to the scrollable beneath. let menu_element: Element<'_, Message, Theme, iced_wgpu::Renderer> = menu.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) .height(Length::Fixed(state.y)); let h_spacer = iced_widget::Space::new() .width(Length::Fixed(state.x)) .height(Length::Shrink); iced_widget::column![ v_spacer, iced_widget::row![h_spacer, menu_element] ] .into() } fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); let search_input = text_input::TextInput::new("Find...", &self.find.query) .on_input(Message::FindQueryChanged) .on_submit(Message::FindNext) .id(WidgetId::new(FIND_INPUT_ID)) .font(Font::MONOSPACE) .size(13.0) .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) .width(Length::FillPortion(3)) .style(find_input_style); let replace_input = text_input::TextInput::new("Replace...", &self.find.replacement) .on_input(Message::ReplaceQueryChanged) .on_submit(Message::ReplaceOne) .id(WidgetId::new(REPLACE_INPUT_ID)) .font(Font::MONOSPACE) .size(13.0) .padding(Padding { top: 3.0, right: 6.0, bottom: 3.0, left: 6.0 }) .width(Length::FillPortion(3)) .style(find_input_style); let match_label = if self.find.matches.is_empty() { if self.find.query.is_empty() { String::new() } else { "0/0".into() } } else { format!("{}/{}", self.find.current + 1, self.find.matches.len()) }; let label: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::text(match_label) .font(Font::MONOSPACE) .size(11.0) .color(oklab::lighten_for_size(p.overlay1, 11.0)) .into(); let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { iced_widget::button( iced_widget::text(txt).font(Font::MONOSPACE).size(11.0) ) .on_press(msg) .padding(Padding { top: 2.0, right: 6.0, bottom: 2.0, left: 6.0 }) .style(find_btn_style) .into() }; let row = iced_widget::row![ search_input, label, btn("Prev".into(), Message::FindPrev), btn("Next".into(), Message::FindNext), replace_input, btn("Repl".into(), Message::ReplaceOne), btn("All".into(), Message::ReplaceAll), btn("X".into(), Message::HideFind), ] .spacing(4.0) .align_y(alignment::Vertical::Center); iced_widget::container(row) .width(Length::Fill) .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 8.0 }) .style(|_theme: &Theme| { let p = palette::current(); container::Style { background: Some(Background::Color(p.mantle)), border: Border::default(), text_color: None, shadow: Shadow::default(), snap: false, } }) .into() } } fn find_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { let p = palette::current(); text_input::Style { background: Background::Color(p.surface0), border: Border { color: p.surface2, width: 1.0, radius: 3.0.into(), }, icon: p.overlay2, placeholder: p.overlay0, value: p.text, selection: Color { a: 0.4, ..p.blue }, } } fn find_btn_style( _theme: &Theme, _status: iced_widget::button::Status, ) -> iced_widget::button::Style { let p = palette::current(); iced_widget::button::Style { background: Some(Background::Color(p.surface1)), text_color: p.text, border: Border { color: p.surface2, width: 1.0, radius: 3.0.into(), }, shadow: Shadow::default(), snap: false, } } fn context_menu_item_style( _theme: &Theme, status: iced_widget::button::Status, ) -> iced_widget::button::Style { let p = palette::current(); let bg = match status { iced_widget::button::Status::Hovered => Some(Background::Color(p.surface1)), iced_widget::button::Status::Pressed => Some(Background::Color(p.surface2)), _ => None, }; iced_widget::button::Style { background: bg, text_color: p.text, border: Border::default(), shadow: Shadow::default(), snap: false, } } // Strip obsolete inline-result lines from documents saved before eval // results moved into anchored child elements. fn is_result_line(line: &str) -> bool { let trimmed = line.trim_start(); trimmed.starts_with(RESULT_PREFIX) || trimmed.starts_with(ERROR_PREFIX) } fn strip_result_lines(text: &str) -> String { let lines: Vec<&str> = text.lines().filter(|l| !is_result_line(l)).collect(); let mut result = lines.join("\n"); if text.ends_with('\n') { result.push('\n'); } result } fn block_editor_id(block_id: u64) -> WidgetId { WidgetId::from(format!("block_editor_{block_id}")) } 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("z") if modifiers.logo() && modifiers.shift() => { Some(Binding::Custom(Message::Redo)) } keyboard::Key::Character("z") if modifiers.logo() => { Some(Binding::Custom(Message::Undo)) } keyboard::Key::Character("=" | "+") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomIn)) } keyboard::Key::Character("-") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomOut)) } // Cmd+0 lives in handle.rs now (FixUp); Cmd+Shift+0 resets zoom. keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { Some(Binding::Custom(Message::AutoPair("[", "]"))) } keyboard::Key::Character("{") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { Some(Binding::Custom(Message::AutoPair("{", "}"))) } 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)) } keyboard::Key::Named(key::Named::Tab) if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && modifiers.shift() => { Some(Binding::Custom(Message::OutdentTab)) } keyboard::Key::Named(key::Named::Tab) if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { Some(Binding::Custom(Message::IndentTab)) } _ => 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] } /// Count consecutive trailing occurrences of `c` at the end of `s`. fn count_trailing_char(s: &str, c: char) -> usize { s.chars().rev().take_while(|&x| x == c).count() } /// Count consecutive leading occurrences of `c` at the start of `s`. fn count_leading_char(s: &str, c: char) -> usize { s.chars().take_while(|&x| x == c).count() } /// Convert an iced `Position { line, column }` to a byte offset within /// `text`. column is interpreted as char count (cosmic-text convention). fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { let mut byte = 0usize; let mut line_idx = 0usize; for line in text.split_inclusive('\n') { if line_idx == pos.line { let col = pos.column; for (i, _) in line.char_indices().take(col) { byte += line.as_bytes()[i..i + 1].len(); } // Walk col chars precisely. let mut walked = 0usize; for (ci, _) in line.char_indices() { if walked == col { return byte.saturating_sub(line.len()) + ci; } walked += 1; } // col >= line length: clamp to end of line content (before \n). return byte + line.trim_end_matches('\n').len(); } byte += line.len(); line_idx += 1; } text.len() } /// Inverse of `byte_offset_for_cursor`. Returns (line, column). fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { let mut acc = 0usize; let mut line_idx = 0usize; for line in text.split_inclusive('\n') { if byte < acc + line.len() { let local = &line[..byte - acc]; return (line_idx, local.chars().count()); } acc += line.len(); line_idx += 1; } let last_line = text.lines().count().saturating_sub(1); (last_line, text.lines().last().map(|l| l.chars().count()).unwrap_or(0)) } /// Walk `text` left-to-right tracking a delimiter stack. Return the /// `close` char of the innermost still-open pair, or None if balanced. /// Pairs tracked: `()`, `[]`, `{}`. (Quotes/HTML are intentionally out /// of scope — too ambiguous in markdown.) fn innermost_unclosed_delim(text: &str) -> Option { let mut stack: Vec = Vec::new(); for c in text.chars() { match c { '(' => stack.push(')'), '[' => stack.push(']'), '{' => stack.push('}'), ')' | ']' | '}' => { if stack.last() == Some(&c) { stack.pop(); } } _ => {} } } stack.last().copied() } /// Find the byte offset of the next outer scope's CLOSING delimiter /// after `pos`. Used by FixUp to step the cursor out one scope. fn next_closing_delim_after(text: &str, pos: usize) -> Option { let mut depth: i32 = 0; let bytes = text.as_bytes(); for i in pos..bytes.len() { match bytes[i] { b'(' | b'[' | b'{' => depth += 1, b')' | b']' | b'}' => { if depth == 0 { return Some(i); } depth -= 1; } _ => {} } } None } /// Parse a markdown image reference `![alt](src)` from a line. Returns /// `(alt, src)` if found. Only matches if the `![` is the first /// non-whitespace on the line (inline images inside text are not rendered /// as block-level anchored items). fn parse_image_ref(line: &str) -> Option<(String, String)> { let trimmed = line.trim_start(); if !trimmed.starts_with("![") { return None; } let after_bang = &trimmed[2..]; let close_bracket = after_bang.find(']')?; let alt = after_bang[..close_bracket].to_string(); let rest = &after_bang[close_bracket + 1..]; if !rest.starts_with('(') { return None; } let close_paren = rest.find(')')?; let src = rest[1..close_paren].trim().to_string(); if src.is_empty() { return None; } Some((alt, src)) } /// Load an image. `src` may be `http(s)://`, `~/…`, or a filesystem path. /// Result is always PNG bytes regardless of source format. fn load_image_from_path(src: &str) -> Option { let raw = if src.starts_with("http://") || src.starts_with("https://") { let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(5))) .build() .into(); let mut resp = agent.get(src).call().ok()?; resp.body_mut().read_to_vec().ok()? } else { let path = if src.starts_with("~/") { dirs::home_dir()?.join(&src[2..]) } else { std::path::PathBuf::from(src) }; std::fs::read(&path).ok()? }; let img = image::load_from_memory(&raw).ok()?; let (width, height) = (img.width(), img.height()); let rgba = img.into_rgba8(); let pixels = rgba.into_raw(); let handle = iced_widget::image::Handle::from_rgba(width, height, pixels); Some(ImageCacheEntry { handle, width, height }) } /// Encode a clipboard image (RGBA from `arboard`) to PNG and write it into /// `~/.acord/cache/images/{hash}.png`. Returns the absolute path as a /// String suitable for embedding in a `![]( … )` markdown reference. /// Content-addressed: re-pasting the same pixels reuses the same file. pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option { let dir = dirs::home_dir()?.join(".acord").join("cache").join("images"); std::fs::create_dir_all(&dir).ok()?; let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::{Hash, Hasher}; img.width.hash(&mut hasher); img.height.hash(&mut hasher); img.bytes.hash(&mut hasher); let name = format!("{:016x}.png", hasher.finish()); let path = dir.join(&name); if !path.exists() { let buf = image::RgbaImage::from_raw( img.width as u32, img.height as u32, img.bytes.to_vec(), )?; buf.save_with_format(&path, image::ImageFormat::Png).ok()?; } Some(path.to_string_lossy().into_owned()) }