Finally split editor.rs into bit sized pieces. Rest in pieces my 3kloc beloved.

This commit is contained in:
jess 2026-05-10 05:56:35 -07:00
parent 62f5d6212e
commit 943aa82ec1
15 changed files with 5713 additions and 5538 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

@ -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<BoxedBlock>) -> (HashMap<crate::selection::BlockId, BoxedBlock>, Vec<crate::selection::BlockId>) {
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<BoxedBlock> {
self.layout.iter().filter_map(|id| self.registry.remove(id)).collect()
}
pub(super) fn replace_blocks(&mut self, blocks: Vec<BoxedBlock>) {
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<BoxedBlock> {
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<usize> {
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::<TextBlock>())
}
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::<TextBlock>())
}
pub(super) fn table_block_at(&self, idx: usize) -> Option<&TableBlock> {
self.block_at(idx).and_then(|b| b.as_any().downcast_ref::<TableBlock>())
}
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::<TableBlock>())
}
/// returns the layout index of a block id, if any.
pub(super) fn index_of_block_id(&self, bid: crate::selection::BlockId) -> Option<usize> {
self.layout.iter().position(|&id| id == bid)
}
pub(super) fn first_text_block_index(&self) -> Option<usize> {
self.layout.iter().enumerate().find_map(|(i, id)| {
self.registry.get(id).and_then(|b| {
if b.as_any().is::<TextBlock>() { 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
}
}

View File

@ -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::<TableBlock>() {
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::<TextBlock>())
.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::<TextBlock>()
.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::<TableBlock>() {
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::<TableBlock>() 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<usize> {
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::<TableBlock>() {
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::<TableBlock>() {
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<usize> {
let block = self.block_at(self.focused_block)?;
let tb = block.as_any().downcast_ref::<TableBlock>()?;
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::<TableBlock>() 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::<TableBlock>() 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<String> {
let block = self.block_at(self.focused_block)?;
let tb = block.as_any().downcast_ref::<TableBlock>()?;
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()
}
}

695
viewport/src/editor/eval.rs Normal file
View File

@ -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<crate::selection::BlockId> = 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::<TableBlock>() {
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<crate::module::BlockInfo> {
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::<HeadingBlock>() {
(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::<TableBlock>() {
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<crate::selection::BlockId, String> = 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<String, crate::selection::BlockId> = HashMap::new();
let mut canonical: HashMap<crate::selection::BlockId, String> = 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::<TableBlock>() 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<String> = 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::<TableBlock>() {
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<Cell> = Vec::new();
let mut parse_errors: Vec<(crate::selection::BlockId, u32, u32, String)> = Vec::new();
let mut seen_blocks: std::collections::HashSet<crate::selection::BlockId> =
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::<TableBlock>() 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<usize>> = vec![Vec::new(); formulas.len()];
let mut in_degree: Vec<usize> = 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<usize> = in_degree.iter().enumerate()
.filter_map(|(i, &d)| if d == 0 { Some(i) } else { None })
.collect();
let mut order: Vec<usize> = 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<usize> = 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<acord_core::interp::TableWrite>,
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::<TableBlock>() 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<String> = 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<crate::selection::BlockId> = 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::<TextBlock>() {
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<String> = 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<String>,
) -> 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<String> = Vec::new();
let mut boundaries: Vec<(usize, crate::selection::BlockId)> = Vec::new();
let mut cumulative = 0usize;
let mut block_ids: Vec<crate::selection::BlockId> = 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::<Vec<Vec<String>>>(&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::<serde_json::Value>(&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<crate::module::Module> = 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<String> {
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<String> {
let block = self.registry.get(&block_id)?;
let tb = block.as_any().downcast_ref::<TextBlock>()?;
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<String> {
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())
}

View File

@ -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,
});
}
}

View File

@ -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::<TableBlock>() {
let w: f32 = tab.col_widths.iter().sum::<f32>() + 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::<HeadingBlock>() {
total_h += self.font_size * 2.4 + 8.0;
} else if any.is::<HrBlock>() {
total_h += 24.0;
} else if let Some(tb) = any.downcast_ref::<TextBlock>() {
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<usize>) {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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::<TextBlock>() {
return Some(self.build_text_block_widget(tb, bi, 0, 0.0));
}
if let Some(tab) = any.downcast_ref::<TableBlock>() {
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::<HeadingBlock>() {
return Some(<HeadingBlock as BlockTrait<Message>>::view(hb, &ctx).base);
}
if let Some(hr) = any.downcast_ref::<HrBlock>() {
return Some(<HrBlock as BlockTrait<Message>>::view(hr, &ctx).base);
}
if let Some(tree) = any.downcast_ref::<TreeBlock>() {
return Some(<TreeBlock as BlockTrait<Message>>::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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for &(id, placement) in &placed {
let inner_opt: Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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())
}
}
}

1505
viewport/src/editor/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<crate::selection::BlockId> {
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::<HeadingBlock>() || any.is::<HrBlock>();
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::<HeadingBlock>() || any.is::<HrBlock>() {
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;
}
}

View File

@ -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<Vec<u8>> {
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<sidecar::BlockFile> {
use std::collections::HashSet;
let mut files = Vec::with_capacity(self.modules.len());
let mut used: HashSet<String> = HashSet::new();
for (index, module) in self.modules.iter().enumerate() {
let mut source_parts: Vec<String> = 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::<HeadingBlock>() {
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>,
) -> 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::<TableBlock>() 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::<TableBlock>() 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::<usize>() {
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::<TextBlock>() {
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<ImageCacheEntry> {
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<String> {
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())
}

View File

@ -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<crate::selection::BlockId, BoxedBlock>,
pub layout: Vec<crate::selection::BlockId>,
pub modules: Vec<crate::module::Module>,
pub focused_block: usize,
pub font_size: f32,
pub preview: bool,
pub render_mode: RenderMode,
pub parsed: Vec<markdown::Item>,
pub lang: Option<String>,
pub(super) scroll_offset: f32,
pub(super) eval_dirty: bool,
pub(super) last_edit: Instant,
pub(super) undo_stack: Vec<UndoSnapshot>,
pub(super) redo_stack: Vec<UndoSnapshot>,
pub(super) last_edit_kind: EditKind,
pub(super) last_edit_time: Instant,
pub find: FindState,
pub pending_focus: Option<WidgetId>,
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<crate::selection::NodePath>,
/// path of the cell currently in text-input edit mode
#[allow(dead_code)]
pub(crate) editing: Option<crate::selection::NodePath>,
/// 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<ContextMenuState>,
pub eval_results: Vec<InlineResult>,
pub computed_tables: Vec<ComputedTable>,
pub computed_trees: Vec<ComputedTree>,
/// 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<InlinePressState>,
/// 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<String>,
pub computed_images: Vec<ComputedImage>,
pub image_cache: HashMap<String, ImageCacheEntry>,
/// previous global cursor line, used to detect line changes
pub(super) prev_cursor_line: usize,
pub menu_open: Option<MenuCategory>,
pub pending_shell_action: Option<ShellAction>,
pub settings_open: bool,
pub settings_view: SettingsView,
pub free_placements: HashMap<FreeNodeId, FreePlacement>,
pub frozen_doc_size: Option<(f32, f32)>,
pub viewport_size: (f32, f32),
pub promote_drag: Option<PromoteDragState>,
pub promote_snapshot_pushed: bool,
pub resize_drag: Option<ResizeDragState>,
pub active_free: Option<FreeNodeId>,
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<ShellAction> {
self.pending_shell_action.take()
}
pub fn take_pending_focus(&mut self) -> Option<WidgetId> {
self.pending_focus.take()
}
/// drains the accumulated wheel-scroll delta
pub fn take_pending_scroll(&mut self) -> Option<f32> {
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::<TableBlock>())
.is_some_and(|tb| tb.has_pending_hover())
})
}
/// walks the live blocks in document order.
pub fn iter_blocks(&self) -> impl Iterator<Item = &BoxedBlock> {
self.layout.iter().filter_map(move |id| self.registry.get(id))
}
pub fn get_clean_text(&self) -> String {
self.full_text()
}
}

View File

@ -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<String> = 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<char> {
let mut stack: Vec<char> = 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<usize> {
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
}

View File

@ -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<Vec<String>>,
pub col_widths: Vec<f32>,
}
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<usize>,
}
/// 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<FreeNodeId, FreePlacement>,
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<String, crate::selection::BlockId>,
pub canonical: HashMap<crate::selection::BlockId, String>,
}
pub(super) fn resolve_ref_key(
r: &acord_core::interp::FormulaRef,
table_index: &TableIndex,
) -> Option<String> {
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,
}
}

View File

@ -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<EditKind> {
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;
}
}

View File

@ -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<usize> =
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<String> = 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::<String>()
.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::<String>()
.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<String>> = 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("<u>", "</u>"),
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::<TableBlock>()) {
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;
}
}
}
}