diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs deleted file mode 100644 index 56b6c3f..0000000 --- a/viewport/src/editor.rs +++ /dev/null @@ -1,5538 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU8, Ordering}; -use std::time::Instant; - -pub mod auto_pair { - use super::{AtomicU8, Ordering}; - - pub const PAREN: u8 = 1 << 0; - pub const BRACKET: u8 = 1 << 1; - pub const BRACE: u8 = 1 << 2; - pub const SINGLE: u8 = 1 << 3; - pub const DOUBLE: u8 = 1 << 4; - pub const BACKTICK: u8 = 1 << 5; - - pub const ALL: u8 = PAREN | BRACKET | BRACE | SINGLE | DOUBLE | BACKTICK; - - static FLAGS: AtomicU8 = AtomicU8::new(ALL); - - pub fn enabled(flag: u8) -> bool { - FLAGS.load(Ordering::Relaxed) & flag != 0 - } - - pub fn flags() -> u8 { - FLAGS.load(Ordering::Relaxed) - } - - pub fn set_flags(flags: u8) { - FLAGS.store(flags, Ordering::Relaxed); - } -} - -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 one text_editor, no eval, no block splitting - Editor, - /// read-only rendered view - View, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LayoutMode { - /// objects float on layers above zero with absolute coordinates - Free, - /// objects stay on layer 0 and reorder by drag - Relative, - /// objects sit at fixed positions and layer 0 wraps around the cutouts - Anchored, -} - -/// gutter line-number and cursorline display mode -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LineIndicator { - /// absolute line numbers with full-row cursorline band - On, - /// no line numbers and no cursorline band - Off, - /// vim-style relative line numbers with cursorline band - 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, - /// wraps the selection in matching delimiters, or unwraps an existing pair - WrapWith(&'static str, &'static str), - /// inserts paired `[]` or `{}` and places the cursor between them - AutoPair(&'static str, &'static str), - /// incremental scope exit, then newline-sandwich placement - FixUp, - Evaluate, - /// evaluates every module in document order - 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 a table's top row escapes upward - EscapeTableUp(usize), - /// down arrow on a table's last row escapes downward - EscapeTableDown(usize), - /// moves the focused cell up by one row, staying inside the same table - TableMoveUp, - /// moves the focused cell down by one row, staying inside the same table - TableMoveDown, - /// moves the focused cell left by one column - TableMoveLeft, - /// moves the focused cell right by one column - TableMoveRight, - /// backspace or delete on a selected (not editing) cell - ClearSelectedCell, - /// second cmd+a press escalates to whole-document selection - SelectAllBlocks, - /// backspace or delete while all blocks are selected - ClearAllBlocks, - /// cmd+backspace while all blocks are selected - DeleteAllBlocks, - /// right-click on a table cell - ShowContextMenu { block_idx: usize }, - /// explicitly closes the context menu - HideContextMenu, - /// pushes a literal string into the clipboard out-channel - CopyLiteral(String), - /// copies the current table selection as TSV - CopyFocusedTableSelection, - /// escape from cell edit mode - ExitCellEdit, - /// replaces the selected cell with one character and enters edit mode - EnterCellEditWithChar(char), - /// tab key indents the current line - IndentTab, - OutdentTab, - SetRenderMode(RenderMode), - /// mouse pressed on an inline result, arms the long-press timer - InlineResultPress { block_id: crate::selection::BlockId, after_line: usize }, - /// mouse released anywhere, cancels a pending long-press - InlineResultRelease, - /// double-click on an inline result - InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, - /// mouse pressed on a heading or hr block, arms a free-layer drag. - BlockPromotePress(crate::selection::BlockId), - /// mouse pressed on an inline image, arms a free-layer drag. - ImagePromotePress { block_id: crate::selection::BlockId, after_line: usize, src: String }, - /// mouse released after a block or image promote press. - PromoteRelease, - /// mouse pressed on a resize band of a free-layer object. - ResizePress { node_id: FreeNodeId, horiz: bool, vert: bool }, - /// mouse released after a resize press. - ResizeRelease, - /// switches between free, relative, and anchored layout modes. - SetLayoutMode(LayoutMode), - /// toggles 0.25-line snap on drags and resizes. - ToggleSnapping, - ToggleMenu(MenuCategory), - CloseMenu, - Shell(ShellAction), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MenuCategory { - File, - Edit, - Render, - Mode, - View, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ShellAction { - NewNote, - Open, - Save, - SaveAs, - Quit, - Settings, - ExportCrate, - Print, - ToggleBrowser, - SetThemeMode(String), - SetLineIndicator(String), - SetGutterRainbow(bool), - PickAutoSaveDir, -} - -#[cfg(any(target_os = "linux", target_os = "windows"))] -const MENU_CATS: [(MenuCategory, &'static str); 5] = [ - (MenuCategory::File, "File"), - (MenuCategory::Edit, "Edit"), - (MenuCategory::Render, "Render"), - (MenuCategory::Mode, "Mode"), - (MenuCategory::View, "View"), -]; - -#[cfg(any(target_os = "linux", target_os = "windows"))] -fn cat_btn_width(label: &str, char_w: f32, pad_x: f32) -> f32 { - label.chars().count() as f32 * char_w + pad_x * 2.0 -} - -pub const RESULT_PREFIX: &str = "→ "; - -/// long-press and double-click state for inline eval results -#[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; - - -/// anchor linking a computed item to a text block -#[derive(Debug, Clone)] -pub struct Anchor { - pub block_id: crate::selection::BlockId, - pub after_line: usize, -} - -/// inline eval result text or error message -#[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 } -} - -/// computed table produced by `/=|` 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 - } -} - -/// computed tree produced by `/=\` 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) - } -} - -/// embedded image referenced by `![alt](src)` -#[derive(Debug, Clone)] -pub struct ComputedImage { - pub anchor: Anchor, - pub src: String, - pub alt: String, - /// pre-computed display height, or a placeholder while loading - pub display_height: f32, -} - -/// cached image data keyed by source path or URL -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; - -/// reference to a computed layer item for interleaved rendering -enum LayerItem<'a> { - Inline(&'a InlineResult), - Table(&'a ComputedTable), - Tree(&'a ComputedTree), - Image(&'a ComputedImage), -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum FreeNodeId { - Block(crate::selection::BlockId), - Table(crate::selection::BlockId, usize), - Tree(crate::selection::BlockId, usize), - Image(crate::selection::BlockId, usize, String), -} - -/// position and size of a free-layer object in editor-content coordinates. -#[derive(Debug, Clone, Copy)] -pub struct FreePlacement { - pub layer: u8, - pub x: f32, - pub y: f32, - pub w: f32, - pub h: f32, -} - -/// pending drag state for promoting a block onto a free layer. -#[derive(Debug, Clone)] -pub struct PromoteDragState { - pub node_id: FreeNodeId, - pub start_cursor: Point, - pub origin: (f32, f32), - pub size: (f32, f32), - pub layer: u8, - pub escalated: bool, - pub fallback_table_idx: Option, -} - -/// pending drag state for resizing a free-layer object from an edge band. -#[derive(Debug, Clone)] -pub struct ResizeDragState { - pub node_id: FreeNodeId, - pub start_cursor: Point, - pub start_size: (f32, f32), - pub axes: (bool, bool), - pub snapshot_pushed: bool, -} - -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 widget id for the document scrollable -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, - free_placements: HashMap, - frozen_doc_size: Option<(f32, f32)>, -} - -#[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, - - fallback_text: text_widget::Content, - - /// live keyboard modifier state - pub mods: Modifiers, - - pub(crate) selection: crate::selection::Selection, - /// the single path that keys are routed to - pub(crate) focus: Option, - /// path of the cell currently in text-input edit mode - #[allow(dead_code)] - pub(crate) editing: Option, - /// cmd+a escalation flag for whole-document selection - pub cmd_a_armed: bool, - /// whole-document selection mode flag - pub all_blocks_selected: bool, - /// latest cursor position in viewport coordinates - pub cursor_pos: Point, - /// pending pixel scroll delta forwarded to the document scrollable - pub pending_scroll: f32, - /// active context menu state, if any - pub context_menu: Option, - - pub eval_results: Vec, - pub computed_tables: Vec, - pub computed_trees: Vec, - /// per-cell evaluated formula results, keyed by (block_id, col, row) - pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, - - /// active long-press state for the result-copy gesture - pub inline_press: Option, - - /// gutter line-indicator mode - pub line_indicator: LineIndicator, - /// whether the gutter line numbers cycle through the rainbow palette - pub gutter_rainbow: bool, - - /// pending clipboard text, drained by the shell each frame - pub pending_clipboard: Option, - - pub computed_images: Vec, - pub image_cache: HashMap, - - /// previous global cursor line, used to detect line changes - prev_cursor_line: usize, - - pub menu_open: Option, - pub pending_shell_action: Option, - pub settings_open: bool, - pub settings_view: SettingsView, - - pub free_placements: HashMap, - pub frozen_doc_size: Option<(f32, f32)>, - pub viewport_size: (f32, f32), - pub promote_drag: Option, - pub promote_snapshot_pushed: bool, - pub resize_drag: Option, - pub active_free: Option, - pub layout_mode: LayoutMode, - pub snapping: bool, -} - -#[derive(Debug, Clone)] -pub struct SettingsView { - pub theme_mode: String, - pub line_indicator: String, - pub gutter_rainbow: bool, - pub auto_save_dir: String, -} - -impl Default for SettingsView { - fn default() -> Self { - Self { - theme_mode: "auto".to_string(), - line_indicator: "on".to_string(), - gutter_rainbow: true, - auto_save_dir: String::new(), - } - } -} - -/// per-eval table name to id bookkeeping -pub struct TableIndex { - pub keys: HashMap, - pub canonical: HashMap, -} - -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 } - } - } -} - -/// on-screen context menu state anchored at viewport coordinates -#[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 = "# "; - 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, - menu_open: None, - pending_shell_action: None, - settings_open: false, - settings_view: SettingsView::default(), - free_placements: HashMap::new(), - frozen_doc_size: None, - viewport_size: (0.0, 0.0), - promote_drag: None, - promote_snapshot_pushed: false, - resize_drag: None, - active_free: None, - layout_mode: LayoutMode::Free, - snapping: false, - } - } - - /// rounds a coordinate to the nearest 0.25-line increment. - fn snap_to_grid(&self, value: f32) -> f32 { - if !self.snapping { return value; } - let unit = self.font_size * 0.325; - if unit <= 0.0 { return value; } - (value / unit).round() * unit - } - - /// applies the current cursor delta to any active promote drag. - pub fn tick_promote_drag(&mut self) -> bool { - let (node_id, layer, origin, size, dx, dy, was_escalated) = { - let Some(pd) = self.promote_drag.as_ref() else { return false }; - let dx = self.cursor_pos.x - pd.start_cursor.x; - let dy = self.cursor_pos.y - pd.start_cursor.y; - (pd.node_id.clone(), pd.layer, pd.origin, pd.size, dx, dy, pd.escalated) - }; - let dist_sq = dx * dx + dy * dy; - let threshold_sq = 16.0; - if dist_sq < threshold_sq && !was_escalated { - return false; - } - if !was_escalated && !self.promote_snapshot_pushed { - self.push_undo_snapshot(); - self.redo_stack.clear(); - self.promote_snapshot_pushed = true; - } - if let Some(pd) = self.promote_drag.as_mut() { pd.escalated = true; } - let placement = FreePlacement { - layer, - x: self.snap_to_grid(origin.0 + dx), - y: self.snap_to_grid(origin.1 + dy), - w: size.0, - h: size.1, - }; - self.free_placements.insert(node_id, placement); - if self.frozen_doc_size.is_none() && self.viewport_size != (0.0, 0.0) { - self.frozen_doc_size = Some(self.viewport_size); - } - true - } - - /// applies the current cursor delta to any active resize drag. - pub fn tick_resize_drag(&mut self) -> bool { - let (node_id, dx, dy, start_size, axes, snapshot_pushed) = { - let Some(rd) = self.resize_drag.as_ref() else { return false }; - let dx = self.cursor_pos.x - rd.start_cursor.x; - let dy = self.cursor_pos.y - rd.start_cursor.y; - (rd.node_id.clone(), dx, dy, rd.start_size, rd.axes, rd.snapshot_pushed) - }; - let new_w = if axes.0 { self.snap_to_grid((start_size.0 + dx).max(60.0)) } else { start_size.0 }; - let new_h = if axes.1 { self.snap_to_grid((start_size.1 + dy).max(40.0)) } else { start_size.1 }; - let changed = self - .free_placements - .get(&node_id) - .map(|p| (p.w - new_w).abs() > 0.5 || (p.h - new_h).abs() > 0.5) - .unwrap_or(false); - if !changed { return false; } - if !snapshot_pushed { - self.push_undo_snapshot(); - self.redo_stack.clear(); - if let Some(rd) = self.resize_drag.as_mut() { rd.snapshot_pushed = true; } - } - if let Some(p) = self.free_placements.get_mut(&node_id) { - p.w = new_w; - p.h = new_h; - } - true - } - - /// returns the layout block ids belonging to the module anchored by the given block. - pub fn module_block_ids(&self, anchor: crate::selection::BlockId) -> Vec { - let Some(start) = self.layout.iter().position(|id| *id == anchor) else { return Vec::new() }; - let anchor_block = match self.registry.get(&anchor) { - Some(b) => b, - None => return Vec::new(), - }; - let any = anchor_block.as_any(); - let is_module_anchor = any.is::() || any.is::(); - if !is_module_anchor { - return vec![anchor]; - } - let mut ids = vec![anchor]; - for &next_id in &self.layout[start + 1..] { - let Some(block) = self.registry.get(&next_id) else { break }; - let any = block.as_any(); - if any.is::() || any.is::() { - break; - } - ids.push(next_id); - } - ids - } - - /// returns the layer-0 natural size for a given free-layer node. - pub fn natural_size_for_node(&self, node_id: &FreeNodeId) -> (f32, f32) { - let viewport_w = if self.viewport_size.0 > 0.0 { self.viewport_size.0 } else { 800.0 }; - let default_w = viewport_w.min(700.0).max(300.0); - match node_id { - FreeNodeId::Block(block_id) => { - let module = self.module_block_ids(*block_id); - let mut total_h: f32 = 0.0; - let mut max_w: f32 = default_w; - for id in &module { - let Some(block) = self.registry.get(id) else { continue }; - let any = block.as_any(); - if let Some(tab) = any.downcast_ref::() { - let w: f32 = tab.col_widths.iter().sum::() + 60.0; - let h = (tab.rows.len().max(1) as f32) * (self.font_size * 1.6) + 16.0; - total_h += h; - if w > max_w { max_w = w; } - } else if any.is::() { - total_h += self.font_size * 2.4 + 8.0; - } else if any.is::() { - total_h += 24.0; - } else if let Some(tb) = any.downcast_ref::() { - let lc = tb.content.line_count() as f32; - total_h += lc * self.font_size * 1.3 + 16.0; - } - } - (max_w, total_h.max(80.0)) - } - FreeNodeId::Image(block_id, after_line, _src) => { - let img = self.computed_images.iter().find(|i| - i.anchor.block_id == *block_id && i.anchor.after_line == *after_line - ); - match img { - Some(img) => (img.display_height * 4.0 / 3.0, img.display_height), - None => (default_w, 200.0), - } - } - _ => (default_w, 200.0), - } - } - - /// arms a drag promotion for any free-layer node in live mode. - fn start_promote(&mut self, node_id: FreeNodeId, fallback_table_idx: Option) { - if !matches!(self.render_mode, RenderMode::Live) { return; } - if !matches!(self.layout_mode, LayoutMode::Free) { return; } - let existing = self.free_placements.get(&node_id).copied(); - let (origin, size, layer) = match existing { - Some(p) => ((p.x, p.y), (p.w, p.h), p.layer), - None => { - let s = self.natural_size_for_node(&node_id); - ((self.cursor_pos.x, self.cursor_pos.y), s, 1) - } - }; - self.active_free = Some(node_id.clone()); - self.promote_drag = Some(PromoteDragState { - node_id, - start_cursor: self.cursor_pos, - origin, - size, - layer, - escalated: false, - fallback_table_idx, - }); - self.promote_snapshot_pushed = false; - } - - /// arms a corner-drag promotion for the table at a layout index. - pub fn begin_promote_table_corner(&mut self, block_idx: usize) { - let Some(&block_id) = self.layout.get(block_idx) else { return }; - self.start_promote(FreeNodeId::Block(block_id), Some(block_idx)); - } - - /// arms a drag promotion for a heading or hr block. - pub fn begin_promote_block(&mut self, block_id: crate::selection::BlockId) { - self.start_promote(FreeNodeId::Block(block_id), None); - } - - /// arms a drag promotion for an inline image at a specific anchor. - pub fn begin_promote_image(&mut self, block_id: crate::selection::BlockId, after_line: usize, src: String) { - self.start_promote(FreeNodeId::Image(block_id, after_line, src), None); - } - - /// returns the queued shell action and clears it - pub fn take_pending_shell_action(&mut self) -> Option { - self.pending_shell_action.take() - } - - - 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(); - } - } - } - - - 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)); - } - - /// maps a line number in concatenated module source back to a per-block anchor - 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, - } - } - - /// scans text blocks for image references and populates the image cache - 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)); - } - } - } - - let editor_w = 800.0f32; // approximate; TODO: pass actual width - - for (anchor, src, alt) in new_srcs { - 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 - } - - /// updates the focused block index and mirrors it into the selection state - 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); - } - } - - /// marks a cell as selected without entering edit mode - 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); - } - } - - /// marks a cell as in edit mode and gives it iced focus - 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)); - } - } - - /// escapes the table at `table_idx` upward into the previous text block - 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; - } - } - 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(); - } - - /// escapes the table at `table_idx` downward into the next text block - 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() - } - - /// returns the tab width in spaces - 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::()) - } - - /// returns the layout index of a block id, if any. - fn index_of_block_id(&self, bid: crate::selection::BlockId) -> Option { - self.layout.iter().position(|&id| id == bid) - } - - /// fills the first empty `[]` slot in the editing cell's formula with the clicked cell's address; returns true when the click was consumed. - fn try_fill_formula_slot( - &mut self, - click_block_idx: usize, - click_row: usize, - click_col: usize, - ) -> bool { - let editing = match self.editing.clone() { - Some(e) => e, - None => return false, - }; - let (er, ec) = match editing.inner { - crate::selection::InnerPath::Cell { row, col } => (row, col), - _ => return false, - }; - let editing_idx = match self.index_of_block_id(editing.block_id) { - Some(i) => i, - None => return false, - }; - if click_block_idx == editing_idx && click_row == er && click_col == ec { - return false; - } - let cell_text = match self.table_block_at(editing_idx) { - Some(tb) => tb.rows.get(er).and_then(|row| row.get(ec)).cloned().unwrap_or_default(), - None => return false, - }; - if !cell_text.trim_start().starts_with("/=") { - return false; - } - let addr = format!("{}{}", table_block::column_letter(click_col), click_row + 1); - let new_text = match splice_first_empty_slot(&cell_text, &addr) { - Some(t) => t, - None => return false, - }; - if let Some(tb) = self.table_block_at_mut(editing_idx) { - if let Some(row) = tb.rows.get_mut(er) { - if let Some(cell) = row.get_mut(ec) { - *cell = new_text; - } - } - } - self.eval_dirty = true; - self.last_edit = Instant::now(); - true - } - - 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 - } - - /// moves the focused content's cursor to `target`, clamping line and column - 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); - } - - /// handles arrow, backspace, and delete at block boundaries - 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() { - 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 - } - - /// loads a document from raw file bytes - pub fn load_doc(&mut self, text: &str) { - 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); - } - if self.render_mode == RenderMode::Live || self.render_mode == RenderMode::View { - self.run_eval_all(); - } - } - - /// returns the clean markdown body; the archive lives in a separate channel. - pub fn save_doc(&mut self) -> String { - self.get_clean_text() - } - - /// walks the live blocks in document order. - pub fn iter_blocks(&self) -> impl Iterator { - self.layout.iter().filter_map(move |id| self.registry.get(id)) - } - - /// returns the archive zip bytes the shell should embed for in-library .md files. - pub fn save_sidecar_bytes(&mut self) -> Option> { - self.rebuild_modules(); - let sidecar = self.build_sidecar(); - let block_files = self.build_block_files(); - sidecar::build_archive_bytes(&sidecar, &block_files) - } - - /// applies an archive zip's metadata back into the document. - pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) { - if let Some(sc) = sidecar::extract_archive_bytes(bytes) { - self.apply_sidecar(&sc); - } - } - - /// builds the per-block `.cord` source files for the sidecar archive - 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 - } - - /// builds a `Sidecar` snapshot from the current block tree - 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 - } - - 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) { - 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); - } - - /// replaces all text without pushing an undo snapshot - fn replace_text_no_undo(&mut self, text: &str) { - 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 synchronization with iced - 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; - } - } - } - - /// returns true when a non-eval table has a selected cell - pub(crate) fn active_table_index(&self) -> Option { - self.focused_table_index() - } - - /// returns true when the focused block is a non-eval table - 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 - } - - /// returns true when the focused block is a table in whole-table-select mode - 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 focused cell's table - 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 focused block index when it's a table - 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 - } - } - - /// returns true when the focused block is a table with a focused cell - 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() - } - - /// returns true when Cmd+C should copy the table selection instead of cell text - 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() - } - - /// builds the clipboard payload from the focused table - 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(); - } - { - 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(); - } - } - } - 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); - } - } - 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(); - } - } - } - } - - /// returns true while an eval debounce is pending - 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(); - } - - 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() - } - - /// rebuilds the module list and applies heading-based table names - 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); - } - } - } - } - - /// registers every non-eval-result table on the interpreter and returns the alias index - 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(); - - 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()); - - 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 { - 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 } - } - - /// returns true if any visible table contains a `/=` formula cell - 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 - } - - /// parses, topo-sorts, and evaluates every visible cell formula - 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 }; - 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)), - } - } - } - } - - 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; - } - - 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); - - if !result.is_error() { - let display = result.display(); - 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())); - } - } - - /// applies cell writes logged by the interpreter to live tables - 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; - } - } - - /// returns true when an edit changed the block structure - 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(); - } - - /// wraps a selection in matching delimiters or unwraps an existing pair - 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 => { - 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(); - 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 { - 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; - } - } - - let wrapped = format!("{open}{selected}{close}"); - self.content_mut().perform(text_widget::Action::Edit( - text_widget::Edit::Paste(Arc::new(wrapped)), - )); - self.reparse(); - } - - /// replaces a byte range in the current content with `replacement` - 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..]); - 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())), - )); - 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(); - } - - /// returns the byte range of the current selection, or None - fn selection_byte_range(&self, text: &str, _cursor_pos: usize) -> Option<(usize, usize)> { - let sel = self.content().selection()?; - let cursor = self.content().cursor(); - let cursor_byte = byte_offset_for_cursor(text, &cursor.position); - let len = sel.len(); - if cursor_byte >= len && &text[cursor_byte - len..cursor_byte] == sel.as_str() { - return Some((cursor_byte - len, cursor_byte)); - } - if cursor_byte + len <= text.len() && &text[cursor_byte..cursor_byte + len] == sel.as_str() { - return Some((cursor_byte, cursor_byte + len)); - } - text.find(sel.as_str()).map(|s| (s, s + len)) - } - - /// inserts paired delimiters and places the caret between them - 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)); - } - } - - /// toggles the `> ` blockquote prefix on the current line - 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)); - 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(); - } - - /// incremental scope-exit and newline-sandwich placement - 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(); - } - - /// places the cursor on its own line with one blank line of padding above and below - 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); - 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; } - } - 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() - } - - /// switches to editor mode by collapsing all blocks into one text buffer - 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(); - 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)); - } - } - - /// switches back to live mode and reparses the buffer into blocks - 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(); - } - - /// switches to view mode - pub fn enter_view_mode(&mut self) { - if self.render_mode == RenderMode::View { return; } - 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; - } - - /// returns the concatenated text of all text blocks in 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") - } - - /// builds an interpreter pre-populated with root and `use`'d module exports - 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, - }; - - let my_module = self.modules.iter().find(|m| m.block_ids.contains(&block_id)); - - 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 mut visited: std::collections::HashSet = std::collections::HashSet::new(); - let root_exports = self.resolve_module_exports(root, &mut visited); - eval_interp.import_all(&root_exports); - } - } - - let use_block_ids: Vec = my_module - .map(|m| m.block_ids.clone()) - .unwrap_or_default(); - let my_module_name = my_module.map(|m| m.name.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 mut visited: std::collections::HashSet = std::collections::HashSet::new(); - if !my_module_name.is_empty() { - visited.insert(my_module_name.clone()); - } - let dep_exports = self.resolve_module_exports(dep_module, &mut visited); - 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 - } - - /// recursively evaluates a module with its `use` declarations resolved - fn resolve_module_exports( - &self, - module: &crate::module::Module, - visited: &mut std::collections::HashSet, - ) -> acord_core::interp::ModuleExports { - use acord_core::interp; - - if !module.name.is_empty() && !visited.insert(module.name.clone()) { - return interp::ModuleExports::default(); - } - - let mut interp = interp::Interpreter::new(); - - if !module.is_root { - if let Some(root) = self.modules.iter().find(|m| m.is_root) { - if root.name != module.name { - let root_exports = self.resolve_module_exports(root, visited); - interp.import_all(&root_exports); - } - } - } - - let module_text = self.module_source_text(module); - let use_decls = interp::extract_use_declarations(&module_text); - for decl in &use_decls { - if let Some(dep) = self.modules.iter().find(|m| m.name == decl.module) { - let dep_exports = self.resolve_module_exports(dep, visited); - match &decl.item { - None => interp.import_all(&dep_exports), - Some(s) if s == "*" => interp.import_all(&dep_exports), - Some(item) => { interp.import_item(&dep_exports, item); } - } - } - } - - crate::eval::evaluate_document_with_interp(&mut interp, &module_text); - interp.exports() - } - - fn run_eval(&mut self) { - self.rebuild_modules(); - - 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, - }; - - 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"); - - 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); - - self.evaluate_cell_formulas(&mut interp, &table_keys); - - let doc = crate::eval::evaluate_document_with_interp(&mut interp, &source); - - let writes = interp.drain_table_writes(); - self.apply_table_writes(writes, &table_keys); - - self.clear_layers_for_blocks(&block_ids); - - 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; - } - _ => {} - } - 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, - }); - } - - } - - /// evaluates every module in document order - fn run_eval_all(&mut self) { - self.rebuild_modules(); - self.eval_results.clear(); - self.computed_tables.clear(); - self.computed_trees.clear(); - self.computed_cells.clear(); - - 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() - } - - /// drains the accumulated wheel-scroll delta - 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, - free_placements: self.free_placements.clone(), - frozen_doc_size: self.frozen_doc_size, - } - } - - 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) { - 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, - }); - self.free_placements = snap.free_placements.clone(); - self.frozen_doc_size = snap.frozen_doc_size; - } - - 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, - }); - } - - /// returns true when `message` is safe to dispatch in view mode - 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::ToggleMenu(_) | Message::CloseMenu | Message::Shell(_) => 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) { - #[cfg(debug_assertions)] - println!("Received message: {:?}", message); - if self.render_mode == RenderMode::View && !Self::message_is_view_safe(&message) { - return; - } - - 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; - } - - let preserve_context_menu = matches!( - &message, - Message::ShowContextMenu { .. } // Opening the menu - | Message::FocusedTableOp(..) // Menu item actions that operate on the focused table - | Message::TableMsg(_, TableMessage::CursorMove(_,_)) // Any table operation (including hover, cursor move) - | Message::TableMsg(_, TableMessage::CellEnter(_,_)) // Any table operation (including hover, cursor move) - | Message::HideContextMenu // Don't close before processing the close - ); - if !preserve_context_menu && self.context_menu.is_some() { - self.context_menu = None; - } - - let preserve_menu_strip = matches!( - &message, - Message::ToggleMenu(_), - ); - if !preserve_menu_strip && self.menu_open.is_some() { - self.menu_open = 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(); - 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)); - self.pending_scroll += *lines as f32 * lh; - } - - 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); - } - } - - 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); - 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(); - self.set_editing_cell(insert_at, 1, 0); - self.reparse(); - } - 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]; - 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(); - - 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) => { - match &tmsg { - TableMessage::PromoteCornerPress => { - self.begin_promote_table_corner(idx); - return; - } - TableMessage::PromoteCornerRelease => { - if let Some(pd) = self.promote_drag.take() { - if !pd.escalated { - if let Some(fb_idx) = pd.fallback_table_idx { - self.update(Message::TableMsg(fb_idx, TableMessage::SelectAll)); - } - } - } - return; - } - _ => {} - } - let structural = matches!( - &tmsg, - TableMessage::InsertRowAbove - | TableMessage::InsertRowBelow - | TableMessage::DeleteRow - | TableMessage::InsertColLeft - | TableMessage::InsertColRight - | TableMessage::DeleteCol - | TableMessage::AddRow - | TableMessage::AddColumn - ); - 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; - } - } - } - 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; - } - 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; - } - 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(); - } - - let select_target = if let TableMessage::SelectCell(r, c) = &tmsg { - Some((*r, *c)) - } else { - None - }; - - // formula-fill interception: clicking a different cell while - // editing a `/=` formula fills its first empty `[]` slot with - // the clicked cell's address instead of switching focus. - if let Some((r, c)) = select_target { - if self.try_fill_formula_slot(idx, r, c) { - return; - } - } - let edit_target = if let TableMessage::EditCell(r, c) = &tmsg { - Some((*r, *c)) - } else { - None - }; - - let mods = self.mods; - - if let Some(tb) = self.table_block_at_mut(idx) { - tb.handle(tmsg); - } - - if let Some((r, c)) = select_target { - 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 { - 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)); - } - 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 => { - if let Some(path) = self.editing.clone() { - self.editing = None; - if let crate::selection::InnerPath::Cell { row, col } = path.inner { - 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 }; - 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 => { - 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(); - 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 => { - 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 } => { - self.context_menu = Some(ContextMenuState { - block_idx, - x: self.cursor_pos.x, - y: self.cursor_pos.y, - }); - } - Message::HideContextMenu => { - self.context_menu = None; - } - Message::ToggleMenu(cat) => { - self.menu_open = if self.menu_open == Some(cat) { None } else { Some(cat) }; - } - Message::CloseMenu => { - self.menu_open = None; - } - Message::Shell(action) => { - self.menu_open = None; - match action { - ShellAction::Settings => { - self.settings_open = !self.settings_open; - } - other => { - self.pending_shell_action = Some(other); - } - } - } - 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 => { - 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); - } - Message::BlockPromotePress(block_id) => { - self.begin_promote_block(block_id); - } - Message::ImagePromotePress { block_id, after_line, src } => { - self.begin_promote_image(block_id, after_line, src); - } - Message::PromoteRelease => { - self.promote_drag = None; - } - Message::ResizePress { node_id, horiz, vert } => { - if !matches!(self.render_mode, RenderMode::Live) { return; } - if self.active_free.as_ref() != Some(&node_id) { return; } - let Some(p) = self.free_placements.get(&node_id).copied() else { return }; - self.resize_drag = Some(ResizeDragState { - node_id, - start_cursor: self.cursor_pos, - start_size: (p.w, p.h), - axes: (horiz, vert), - snapshot_pushed: false, - }); - } - Message::ResizeRelease => { - self.resize_drag = None; - } - Message::SetLayoutMode(mode) => { - self.layout_mode = mode; - self.snapping = !matches!(mode, LayoutMode::Free); - } - Message::ToggleSnapping => { - self.snapping = !self.snapping; - } - } - } - - /// returns the inline result text for a given anchor - 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()) - } - - /// reads line `line_idx` from the text block with the given id - 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()) - } - - /// copies `{source} → {value}` to the clipboard - 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}")); - } - - /// copies the result and drops a `let _ = value` line below the source - 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, - }; - if self.text_block_at(block_idx).is_none() { return; } - - self.push_undo_snapshot(); - self.redo_stack.clear(); - self.set_focused_block(block_idx); - - 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)); - - let paste = format!("\n\nlet = {value}"); - content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste)))); - - 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 { - let editor = self.view_blocks(); - match self.build_free_overlay() { - Some(overlay) => iced_widget::stack![editor, overlay].into(), - None => editor, - } - }; - - 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(); - - #[cfg(any(target_os = "linux", target_os = "windows"))] - col_items.push(self.menu_strip()); - - col_items.push(main_content); - - if self.find.visible { - col_items.push(self.find_bar()); - } - - col_items.push(status_bar.into()); - - let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column(col_items) - .width(Length::Fill) - .height(Length::Fill) - .into(); - - if self.settings_open { - return iced_widget::stack![body, self.settings_panel()].into(); - } - - #[cfg(any(target_os = "linux", target_os = "windows"))] - if let Some(cat) = self.menu_open { - return iced_widget::stack![body, self.menu_dropdown(cat)].into(); - } - - body - } - - 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; - - #[cfg(any(target_os = "linux", target_os = "windows"))] - let title_bar_h = 0.0_f32; - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - 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 hidden_blocks: HashSet = self - .free_placements - .keys() - .filter_map(|id| match id { - FreeNodeId::Block(bid) => Some(*bid), - _ => None, - }) - .flat_map(|bid| self.module_block_ids(bid)) - .collect(); - - let mut global_line = 0usize; - for (bi, &block_id) in self.layout.iter().enumerate() { - if hidden_blocks.contains(&block_id) { - continue; - } - 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 line_count = tb.content.line_count(); - let this_global_line = global_line; - global_line += line_count; - let _ = line_h; // text_widget::layout owns the height now - let _ = lang_for_block; // build_text_block_widget reads lang_str directly - block_elements.push(self.build_text_block_widget(tb, block_idx, this_global_line, top_pad)); - } - continue; - } - - if let Some(tab) = any.downcast_ref::() { - let block_idx = bi; - 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; - } - - 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(self.wrap_block_with_promote(layered.base, hb.id)); - global_line += 1; - continue; - } - - if let Some(hr) = any.downcast_ref::() { - let layered = >::view(hr, &ctx); - block_elements.push(self.wrap_block_with_promote(layered.base, hr.id)); - 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() - }; - - 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 - }; - - 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 - }; - - if let Some(popup) = self.spillover_view() { - iced_widget::stack![with_ctx, popup].into() - } else { - with_ctx - } - } - - /// renders the spillover popup of the first table that has one open - 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, - }); - - 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() - ) - } - - /// returns (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() - } - - - - /// returns layer items for a block sorted by anchor line - 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 { - let id = FreeNodeId::Table(ct.anchor.block_id, ct.anchor.after_line); - if self.free_placements.contains_key(&id) { continue; } - items.push((ct.anchor.after_line, LayerItem::Table(ct))); - } - } - for ct in &self.computed_trees { - if ct.anchor.block_id == block_id { - let id = FreeNodeId::Tree(ct.anchor.block_id, ct.anchor.after_line); - if self.free_placements.contains_key(&id) { continue; } - items.push((ct.anchor.after_line, LayerItem::Tree(ct))); - } - } - for img in &self.computed_images { - if img.anchor.block_id == block_id { - let id = FreeNodeId::Image(img.anchor.block_id, img.anchor.after_line, img.src.clone()); - if self.free_placements.contains_key(&id) { continue; } - items.push((img.anchor.after_line, LayerItem::Image(img))); - } - } - items.sort_by_key(|(line, _)| *line); - items - } - - /// builds anchored child elements for the text widget compositor - 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 inner = if r.is_error { - iced_widget::container( - iced_widget::text(&r.text) - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(oklab::lighten_for_size(p.red, self.font_size)) - ) - .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) - .width(Length::Fill) - } else { - let value = r.text - .strip_prefix(RESULT_PREFIX) - .unwrap_or(&r.text) - .to_string(); - let arrow_color = oklab::lighten_for_size(palette::eval_arrow_color(), self.font_size); - let value_color = oklab::lighten_for_size(palette::eval_value_color(), self.font_size); - let bold = Font { - weight: iced_wgpu::core::font::Weight::Bold, - ..syntax::EDITOR_FONT - }; - let row = iced_widget::row![ - iced_widget::text("→ ") - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(arrow_color), - iced_widget::text(value) - .font(bold) - .size(self.font_size) - .color(value_color), - iced_widget::text(" ←") - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(arrow_color), - ] - .spacing(0.0); - iced_widget::container(row) - .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) - .width(Length::Fill) - }; - 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 inner = self.build_computed_table_widget(ct); - let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = - iced_widget::container(inner) - .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 { - 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() - }; - let wrapped = self.wrap_image_with_promote( - el, - img.anchor.block_id, - img.anchor.after_line, - img.src.clone(), - ); - anchored.push(AnchoredItem { - after_line: *after_line, - height: item.element_height(lh, self.font_size), - element: wrapped, - }); - } - } - } - - anchored - } - - /// builds the text-editor widget for a text block at a layout index. - fn build_text_block_widget<'a>( - &'a self, - tb: &'a TextBlock, - block_idx: usize, - this_global_line: usize, - top_pad: f32, - ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - let is_focused = block_idx == self.focused_block; - let anchored_items = self.build_anchored_items(tb.id); - let cursor_line = tb.content.cursor().position.line; - let text = tb.content.text(); - let decors = compute_line_decors(&text); - let lang_for_block = self.lang_str(); - - 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, - source: tb.content.text(), - }; - editor - .highlight_with::( - settings, - |highlight, _theme| Format { - color: Some(syntax::highlight_color(highlight.kind)), - font: syntax::highlight_font(highlight.kind), - }, - ) - .into() - } - - /// builds a column of cell rows from a computed table. - fn build_computed_table_widget<'a>( - &self, - ct: &'a ComputedTable, - ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - let p = palette::current(); - 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()); - } - iced_widget::column(table_rows).into() - } - - /// wraps a block element with the promote press/release mouse area in live mode. - fn wrap_block_with_promote<'a>( - &self, - elem: Element<'a, Message, Theme, iced_wgpu::Renderer>, - block_id: crate::selection::BlockId, - ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - if !matches!(self.render_mode, RenderMode::Live) { - return elem; - } - MouseArea::new(elem) - .on_press(Message::BlockPromotePress(block_id)) - .on_release(Message::PromoteRelease) - .into() - } - - /// wraps an inline image element with the promote press/release mouse area in live mode. - fn wrap_image_with_promote<'a>( - &self, - elem: Element<'a, Message, Theme, iced_wgpu::Renderer>, - block_id: crate::selection::BlockId, - after_line: usize, - src: String, - ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - if !matches!(self.render_mode, RenderMode::Live) { - return elem; - } - MouseArea::new(elem) - .on_press(Message::ImagePromotePress { block_id, after_line, src }) - .on_release(Message::PromoteRelease) - .into() - } - - /// builds the overlay widget for a single block at the given layout index. - fn build_free_block_widget( - &self, - block_id: crate::selection::BlockId, - ) -> Option> { - let bi = self.layout.iter().position(|id| *id == block_id)?; - let block = self.registry.get(&block_id)?; - let any = block.as_any(); - if let Some(tb) = any.downcast_ref::() { - return Some(self.build_text_block_widget(tb, bi, 0, 0.0)); - } - if let Some(tab) = any.downcast_ref::() { - 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, - }; - let block_idx = bi; - return Some(table_block::table_view( - tab, - editing_cell, - self.font_size, - &self.computed_cells, - move |tmsg| Message::TableMsg(block_idx, tmsg), - )); - } - 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::() { - return Some(>::view(hb, &ctx).base); - } - if let Some(hr) = any.downcast_ref::() { - return Some(>::view(hr, &ctx).base); - } - if let Some(tree) = any.downcast_ref::() { - return Some(>::view(tree, &ctx).base); - } - None - } - - /// builds a column of overlay widgets for every block in a module. - fn build_free_module_widget( - &self, - anchor: crate::selection::BlockId, - ) -> Option> { - let ids = self.module_block_ids(anchor); - if ids.is_empty() { return None; } - if ids.len() == 1 { - return self.build_free_block_widget(ids[0]); - } - let parts: Vec> = ids - .iter() - .filter_map(|id| self.build_free_block_widget(*id)) - .collect(); - if parts.is_empty() { return None; } - Some(iced_widget::column(parts).into()) - } - - /// builds a single resize band aligned to one or two edges of a placement. - fn build_resize_band( - &self, - node_id: FreeNodeId, - horiz: bool, - vert: bool, - align_x: alignment::Horizontal, - align_y: alignment::Vertical, - zone_w: Length, - zone_h: Length, - ) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let zone = iced_widget::container( - iced_widget::Space::new().width(Length::Fill).height(Length::Fill) - ) - .width(zone_w) - .height(zone_h); - let area = MouseArea::new(zone) - .on_press(Message::ResizePress { node_id, horiz, vert }) - .on_release(Message::ResizeRelease); - iced_widget::container(area) - .width(Length::Fill) - .height(Length::Fill) - .align_x(align_x) - .align_y(align_y) - .into() - } - - /// stacks free-placed objects at absolute positions over the editor body. - fn build_free_overlay(&self) -> Option> { - if self.free_placements.is_empty() { - return None; - } - let mut placed: Vec<(&FreeNodeId, &FreePlacement)> = self.free_placements.iter().collect(); - placed.sort_by_key(|(_, pl)| pl.layer); - - let mut layers: Vec> = Vec::new(); - for &(id, placement) in &placed { - let inner_opt: Option> = match id { - FreeNodeId::Table(block_id, after_line) => self - .computed_tables - .iter() - .find(|ct| ct.anchor.block_id == *block_id && ct.anchor.after_line == *after_line) - .map(|ct| self.build_computed_table_widget(ct)), - FreeNodeId::Block(block_id) => self - .build_free_module_widget(*block_id) - .map(|el| self.wrap_block_with_promote(el, *block_id)), - FreeNodeId::Image(block_id, after_line, src) => self - .computed_images - .iter() - .find(|img| img.anchor.block_id == *block_id - && img.anchor.after_line == *after_line - && img.src == *src) - .map(|img| { - let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = - if let Some(entry) = self.image_cache.get(&img.src) { - iced_widget::image(entry.handle.clone()) - .width(Length::Fill) - .height(Length::Fill) - .into() - } else { - let p = palette::current(); - iced_widget::text(format!("[image: {}]", img.alt)) - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(p.overlay0) - .into() - }; - self.wrap_image_with_promote( - inner, - img.anchor.block_id, - img.anchor.after_line, - img.src.clone(), - ) - }), - FreeNodeId::Tree(_, _) => None, - }; - let Some(inner) = inner_opt else { continue }; - - let is_active = self.active_free.as_ref() == Some(id); - let band = (self.font_size * 1.3) * 0.5; - let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if is_active { - let right = self.build_resize_band( - id.clone(), - true, false, - alignment::Horizontal::Right, alignment::Vertical::Top, - Length::Fixed(band), Length::Fixed((placement.h - band).max(0.0)), - ); - let bottom = self.build_resize_band( - id.clone(), - false, true, - alignment::Horizontal::Left, alignment::Vertical::Bottom, - Length::Fixed((placement.w - band).max(0.0)), Length::Fixed(band), - ); - let corner = self.build_resize_band( - id.clone(), - true, true, - alignment::Horizontal::Right, alignment::Vertical::Bottom, - Length::Fixed(band), Length::Fixed(band), - ); - iced_widget::stack![iced_widget::scrollable(inner), right, bottom, corner].into() - } else { - iced_widget::scrollable(inner).into() - }; - let sized = iced_widget::container(body) - .width(Length::Fixed(placement.w)) - .height(Length::Fixed(placement.h)) - .style(move |_theme: &Theme| { - let p = palette::current(); - let (border_color, border_w) = if is_active { - (p.blue, 2.0) - } else { - (p.surface1, 1.0) - }; - container::Style { - background: Some(Background::Color(p.base)), - border: Border { color: border_color, width: border_w, radius: 4.0.into() }, - text_color: None, - shadow: Shadow::default(), - snap: false, - } - }); - - let positioned = iced_widget::container(sized) - .padding(Padding { top: placement.y, right: 0.0, bottom: 0.0, left: placement.x }) - .width(Length::Fill) - .height(Length::Fill); - layers.push(positioned.into()); - } - - if layers.is_empty() { - None - } else { - Some(iced_widget::stack(layers).into()) - } - } - - /// builds the context menu overlay for a right-clicked cell - 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, - }); - - 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() - } - - #[cfg(any(target_os = "linux", target_os = "windows"))] - fn menu_strip(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let p = palette::current(); - let f = self.font_size; - let char_w = f * 0.6; - let cat_pad_x = f * 0.85; - let strip_pad_y = f * 0.18; - let strip_label_size = f * 0.92; - - let mut row: Vec> = Vec::new(); - for (cat, label) in MENU_CATS { - let active = self.menu_open == Some(cat); - row.push( - iced_widget::button( - iced_widget::text(label.to_string()) - .size(strip_label_size) - .font(syntax::EDITOR_FONT) - ) - .width(Length::Fixed(cat_btn_width(label, char_w, cat_pad_x))) - .padding(Padding { top: strip_pad_y, right: cat_pad_x, bottom: strip_pad_y, left: cat_pad_x }) - .style(move |_t: &Theme, _s| iced_widget::button::Style { - background: if active { Some(Background::Color(p.surface1)) } else { None }, - text_color: p.text, - border: Border::default(), - shadow: Shadow::default(), - snap: false, - }) - .on_press(Message::ToggleMenu(cat)) - .into() - ); - } - - iced_widget::container(iced_widget::row(row).spacing(0.0)) - .width(Length::Fill) - .style(move |_t: &Theme| iced_widget::container::Style { - background: Some(Background::Color(p.mantle)), - border: Border::default(), - text_color: Some(p.text), - shadow: Shadow::default(), - snap: false, - }) - .into() - } - - /// returns the dropdown panel for the open category, anchored under its strip button - #[cfg(any(target_os = "linux", target_os = "windows"))] - fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let p = palette::current(); - let f = self.font_size; - let char_w = f * 0.6; - let cat_pad_x = f * 0.85; - let strip_pad_y = f * 0.18; - let strip_label_size = f * 0.92; - let item_pad_x = f * 0.95; - let item_pad_y = f * 0.32; - let dropdown_radius = f * 0.30; - let separator_h = (f * 0.08).max(1.0); - let label_size = f * 0.85; - let hint_size = f * 0.78; - - let strip_h = strip_label_size * 1.3 + strip_pad_y * 2.0; - - let item = |label: &str, shortcut: &str, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let label_w = iced_widget::text(label.to_string()) - .size(label_size) - .font(syntax::EDITOR_FONT) - .width(Length::Fill); - let hint_w = iced_widget::text(shortcut.to_string()) - .size(hint_size) - .font(syntax::EDITOR_FONT) - .color(p.overlay0); - iced_widget::button( - iced_widget::row![label_w, hint_w].spacing(f) - ) - .width(Length::Fill) - .padding(Padding { top: item_pad_y, right: item_pad_x, bottom: item_pad_y, left: item_pad_x }) - .style(context_menu_item_style) - .on_press(msg) - .into() - }; - - let sep = || -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - iced_widget::container(iced_widget::text("")) - .width(Length::Fill) - .height(Length::Fixed(separator_h)) - .style(move |_t: &Theme| iced_widget::container::Style { - background: Some(Background::Color(p.surface1)), - border: Border::default(), - text_color: None, - shadow: Shadow::default(), - snap: false, - }) - .into() - }; - - let items: Vec> = match cat { - MenuCategory::File => vec![ - item("New Note", "Ctrl+N", Message::Shell(ShellAction::NewNote)), - item("Open...", "Ctrl+O", Message::Shell(ShellAction::Open)), - item("Documents...", "Alt+B", Message::Shell(ShellAction::ToggleBrowser)), - sep(), - item("Save", "Ctrl+S", Message::Shell(ShellAction::Save)), - item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)), - sep(), - item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)), - item("Print...", "Ctrl+P", Message::Shell(ShellAction::Print)), - sep(), - item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)), - item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)), - ], - MenuCategory::Edit => vec![ - item("Undo", "Ctrl+Z", Message::Undo), - item("Redo", "Ctrl+Shift+Z", Message::Redo), - sep(), - item("Bold", "Ctrl+B", Message::ToggleBold), - item("Italic", "Ctrl+I", Message::ToggleItalic), - item("Insert Table", "Ctrl+T", Message::InsertTable), - sep(), - item("Find...", "Ctrl+F", Message::ToggleFind), - ], - MenuCategory::Render => vec![ - item("Live", "", Message::SetRenderMode(RenderMode::Live)), - item("Editor", "", Message::SetRenderMode(RenderMode::Editor)), - item("View", "", Message::SetRenderMode(RenderMode::View)), - sep(), - item("Evaluate", "Ctrl+E", Message::SmartEval), - ], - MenuCategory::Mode => vec![ - item("Free", if matches!(self.layout_mode, LayoutMode::Free) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Free)), - item("Relative", if matches!(self.layout_mode, LayoutMode::Relative) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Relative)), - item("Anchored", if matches!(self.layout_mode, LayoutMode::Anchored) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Anchored)), - sep(), - item("Snapping", if self.snapping { "✓" } else { "" }, Message::ToggleSnapping), - ], - MenuCategory::View => vec![ - item("Zoom In", "Ctrl+=", Message::ZoomIn), - item("Zoom Out", "Ctrl+-", Message::ZoomOut), - item("Reset Zoom", "Ctrl+Shift+0", Message::ZoomReset), - ], - }; - - let mut x_offset = 0.0_f32; - for (c, label) in MENU_CATS { - if c == cat { break; } - x_offset += cat_btn_width(label, char_w, cat_pad_x); - } - - let dropdown_width = { - let max_label_chars = match cat { - MenuCategory::File => "Export as Rust Library".len(), - MenuCategory::Edit => "Insert Table".len(), - MenuCategory::Render => "Evaluate".len(), - MenuCategory::Mode => "Anchored".len(), - MenuCategory::View => "Reset Zoom".len(), - }; - let max_hint_chars = 13_usize; // widest hint string in chars - (max_label_chars + max_hint_chars) as f32 * char_w + item_pad_x * 2.0 + f - }; - - let panel = iced_widget::container( - iced_widget::column(items).spacing(0.0).width(Length::Fixed(dropdown_width)) - ) - .style(move |_t: &Theme| iced_widget::container::Style { - background: Some(Background::Color(p.surface0)), - border: Border { - color: p.surface1, - width: 1.0, - radius: dropdown_radius.into(), - }, - text_color: Some(p.text), - shadow: Shadow::default(), - snap: false, - }); - - iced_widget::column![ - iced_widget::Space::new().width(Length::Shrink).height(Length::Fixed(strip_h)), - iced_widget::row![ - iced_widget::Space::new().width(Length::Fixed(x_offset)).height(Length::Shrink), - panel, - ], - ] - .into() - } - - fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { - let p = palette::current(); - let f = self.font_size; - let item_pad_x = f * 0.95; - let item_pad_y = f * 0.32; - let panel_radius = f * 0.30; - let label_size = f * 0.92; - let title_size = f * 1.05; - let row_gap = f * 0.55; - let panel_width = f * 28.0; - - let title = iced_widget::text("Settings") - .size(title_size) - .font(syntax::EDITOR_FONT) - .color(p.text); - - let theme_row = self.settings_segment_row( - "Theme", - label_size, - &[ - ("Auto", "auto"), - ("Light", "light"), - ("Dark", "dark"), - ], - &self.settings_view.theme_mode, - |v| Message::Shell(ShellAction::SetThemeMode(v.to_string())), - ); - - let line_row = self.settings_segment_row( - "Line indicator", - label_size, - &[ - ("On", "on"), - ("Off", "off"), - ("Vim", "vim"), - ], - &self.settings_view.line_indicator, - |v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())), - ); - - let rainbow_row = self.settings_segment_row( - "Gutter rainbow", - label_size, - &[ - ("Off", "false"), - ("On", "true"), - ], - if self.settings_view.gutter_rainbow { "true" } else { "false" }, - |v| Message::Shell(ShellAction::SetGutterRainbow(v == "true")), - ); - - let dir_label = iced_widget::text("Auto-save folder") - .size(label_size) - .font(syntax::EDITOR_FONT) - .color(p.text) - .width(Length::Fill); - let dir_value = iced_widget::text(self.settings_view.auto_save_dir.clone()) - .size(label_size) - .font(syntax::EDITOR_FONT) - .color(p.subtext0) - .width(Length::Fill); - let dir_btn = iced_widget::button( - iced_widget::text("Choose…") - .size(label_size) - .font(syntax::EDITOR_FONT) - ) - .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x * 0.7, bottom: item_pad_y * 0.6, left: item_pad_x * 0.7 }) - .on_press(Message::Shell(ShellAction::PickAutoSaveDir)) - .style(context_menu_item_style); - let dir_row: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column![ - dir_label, - iced_widget::row![dir_value, dir_btn].spacing(f * 0.5), - ] - .spacing(f * 0.2) - .into(); - - let close_btn = iced_widget::button( - iced_widget::text("Close") - .size(label_size) - .font(syntax::EDITOR_FONT) - ) - .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x, bottom: item_pad_y * 0.6, left: item_pad_x }) - .on_press(Message::Shell(ShellAction::Settings)) - .style(context_menu_item_style); - - let panel = iced_widget::container( - iced_widget::column![ - title, - theme_row, - line_row, - rainbow_row, - dir_row, - iced_widget::row![ - iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), - close_btn, - ], - ] - .spacing(row_gap) - .width(Length::Fixed(panel_width)) - ) - .padding(Padding { top: f, right: f, bottom: f, left: f }) - .style(move |_t: &Theme| iced_widget::container::Style { - background: Some(Background::Color(p.surface0)), - border: Border { - color: p.surface1, - width: 1.0, - radius: panel_radius.into(), - }, - text_color: Some(p.text), - shadow: Shadow::default(), - snap: false, - }); - - iced_widget::container(panel) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(move |_t: &Theme| iced_widget::container::Style { - background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 })), - border: Border::default(), - text_color: None, - shadow: Shadow::default(), - snap: false, - }) - .into() - } - - fn settings_segment_row<'a>( - &'a self, - label: &str, - label_size: f32, - options: &[(&str, &'a str)], - current: &str, - msg_for: impl Fn(&'a str) -> Message, - ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { - let p = palette::current(); - let f = self.font_size; - let mut buttons: Vec> = Vec::new(); - for (display, value) in options { - let active = *value == current; - let display = display.to_string(); - let value = *value; - buttons.push( - iced_widget::button( - iced_widget::text(display) - .size(label_size) - .font(syntax::EDITOR_FONT) - ) - .padding(Padding { top: f * 0.18, right: f * 0.55, bottom: f * 0.18, left: f * 0.55 }) - .style(move |_t: &Theme, _s| iced_widget::button::Style { - background: if active { Some(Background::Color(p.surface2)) } else { Some(Background::Color(p.surface1)) }, - text_color: if active { p.text } else { p.subtext0 }, - border: Border { color: p.surface2, width: 1.0, radius: (f * 0.18).into() }, - shadow: Shadow::default(), - snap: false, - }) - .on_press(msg_for(value)) - .into() - ); - } - let label_w = iced_widget::text(label.to_string()) - .size(label_size) - .font(syntax::EDITOR_FONT) - .color(p.text) - .width(Length::Fill); - iced_widget::row![ - label_w, - iced_widget::row(buttons).spacing(f * 0.25), - ] - .spacing(f) - .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, - } -} - - -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()) -} - -/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[]`. -fn splice_first_empty_slot(text: &str, addr: &str) -> Option { - let bytes = text.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'[' { - let mut j = i + 1; - while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } - if j < bytes.len() && bytes[j] == b']' { - let mut out = String::with_capacity(text.len() + addr.len()); - out.push_str(&text[..i + 1]); - out.push_str(addr); - out.push_str(&text[j..]); - return Some(out); - } - } - i += 1; - } - None -} - -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)) - } - keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACKET) => { - Some(Binding::Custom(Message::AutoPair("[", "]"))) - } - keyboard::Key::Character("{") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACE) => { - Some(Binding::Custom(Message::AutoPair("{", "}"))) - } - keyboard::Key::Character("(") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::PAREN) => { - Some(Binding::Custom(Message::AutoPair("(", ")"))) - } - keyboard::Key::Character("'") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::SINGLE) => { - Some(Binding::Custom(Message::AutoPair("'", "'"))) - } - keyboard::Key::Character("\"") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::DOUBLE) => { - Some(Binding::Custom(Message::AutoPair("\"", "\""))) - } - keyboard::Key::Character("`") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BACKTICK) => { - 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] -} - -/// counts consecutive trailing occurrences of `c` in `s` -fn count_trailing_char(s: &str, c: char) -> usize { - s.chars().rev().take_while(|&x| x == c).count() -} - -/// counts consecutive leading occurrences of `c` in `s` -fn count_leading_char(s: &str, c: char) -> usize { - s.chars().take_while(|&x| x == c).count() -} - -/// converts a line/column position to a byte offset in `text` -fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { - let mut byte = 0usize; - for (line_idx, line) in text.split_inclusive('\n').enumerate() { - if line_idx == pos.line { - for (col_idx, (ci, _)) in line.char_indices().enumerate() { - if col_idx == pos.column { return byte + ci; } - } - return byte + line.trim_end_matches('\n').len(); - } - byte += line.len(); - } - text.len() -} - -/// inverse of `byte_offset_for_cursor` -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)) -} - -/// walks `text` left-to-right tracking a delimiter stack -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() -} - -/// returns the byte offset of the next outer scope's closing delimiter -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 -} - -/// parses a markdown image reference `![alt](src)` from a line -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)) -} - -/// loads an image from a local path or http(s) URL -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 }) -} - -/// encodes a clipboard image to PNG and writes it into the on-disk cache -#[cfg(not(target_os = "ios"))] -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()) -} - diff --git a/viewport/src/editor/auto_pair.rs b/viewport/src/editor/auto_pair.rs new file mode 100644 index 0000000..e61d6c6 --- /dev/null +++ b/viewport/src/editor/auto_pair.rs @@ -0,0 +1,24 @@ +use std::sync::atomic::{AtomicU8, Ordering}; + +pub const PAREN: u8 = 1 << 0; +pub const BRACKET: u8 = 1 << 1; +pub const BRACE: u8 = 1 << 2; +pub const SINGLE: u8 = 1 << 3; +pub const DOUBLE: u8 = 1 << 4; +pub const BACKTICK: u8 = 1 << 5; + +pub const ALL: u8 = PAREN | BRACKET | BRACE | SINGLE | DOUBLE | BACKTICK; + +static FLAGS: AtomicU8 = AtomicU8::new(ALL); + +pub fn enabled(flag: u8) -> bool { + FLAGS.load(Ordering::Relaxed) & flag != 0 +} + +pub fn flags() -> u8 { + FLAGS.load(Ordering::Relaxed) +} + +pub fn set_flags(flags: u8) { + FLAGS.store(flags, Ordering::Relaxed); +} diff --git a/viewport/src/editor/blocks_ops.rs b/viewport/src/editor/blocks_ops.rs new file mode 100644 index 0000000..b5c8796 --- /dev/null +++ b/viewport/src/editor/blocks_ops.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; + +use crate::blocks::BoxedBlock; +use crate::table_block::TableBlock; +use crate::text_block::TextBlock; +use crate::text_widget::{self, Cursor, Position}; + +impl super::EditorState { + pub(super) 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) + } + + pub(super) fn registry_to_vec(&mut self) -> Vec { + self.layout.iter().filter_map(|id| self.registry.remove(id)).collect() + } + + pub(super) 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); + } + } + + pub(super) fn block_at(&self, idx: usize) -> Option<&BoxedBlock> { + self.layout.get(idx).and_then(|id| self.registry.get(id)) + } + + pub(super) 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)) + } + + pub(super) fn insert_block(&mut self, idx: usize, block: BoxedBlock) { + let id = block.id(); + self.layout.insert(idx, id); + self.registry.insert(id, block); + } + + pub(super) 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 + } + } + + pub(super) fn push_block(&mut self, block: BoxedBlock) { + let id = block.id(); + self.layout.push(id); + self.registry.insert(id, block); + } + + pub(super) fn clear_blocks(&mut self) { + self.layout.clear(); + self.registry.clear(); + } + + pub(super) fn block_count(&self) -> usize { + self.layout.len() + } + + pub(super) 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(); + } + } + } + + pub(super) 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)); + } + + pub(super) 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 + } + + pub(super) fn text_block_at(&self, idx: usize) -> Option<&TextBlock> { + self.block_at(idx).and_then(|b| b.as_any().downcast_ref::()) + } + + pub(super) 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::()) + } + + pub(super) fn table_block_at(&self, idx: usize) -> Option<&TableBlock> { + self.block_at(idx).and_then(|b| b.as_any().downcast_ref::()) + } + + pub(super) 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::()) + } + + /// returns the layout index of a block id, if any. + pub(super) fn index_of_block_id(&self, bid: crate::selection::BlockId) -> Option { + self.layout.iter().position(|&id| id == bid) + } + + pub(super) 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 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) + } + } + + pub(super) 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() { + 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 + } + + pub(super) 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 + } +} diff --git a/viewport/src/editor/content.rs b/viewport/src/editor/content.rs new file mode 100644 index 0000000..e982d90 --- /dev/null +++ b/viewport/src/editor/content.rs @@ -0,0 +1,411 @@ +use std::time::Instant; + +use iced_wgpu::core::widget::Id as WidgetId; + +use crate::blocks::{self, BoxedBlock}; +use crate::table_block::{self, TableBlock}; +use crate::text_block::TextBlock; +use crate::text_widget::{self, Action, Cursor, Motion, Position}; + +use super::{block_editor_id, splice_first_empty_slot}; + +impl super::EditorState { + /// updates the focused block index and mirrors it into the selection state + pub(super) 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); + } + } + + /// marks a cell as selected without entering edit mode + pub(super) 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); + } + } + + /// marks a cell as in edit mode and gives it iced focus + pub(super) 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)); + } + } + + /// escapes the table at `table_idx` upward into the previous text block + pub(super) 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; + } + } + 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(); + } + + /// escapes the table at `table_idx` downward into the next text block + pub(super) 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(); + } + + /// fills the first empty `[]` slot in the editing cell's formula with the clicked cell's address; returns true when the click was consumed. + pub(super) fn try_fill_formula_slot( + &mut self, + click_block_idx: usize, + click_row: usize, + click_col: usize, + ) -> bool { + let editing = match self.editing.clone() { + Some(e) => e, + None => return false, + }; + let (er, ec) = match editing.inner { + crate::selection::InnerPath::Cell { row, col } => (row, col), + _ => return false, + }; + let editing_idx = match self.index_of_block_id(editing.block_id) { + Some(i) => i, + None => return false, + }; + if click_block_idx == editing_idx && click_row == er && click_col == ec { + return false; + } + let cell_text = match self.table_block_at(editing_idx) { + Some(tb) => tb.rows.get(er).and_then(|row| row.get(ec)).cloned().unwrap_or_default(), + None => return false, + }; + if !cell_text.trim_start().starts_with("/=") { + return false; + } + let addr = format!("{}{}", table_block::column_letter(click_col), click_row + 1); + let new_text = match splice_first_empty_slot(&cell_text, &addr) { + Some(t) => t, + None => return false, + }; + if let Some(tb) = self.table_block_at_mut(editing_idx) { + if let Some(row) = tb.rows.get_mut(er) { + if let Some(cell) = row.get_mut(ec) { + *cell = new_text; + } + } + } + self.eval_dirty = true; + self.last_edit = Instant::now(); + true + } + + pub(super) 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 + } + + pub(super) 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 + } + + pub(super) 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") + } + + /// moves the focused content's cursor to `target`, clamping line and column + pub(super) 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); + } + + /// handles arrow, backspace, and delete at block boundaries + pub(super) 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 + } + + /// per-frame focus synchronization with iced + 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; + } + } + } + + /// returns true when a non-eval table has a selected cell + pub(crate) fn active_table_index(&self) -> Option { + self.focused_table_index() + } + + /// returns true when the focused block is a non-eval table + 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 + } + + /// returns true when the focused block is a table in whole-table-select mode + 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 focused cell's table + 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 focused block index when it's a table + 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 + } + } + + /// returns true when the focused block is a table with a focused cell + 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() + } + + /// returns true when Cmd+C should copy the table selection instead of cell text + 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() + } + + /// builds the clipboard payload from the focused table + pub(super) fn copy_focused_table_selection(&self) -> Option { + let block = self.block_at(self.focused_block)?; + 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() + } +} diff --git a/viewport/src/editor/eval.rs b/viewport/src/editor/eval.rs new file mode 100644 index 0000000..652f5e5 --- /dev/null +++ b/viewport/src/editor/eval.rs @@ -0,0 +1,695 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use iced_widget::markdown; + +use crate::blocks; +use crate::table_block::TableBlock; +use crate::text_block::TextBlock; +use crate::text_widget::{self, Action, Motion}; + +use super::types::{ + Anchor, ComputedTable, ComputedTree, InlineResult, TableIndex, + EVAL_DEBOUNCE_MS, LONG_PRESS_MS, +}; +use super::types::{resolve_ref_key, ERROR_PREFIX, RESULT_PREFIX}; +use super::RenderMode; + +impl super::EditorState { + /// maps a line number in concatenated module source back to a per-block anchor + pub(super) 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, + } + } + + 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(); + } + { + 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(); + } + } + } + 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); + } + } + 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(); + } + } + } + } + + pub(super) fn reparse(&mut self) { + let text = self.get_clean_text(); + self.parsed = markdown::parse(&text).collect(); + self.rebuild_modules(); + } + + pub(super) 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() + } + + /// rebuilds the module list and applies heading-based table names + pub(super) 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); + } + } + } + } + + /// registers every non-eval-result table on the interpreter and returns the alias index + pub(super) 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(); + + 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()); + + 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 { + 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 } + } + + /// returns true if any visible table contains a `/=` formula cell + pub(super) 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 + } + + /// parses, topo-sorts, and evaluates every visible cell formula + pub(super) 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 }; + 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)), + } + } + } + } + + 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; + } + + 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); + + if !result.is_error() { + let display = result.display(); + 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())); + } + } + + /// applies cell writes logged by the interpreter to live tables + pub(super) 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; + } + } + + /// returns true when an edit changed the block structure + pub(super) 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(); + } + + /// returns the concatenated text of all text blocks in a module + pub(super) 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") + } + + /// builds an interpreter pre-populated with root and `use`'d module exports + pub(super) 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, + }; + + let my_module = self.modules.iter().find(|m| m.block_ids.contains(&block_id)); + + 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 mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let root_exports = self.resolve_module_exports(root, &mut visited); + eval_interp.import_all(&root_exports); + } + } + + let use_block_ids: Vec = my_module + .map(|m| m.block_ids.clone()) + .unwrap_or_default(); + let my_module_name = my_module.map(|m| m.name.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 mut visited: std::collections::HashSet = std::collections::HashSet::new(); + if !my_module_name.is_empty() { + visited.insert(my_module_name.clone()); + } + let dep_exports = self.resolve_module_exports(dep_module, &mut visited); + 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 + } + + /// recursively evaluates a module with its `use` declarations resolved + pub(super) fn resolve_module_exports( + &self, + module: &crate::module::Module, + visited: &mut std::collections::HashSet, + ) -> acord_core::interp::ModuleExports { + use acord_core::interp; + + if !module.name.is_empty() && !visited.insert(module.name.clone()) { + return interp::ModuleExports::default(); + } + + let mut interp = interp::Interpreter::new(); + + if !module.is_root { + if let Some(root) = self.modules.iter().find(|m| m.is_root) { + if root.name != module.name { + let root_exports = self.resolve_module_exports(root, visited); + interp.import_all(&root_exports); + } + } + } + + let module_text = self.module_source_text(module); + let use_decls = interp::extract_use_declarations(&module_text); + for decl in &use_decls { + if let Some(dep) = self.modules.iter().find(|m| m.name == decl.module) { + let dep_exports = self.resolve_module_exports(dep, visited); + match &decl.item { + None => interp.import_all(&dep_exports), + Some(s) if s == "*" => interp.import_all(&dep_exports), + Some(item) => { interp.import_item(&dep_exports, item); } + } + } + } + + crate::eval::evaluate_document_with_interp(&mut interp, &module_text); + interp.exports() + } + + pub(super) fn run_eval(&mut self) { + self.rebuild_modules(); + + 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, + }; + + 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"); + + 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); + + self.evaluate_cell_formulas(&mut interp, &table_keys); + + let doc = crate::eval::evaluate_document_with_interp(&mut interp, &source); + + let writes = interp.drain_table_writes(); + self.apply_table_writes(writes, &table_keys); + + self.clear_layers_for_blocks(&block_ids); + + 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; + } + _ => {} + } + 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, + }); + } + } + + /// evaluates every module in document order + pub(super) fn run_eval_all(&mut self) { + self.rebuild_modules(); + self.eval_results.clear(); + self.computed_tables.clear(); + self.computed_trees.clear(); + self.computed_cells.clear(); + + 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; + } + + /// returns the inline result text for a given anchor + pub(super) 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()) + } + + /// reads line `line_idx` from the text block with the given id + pub(super) 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()) + } + + /// copies `{source} → {value}` to the clipboard + pub(super) 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}")); + } + + /// copies the result and drops a `let _ = value` line below the source + pub(super) 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, + }; + if self.text_block_at(block_idx).is_none() { return; } + + self.push_undo_snapshot(); + self.redo_stack.clear(); + self.set_focused_block(block_idx); + + 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)); + + let paste = format!("\n\nlet = {value}"); + content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste)))); + + 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(super) 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()) +} diff --git a/viewport/src/editor/find.rs b/viewport/src/editor/find.rs new file mode 100644 index 0000000..4c05859 --- /dev/null +++ b/viewport/src/editor/find.rs @@ -0,0 +1,45 @@ +use crate::text_widget::{Cursor, Position}; + +impl super::EditorState { + pub(super) 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; + } + } + + pub(super) 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, + }); + } +} diff --git a/viewport/src/editor/free_layer.rs b/viewport/src/editor/free_layer.rs new file mode 100644 index 0000000..1a03679 --- /dev/null +++ b/viewport/src/editor/free_layer.rs @@ -0,0 +1,406 @@ +use iced_wgpu::core::{alignment, Background, Border, Element, Length, Padding, Shadow, Theme}; +use iced_widget::{container, MouseArea}; + +use crate::block::Block as BlockTrait; +use crate::block::ViewCtx; +use crate::heading_block::HeadingBlock; +use crate::hr_block::HrBlock; +use crate::palette; +use crate::syntax; +use crate::table_block::{self, TableBlock}; +use crate::text_block::TextBlock; +use crate::tree_block::TreeBlock; + +use super::types::{ + FreeNodeId, FreePlacement, LayoutMode, Message, PromoteDragState, RenderMode, +}; + +impl super::EditorState { + /// rounds a coordinate to the nearest 0.25-line increment. + pub(super) fn snap_to_grid(&self, value: f32) -> f32 { + if !self.snapping { return value; } + let unit = self.font_size * 0.325; + if unit <= 0.0 { return value; } + (value / unit).round() * unit + } + + /// applies the current cursor delta to any active promote drag. + pub fn tick_promote_drag(&mut self) -> bool { + let (node_id, layer, origin, size, dx, dy, was_escalated) = { + let Some(pd) = self.promote_drag.as_ref() else { return false }; + let dx = self.cursor_pos.x - pd.start_cursor.x; + let dy = self.cursor_pos.y - pd.start_cursor.y; + (pd.node_id.clone(), pd.layer, pd.origin, pd.size, dx, dy, pd.escalated) + }; + let dist_sq = dx * dx + dy * dy; + let threshold_sq = 16.0; + if dist_sq < threshold_sq && !was_escalated { + return false; + } + if !was_escalated && !self.promote_snapshot_pushed { + self.push_undo_snapshot(); + self.redo_stack.clear(); + self.promote_snapshot_pushed = true; + } + if let Some(pd) = self.promote_drag.as_mut() { pd.escalated = true; } + let placement = FreePlacement { + layer, + x: self.snap_to_grid(origin.0 + dx), + y: self.snap_to_grid(origin.1 + dy), + w: size.0, + h: size.1, + }; + self.free_placements.insert(node_id, placement); + if self.frozen_doc_size.is_none() && self.viewport_size != (0.0, 0.0) { + self.frozen_doc_size = Some(self.viewport_size); + } + true + } + + /// applies the current cursor delta to any active resize drag. + pub fn tick_resize_drag(&mut self) -> bool { + let (node_id, dx, dy, start_size, axes, snapshot_pushed) = { + let Some(rd) = self.resize_drag.as_ref() else { return false }; + let dx = self.cursor_pos.x - rd.start_cursor.x; + let dy = self.cursor_pos.y - rd.start_cursor.y; + (rd.node_id.clone(), dx, dy, rd.start_size, rd.axes, rd.snapshot_pushed) + }; + let new_w = if axes.0 { self.snap_to_grid((start_size.0 + dx).max(60.0)) } else { start_size.0 }; + let new_h = if axes.1 { self.snap_to_grid((start_size.1 + dy).max(40.0)) } else { start_size.1 }; + let changed = self + .free_placements + .get(&node_id) + .map(|p| (p.w - new_w).abs() > 0.5 || (p.h - new_h).abs() > 0.5) + .unwrap_or(false); + if !changed { return false; } + if !snapshot_pushed { + self.push_undo_snapshot(); + self.redo_stack.clear(); + if let Some(rd) = self.resize_drag.as_mut() { rd.snapshot_pushed = true; } + } + if let Some(p) = self.free_placements.get_mut(&node_id) { + p.w = new_w; + p.h = new_h; + } + true + } + + /// returns the layer-0 natural size for a given free-layer node. + pub fn natural_size_for_node(&self, node_id: &FreeNodeId) -> (f32, f32) { + let viewport_w = if self.viewport_size.0 > 0.0 { self.viewport_size.0 } else { 800.0 }; + let default_w = viewport_w.min(700.0).max(300.0); + match node_id { + FreeNodeId::Block(block_id) => { + let module = self.module_block_ids(*block_id); + let mut total_h: f32 = 0.0; + let mut max_w: f32 = default_w; + for id in &module { + let Some(block) = self.registry.get(id) else { continue }; + let any = block.as_any(); + if let Some(tab) = any.downcast_ref::() { + let w: f32 = tab.col_widths.iter().sum::() + 60.0; + let h = (tab.rows.len().max(1) as f32) * (self.font_size * 1.6) + 16.0; + total_h += h; + if w > max_w { max_w = w; } + } else if any.is::() { + total_h += self.font_size * 2.4 + 8.0; + } else if any.is::() { + total_h += 24.0; + } else if let Some(tb) = any.downcast_ref::() { + let lc = tb.content.line_count() as f32; + total_h += lc * self.font_size * 1.3 + 16.0; + } + } + (max_w, total_h.max(80.0)) + } + FreeNodeId::Image(block_id, after_line, _src) => { + let img = self.computed_images.iter().find(|i| + i.anchor.block_id == *block_id && i.anchor.after_line == *after_line + ); + match img { + Some(img) => (img.display_height * 4.0 / 3.0, img.display_height), + None => (default_w, 200.0), + } + } + _ => (default_w, 200.0), + } + } + + /// arms a drag promotion for any free-layer node in live mode. + pub(super) fn start_promote(&mut self, node_id: FreeNodeId, fallback_table_idx: Option) { + if !matches!(self.render_mode, RenderMode::Live) { return; } + if !matches!(self.layout_mode, LayoutMode::Free) { return; } + let existing = self.free_placements.get(&node_id).copied(); + let (origin, size, layer) = match existing { + Some(p) => ((p.x, p.y), (p.w, p.h), p.layer), + None => { + let s = self.natural_size_for_node(&node_id); + ((self.cursor_pos.x, self.cursor_pos.y), s, 1) + } + }; + self.active_free = Some(node_id.clone()); + self.promote_drag = Some(PromoteDragState { + node_id, + start_cursor: self.cursor_pos, + origin, + size, + layer, + escalated: false, + fallback_table_idx, + }); + self.promote_snapshot_pushed = false; + } + + /// arms a corner-drag promotion for the table at a layout index. + pub fn begin_promote_table_corner(&mut self, block_idx: usize) { + let Some(&block_id) = self.layout.get(block_idx) else { return }; + self.start_promote(FreeNodeId::Block(block_id), Some(block_idx)); + } + + /// arms a drag promotion for a heading or hr block. + pub fn begin_promote_block(&mut self, block_id: crate::selection::BlockId) { + self.start_promote(FreeNodeId::Block(block_id), None); + } + + /// arms a drag promotion for an inline image at a specific anchor. + pub fn begin_promote_image(&mut self, block_id: crate::selection::BlockId, after_line: usize, src: String) { + self.start_promote(FreeNodeId::Image(block_id, after_line, src), None); + } + + /// wraps a block element with the promote press/release mouse area in live mode. + pub(super) fn wrap_block_with_promote<'a>( + &self, + elem: Element<'a, Message, Theme, iced_wgpu::Renderer>, + block_id: crate::selection::BlockId, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + if !matches!(self.render_mode, RenderMode::Live) { + return elem; + } + MouseArea::new(elem) + .on_press(Message::BlockPromotePress(block_id)) + .on_release(Message::PromoteRelease) + .into() + } + + /// wraps an inline image element with the promote press/release mouse area in live mode. + pub(super) fn wrap_image_with_promote<'a>( + &self, + elem: Element<'a, Message, Theme, iced_wgpu::Renderer>, + block_id: crate::selection::BlockId, + after_line: usize, + src: String, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + if !matches!(self.render_mode, RenderMode::Live) { + return elem; + } + MouseArea::new(elem) + .on_press(Message::ImagePromotePress { block_id, after_line, src }) + .on_release(Message::PromoteRelease) + .into() + } + + /// builds the overlay widget for a single block at the given layout index. + pub(super) fn build_free_block_widget( + &self, + block_id: crate::selection::BlockId, + ) -> Option> { + let bi = self.layout.iter().position(|id| *id == block_id)?; + let block = self.registry.get(&block_id)?; + let any = block.as_any(); + if let Some(tb) = any.downcast_ref::() { + return Some(self.build_text_block_widget(tb, bi, 0, 0.0)); + } + if let Some(tab) = any.downcast_ref::() { + 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, + }; + let block_idx = bi; + return Some(table_block::table_view( + tab, + editing_cell, + self.font_size, + &self.computed_cells, + move |tmsg| Message::TableMsg(block_idx, tmsg), + )); + } + 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::() { + return Some(>::view(hb, &ctx).base); + } + if let Some(hr) = any.downcast_ref::() { + return Some(>::view(hr, &ctx).base); + } + if let Some(tree) = any.downcast_ref::() { + return Some(>::view(tree, &ctx).base); + } + None + } + + /// builds a column of overlay widgets for every block in a module. + pub(super) fn build_free_module_widget( + &self, + anchor: crate::selection::BlockId, + ) -> Option> { + let ids = self.module_block_ids(anchor); + if ids.is_empty() { return None; } + if ids.len() == 1 { + return self.build_free_block_widget(ids[0]); + } + let parts: Vec> = ids + .iter() + .filter_map(|id| self.build_free_block_widget(*id)) + .collect(); + if parts.is_empty() { return None; } + Some(iced_widget::column(parts).into()) + } + + /// builds a single resize band aligned to one or two edges of a placement. + pub(super) fn build_resize_band( + &self, + node_id: FreeNodeId, + horiz: bool, + vert: bool, + align_x: alignment::Horizontal, + align_y: alignment::Vertical, + zone_w: Length, + zone_h: Length, + ) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let zone = iced_widget::container( + iced_widget::Space::new().width(Length::Fill).height(Length::Fill) + ) + .width(zone_w) + .height(zone_h); + let area = MouseArea::new(zone) + .on_press(Message::ResizePress { node_id, horiz, vert }) + .on_release(Message::ResizeRelease); + iced_widget::container(area) + .width(Length::Fill) + .height(Length::Fill) + .align_x(align_x) + .align_y(align_y) + .into() + } + + /// stacks free-placed objects at absolute positions over the editor body. + pub(super) fn build_free_overlay(&self) -> Option> { + if self.free_placements.is_empty() { + return None; + } + let mut placed: Vec<(&FreeNodeId, &FreePlacement)> = self.free_placements.iter().collect(); + placed.sort_by_key(|(_, pl)| pl.layer); + + let mut layers: Vec> = Vec::new(); + for &(id, placement) in &placed { + let inner_opt: Option> = match id { + FreeNodeId::Table(block_id, after_line) => self + .computed_tables + .iter() + .find(|ct| ct.anchor.block_id == *block_id && ct.anchor.after_line == *after_line) + .map(|ct| self.build_computed_table_widget(ct)), + FreeNodeId::Block(block_id) => self + .build_free_module_widget(*block_id) + .map(|el| self.wrap_block_with_promote(el, *block_id)), + FreeNodeId::Image(block_id, after_line, src) => self + .computed_images + .iter() + .find(|img| img.anchor.block_id == *block_id + && img.anchor.after_line == *after_line + && img.src == *src) + .map(|img| { + let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = + if let Some(entry) = self.image_cache.get(&img.src) { + iced_widget::image(entry.handle.clone()) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + let p = palette::current(); + iced_widget::text(format!("[image: {}]", img.alt)) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(p.overlay0) + .into() + }; + self.wrap_image_with_promote( + inner, + img.anchor.block_id, + img.anchor.after_line, + img.src.clone(), + ) + }), + FreeNodeId::Tree(_, _) => None, + }; + let Some(inner) = inner_opt else { continue }; + + let is_active = self.active_free.as_ref() == Some(id); + let band = (self.font_size * 1.3) * 0.5; + let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if is_active { + let right = self.build_resize_band( + id.clone(), + true, false, + alignment::Horizontal::Right, alignment::Vertical::Top, + Length::Fixed(band), Length::Fixed((placement.h - band).max(0.0)), + ); + let bottom = self.build_resize_band( + id.clone(), + false, true, + alignment::Horizontal::Left, alignment::Vertical::Bottom, + Length::Fixed((placement.w - band).max(0.0)), Length::Fixed(band), + ); + let corner = self.build_resize_band( + id.clone(), + true, true, + alignment::Horizontal::Right, alignment::Vertical::Bottom, + Length::Fixed(band), Length::Fixed(band), + ); + iced_widget::stack![iced_widget::scrollable(inner), right, bottom, corner].into() + } else { + iced_widget::scrollable(inner).into() + }; + let sized = iced_widget::container(body) + .width(Length::Fixed(placement.w)) + .height(Length::Fixed(placement.h)) + .style(move |_theme: &Theme| { + let p = palette::current(); + let (border_color, border_w) = if is_active { + (p.blue, 2.0) + } else { + (p.surface1, 1.0) + }; + container::Style { + background: Some(Background::Color(p.base)), + border: Border { color: border_color, width: border_w, radius: 4.0.into() }, + text_color: None, + shadow: Shadow::default(), + snap: false, + } + }); + + let positioned = iced_widget::container(sized) + .padding(Padding { top: placement.y, right: 0.0, bottom: 0.0, left: placement.x }) + .width(Length::Fill) + .height(Length::Fill); + layers.push(positioned.into()); + } + + if layers.is_empty() { + None + } else { + Some(iced_widget::stack(layers).into()) + } + } +} diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs new file mode 100644 index 0000000..61d0c5d --- /dev/null +++ b/viewport/src/editor/mod.rs @@ -0,0 +1,1505 @@ +use std::collections::HashSet; + +pub mod auto_pair; +mod blocks_ops; +mod content; +mod eval; +mod find; +mod free_layer; +mod mode; +mod sidecar_io; +mod state; +mod text_ops; +mod types; +mod undo; +mod update; +pub use state::EditorState; +#[cfg(not(target_os = "ios"))] +pub use sidecar_io::write_clipboard_image_to_cache; +pub use types::{ + Anchor, ComputedImage, ComputedTable, ComputedTree, ContextMenuState, FindState, + FreeNodeId, FreePlacement, ImageCacheEntry, InlinePressState, InlineResult, + LayoutMode, LineIndicator, MenuCategory, Message, PromoteDragState, RenderMode, + ResizeDragState, SettingsView, ShellAction, TableIndex, + DOC_SCROLLABLE_ID, ERROR_PREFIX, FIND_INPUT_ID, REPLACE_INPUT_ID, RESULT_PREFIX, +}; +use types::{ + LayerItem, + IMAGE_VPAD, + md_style, +}; +#[cfg(any(target_os = "linux", target_os = "windows"))] +use types::{cat_btn_width, MENU_CATS}; + +use iced_wgpu::core::keyboard::{self}; +use iced_wgpu::core::keyboard::key; +use iced_wgpu::core::text::Wrapping; +use iced_wgpu::core::{ + border, alignment, Background, Border, Color, Element, Font, Length, + Padding, Shadow, Theme, +}; +use iced_widget::container; +use iced_widget::markdown; +use iced_widget::MouseArea; +use crate::text_widget::{self, AnchoredItem, Binding, KeyPress, Motion, 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::heading_block::HeadingBlock; +use crate::hr_block::HrBlock; +use crate::oklab; +use crate::palette; +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; + +impl EditorState { + 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 { + let editor = self.view_blocks(); + match self.build_free_overlay() { + Some(overlay) => iced_widget::stack![editor, overlay].into(), + None => editor, + } + }; + + 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(); + + #[cfg(any(target_os = "linux", target_os = "windows"))] + col_items.push(self.menu_strip()); + + col_items.push(main_content); + + if self.find.visible { + col_items.push(self.find_bar()); + } + + col_items.push(status_bar.into()); + + let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column(col_items) + .width(Length::Fill) + .height(Length::Fill) + .into(); + + if self.settings_open { + return iced_widget::stack![body, self.settings_panel()].into(); + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + if let Some(cat) = self.menu_open { + return iced_widget::stack![body, self.menu_dropdown(cat)].into(); + } + + body + } + + 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; + + #[cfg(any(target_os = "linux", target_os = "windows"))] + let title_bar_h = 0.0_f32; + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + 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 hidden_blocks: HashSet = self + .free_placements + .keys() + .filter_map(|id| match id { + FreeNodeId::Block(bid) => Some(*bid), + _ => None, + }) + .flat_map(|bid| self.module_block_ids(bid)) + .collect(); + + let mut global_line = 0usize; + for (bi, &block_id) in self.layout.iter().enumerate() { + if hidden_blocks.contains(&block_id) { + continue; + } + 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 line_count = tb.content.line_count(); + let this_global_line = global_line; + global_line += line_count; + let _ = line_h; // text_widget::layout owns the height now + let _ = lang_for_block; // build_text_block_widget reads lang_str directly + block_elements.push(self.build_text_block_widget(tb, block_idx, this_global_line, top_pad)); + } + continue; + } + + if let Some(tab) = any.downcast_ref::() { + let block_idx = bi; + 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; + } + + 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(self.wrap_block_with_promote(layered.base, hb.id)); + global_line += 1; + continue; + } + + if let Some(hr) = any.downcast_ref::() { + let layered = >::view(hr, &ctx); + block_elements.push(self.wrap_block_with_promote(layered.base, hr.id)); + 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() + }; + + 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 + }; + + 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 + }; + + if let Some(popup) = self.spillover_view() { + iced_widget::stack![with_ctx, popup].into() + } else { + with_ctx + } + } + + /// renders the spillover popup of the first table that has one open + 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, + }); + + 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() + ) + } + + /// returns (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() + } + + + + /// returns layer items for a block sorted by anchor line + 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 { + let id = FreeNodeId::Table(ct.anchor.block_id, ct.anchor.after_line); + if self.free_placements.contains_key(&id) { continue; } + items.push((ct.anchor.after_line, LayerItem::Table(ct))); + } + } + for ct in &self.computed_trees { + if ct.anchor.block_id == block_id { + let id = FreeNodeId::Tree(ct.anchor.block_id, ct.anchor.after_line); + if self.free_placements.contains_key(&id) { continue; } + items.push((ct.anchor.after_line, LayerItem::Tree(ct))); + } + } + for img in &self.computed_images { + if img.anchor.block_id == block_id { + let id = FreeNodeId::Image(img.anchor.block_id, img.anchor.after_line, img.src.clone()); + if self.free_placements.contains_key(&id) { continue; } + items.push((img.anchor.after_line, LayerItem::Image(img))); + } + } + items.sort_by_key(|(line, _)| *line); + items + } + + /// builds anchored child elements for the text widget compositor + 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 inner = if r.is_error { + iced_widget::container( + iced_widget::text(&r.text) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(oklab::lighten_for_size(p.red, self.font_size)) + ) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + } else { + let value = r.text + .strip_prefix(RESULT_PREFIX) + .unwrap_or(&r.text) + .to_string(); + let arrow_color = oklab::lighten_for_size(palette::eval_arrow_color(), self.font_size); + let value_color = oklab::lighten_for_size(palette::eval_value_color(), self.font_size); + let bold = Font { + weight: iced_wgpu::core::font::Weight::Bold, + ..syntax::EDITOR_FONT + }; + let row = iced_widget::row![ + iced_widget::text("→ ") + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(arrow_color), + iced_widget::text(value) + .font(bold) + .size(self.font_size) + .color(value_color), + iced_widget::text(" ←") + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(arrow_color), + ] + .spacing(0.0); + iced_widget::container(row) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + }; + 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 inner = self.build_computed_table_widget(ct); + let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::container(inner) + .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 { + 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() + }; + let wrapped = self.wrap_image_with_promote( + el, + img.anchor.block_id, + img.anchor.after_line, + img.src.clone(), + ); + anchored.push(AnchoredItem { + after_line: *after_line, + height: item.element_height(lh, self.font_size), + element: wrapped, + }); + } + } + } + + anchored + } + + /// builds the text-editor widget for a text block at a layout index. + fn build_text_block_widget<'a>( + &'a self, + tb: &'a TextBlock, + block_idx: usize, + this_global_line: usize, + top_pad: f32, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let is_focused = block_idx == self.focused_block; + let anchored_items = self.build_anchored_items(tb.id); + let cursor_line = tb.content.cursor().position.line; + let text = tb.content.text(); + let decors = compute_line_decors(&text); + let lang_for_block = self.lang_str(); + + 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, + source: tb.content.text(), + }; + editor + .highlight_with::( + settings, + |highlight, _theme| Format { + color: Some(syntax::highlight_color(highlight.kind)), + font: syntax::highlight_font(highlight.kind), + }, + ) + .into() + } + + /// builds a column of cell rows from a computed table. + fn build_computed_table_widget<'a>( + &self, + ct: &'a ComputedTable, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + 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()); + } + iced_widget::column(table_rows).into() + } + + + /// builds the context menu overlay for a right-clicked cell + 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, + }); + + 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() + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + fn menu_strip(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let f = self.font_size; + let char_w = f * 0.6; + let cat_pad_x = f * 0.85; + let strip_pad_y = f * 0.18; + let strip_label_size = f * 0.92; + + let mut row: Vec> = Vec::new(); + for (cat, label) in MENU_CATS { + let active = self.menu_open == Some(cat); + row.push( + iced_widget::button( + iced_widget::text(label.to_string()) + .size(strip_label_size) + .font(syntax::EDITOR_FONT) + ) + .width(Length::Fixed(cat_btn_width(label, char_w, cat_pad_x))) + .padding(Padding { top: strip_pad_y, right: cat_pad_x, bottom: strip_pad_y, left: cat_pad_x }) + .style(move |_t: &Theme, _s| iced_widget::button::Style { + background: if active { Some(Background::Color(p.surface1)) } else { None }, + text_color: p.text, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }) + .on_press(Message::ToggleMenu(cat)) + .into() + ); + } + + iced_widget::container(iced_widget::row(row).spacing(0.0)) + .width(Length::Fill) + .style(move |_t: &Theme| iced_widget::container::Style { + background: Some(Background::Color(p.mantle)), + border: Border::default(), + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }) + .into() + } + + /// returns the dropdown panel for the open category, anchored under its strip button + #[cfg(any(target_os = "linux", target_os = "windows"))] + fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let f = self.font_size; + let char_w = f * 0.6; + let cat_pad_x = f * 0.85; + let strip_pad_y = f * 0.18; + let strip_label_size = f * 0.92; + let item_pad_x = f * 0.95; + let item_pad_y = f * 0.32; + let dropdown_radius = f * 0.30; + let separator_h = (f * 0.08).max(1.0); + let label_size = f * 0.85; + let hint_size = f * 0.78; + + let strip_h = strip_label_size * 1.3 + strip_pad_y * 2.0; + + let item = |label: &str, shortcut: &str, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let label_w = iced_widget::text(label.to_string()) + .size(label_size) + .font(syntax::EDITOR_FONT) + .width(Length::Fill); + let hint_w = iced_widget::text(shortcut.to_string()) + .size(hint_size) + .font(syntax::EDITOR_FONT) + .color(p.overlay0); + iced_widget::button( + iced_widget::row![label_w, hint_w].spacing(f) + ) + .width(Length::Fill) + .padding(Padding { top: item_pad_y, right: item_pad_x, bottom: item_pad_y, left: item_pad_x }) + .style(context_menu_item_style) + .on_press(msg) + .into() + }; + + let sep = || -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + iced_widget::container(iced_widget::text("")) + .width(Length::Fill) + .height(Length::Fixed(separator_h)) + .style(move |_t: &Theme| iced_widget::container::Style { + background: Some(Background::Color(p.surface1)), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() + }; + + let items: Vec> = match cat { + MenuCategory::File => vec![ + item("New Note", "Ctrl+N", Message::Shell(ShellAction::NewNote)), + item("Open...", "Ctrl+O", Message::Shell(ShellAction::Open)), + item("Documents...", "Alt+B", Message::Shell(ShellAction::ToggleBrowser)), + sep(), + item("Save", "Ctrl+S", Message::Shell(ShellAction::Save)), + item("Save As...", "Ctrl+Shift+S", Message::Shell(ShellAction::SaveAs)), + sep(), + item("Export as Rust Library", "Ctrl+Shift+E", Message::Shell(ShellAction::ExportCrate)), + item("Print...", "Ctrl+P", Message::Shell(ShellAction::Print)), + sep(), + item("Settings...", "Ctrl+,", Message::Shell(ShellAction::Settings)), + item("Quit", "Ctrl+Q", Message::Shell(ShellAction::Quit)), + ], + MenuCategory::Edit => vec![ + item("Undo", "Ctrl+Z", Message::Undo), + item("Redo", "Ctrl+Shift+Z", Message::Redo), + sep(), + item("Bold", "Ctrl+B", Message::ToggleBold), + item("Italic", "Ctrl+I", Message::ToggleItalic), + item("Insert Table", "Ctrl+T", Message::InsertTable), + sep(), + item("Find...", "Ctrl+F", Message::ToggleFind), + ], + MenuCategory::Render => vec![ + item("Live", "", Message::SetRenderMode(RenderMode::Live)), + item("Editor", "", Message::SetRenderMode(RenderMode::Editor)), + item("View", "", Message::SetRenderMode(RenderMode::View)), + sep(), + item("Evaluate", "Ctrl+E", Message::SmartEval), + ], + MenuCategory::Mode => vec![ + item("Free", if matches!(self.layout_mode, LayoutMode::Free) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Free)), + item("Relative", if matches!(self.layout_mode, LayoutMode::Relative) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Relative)), + item("Anchored", if matches!(self.layout_mode, LayoutMode::Anchored) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Anchored)), + sep(), + item("Snapping", if self.snapping { "✓" } else { "" }, Message::ToggleSnapping), + ], + MenuCategory::View => vec![ + item("Zoom In", "Ctrl+=", Message::ZoomIn), + item("Zoom Out", "Ctrl+-", Message::ZoomOut), + item("Reset Zoom", "Ctrl+Shift+0", Message::ZoomReset), + ], + }; + + let mut x_offset = 0.0_f32; + for (c, label) in MENU_CATS { + if c == cat { break; } + x_offset += cat_btn_width(label, char_w, cat_pad_x); + } + + let dropdown_width = { + let max_label_chars = match cat { + MenuCategory::File => "Export as Rust Library".len(), + MenuCategory::Edit => "Insert Table".len(), + MenuCategory::Render => "Evaluate".len(), + MenuCategory::Mode => "Anchored".len(), + MenuCategory::View => "Reset Zoom".len(), + }; + let max_hint_chars = 13_usize; // widest hint string in chars + (max_label_chars + max_hint_chars) as f32 * char_w + item_pad_x * 2.0 + f + }; + + let panel = iced_widget::container( + iced_widget::column(items).spacing(0.0).width(Length::Fixed(dropdown_width)) + ) + .style(move |_t: &Theme| iced_widget::container::Style { + background: Some(Background::Color(p.surface0)), + border: Border { + color: p.surface1, + width: 1.0, + radius: dropdown_radius.into(), + }, + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }); + + iced_widget::column![ + iced_widget::Space::new().width(Length::Shrink).height(Length::Fixed(strip_h)), + iced_widget::row![ + iced_widget::Space::new().width(Length::Fixed(x_offset)).height(Length::Shrink), + panel, + ], + ] + .into() + } + + fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let f = self.font_size; + let item_pad_x = f * 0.95; + let item_pad_y = f * 0.32; + let panel_radius = f * 0.30; + let label_size = f * 0.92; + let title_size = f * 1.05; + let row_gap = f * 0.55; + let panel_width = f * 28.0; + + let title = iced_widget::text("Settings") + .size(title_size) + .font(syntax::EDITOR_FONT) + .color(p.text); + + let theme_row = self.settings_segment_row( + "Theme", + label_size, + &[ + ("Auto", "auto"), + ("Light", "light"), + ("Dark", "dark"), + ], + &self.settings_view.theme_mode, + |v| Message::Shell(ShellAction::SetThemeMode(v.to_string())), + ); + + let line_row = self.settings_segment_row( + "Line indicator", + label_size, + &[ + ("On", "on"), + ("Off", "off"), + ("Vim", "vim"), + ], + &self.settings_view.line_indicator, + |v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())), + ); + + let rainbow_row = self.settings_segment_row( + "Gutter rainbow", + label_size, + &[ + ("Off", "false"), + ("On", "true"), + ], + if self.settings_view.gutter_rainbow { "true" } else { "false" }, + |v| Message::Shell(ShellAction::SetGutterRainbow(v == "true")), + ); + + let dir_label = iced_widget::text("Auto-save folder") + .size(label_size) + .font(syntax::EDITOR_FONT) + .color(p.text) + .width(Length::Fill); + let dir_value = iced_widget::text(self.settings_view.auto_save_dir.clone()) + .size(label_size) + .font(syntax::EDITOR_FONT) + .color(p.subtext0) + .width(Length::Fill); + let dir_btn = iced_widget::button( + iced_widget::text("Choose…") + .size(label_size) + .font(syntax::EDITOR_FONT) + ) + .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x * 0.7, bottom: item_pad_y * 0.6, left: item_pad_x * 0.7 }) + .on_press(Message::Shell(ShellAction::PickAutoSaveDir)) + .style(context_menu_item_style); + let dir_row: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column![ + dir_label, + iced_widget::row![dir_value, dir_btn].spacing(f * 0.5), + ] + .spacing(f * 0.2) + .into(); + + let close_btn = iced_widget::button( + iced_widget::text("Close") + .size(label_size) + .font(syntax::EDITOR_FONT) + ) + .padding(Padding { top: item_pad_y * 0.6, right: item_pad_x, bottom: item_pad_y * 0.6, left: item_pad_x }) + .on_press(Message::Shell(ShellAction::Settings)) + .style(context_menu_item_style); + + let panel = iced_widget::container( + iced_widget::column![ + title, + theme_row, + line_row, + rainbow_row, + dir_row, + iced_widget::row![ + iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), + close_btn, + ], + ] + .spacing(row_gap) + .width(Length::Fixed(panel_width)) + ) + .padding(Padding { top: f, right: f, bottom: f, left: f }) + .style(move |_t: &Theme| iced_widget::container::Style { + background: Some(Background::Color(p.surface0)), + border: Border { + color: p.surface1, + width: 1.0, + radius: panel_radius.into(), + }, + text_color: Some(p.text), + shadow: Shadow::default(), + snap: false, + }); + + iced_widget::container(panel) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(move |_t: &Theme| iced_widget::container::Style { + background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 })), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() + } + + fn settings_segment_row<'a>( + &'a self, + label: &str, + label_size: f32, + options: &[(&str, &'a str)], + current: &str, + msg_for: impl Fn(&'a str) -> Message, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let f = self.font_size; + let mut buttons: Vec> = Vec::new(); + for (display, value) in options { + let active = *value == current; + let display = display.to_string(); + let value = *value; + buttons.push( + iced_widget::button( + iced_widget::text(display) + .size(label_size) + .font(syntax::EDITOR_FONT) + ) + .padding(Padding { top: f * 0.18, right: f * 0.55, bottom: f * 0.18, left: f * 0.55 }) + .style(move |_t: &Theme, _s| iced_widget::button::Style { + background: if active { Some(Background::Color(p.surface2)) } else { Some(Background::Color(p.surface1)) }, + text_color: if active { p.text } else { p.subtext0 }, + border: Border { color: p.surface2, width: 1.0, radius: (f * 0.18).into() }, + shadow: Shadow::default(), + snap: false, + }) + .on_press(msg_for(value)) + .into() + ); + } + let label_w = iced_widget::text(label.to_string()) + .size(label_size) + .font(syntax::EDITOR_FONT) + .color(p.text) + .width(Length::Fill); + iced_widget::row![ + label_w, + iced_widget::row(buttons).spacing(f * 0.25), + ] + .spacing(f) + .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, + } +} + + +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}")) +} + + +/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[]`. +fn splice_first_empty_slot(text: &str, addr: &str) -> Option { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' { + let mut j = i + 1; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } + if j < bytes.len() && bytes[j] == b']' { + let mut out = String::with_capacity(text.len() + addr.len()); + out.push_str(&text[..i + 1]); + out.push_str(addr); + out.push_str(&text[j..]); + return Some(out); + } + } + i += 1; + } + None +} + +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)) + } + keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACKET) => { + Some(Binding::Custom(Message::AutoPair("[", "]"))) + } + keyboard::Key::Character("{") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACE) => { + Some(Binding::Custom(Message::AutoPair("{", "}"))) + } + keyboard::Key::Character("(") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::PAREN) => { + Some(Binding::Custom(Message::AutoPair("(", ")"))) + } + keyboard::Key::Character("'") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::SINGLE) => { + Some(Binding::Custom(Message::AutoPair("'", "'"))) + } + keyboard::Key::Character("\"") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::DOUBLE) => { + Some(Binding::Custom(Message::AutoPair("\"", "\""))) + } + keyboard::Key::Character("`") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BACKTICK) => { + 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 +} + + diff --git a/viewport/src/editor/mode.rs b/viewport/src/editor/mode.rs new file mode 100644 index 0000000..7add18d --- /dev/null +++ b/viewport/src/editor/mode.rs @@ -0,0 +1,86 @@ +use crate::blocks; +use crate::heading_block::HeadingBlock; +use crate::hr_block::HrBlock; +use crate::text_block::TextBlock; +use crate::text_widget::{Action, Motion}; + +use super::{block_editor_id, RenderMode}; + +impl super::EditorState { + /// returns the layout block ids belonging to the module anchored by the given block. + pub fn module_block_ids(&self, anchor: crate::selection::BlockId) -> Vec { + let Some(start) = self.layout.iter().position(|id| *id == anchor) else { return Vec::new() }; + let anchor_block = match self.registry.get(&anchor) { + Some(b) => b, + None => return Vec::new(), + }; + let any = anchor_block.as_any(); + let is_module_anchor = any.is::() || any.is::(); + if !is_module_anchor { + return vec![anchor]; + } + let mut ids = vec![anchor]; + for &next_id in &self.layout[start + 1..] { + let Some(block) = self.registry.get(&next_id) else { break }; + let any = block.as_any(); + if any.is::() || any.is::() { + break; + } + ids.push(next_id); + } + ids + } + + 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(); + 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)); + } + } + + /// switches back to live mode and reparses the buffer into blocks + 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(); + } + + /// switches to view mode + pub fn enter_view_mode(&mut self) { + if self.render_mode == RenderMode::View { return; } + 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; + } +} diff --git a/viewport/src/editor/sidecar_io.rs b/viewport/src/editor/sidecar_io.rs new file mode 100644 index 0000000..b798a53 --- /dev/null +++ b/viewport/src/editor/sidecar_io.rs @@ -0,0 +1,300 @@ +use crate::heading_block::HeadingBlock; +use crate::sidecar::{self, Sidecar, TableSidecar}; +use crate::table_block::TableBlock; +use crate::text_block::TextBlock; + +use super::types::{Anchor, ComputedImage, ImageCacheEntry, IMAGE_MAX_H, IMAGE_PADDING, IMAGE_PLACEHOLDER_H}; +use super::{strip_result_lines, RenderMode}; + +impl super::EditorState { + pub fn load_doc(&mut self, text: &str) { + 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); + } + if self.render_mode == RenderMode::Live || self.render_mode == RenderMode::View { + self.run_eval_all(); + } + } + + /// returns the clean markdown body; the archive lives in a separate channel. + pub fn save_doc(&mut self) -> String { + self.get_clean_text() + } + + /// returns the archive zip bytes the shell should embed for in-library .md files. + pub fn save_sidecar_bytes(&mut self) -> Option> { + self.rebuild_modules(); + let sidecar = self.build_sidecar(); + let block_files = self.build_block_files(); + sidecar::build_archive_bytes(&sidecar, &block_files) + } + + /// applies an archive zip's metadata back into the document. + pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) { + if let Some(sc) = sidecar::extract_archive_bytes(bytes) { + self.apply_sidecar(&sc); + } + } + + /// builds the per-block `.cord` source files for the sidecar archive + 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 + } + + pub(super) 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 + } + + /// builds a `Sidecar` snapshot from the current block tree + pub(super) 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 + } + + pub(super) 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; + } + } + + /// scans text blocks for image references and populates the image cache + pub(super) 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)); + } + } + } + + let editor_w = 800.0f32; // approximate; TODO: pass actual width + + for (anchor, src, alt) in new_srcs { + 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, + }); + } + } +} + +/// parses a markdown image reference `![alt](src)` from a line +pub(super) 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)) +} + +/// loads an image from a local path or http(s) URL +pub(super) 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 }) +} + +/// encodes a clipboard image to PNG and writes it into the on-disk cache +#[cfg(not(target_os = "ios"))] +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()) +} diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs new file mode 100644 index 0000000..e819046 --- /dev/null +++ b/viewport/src/editor/state.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; +use std::time::Instant; + +use iced_wgpu::core::keyboard::Modifiers; +use iced_wgpu::core::widget::Id as WidgetId; +use iced_wgpu::core::Point; +use iced_widget::markdown; + +use crate::blocks::{self, BoxedBlock}; +use crate::table_block::TableBlock; +use crate::text_widget; + +use super::types::{ + ComputedImage, ComputedTable, ComputedTree, ContextMenuState, EditKind, FindState, + FreeNodeId, FreePlacement, ImageCacheEntry, InlinePressState, InlineResult, LayoutMode, + LineIndicator, MenuCategory, PromoteDragState, RenderMode, ResizeDragState, SettingsView, + ShellAction, UndoSnapshot, +}; + +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, + pub(super) scroll_offset: f32, + pub(super) eval_dirty: bool, + pub(super) last_edit: Instant, + + pub(super) undo_stack: Vec, + pub(super) redo_stack: Vec, + pub(super) last_edit_kind: EditKind, + pub(super) last_edit_time: Instant, + + pub find: FindState, + pub pending_focus: Option, + + pub(super) fallback_text: text_widget::Content, + + /// live keyboard modifier state + pub mods: Modifiers, + + pub(crate) selection: crate::selection::Selection, + /// the single path that keys are routed to + pub(crate) focus: Option, + /// path of the cell currently in text-input edit mode + #[allow(dead_code)] + pub(crate) editing: Option, + /// cmd+a escalation flag for whole-document selection + pub cmd_a_armed: bool, + /// whole-document selection mode flag + pub all_blocks_selected: bool, + /// latest cursor position in viewport coordinates + pub cursor_pos: Point, + /// pending pixel scroll delta forwarded to the document scrollable + pub pending_scroll: f32, + /// active context menu state, if any + pub context_menu: Option, + + pub eval_results: Vec, + pub computed_tables: Vec, + pub computed_trees: Vec, + /// per-cell evaluated formula results, keyed by (block_id, col, row) + pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, + + /// active long-press state for the result-copy gesture + pub inline_press: Option, + + /// gutter line-indicator mode + pub line_indicator: LineIndicator, + /// whether the gutter line numbers cycle through the rainbow palette + pub gutter_rainbow: bool, + + /// pending clipboard text, drained by the shell each frame + pub pending_clipboard: Option, + + pub computed_images: Vec, + pub image_cache: HashMap, + + /// previous global cursor line, used to detect line changes + pub(super) prev_cursor_line: usize, + + pub menu_open: Option, + pub pending_shell_action: Option, + pub settings_open: bool, + pub settings_view: SettingsView, + + pub free_placements: HashMap, + pub frozen_doc_size: Option<(f32, f32)>, + pub viewport_size: (f32, f32), + pub promote_drag: Option, + pub promote_snapshot_pushed: bool, + pub resize_drag: Option, + pub active_free: Option, + pub layout_mode: LayoutMode, + pub snapping: bool, +} + +impl EditorState { + pub fn new() -> Self { + let sample = "# "; + 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, + menu_open: None, + pending_shell_action: None, + settings_open: false, + settings_view: SettingsView::default(), + free_placements: HashMap::new(), + frozen_doc_size: None, + viewport_size: (0.0, 0.0), + promote_drag: None, + promote_snapshot_pushed: false, + resize_drag: None, + active_free: None, + layout_mode: LayoutMode::Free, + snapping: false, + } + } + + /// returns the queued shell action and clears it + pub fn take_pending_shell_action(&mut self) -> Option { + self.pending_shell_action.take() + } + + pub fn take_pending_focus(&mut self) -> Option { + self.pending_focus.take() + } + + /// drains the accumulated wheel-scroll delta + 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) + } + + pub fn set_lang_from_ext(&mut self, ext: &str) { + self.lang = super::lang_from_extension(ext); + } + + pub(super) fn lang_str(&self) -> String { + self.lang.clone().unwrap_or_default() + } + + /// returns the tab width in spaces + pub(super) fn tab_width(&self) -> usize { + 4 + } + + pub(super) fn line_height(&self) -> f32 { + self.font_size * 1.3 + } + + /// returns true while an eval debounce is pending + 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()) + }) + } + + /// walks the live blocks in document order. + pub fn iter_blocks(&self) -> impl Iterator { + self.layout.iter().filter_map(move |id| self.registry.get(id)) + } + + pub fn get_clean_text(&self) -> String { + self.full_text() + } +} diff --git a/viewport/src/editor/text_ops.rs b/viewport/src/editor/text_ops.rs new file mode 100644 index 0000000..0199bab --- /dev/null +++ b/viewport/src/editor/text_ops.rs @@ -0,0 +1,334 @@ +use std::sync::Arc; + +use crate::blocks; +use crate::text_block::TextBlock; +use crate::text_widget::{self, Motion}; + +use super::types::EditKind; +use super::RenderMode; + +impl super::EditorState { + pub fn set_text(&mut self, text: &str) { + 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); + } + + /// replaces all text without pushing an undo snapshot + pub(super) fn replace_text_no_undo(&mut self, text: &str) { + 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(); + } + + pub(super) 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 => { + 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(); + 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 { + 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; + } + } + + let wrapped = format!("{open}{selected}{close}"); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(wrapped)), + )); + self.reparse(); + } + + /// replaces a byte range in the current content with `replacement` + pub(super) 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..]); + 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())), + )); + 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(); + } + + /// returns the byte range of the current selection, or None + pub(super) fn selection_byte_range(&self, text: &str, _cursor_pos: usize) -> Option<(usize, usize)> { + let sel = self.content().selection()?; + let cursor = self.content().cursor(); + let cursor_byte = byte_offset_for_cursor(text, &cursor.position); + let len = sel.len(); + if cursor_byte >= len && &text[cursor_byte - len..cursor_byte] == sel.as_str() { + return Some((cursor_byte - len, cursor_byte)); + } + if cursor_byte + len <= text.len() && &text[cursor_byte..cursor_byte + len] == sel.as_str() { + return Some((cursor_byte, cursor_byte + len)); + } + text.find(sel.as_str()).map(|s| (s, s + len)) + } + + /// inserts paired delimiters and places the caret between them + pub(super) 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)); + } + } + + /// toggles the `> ` blockquote prefix on the current line + pub(super) 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)); + 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(); + } + + /// incremental scope-exit and newline-sandwich placement + pub(super) 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(); + } + + /// places the cursor on its own line with one blank line of padding above and below + pub(super) 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); + 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; } + } + 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(super) fn leading_whitespace(line: &str) -> &str { + let end = line.len() - line.trim_start().len(); + &line[..end] +} + +/// counts consecutive trailing occurrences of `c` in `s` +pub(super) fn count_trailing_char(s: &str, c: char) -> usize { + s.chars().rev().take_while(|&x| x == c).count() +} + +/// counts consecutive leading occurrences of `c` in `s` +pub(super) fn count_leading_char(s: &str, c: char) -> usize { + s.chars().take_while(|&x| x == c).count() +} + +/// converts a line/column position to a byte offset in `text` +pub(super) fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { + let mut byte = 0usize; + for (line_idx, line) in text.split_inclusive('\n').enumerate() { + if line_idx == pos.line { + for (col_idx, (ci, _)) in line.char_indices().enumerate() { + if col_idx == pos.column { return byte + ci; } + } + return byte + line.trim_end_matches('\n').len(); + } + byte += line.len(); + } + text.len() +} + +/// inverse of `byte_offset_for_cursor` +pub(super) 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)) +} + +/// walks `text` left-to-right tracking a delimiter stack +pub(super) 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() +} + +/// returns the byte offset of the next outer scope's closing delimiter +pub(super) 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 +} diff --git a/viewport/src/editor/types.rs b/viewport/src/editor/types.rs new file mode 100644 index 0000000..aa161c9 --- /dev/null +++ b/viewport/src/editor/types.rs @@ -0,0 +1,451 @@ +use std::collections::HashMap; +use std::time::Instant; + +use iced_wgpu::core::text::Highlight; +use iced_wgpu::core::{border, padding, Font, Point}; +use iced_widget::markdown; + +use crate::palette; +use crate::table_block::TableMessage; +use crate::text_widget; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// blocks rendered, eval runs, tables interactive + Live, + /// raw markdown in one text_editor, no eval, no block splitting + Editor, + /// read-only rendered view + View, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutMode { + /// objects float on layers above zero with absolute coordinates + Free, + /// objects stay on layer 0 and reorder by drag + Relative, + /// objects sit at fixed positions and layer 0 wraps around the cutouts + Anchored, +} + +/// gutter line-number and cursorline display mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineIndicator { + /// absolute line numbers with full-row cursorline band + On, + /// no line numbers and no cursorline band + Off, + /// vim-style relative line numbers with cursorline band + 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, + /// wraps the selection in matching delimiters, or unwraps an existing pair + WrapWith(&'static str, &'static str), + /// inserts paired `[]` or `{}` and places the cursor between them + AutoPair(&'static str, &'static str), + /// incremental scope exit, then newline-sandwich placement + FixUp, + Evaluate, + /// evaluates every module in document order + 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 a table's top row escapes upward + EscapeTableUp(usize), + /// down arrow on a table's last row escapes downward + EscapeTableDown(usize), + /// moves the focused cell up by one row, staying inside the same table + TableMoveUp, + /// moves the focused cell down by one row, staying inside the same table + TableMoveDown, + /// moves the focused cell left by one column + TableMoveLeft, + /// moves the focused cell right by one column + TableMoveRight, + /// backspace or delete on a selected (not editing) cell + ClearSelectedCell, + /// second cmd+a press escalates to whole-document selection + SelectAllBlocks, + /// backspace or delete while all blocks are selected + ClearAllBlocks, + /// cmd+backspace while all blocks are selected + DeleteAllBlocks, + /// right-click on a table cell + ShowContextMenu { block_idx: usize }, + /// explicitly closes the context menu + HideContextMenu, + /// pushes a literal string into the clipboard out-channel + CopyLiteral(String), + /// copies the current table selection as TSV + CopyFocusedTableSelection, + /// escape from cell edit mode + ExitCellEdit, + /// replaces the selected cell with one character and enters edit mode + EnterCellEditWithChar(char), + /// tab key indents the current line + IndentTab, + OutdentTab, + SetRenderMode(RenderMode), + /// mouse pressed on an inline result, arms the long-press timer + InlineResultPress { block_id: crate::selection::BlockId, after_line: usize }, + /// mouse released anywhere, cancels a pending long-press + InlineResultRelease, + /// double-click on an inline result + InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, + /// mouse pressed on a heading or hr block, arms a free-layer drag. + BlockPromotePress(crate::selection::BlockId), + /// mouse pressed on an inline image, arms a free-layer drag. + ImagePromotePress { block_id: crate::selection::BlockId, after_line: usize, src: String }, + /// mouse released after a block or image promote press. + PromoteRelease, + /// mouse pressed on a resize band of a free-layer object. + ResizePress { node_id: FreeNodeId, horiz: bool, vert: bool }, + /// mouse released after a resize press. + ResizeRelease, + /// switches between free, relative, and anchored layout modes. + SetLayoutMode(LayoutMode), + /// toggles 0.25-line snap on drags and resizes. + ToggleSnapping, + ToggleMenu(MenuCategory), + CloseMenu, + Shell(ShellAction), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuCategory { + File, + Edit, + Render, + Mode, + View, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellAction { + NewNote, + Open, + Save, + SaveAs, + Quit, + Settings, + ExportCrate, + Print, + ToggleBrowser, + SetThemeMode(String), + SetLineIndicator(String), + SetGutterRainbow(bool), + PickAutoSaveDir, +} + +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub(super) const MENU_CATS: [(MenuCategory, &'static str); 5] = [ + (MenuCategory::File, "File"), + (MenuCategory::Edit, "Edit"), + (MenuCategory::Render, "Render"), + (MenuCategory::Mode, "Mode"), + (MenuCategory::View, "View"), +]; + +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub(super) fn cat_btn_width(label: &str, char_w: f32, pad_x: f32) -> f32 { + label.chars().count() as f32 * char_w + pad_x * 2.0 +} + +pub const RESULT_PREFIX: &str = "→ "; + +/// long-press and double-click state for inline eval results +#[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, +} + +pub(super) const LONG_PRESS_MS: u128 = 300; + +pub const ERROR_PREFIX: &str = "⚠ "; + +pub(super) const EVAL_DEBOUNCE_MS: u128 = 300; + +/// anchor linking a computed item to a text block +#[derive(Debug, Clone)] +pub struct Anchor { + pub block_id: crate::selection::BlockId, + pub after_line: usize, +} + +/// inline eval result text or error message +#[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 } +} + +/// computed table produced by `/=|` 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 + } +} + +/// computed tree produced by `/=\` 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) + } +} + +/// embedded image referenced by `![alt](src)` +#[derive(Debug, Clone)] +pub struct ComputedImage { + pub anchor: Anchor, + pub src: String, + pub alt: String, + /// pre-computed display height, or a placeholder while loading + pub display_height: f32, +} + +/// cached image data keyed by source path or URL +pub struct ImageCacheEntry { + pub handle: iced_widget::image::Handle, + pub width: u32, + pub height: u32, +} + +pub(super) const IMAGE_PLACEHOLDER_H: f32 = 24.0; +pub(super) const IMAGE_MAX_H: f32 = 600.0; +pub(super) const IMAGE_PADDING: f32 = 48.0; +pub(super) const IMAGE_VPAD: f32 = 4.0; + +/// reference to a computed layer item for interleaved rendering +pub(super) enum LayerItem<'a> { + Inline(&'a InlineResult), + Table(&'a ComputedTable), + Tree(&'a ComputedTree), + Image(&'a ComputedImage), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FreeNodeId { + Block(crate::selection::BlockId), + Table(crate::selection::BlockId, usize), + Tree(crate::selection::BlockId, usize), + Image(crate::selection::BlockId, usize, String), +} + +/// position and size of a free-layer object in editor-content coordinates. +#[derive(Debug, Clone, Copy)] +pub struct FreePlacement { + pub layer: u8, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// pending drag state for promoting a block onto a free layer. +#[derive(Debug, Clone)] +pub struct PromoteDragState { + pub node_id: FreeNodeId, + pub start_cursor: Point, + pub origin: (f32, f32), + pub size: (f32, f32), + pub layer: u8, + pub escalated: bool, + pub fallback_table_idx: Option, +} + +/// pending drag state for resizing a free-layer object from an edge band. +#[derive(Debug, Clone)] +pub struct ResizeDragState { + pub node_id: FreeNodeId, + pub start_cursor: Point, + pub start_size: (f32, f32), + pub axes: (bool, bool), + pub snapshot_pushed: bool, +} + +impl LayerItem<'_> { + pub(super) 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 widget id for the document scrollable +pub const DOC_SCROLLABLE_ID: &str = "doc_scrollable"; +pub(super) const UNDO_MAX: usize = 200; +pub(super) const COALESCE_MS: u128 = 500; + +pub(super) struct UndoSnapshot { + pub(super) text: String, + pub(super) cursor_line: usize, + pub(super) cursor_col: usize, + pub(super) free_placements: HashMap, + pub(super) frozen_doc_size: Option<(f32, f32)>, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub(super) 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 { + pub(super) fn new() -> Self { + Self { + visible: false, + query: String::new(), + replacement: String::new(), + matches: Vec::new(), + current: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct SettingsView { + pub theme_mode: String, + pub line_indicator: String, + pub gutter_rainbow: bool, + pub auto_save_dir: String, +} + +impl Default for SettingsView { + fn default() -> Self { + Self { + theme_mode: "auto".to_string(), + line_indicator: "on".to_string(), + gutter_rainbow: true, + auto_save_dir: String::new(), + } + } +} + +/// per-eval table name to id bookkeeping +pub struct TableIndex { + pub keys: HashMap, + pub canonical: HashMap, +} + +pub(super) 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 } + } + } +} + +/// on-screen context menu state anchored at viewport coordinates +#[derive(Debug, Clone)] +pub struct ContextMenuState { + pub block_idx: usize, + pub x: f32, + pub y: f32, +} + +pub(super) 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, + } +} diff --git a/viewport/src/editor/undo.rs b/viewport/src/editor/undo.rs new file mode 100644 index 0000000..bb8317e --- /dev/null +++ b/viewport/src/editor/undo.rs @@ -0,0 +1,90 @@ +use std::time::Instant; + +use crate::text_widget::{self, Action, Cursor, Position}; + +use super::types::{EditKind, UndoSnapshot, COALESCE_MS, UNDO_MAX}; + +impl super::EditorState { + pub(super) 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, + free_placements: self.free_placements.clone(), + frozen_doc_size: self.frozen_doc_size, + } + } + + pub(super) 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); + } + } + + pub(super) 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(); + } + + pub(super) 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, + } + } + + pub(super) fn restore_snapshot(&mut self, snap: &UndoSnapshot) { + 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, + }); + self.free_placements = snap.free_placements.clone(); + self.frozen_doc_size = snap.frozen_doc_size; + } + + pub(super) 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; + } + + pub(super) 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; + } +} diff --git a/viewport/src/editor/update.rs b/viewport/src/editor/update.rs new file mode 100644 index 0000000..0911286 --- /dev/null +++ b/viewport/src/editor/update.rs @@ -0,0 +1,940 @@ +use std::sync::Arc; +use std::time::Instant; + +use iced_wgpu::core::widget::Id as WidgetId; + +use crate::blocks::{self, BoxedBlock}; +use crate::table_block::{self, TableBlock, TableMessage}; +use crate::text_block::TextBlock; +use crate::text_widget::{self, Action, Cursor, Motion, Position}; + +use super::types::{ + ContextMenuState, InlinePressState, LayoutMode, Message, RenderMode, + ResizeDragState, ShellAction, FIND_INPUT_ID, +}; +use super::{block_editor_id, detect_lang_from_content}; +use super::eval::parse_let_binding; +use super::text_ops::leading_whitespace; + +impl super::EditorState { + /// returns true when `message` is safe to dispatch in view mode + pub(super) 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::ToggleMenu(_) | Message::CloseMenu | Message::Shell(_) => true, + Message::CopyLiteral(_) | Message::CopyFocusedTableSelection => true, + Message::InlineResultPress { .. } | Message::InlineResultRelease => true, + Message::EvalAll => true, + Message::EditorAction(action) | Message::BlockAction(_, action) => { + !action.is_edit() + } + _ => false, + } + } + + /// dispatches a text-widget action through the editor's edit pipeline. + pub(super) fn handle_editor_action(&mut self, action: text_widget::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(); + 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)); + self.pending_scroll += *lines as f32 * lh; + } + + 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); + } + } + + 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; + } + } + } + + /// replaces the currently-selected match with the find-bar replacement text. + pub(super) fn handle_replace_one(&mut self) { + 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]; + 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(); + } + } + + /// replaces every find-bar match across the document in one pass. + pub(super) fn handle_replace_all(&mut self) { + if self.find.matches.is_empty() || self.find.query.is_empty() { + return; + } + self.push_undo_snapshot(); + self.redo_stack.clear(); + + 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(); + } + + /// dispatches a TableMessage at block index, intercepting promote, delete, and context-menu cases. + pub(super) fn handle_table_msg(&mut self, idx: usize, tmsg: TableMessage) { + match &tmsg { + TableMessage::PromoteCornerPress => { + self.begin_promote_table_corner(idx); + return; + } + TableMessage::PromoteCornerRelease => { + if let Some(pd) = self.promote_drag.take() { + if !pd.escalated { + if let Some(fb_idx) = pd.fallback_table_idx { + self.update(Message::TableMsg(fb_idx, TableMessage::SelectAll)); + } + } + } + return; + } + _ => {} + } + let structural = matches!( + &tmsg, + TableMessage::InsertRowAbove + | TableMessage::InsertRowBelow + | TableMessage::DeleteRow + | TableMessage::InsertColLeft + | TableMessage::InsertColRight + | TableMessage::DeleteCol + | TableMessage::AddRow + | TableMessage::AddColumn + ); + 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; + } + } + } + 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; + } + 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; + } + 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(); + } + + let select_target = if let TableMessage::SelectCell(r, c) = &tmsg { + Some((*r, *c)) + } else { + None + }; + + // formula-fill interception: clicking a different cell while + // editing a `/=` formula fills its first empty `[]` slot with + // the clicked cell's address instead of switching focus. + if let Some((r, c)) = select_target { + if self.try_fill_formula_slot(idx, r, c) { + return; + } + } + let edit_target = if let TableMessage::EditCell(r, c) = &tmsg { + Some((*r, *c)) + } else { + None + }; + + let mods = self.mods; + + if let Some(tb) = self.table_block_at_mut(idx) { + tb.handle(tmsg); + } + + if let Some((r, c)) = select_target { + 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 { + 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(); + } + } + + pub fn update(&mut self, message: Message) { + #[cfg(debug_assertions)] + println!("Received message: {:?}", message); + if self.render_mode == RenderMode::View && !Self::message_is_view_safe(&message) { + return; + } + + 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; + } + + let preserve_context_menu = matches!( + &message, + Message::ShowContextMenu { .. } + | Message::FocusedTableOp(..) + | Message::TableMsg(_, TableMessage::CursorMove(_,_)) + | Message::TableMsg(_, TableMessage::CellEnter(_,_)) + | Message::HideContextMenu + ); + if !preserve_context_menu && self.context_menu.is_some() { + self.context_menu = None; + } + + let preserve_menu_strip = matches!( + &message, + Message::ToggleMenu(_), + ); + if !preserve_menu_strip && self.menu_open.is_some() { + self.menu_open = None; + } + + match message { + Message::EditorAction(action) => self.handle_editor_action(action), + 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); + 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(); + self.set_editing_cell(insert_at, 1, 0); + self.reparse(); + } + 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 => self.handle_replace_one(), + Message::ReplaceAll => self.handle_replace_all(), + Message::TableMsg(idx, tmsg) => self.handle_table_msg(idx, tmsg), + 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)); + } + 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 => { + if let Some(path) = self.editing.clone() { + self.editing = None; + if let crate::selection::InnerPath::Cell { row, col } = path.inner { + 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 }; + 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 => { + 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(); + 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 => { + 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 } => { + self.context_menu = Some(ContextMenuState { + block_idx, + x: self.cursor_pos.x, + y: self.cursor_pos.y, + }); + } + Message::HideContextMenu => { + self.context_menu = None; + } + Message::ToggleMenu(cat) => { + self.menu_open = if self.menu_open == Some(cat) { None } else { Some(cat) }; + } + Message::CloseMenu => { + self.menu_open = None; + } + Message::Shell(action) => { + self.menu_open = None; + match action { + ShellAction::Settings => { + self.settings_open = !self.settings_open; + } + other => { + self.pending_shell_action = Some(other); + } + } + } + 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 => { + 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); + } + Message::BlockPromotePress(block_id) => { + self.begin_promote_block(block_id); + } + Message::ImagePromotePress { block_id, after_line, src } => { + self.begin_promote_image(block_id, after_line, src); + } + Message::PromoteRelease => { + self.promote_drag = None; + } + Message::ResizePress { node_id, horiz, vert } => { + if !matches!(self.render_mode, RenderMode::Live) { return; } + if self.active_free.as_ref() != Some(&node_id) { return; } + let Some(p) = self.free_placements.get(&node_id).copied() else { return }; + self.resize_drag = Some(ResizeDragState { + node_id, + start_cursor: self.cursor_pos, + start_size: (p.w, p.h), + axes: (horiz, vert), + snapshot_pushed: false, + }); + } + Message::ResizeRelease => { + self.resize_drag = None; + } + Message::SetLayoutMode(mode) => { + self.layout_mode = mode; + self.snapping = !matches!(mode, LayoutMode::Free); + } + Message::ToggleSnapping => { + self.snapping = !self.snapping; + } + } + } +}