diff --git a/viewport/src/blocks.rs b/viewport/src/blocks.rs new file mode 100644 index 0000000..b16d793 --- /dev/null +++ b/viewport/src/blocks.rs @@ -0,0 +1,476 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use iced_widget::text_editor; + +static NEXT_BLOCK_ID: AtomicU64 = AtomicU64::new(1); + +pub fn next_id() -> u64 { + NEXT_BLOCK_ID.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockKind { + Text, + Table, + HorizontalRule, + Heading, + Tree, + EvalResult, +} + +pub struct Block { + pub id: u64, + pub kind: BlockKind, + pub content: text_editor::Content, + pub start_line: usize, + pub line_count: usize, + pub heading_level: u8, + pub heading_text: String, + pub table_rows: Vec>, + pub tree_data: Option, + pub eval_text: String, +} + +impl Block { + pub fn text(text: &str, start_line: usize) -> Self { + let lc = text.lines().count().max(1); + Self { + id: next_id(), + kind: BlockKind::Text, + content: text_editor::Content::with_text(text), + start_line, + line_count: lc, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + tree_data: None, + eval_text: String::new(), + } + } + + pub fn heading(level: u8, text: String, line: usize) -> Self { + Self { + id: next_id(), + kind: BlockKind::Heading, + content: text_editor::Content::with_text(""), + start_line: line, + line_count: 1, + heading_level: level, + heading_text: text, + table_rows: Vec::new(), + tree_data: None, + eval_text: String::new(), + } + } + + pub fn hr(line: usize) -> Self { + Self { + id: next_id(), + kind: BlockKind::HorizontalRule, + content: text_editor::Content::with_text(""), + start_line: line, + line_count: 1, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + tree_data: None, + eval_text: String::new(), + } + } + + pub fn table(rows: Vec>, start_line: usize, line_count: usize) -> Self { + Self { + id: next_id(), + kind: BlockKind::Table, + content: text_editor::Content::with_text(""), + start_line, + line_count, + heading_level: 0, + heading_text: String::new(), + table_rows: rows, + tree_data: None, + eval_text: String::new(), + } + } + + pub fn eval_result(text: String, line: usize) -> Self { + Self { + id: next_id(), + kind: BlockKind::EvalResult, + content: text_editor::Content::with_text(""), + start_line: line, + line_count: 1, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + tree_data: None, + eval_text: text, + } + } + + pub fn tree(data: serde_json::Value, line: usize) -> Self { + Self { + id: next_id(), + kind: BlockKind::Tree, + content: text_editor::Content::with_text(""), + start_line: line, + line_count: 1, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + tree_data: Some(data), + eval_text: String::new(), + } + } +} + +fn is_hr_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-') +} + +fn heading_prefix(line: &str) -> Option<(u8, &str)> { + let trimmed = line.trim_start(); + let bytes = trimmed.as_bytes(); + if bytes.is_empty() || bytes[0] != b'#' { + return None; + } + let mut level = 0u8; + while (level as usize) < bytes.len() && bytes[level as usize] == b'#' { + level += 1; + } + if level > 3 || (level as usize) >= bytes.len() || bytes[level as usize] != b' ' { + return None; + } + Some((level, &trimmed[level as usize + 1..])) +} + +fn is_table_start(lines: &[&str], idx: usize) -> bool { + if idx + 1 >= lines.len() { + return false; + } + let line = lines[idx].trim(); + let next = lines[idx + 1].trim(); + if !line.starts_with('|') || !next.starts_with('|') { + return false; + } + let inner = next.strip_prefix('|').unwrap_or(next); + let inner = inner.strip_suffix('|').unwrap_or(inner); + inner.split('|').all(|seg| { + let s = seg.trim(); + !s.is_empty() && s.chars().all(|c| c == '-' || c == ':') + }) +} + +fn consume_table(lines: &[&str], start: usize) -> (Vec>, usize) { + let parse_row = |line: &str| -> Vec { + let trimmed = line.trim(); + let inner = trimmed.strip_prefix('|').unwrap_or(trimmed); + let inner = inner.strip_suffix('|').unwrap_or(inner); + inner.split('|').map(|c| c.trim().to_string()).collect() + }; + + let mut rows = vec![parse_row(lines[start])]; + let mut end = start + 2; // skip header + separator + while end < lines.len() { + let trimmed = lines[end].trim(); + if trimmed.is_empty() || !trimmed.contains('|') { + break; + } + rows.push(parse_row(lines[end])); + end += 1; + } + (rows, end) +} + +/// Classify each line into a block kind, returning (kind, line_index) boundaries. +struct BlockSpan { + kind: BlockKind, + start: usize, + end: usize, // exclusive + heading_level: u8, + heading_text: String, + table_rows: Vec>, +} + +fn classify_spans(lines: &[&str]) -> Vec { + let mut spans = Vec::new(); + let mut i = 0; + let mut text_start: Option = None; + + let flush_text = |text_start: &mut Option, i: usize, spans: &mut Vec| { + if let Some(s) = text_start.take() { + if s < i { + spans.push(BlockSpan { + kind: BlockKind::Text, + start: s, + end: i, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + }); + } + } + }; + + while i < lines.len() { + if is_hr_line(lines[i]) { + flush_text(&mut text_start, i, &mut spans); + spans.push(BlockSpan { + kind: BlockKind::HorizontalRule, + start: i, + end: i + 1, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + }); + i += 1; + } else if let Some((level, text)) = heading_prefix(lines[i]) { + flush_text(&mut text_start, i, &mut spans); + spans.push(BlockSpan { + kind: BlockKind::Heading, + start: i, + end: i + 1, + heading_level: level, + heading_text: text.to_string(), + table_rows: Vec::new(), + }); + i += 1; + } else if is_table_start(lines, i) { + flush_text(&mut text_start, i, &mut spans); + let (rows, end) = consume_table(lines, i); + spans.push(BlockSpan { + kind: BlockKind::Table, + start: i, + end, + heading_level: 0, + heading_text: String::new(), + table_rows: rows, + }); + i = end; + } else { + if text_start.is_none() { + text_start = Some(i); + } + i += 1; + } + } + flush_text(&mut text_start, lines.len(), &mut spans); + + if spans.is_empty() { + spans.push(BlockSpan { + kind: BlockKind::Text, + start: 0, + end: 0, + heading_level: 0, + heading_text: String::new(), + table_rows: Vec::new(), + }); + } + + spans +} + +/// Parse document text into blocks, detecting HR, headings, and tables. +pub fn parse_blocks(text: &str) -> Vec { + if text.is_empty() { + return vec![Block::text("", 0)]; + } + + let lines: Vec<&str> = text.lines().collect(); + let spans = classify_spans(&lines); + let mut blocks = Vec::with_capacity(spans.len()); + + for span in &spans { + match span.kind { + BlockKind::Text => { + let block_text = lines[span.start..span.end].join("\n"); + blocks.push(Block::text(&block_text, span.start)); + } + BlockKind::HorizontalRule => { + blocks.push(Block::hr(span.start)); + } + BlockKind::Heading => { + blocks.push(Block::heading(span.heading_level, span.heading_text.clone(), span.start)); + } + BlockKind::Table => { + blocks.push(Block::table(span.table_rows.clone(), span.start, span.end - span.start)); + } + _ => {} + } + } + + if blocks.is_empty() { + blocks.push(Block::text("", 0)); + } + + blocks +} + +/// Incremental reparse: compare old block kinds to new, reuse unchanged blocks. +/// Returns the new block list, preserving IDs and content of blocks that didn't change type. +pub fn reparse_incremental(old_blocks: &mut Vec, text: &str) { + let lines: Vec<&str> = if text.is_empty() { + Vec::new() + } else { + text.lines().collect() + }; + let spans = classify_spans(&lines); + + let old_kinds: Vec<(BlockKind, usize, usize)> = old_blocks + .iter() + .map(|b| (b.kind, b.start_line, b.line_count)) + .collect(); + + let new_kinds: Vec<(BlockKind, usize, usize)> = spans + .iter() + .map(|s| (s.kind, s.start, s.end - s.start)) + .collect(); + + if old_kinds == new_kinds { + // Same structure: update text content in-place for text blocks + for (block, span) in old_blocks.iter_mut().zip(spans.iter()) { + block.start_line = span.start; + block.line_count = span.end - span.start; + if block.kind == BlockKind::Text { + let block_text = lines[span.start..span.end].join("\n"); + let current = block.content.text(); + if current != block_text { + block.content = text_editor::Content::with_text(&block_text); + } + } + } + return; + } + + // Structure changed: rebuild, but try to reuse blocks at matching positions + let mut new_blocks = Vec::with_capacity(spans.len()); + for (i, span) in spans.iter().enumerate() { + let reuse = if i < old_blocks.len() + && old_blocks[i].kind == span.kind + && old_blocks[i].start_line == span.start + { + true + } else { + false + }; + + if reuse { + let mut b = std::mem::replace(&mut old_blocks[i], Block::text("", 0)); + b.start_line = span.start; + b.line_count = span.end - span.start; + if b.kind == BlockKind::Text { + let block_text = lines[span.start..span.end].join("\n"); + let current = b.content.text(); + if current != block_text { + b.content = text_editor::Content::with_text(&block_text); + } + } + new_blocks.push(b); + } else { + match span.kind { + BlockKind::Text => { + let block_text = lines[span.start..span.end].join("\n"); + new_blocks.push(Block::text(&block_text, span.start)); + } + BlockKind::HorizontalRule => { + new_blocks.push(Block::hr(span.start)); + } + BlockKind::Heading => { + new_blocks.push(Block::heading(span.heading_level, span.heading_text.clone(), span.start)); + } + BlockKind::Table => { + new_blocks.push(Block::table(span.table_rows.clone(), span.start, span.end - span.start)); + } + _ => {} + } + } + } + + if new_blocks.is_empty() { + new_blocks.push(Block::text("", 0)); + } + + *old_blocks = new_blocks; +} + +/// Serialize blocks back to document text. +pub fn serialize_blocks(blocks: &[Block]) -> String { + let mut parts = Vec::new(); + for block in blocks { + match block.kind { + BlockKind::Text => { + parts.push(block.content.text()); + } + BlockKind::Table => { + if let Some(header) = block.table_rows.first() { + let cells: Vec<&str> = header.iter().map(|s| s.as_str()).collect(); + parts.push(format!("| {} |", cells.join(" | "))); + let sep = header.iter() + .map(|c| "-".repeat(c.len().max(3))) + .collect::>() + .join(" | "); + parts.push(format!("| {} |", sep)); + for row in block.table_rows.iter().skip(1) { + let cells: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); + parts.push(format!("| {} |", cells.join(" | "))); + } + } + } + BlockKind::HorizontalRule => { + parts.push("---".to_string()); + } + BlockKind::Heading => { + let prefix = "#".repeat(block.heading_level as usize); + parts.push(format!("{prefix} {}", block.heading_text)); + } + BlockKind::Tree => {} + BlockKind::EvalResult => { + parts.push(block.eval_text.clone()); + } + } + } + parts.join("\n") +} + +/// Extract plain text from all TextBlocks. +pub fn collect_text(blocks: &[Block]) -> String { + let mut parts = Vec::new(); + for block in blocks { + if block.kind == BlockKind::Text { + parts.push(block.content.text()); + } + } + parts.join("\n") +} + +/// Merge two adjacent text blocks. Returns the merged text. +pub fn merge_text_blocks(first: &Block, second: &Block) -> String { + let a = first.content.text(); + let b = second.content.text(); + if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + format!("{}\n{}", a, b) + } +} + +/// Update start_line counts after blocks change. +pub fn recount_lines(blocks: &mut [Block]) { + let mut line = 0; + for block in blocks.iter_mut() { + block.start_line = line; + line += block.line_count; + } +} + +/// Find the block index containing a given global line number. +pub fn block_at_line(blocks: &[Block], global_line: usize) -> Option { + for (i, block) in blocks.iter().enumerate() { + if global_line >= block.start_line && global_line < block.start_line + block.line_count { + return Some(i); + } + } + None +} diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 88850d0..a61cd54 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -17,10 +17,13 @@ use iced_wgpu::core::text::highlighter::Format; use iced_wgpu::core::widget::Id as WidgetId; use crate::palette; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors}; +use crate::table_block::TableMessage; #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { + BlockAction(usize, text_editor::Action), + FocusBlock(usize), EditorAction(text_editor::Action), TogglePreview, MarkdownLink(markdown::Uri), @@ -42,6 +45,7 @@ pub enum Message { ReplaceQueryChanged(String), ReplaceOne, ReplaceAll, + TableMsg(usize, TableMessage), } pub const RESULT_PREFIX: &str = "→ "; @@ -91,7 +95,8 @@ impl FindState { } pub struct EditorState { - pub content: text_editor::Content, + pub blocks: Vec, + pub focused_block: usize, pub font_size: f32, pub preview: bool, pub parsed: Vec, @@ -107,6 +112,7 @@ pub struct EditorState { pub find: FindState, pub pending_focus: Option, + pub table_states: std::collections::HashMap, } fn md_style() -> markdown::Style { @@ -128,32 +134,22 @@ fn md_style() -> markdown::Style { impl EditorState { pub fn new() -> Self { let sample = concat!( - "use std::collections::HashMap;\n\n", - "/// A simple key-value store.\n", - "pub struct Store {\n", - " data: HashMap,\n", - "}\n\n", - "impl Store {\n", - " pub fn new() -> Self {\n", - " Self { data: HashMap::new() }\n", - " }\n\n", - " pub fn insert(&mut self, key: &str, value: i64) {\n", - " self.data.insert(key.to_string(), value);\n", - " }\n\n", - " pub fn get(&self, key: &str) -> Option<&i64> {\n", - " self.data.get(key)\n", - " }\n", - "}\n\n", - "fn main() {\n", - " let mut store = Store::new();\n", - " store.insert(\"count\", 42);\n", - " if let Some(val) = store.get(\"count\") {\n", - " println!(\"value: {val}\");\n", - " }\n", - "}\n", + "# Block Compositor\n", + "Acord renders structured documents with mixed content.\n\n", + "## Data Table\n", + "| Name | Age | Role |\n", + "|-------|-----|----------|\n", + "| Alice | 30 | Engineer |\n", + "| Bob | 25 | Designer |\n", + "| Carol | 35 | Manager |\n\n", + "---\n\n", + "### Code Section\n", + "let x = 42\n", + "/= x * 2\n", ); - Self { - content: text_editor::Content::with_text(sample), + let mut s = Self { + blocks: crate::blocks::parse_blocks(sample), + focused_block: 0, font_size: 14.0, preview: false, parsed: Vec::new(), @@ -167,19 +163,206 @@ impl EditorState { last_edit_time: Instant::now(), find: FindState::new(), pending_focus: None, + table_states: std::collections::HashMap::new(), + }; + s.sync_table_states(); + s + } + + fn content(&self) -> &text_editor::Content { + let block = &self.blocks[self.focused_block]; + if block.kind == crate::blocks::BlockKind::Text { + &block.content + } else { + &self.blocks[0].content } } + fn content_mut(&mut self) -> &mut text_editor::Content { + let idx = self.focused_block; + let target = if self.blocks[idx].kind == crate::blocks::BlockKind::Text { + idx + } else { + 0 + }; + &mut self.blocks[target].content + } + + fn full_text(&self) -> String { + crate::blocks::serialize_blocks(&self.blocks) + } + + fn set_block_text(&mut self, idx: usize, text: &str) { + if let Some(block) = self.blocks.get_mut(idx) { + if block.kind == crate::blocks::BlockKind::Text { + block.content = text_editor::Content::with_text(text); + block.line_count = text.lines().count().max(1); + } + } + } + + #[allow(dead_code)] + fn block_content(&self, idx: usize) -> Option<&text_editor::Content> { + self.blocks.get(idx) + .filter(|b| b.kind == crate::blocks::BlockKind::Text) + .map(|b| &b.content) + } + + #[allow(dead_code)] + fn block_content_mut(&mut self, idx: usize) -> Option<&mut text_editor::Content> { + self.blocks.get_mut(idx) + .filter(|b| b.kind == crate::blocks::BlockKind::Text) + .map(|b| &mut b.content) + } + fn line_height(&self) -> f32 { self.font_size * 1.3 } + /// Handle arrow/backspace/delete at block boundaries. + /// Returns true if the action was consumed (focus change or merge). + fn handle_block_boundary(&mut self, action: &text_editor::Action) -> bool { + use crate::blocks::BlockKind; + + let idx = self.focused_block; + if self.blocks[idx].kind != BlockKind::Text { + return false; + } + + match action { + // Arrow up at line 0 -> focus previous block + Action::Move(Motion::Up) | Action::Select(Motion::Up) => { + let cursor = self.content().cursor(); + if cursor.position.line == 0 && idx > 0 { + self.focused_block = idx - 1; + return true; + } + } + // Arrow down at last line -> focus next block + 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.blocks.len() { + self.focused_block = idx + 1; + return true; + } + } + // Backspace at position 0 with no selection -> merge with previous text block + Action::Edit(text_editor::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); + } + } + } + // Delete at end of block -> merge with next text block + Action::Edit(text_editor::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.blocks.len() { + return self.merge_with_next(idx); + } + } + } + _ => {} + } + false + } + + /// Merge block at `idx` with the previous text block. + fn merge_with_previous(&mut self, idx: usize) -> bool { + use crate::blocks::BlockKind; + + if idx == 0 { + return false; + } + + let prev_idx = idx - 1; + if self.blocks[prev_idx].kind != BlockKind::Text { + // Previous is non-text (HR, heading) -- remove it instead + self.blocks.remove(prev_idx); + self.focused_block = prev_idx.min(self.blocks.len().saturating_sub(1)); + crate::blocks::recount_lines(&mut self.blocks); + return true; + } + + let merged = crate::blocks::merge_text_blocks(&self.blocks[prev_idx], &self.blocks[idx]); + let prev_line_count = self.blocks[prev_idx].line_count; + self.blocks[prev_idx].content = text_editor::Content::with_text(&merged); + self.blocks[prev_idx].line_count = merged.lines().count().max(1); + self.blocks.remove(idx); + self.focused_block = prev_idx; + // Place cursor at the join point + self.content_mut().move_to(Cursor { + position: Position { line: prev_line_count.saturating_sub(1), column: 0 }, + selection: None, + }); + crate::blocks::recount_lines(&mut self.blocks); + true + } + + /// Merge block at `idx` with the next text block. + fn merge_with_next(&mut self, idx: usize) -> bool { + use crate::blocks::BlockKind; + + let next_idx = idx + 1; + if next_idx >= self.blocks.len() { + return false; + } + + if self.blocks[next_idx].kind != BlockKind::Text { + self.blocks.remove(next_idx); + crate::blocks::recount_lines(&mut self.blocks); + return true; + } + + let merged = crate::blocks::merge_text_blocks(&self.blocks[idx], &self.blocks[next_idx]); + self.blocks[idx].content = text_editor::Content::with_text(&merged); + self.blocks[idx].line_count = merged.lines().count().max(1); + self.blocks.remove(next_idx); + crate::blocks::recount_lines(&mut self.blocks); + true + } + pub fn set_text(&mut self, text: &str) { - self.content = text_editor::Content::with_text(text); + if self.blocks.is_empty() { + self.blocks = crate::blocks::parse_blocks(text); + } else { + crate::blocks::reparse_incremental(&mut self.blocks, text); + } + if self.focused_block >= self.blocks.len() { + self.focused_block = 0; + } self.scroll_offset = 0.0; + self.sync_table_states(); self.reparse(); } + fn sync_table_states(&mut self) { + use crate::blocks::BlockKind; + let active_ids: std::collections::HashSet = self.blocks.iter() + .filter(|b| b.kind == BlockKind::Table) + .map(|b| b.id) + .collect(); + self.table_states.retain(|id, _| active_ids.contains(id)); + for block in &self.blocks { + if block.kind == BlockKind::Table { + self.table_states.entry(block.id).or_insert_with(|| { + crate::table_block::TableState::from_eval_rows(block.table_rows.clone()) + }); + } + } + } + pub fn set_lang_from_ext(&mut self, ext: &str) { self.lang = lang_from_extension(ext); } @@ -192,17 +375,17 @@ impl EditorState { } fn strip_results_in_place(&mut self) { - let text = self.content.text(); + let text = self.content().text(); if !text.lines().any(|l| is_result_line(l)) { return; } - let cursor = self.content.cursor(); + let cursor = self.content().cursor(); let clean_line = to_clean_line(&text, cursor.position.line); let clean_col = cursor.position.column; let clean = strip_result_lines(&text); - self.content = text_editor::Content::with_text(&clean); + self.set_block_text(self.focused_block, &clean); let restored_line = from_clean_line(&clean, clean_line); - self.content.move_to(Cursor { + self.content_mut().move_to(Cursor { position: Position { line: restored_line, column: clean_col }, selection: None, }); @@ -213,28 +396,51 @@ impl EditorState { self.parsed = markdown::parse(&text).collect(); } + /// Check if block structure changed after an edit. + /// Serializes current blocks, re-parses, and applies incremental diff. + fn check_block_structure(&mut self) { + let cursor = self.content().cursor(); + let full = self.full_text(); + let old_count = self.blocks.len(); + crate::blocks::reparse_incremental(&mut self.blocks, &full); + if self.focused_block >= self.blocks.len() { + self.focused_block = self.blocks.len().saturating_sub(1); + } + // If structure changed, try to restore cursor position + if self.blocks.len() != old_count { + if let Some(bi) = crate::blocks::block_at_line(&self.blocks, cursor.position.line) { + self.focused_block = bi; + } + } + } + + #[allow(dead_code)] + fn active_text(&self) -> String { + self.content().text() + } + fn toggle_wrap(&mut self, marker: &str) { let mlen = marker.len(); - match self.content.selection() { + match self.content().selection() { Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => { let inner = &sel[mlen..sel.len() - mlen]; - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(inner.to_string())), )); } Some(sel) => { let wrapped = format!("{marker}{sel}{marker}"); - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(wrapped)), )); } None => { let empty = format!("{marker}{marker}"); - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(empty)), )); for _ in 0..mlen { - self.content.perform(text_editor::Action::Move(Motion::Left)); + self.content_mut().perform(text_editor::Action::Move(Motion::Left)); } } } @@ -242,12 +448,12 @@ impl EditorState { } pub fn get_clean_text(&self) -> String { - strip_result_lines(&self.content.text()) + strip_result_lines(&self.full_text()) } fn run_eval(&mut self) { - let old_cursor = self.content.cursor(); - let old_text = self.content.text(); + let old_cursor = self.content().cursor(); + let old_text = self.content().text(); let clean_line = to_clean_line(&old_text, old_cursor.position.line); let clean_col = old_cursor.position.column; @@ -255,9 +461,10 @@ impl EditorState { if !clean.lines().any(|l| l.trim_start().starts_with("/=")) { if clean != old_text { - self.content = text_editor::Content::with_text(&clean); + let idx = self.focused_block; + self.set_block_text(idx, &clean); let restored = from_clean_line(&clean, clean_line); - self.content.move_to(Cursor { + self.content_mut().move_to(Cursor { position: Position { line: restored, column: clean_col }, selection: None, }); @@ -280,9 +487,10 @@ impl EditorState { if insertions.is_empty() { if clean != old_text { - self.content = text_editor::Content::with_text(&clean); + let idx = self.focused_block; + self.set_block_text(idx, &clean); let restored = from_clean_line(&clean, clean_line); - self.content.move_to(Cursor { + self.content_mut().move_to(Cursor { position: Position { line: restored, column: clean_col }, selection: None, }); @@ -308,8 +516,9 @@ impl EditorState { let new_text = out_lines.join("\n"); let new_line = from_clean_line(&new_text, clean_line); - self.content = text_editor::Content::with_text(&new_text); - self.content.move_to(Cursor { + let idx = self.focused_block; + self.set_block_text(idx, &new_text); + self.content_mut().move_to(Cursor { position: Position { line: new_line, column: clean_col }, selection: None, }); @@ -320,7 +529,7 @@ impl EditorState { } fn snapshot(&self) -> UndoSnapshot { - let cursor = self.content.cursor(); + let cursor = self.content().cursor(); UndoSnapshot { text: self.get_clean_text(), cursor_line: cursor.position.line, @@ -370,9 +579,9 @@ impl EditorState { fn restore_snapshot(&mut self, snap: &UndoSnapshot) { self.set_text(&snap.text); self.run_eval(); - let text = self.content.text(); + let text = self.content().text(); let display_line = from_clean_line(&text, snap.cursor_line); - self.content.move_to(Cursor { + self.content_mut().move_to(Cursor { position: Position { line: display_line, column: snap.cursor_col }, selection: None, }); @@ -436,9 +645,9 @@ impl EditorState { } let idx = self.find.current.min(self.find.matches.len() - 1); let (line, col) = self.find.matches[idx]; - let text = self.content.text(); + let text = self.content().text(); let display_line = from_clean_line(&text, line); - self.content.move_to(Cursor { + self.content_mut().move_to(Cursor { position: Position { line: display_line, column: col }, selection: None, }); @@ -459,7 +668,7 @@ impl EditorState { let lh = self.line_height(); self.scroll_offset += *lines as f32 * lh; self.scroll_offset = self.scroll_offset.max(0.0); - let max = (self.content.line_count() as f32 - 1.0) * lh; + let max = (self.content().line_count() as f32 - 1.0) * lh; self.scroll_offset = self.scroll_offset.min(max.max(0.0)); } @@ -468,8 +677,8 @@ impl EditorState { } let auto_indent = if is_enter { - let cursor = self.content.cursor(); - let line_text = self.content.line(cursor.position.line) + let cursor = self.content().cursor(); + let line_text = self.content().line(cursor.position.line) .map(|l| l.text.to_string()) .unwrap_or_default(); let base = leading_whitespace(&line_text).to_string(); @@ -486,8 +695,8 @@ impl EditorState { let dedent = if let text_editor::Action::Edit(text_editor::Edit::Insert(ch)) = &action { matches!(ch, '}' | ']' | ')').then(|| { - let cursor = self.content.cursor(); - let line_text = self.content.line(cursor.position.line) + 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]; @@ -500,11 +709,15 @@ impl EditorState { } else { None }; - self.content.perform(action); + // Focus routing at block boundaries (lesson 5) + let handled_boundary = self.handle_block_boundary(&action); + if !handled_boundary { + self.content_mut().perform(action); + } if let Some(indent) = auto_indent { if !indent.is_empty() { - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(indent)), )); } @@ -512,22 +725,26 @@ impl EditorState { if let Some(col) = dedent { let remove = col.min(4); - self.content.perform(text_editor::Action::Move(Motion::Left)); + self.content_mut().perform(text_editor::Action::Move(Motion::Left)); for _ in 0..remove { - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Backspace, )); } - self.content.perform(text_editor::Action::Move(Motion::Right)); + self.content_mut().perform(text_editor::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.lang = detect_lang_from_content(&self.content().text()); } self.reparse(); + + // On Enter or Paste, check if block boundaries changed + // (e.g., typing `---` then Enter creates an HR block) if is_enter || is_paste { + self.check_block_structure(); self.eval_dirty = false; self.run_eval(); } else { @@ -537,7 +754,7 @@ impl EditorState { } Message::InsertTable => { let table = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| | | |\n| | | |\n"; - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(table.to_string())), )); self.reparse(); @@ -553,16 +770,16 @@ impl EditorState { self.run_eval(); } Message::SmartEval => { - let cursor = self.content.cursor(); - let text = self.content.text(); + let cursor = self.content().cursor(); + let text = self.content().text(); let lines: Vec<&str> = text.lines().collect(); let line_idx = cursor.position.line; if line_idx < lines.len() { let line = lines[line_idx].trim(); if let Some(varname) = parse_let_binding(line) { let insert = format!("\n/= {varname}"); - self.content.perform(text_editor::Action::Move(Motion::End)); - self.content.perform(text_editor::Action::Edit( + self.content_mut().perform(text_editor::Action::Move(Motion::End)); + self.content_mut().perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(insert)), )); self.reparse(); @@ -687,6 +904,24 @@ impl EditorState { self.run_eval(); self.update_find_matches(); } + Message::TableMsg(idx, tmsg) => { + if let Some(block) = self.blocks.get(idx) { + if let Some(ts) = self.table_states.get_mut(&block.id) { + ts.update(tmsg); + } + } + } + Message::BlockAction(idx, action) => { + if idx < self.blocks.len() { + self.focused_block = idx; + } + self.update(Message::EditorAction(action)); + } + Message::FocusBlock(idx) => { + if idx < self.blocks.len() { + self.focused_block = idx; + } + } } } @@ -717,70 +952,12 @@ impl EditorState { }) .into() } else { - let top_pad = 38.0_f32; - let editor = iced_widget::text_editor(&self.content) - .on_action(Message::EditorAction) - .font(Font::MONOSPACE) - .size(self.font_size) - .height(Length::Fill) - .padding(Padding { top: top_pad, right: 8.0, bottom: 8.0, left: 8.0 }) - .wrapping(Wrapping::Word) - .key_binding(macos_key_binding) - .style(|_theme, _status| { - let p = palette::current(); - Style { - background: Background::Color(p.base), - border: Border::default(), - placeholder: p.overlay0, - value: p.text, - selection: Color { a: 0.4, ..p.blue }, - } - }); - - let settings = SyntaxSettings { - lang: self.lang.clone().unwrap_or_default(), - source: self.content.text(), - }; - let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor - .highlight_with::( - settings, - |highlight, _theme| Format { - color: Some(syntax::highlight_color(highlight.kind)), - font: syntax::highlight_font(highlight.kind), - }, - ) - .into(); - - let text = self.content.text(); - let result_mask: Vec = text.lines().map(|l| is_result_line(l)).collect(); - let source_line_count = result_mask.iter().filter(|r| !**r).count(); - let decors = compute_line_decors(&text); - let gutter = Gutter { - line_count: self.content.line_count(), - source_line_count, - font_size: self.font_size, - scroll_offset: self.scroll_offset, - cursor_line: self.content.cursor().position.line, - top_pad, - result_mask, - line_decors: decors, - }; - let gw = gutter.gutter_width(); - - let gutter_canvas: Element<'_, Message, Theme, iced_wgpu::Renderer> = - canvas::Canvas::new(gutter) - .width(Length::Fixed(gw)) - .height(Length::Fill) - .into(); - - iced_widget::row![gutter_canvas, editor_el] - .height(Length::Fill) - .into() + self.view_blocks() }; let mode_label = if self.preview { "Preview" } else { "Edit" }; - let cursor = self.content.cursor(); - let text = self.content.text(); + let cursor = self.content().cursor(); + let text = self.content().text(); let line = to_clean_line(&text, cursor.position.line) + 1; let col = cursor.position.column + 1; @@ -821,6 +998,119 @@ impl EditorState { .into() } + fn view_blocks(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + use crate::blocks::BlockKind; + + let mut block_elements: Vec> = Vec::new(); + + for (bi, block) in self.blocks.iter().enumerate() { + match block.kind { + BlockKind::Text => { + let top_pad = if bi == 0 { 38.0_f32 } else { 4.0 }; + let block_idx = bi; + + let editor = iced_widget::text_editor(&block.content) + .on_action(move |action| Message::BlockAction(block_idx, action)) + .font(Font::MONOSPACE) + .size(self.font_size) + .height(Length::Fill) + .padding(Padding { top: top_pad, right: 8.0, bottom: 8.0, left: 8.0 }) + .wrapping(Wrapping::Word) + .key_binding(macos_key_binding) + .style(|_theme, _status| { + let p = palette::current(); + Style { + background: Background::Color(p.base), + border: Border::default(), + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } + }); + + let settings = SyntaxSettings { + lang: self.lang.clone().unwrap_or_default(), + source: block.content.text(), + }; + let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor + .highlight_with::( + settings, + |highlight, _theme| Format { + color: Some(syntax::highlight_color(highlight.kind)), + font: syntax::highlight_font(highlight.kind), + }, + ) + .into(); + + let text = block.content.text(); + let result_mask: Vec = text.lines().map(|l| is_result_line(l)).collect(); + let source_line_count = result_mask.iter().filter(|r| !**r).count(); + let decors = compute_line_decors(&text); + let gutter = Gutter { + line_count: block.content.line_count(), + source_line_count, + font_size: self.font_size, + scroll_offset: self.scroll_offset, + cursor_line: block.content.cursor().position.line, + top_pad, + result_mask, + line_decors: decors, + }; + let gw = gutter.gutter_width(); + + let gutter_canvas: Element<'_, Message, Theme, iced_wgpu::Renderer> = + canvas::Canvas::new(gutter) + .width(Length::Fixed(gw)) + .height(Length::Fill) + .into(); + + block_elements.push( + iced_widget::row![gutter_canvas, editor_el] + .height(Length::Fill) + .into() + ); + } + BlockKind::Heading => { + let level = match block.heading_level { + 1 => crate::heading_block::HeadingLevel::H1, + 2 => crate::heading_block::HeadingLevel::H2, + _ => crate::heading_block::HeadingLevel::H3, + }; + block_elements.push( + crate::heading_block::view(level, &block.heading_text, self.font_size) + ); + } + BlockKind::HorizontalRule => { + block_elements.push(crate::hr_block::view()); + } + BlockKind::Table => { + if let Some(ts) = self.table_states.get(&block.id) { + block_elements.push( + crate::table_block::table_view( + ts, + move |tmsg| Message::TableMsg(bi, tmsg), + ) + ); + } + } + _ => {} + } + } + + if block_elements.is_empty() { + iced_widget::container(iced_widget::text("")) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else if block_elements.len() == 1 { + block_elements.remove(0) + } else { + iced_widget::column(block_elements) + .height(Length::Fill) + .into() + } + } + fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); @@ -899,6 +1189,124 @@ impl EditorState { }) .into() } + + #[allow(dead_code)] + pub fn snapshot_for_undo(&self) -> BlockUndoSnapshot { + let cursor = self.content().cursor(); + BlockUndoSnapshot { + text: self.get_clean_text(), + focused_block: 0, + cursor_line: cursor.position.line, + cursor_col: cursor.position.column, + } + } + + #[allow(dead_code)] + pub fn restore_from_undo(&mut self, snap: &BlockUndoSnapshot) { + self.set_text(&snap.text); + self.run_eval(); + let text = self.content().text(); + let display_line = from_clean_line(&text, snap.cursor_line); + self.content_mut().move_to(Cursor { + position: Position { line: display_line, column: snap.cursor_col }, + selection: None, + }); + } + + #[allow(dead_code)] + pub fn block_push_undo(&mut self) { + let snap = self.snapshot_for_undo(); + self.undo_stack.push(UndoSnapshot { + text: snap.text, + cursor_line: snap.cursor_line, + cursor_col: snap.cursor_col, + }); + if self.undo_stack.len() > UNDO_MAX { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + #[allow(dead_code)] + pub fn find_matches_in_blocks( + &self, + blocks: &[crate::blocks::Block], + ) -> Vec { + if self.find.query.is_empty() { + return Vec::new(); + } + let query_lower = self.find.query.to_lowercase(); + let mut matches = Vec::new(); + + for (bi, block) in blocks.iter().enumerate() { + let text = match block.kind { + crate::blocks::BlockKind::Text => block.content.text(), + crate::blocks::BlockKind::Heading => block.heading_text.clone(), + crate::blocks::BlockKind::EvalResult => block.eval_text.clone(), + _ => continue, + }; + let text_lower = text.to_lowercase(); + for (li, line) in text_lower.lines().enumerate() { + let mut search_from = 0; + while let Some(pos) = line[search_from..].find(&query_lower) { + let col = search_from + pos; + matches.push(BlockFindMatch { + block_index: bi, + line_in_block: li, + col, + }); + search_from = col + query_lower.len(); + } + } + } + matches + } + + #[allow(dead_code)] + pub fn replace_in_blocks( + &self, + blocks: &[crate::blocks::Block], + match_info: &BlockFindMatch, + ) -> Option { + let block = blocks.get(match_info.block_index)?; + let text = match block.kind { + crate::blocks::BlockKind::Text => block.content.text(), + crate::blocks::BlockKind::Heading => block.heading_text.clone(), + _ => return None, + }; + let lines: Vec<&str> = text.lines().collect(); + if match_info.line_in_block >= lines.len() { + return None; + } + let line = lines[match_info.line_in_block]; + let qlen = self.find.query.len(); + if match_info.col + qlen > line.len() { + return None; + } + let before = &line[..match_info.col]; + let after = &line[match_info.col + qlen..]; + let new_line = format!("{}{}{}", before, self.find.replacement, after); + let mut new_lines: Vec = lines.iter().map(|l: &&str| l.to_string()).collect(); + new_lines[match_info.line_in_block] = new_line; + Some(new_lines.join("\n")) + } +} + + +#[allow(dead_code)] +pub struct BlockUndoSnapshot { + pub text: String, + pub focused_block: usize, + pub cursor_line: usize, + pub cursor_col: usize, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct BlockFindMatch { + pub block_index: usize, + pub line_in_block: usize, + pub col: usize, } fn find_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { diff --git a/viewport/src/eval_block.rs b/viewport/src/eval_block.rs new file mode 100644 index 0000000..a890aed --- /dev/null +++ b/viewport/src/eval_block.rs @@ -0,0 +1,70 @@ +use iced_wgpu::core::text::LineHeight; +use iced_wgpu::core::{ + alignment, Element, Font, Length, Pixels, Point, Rectangle, Theme, +}; +use iced_widget::canvas; + +use crate::palette; + +const FONT_SIZE: f32 = 13.0; +const LINE_HEIGHT: f32 = 20.0; +const LEFT_MARGIN: f32 = 8.0; +const PADDING: f32 = 4.0; + +pub struct EvalResultProgram { + text: String, +} + +impl EvalResultProgram { + pub fn new(result_text: &str) -> Self { + Self { + text: format!("\u{2192} {}", result_text), + } + } + + pub fn height() -> f32 { + LINE_HEIGHT + PADDING * 2.0 + } +} + +impl canvas::Program for EvalResultProgram { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: iced_wgpu::core::mouse::Cursor, + ) -> Vec> { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let p = palette::current(); + + frame.fill_text(canvas::Text { + content: self.text.clone(), + position: Point::new(LEFT_MARGIN, PADDING), + max_width: bounds.width - LEFT_MARGIN * 2.0, + color: p.green, + size: Pixels(FONT_SIZE), + line_height: LineHeight::Relative(1.3), + font: Font::MONOSPACE, + align_x: alignment::Horizontal::Left.into(), + align_y: alignment::Vertical::Top, + shaping: iced_wgpu::core::text::Shaping::Basic, + }); + + vec![frame.into_geometry()] + } +} + +pub fn view<'a, Message: Clone + 'a>( + result_text: &str, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let program = EvalResultProgram::new(result_text); + let h = EvalResultProgram::height(); + canvas::Canvas::new(program) + .width(Length::Fill) + .height(Length::Fixed(h)) + .into() +} diff --git a/viewport/src/heading_block.rs b/viewport/src/heading_block.rs new file mode 100644 index 0000000..f30d1af --- /dev/null +++ b/viewport/src/heading_block.rs @@ -0,0 +1,116 @@ +use iced_wgpu::core::alignment; +use iced_wgpu::core::text::LineHeight; +use iced_wgpu::core::{ + mouse, Element, Font, Length, Pixels, Point, Rectangle, Theme, +}; +use iced_wgpu::core::font::Weight; +use iced_widget::canvas; + +use crate::palette; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeadingLevel { + H1, + H2, + H3, +} + +impl HeadingLevel { + pub fn scale(&self) -> f32 { + match self { + HeadingLevel::H1 => 2.0, + HeadingLevel::H2 => 1.5, + HeadingLevel::H3 => 1.17, + } + } + + pub fn weight(&self) -> Weight { + match self { + HeadingLevel::H1 => Weight::Black, + HeadingLevel::H2 => Weight::Bold, + HeadingLevel::H3 => Weight::Semibold, + } + } + + pub fn from_prefix(line: &str) -> Option<(HeadingLevel, String)> { + let trimmed = line.trim_start(); + let bytes = trimmed.as_bytes(); + if bytes.is_empty() || bytes[0] != b'#' { + return None; + } + let mut level = 0; + while level < bytes.len() && bytes[level] == b'#' { + level += 1; + } + if level > 3 || level >= bytes.len() || bytes[level] != b' ' { + return None; + } + let text = trimmed[level + 1..].to_string(); + let lvl = match level { + 1 => HeadingLevel::H1, + 2 => HeadingLevel::H2, + _ => HeadingLevel::H3, + }; + Some((lvl, text)) + } +} + +struct HeadingProgram { + level: HeadingLevel, + text: String, + font_size: f32, +} + +impl canvas::Program for HeadingProgram { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec> { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let p = palette::current(); + + let color = match self.level { + HeadingLevel::H1 => p.rosewater, + HeadingLevel::H2 => p.peach, + HeadingLevel::H3 => p.yellow, + }; + + frame.fill_text(canvas::Text { + content: self.text.clone(), + position: Point::new(8.0, 4.0), + max_width: bounds.width - 16.0, + color, + size: Pixels(self.font_size), + line_height: LineHeight::Relative(1.4), + font: Font { weight: self.level.weight(), ..Font::DEFAULT }, + align_x: iced_wgpu::core::text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: iced_wgpu::core::text::Shaping::Basic, + }); + + vec![frame.into_geometry()] + } +} + +pub fn view<'a, Message: Clone + 'a>( + level: HeadingLevel, + text: &str, + base_font_size: f32, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let font_size = base_font_size * level.scale(); + let height = font_size * 1.4 + 8.0; + canvas::Canvas::new(HeadingProgram { + level, + text: text.to_string(), + font_size, + }) + .width(Length::Fill) + .height(Length::Fixed(height)) + .into() +} diff --git a/viewport/src/hr_block.rs b/viewport/src/hr_block.rs new file mode 100644 index 0000000..64cdc79 --- /dev/null +++ b/viewport/src/hr_block.rs @@ -0,0 +1,42 @@ +use iced_wgpu::core::{mouse, Element, Length, Point, Rectangle, Theme}; +use iced_widget::canvas; + +use crate::palette; + +struct HRProgram; + +impl canvas::Program for HRProgram { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec> { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let p = palette::current(); + let y = bounds.height / 2.0; + let margin = 8.0; + let path = canvas::Path::line( + Point::new(margin, y), + Point::new(bounds.width - margin, y), + ); + frame.stroke( + &path, + canvas::Stroke::default() + .with_width(1.0) + .with_color(p.overlay0), + ); + vec![frame.into_geometry()] + } +} + +pub fn view<'a, Message: Clone + 'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + canvas::Canvas::new(HRProgram) + .width(Length::Fill) + .height(Length::Fixed(20.0)) + .into() +} diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 556ab60..ac862a9 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -1,10 +1,16 @@ use std::ffi::{c_char, c_void, CStr, CString}; +pub mod blocks; mod bridge; mod editor; +pub mod eval_block; mod handle; +pub mod heading_block; +pub mod hr_block; pub mod palette; mod syntax; +pub mod table_block; +pub mod tree_block; pub use acord_core::*; diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs new file mode 100644 index 0000000..2c57c08 --- /dev/null +++ b/viewport/src/table_block.rs @@ -0,0 +1,329 @@ +use iced_wgpu::core::widget::Id as WidgetId; +use iced_wgpu::core::{ + Background, Border, Color, Element, Font, Length, + Padding, Shadow, Theme, +}; +use iced_widget::container; +use iced_widget::text_input; + +use crate::palette; + +const MIN_COL_WIDTH: f32 = 60.0; +const DEFAULT_COL_WIDTH: f32 = 120.0; +const CELL_PADDING: Padding = Padding { + top: 3.0, + right: 6.0, + bottom: 3.0, + left: 6.0, +}; + +#[derive(Debug, Clone)] +pub enum TableMessage { + CellChanged(usize, usize, String), + FocusCell(usize, usize), + AddRow, + AddColumn, +} + +pub struct TableState { + pub rows: Vec>, + pub col_widths: Vec, + pub focused_cell: Option<(usize, usize)>, +} + +impl TableState { + pub fn new(rows: Vec>, col_widths: Option>) -> Self { + let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); + let widths = col_widths.unwrap_or_else(|| vec![DEFAULT_COL_WIDTH; col_count]); + Self { + rows, + col_widths: widths, + focused_cell: None, + } + } + + pub fn from_markdown(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.len() < 2 { + return None; + } + + let parse_row = |line: &str| -> Vec { + let trimmed = line.trim(); + let inner = trimmed.strip_prefix('|').unwrap_or(trimmed); + let inner = inner.strip_suffix('|').unwrap_or(inner); + inner.split('|').map(|c| c.trim().to_string()).collect() + }; + + let is_separator = |line: &str| -> bool { + let trimmed = line.trim(); + let inner = trimmed.strip_prefix('|').unwrap_or(trimmed); + let inner = inner.strip_suffix('|').unwrap_or(inner); + inner.split('|').all(|seg| { + let s = seg.trim(); + s.chars().all(|c| c == '-' || c == ':') && s.len() >= 1 + }) + }; + + let header = parse_row(lines[0]); + if !is_separator(lines[1]) { + return None; + } + + let mut rows = vec![header]; + for line in &lines[2..] { + let trimmed = line.trim(); + if trimmed.is_empty() { + break; + } + rows.push(parse_row(trimmed)); + } + + let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); + for row in &mut rows { + while row.len() < col_count { + row.push(String::new()); + } + } + + let widths: Vec = (0..col_count) + .map(|ci| { + let max_len = rows.iter() + .map(|r| r.get(ci).map(|s| s.len()).unwrap_or(0)) + .max() + .unwrap_or(0); + ((max_len as f32) * 9.0).max(MIN_COL_WIDTH).min(300.0) + }) + .collect(); + + Some(Self::new(rows, Some(widths))) + } + + pub fn from_eval_rows(data: Vec>) -> Self { + let col_count = data.iter().map(|r| r.len()).max().unwrap_or(0); + let widths: Vec = (0..col_count) + .map(|ci| { + let max_len = data.iter() + .map(|r| r.get(ci).map(|s| s.len()).unwrap_or(0)) + .max() + .unwrap_or(0); + ((max_len as f32) * 9.0).max(MIN_COL_WIDTH).min(300.0) + }) + .collect(); + Self::new(data, Some(widths)) + } + + pub fn col_count(&self) -> usize { + self.col_widths.len() + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } + + pub fn update(&mut self, msg: TableMessage) { + match msg { + TableMessage::CellChanged(row, col, val) => { + if row < self.rows.len() && col < self.rows[row].len() { + self.rows[row][col] = val; + } + } + TableMessage::FocusCell(row, col) => { + self.focused_cell = Some((row, col)); + } + TableMessage::AddRow => { + let cols = self.col_count(); + self.rows.push(vec![String::new(); cols]); + } + TableMessage::AddColumn => { + let new_width = DEFAULT_COL_WIDTH; + self.col_widths.push(new_width); + for row in &mut self.rows { + row.push(String::new()); + } + } + } + } + + pub fn next_cell(&self, row: usize, col: usize) -> Option<(usize, usize)> { + let cols = self.col_count(); + let rows = self.row_count(); + if col + 1 < cols { + Some((row, col + 1)) + } else if row + 1 < rows { + Some((row + 1, 0)) + } else { + None + } + } + + pub fn prev_cell(&self, row: usize, col: usize) -> Option<(usize, usize)> { + let cols = self.col_count(); + if col > 0 { + Some((row, col - 1)) + } else if row > 0 { + Some((row - 1, cols.saturating_sub(1))) + } else { + None + } + } + + pub fn cell_below(&self, row: usize, col: usize) -> Option<(usize, usize)> { + if row + 1 < self.row_count() { + Some((row + 1, col)) + } else { + None + } + } + + pub fn to_markdown(&self) -> String { + if self.rows.is_empty() { + return String::new(); + } + + let col_count = self.col_count(); + let mut widths = vec![0usize; col_count]; + for row in &self.rows { + for (i, cell) in row.iter().enumerate() { + if i < col_count { + widths[i] = widths[i].max(cell.len()).max(3); + } + } + } + + let mut out = String::new(); + + // header + out.push('|'); + if let Some(header) = self.rows.first() { + for (i, cell) in header.iter().enumerate() { + let w = if i < widths.len() { widths[i] } else { 3 }; + out.push_str(&format!(" {:width$} |", cell, width = w)); + } + } + out.push('\n'); + + // separator + out.push('|'); + for w in &widths { + out.push_str(&format!("-{}-|", "-".repeat(*w))); + } + out.push('\n'); + + // data rows + for row in self.rows.iter().skip(1) { + out.push('|'); + for (i, cell) in row.iter().enumerate() { + let w = if i < widths.len() { widths[i] } else { 3 }; + out.push_str(&format!(" {:width$} |", cell, width = w)); + } + out.push('\n'); + } + + out + } + + pub fn pending_focus_id(&self) -> Option { + self.focused_cell + .map(|(r, c)| WidgetId::from(format!("table_cell_{}_{}", r, c))) + } +} + +fn cell_id(row: usize, col: usize) -> WidgetId { + WidgetId::from(format!("table_cell_{}_{}", row, col)) +} + +fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + text_input::Style { + background: Background::Color(p.surface0), + border: Border { + color: p.surface2, + width: 1.0, + radius: 0.0.into(), + }, + icon: p.overlay2, + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } +} + +fn header_cell_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { + let p = palette::current(); + text_input::Style { + background: Background::Color(p.mantle), + border: Border { + color: p.surface2, + width: 1.0, + radius: 0.0.into(), + }, + icon: p.overlay2, + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } +} + +pub fn table_view<'a, Message, F>( + state: &'a TableState, + on_msg: F, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> +where + Message: Clone + 'a, + F: Fn(TableMessage) -> Message + 'a + Copy, +{ + let mut col_elements: Vec> = Vec::new(); + + for (ri, row) in state.rows.iter().enumerate() { + let is_header = ri == 0; + let mut row_cells: Vec> = Vec::new(); + + for (ci, cell) in row.iter().enumerate() { + let width = state.col_widths.get(ci).copied().unwrap_or(DEFAULT_COL_WIDTH); + let r = ri; + let c = ci; + + let style_fn: fn(&Theme, text_input::Status) -> text_input::Style = if is_header { + header_cell_style + } else { + cell_input_style + }; + + let font = if is_header { + Font { weight: iced_wgpu::core::font::Weight::Bold, ..Font::MONOSPACE } + } else { + Font::MONOSPACE + }; + + let input = text_input::TextInput::new("", cell) + .on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val))) + .id(cell_id(ri, ci)) + .font(font) + .size(13.0) + .padding(CELL_PADDING) + .width(Length::Fixed(width)) + .style(style_fn); + + row_cells.push(input.into()); + } + + let row_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::row(row_cells).spacing(0.0).into(); + col_elements.push(row_el); + } + + let table: Element<'a, Message, Theme, iced_wgpu::Renderer> = + iced_widget::column(col_elements).spacing(0.0).into(); + + iced_widget::container(table) + .padding(Padding { top: 4.0, right: 0.0, bottom: 4.0, left: 8.0 }) + .style(|_theme: &Theme| container::Style { + background: None, + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }) + .into() +} diff --git a/viewport/src/tree_block.rs b/viewport/src/tree_block.rs new file mode 100644 index 0000000..340f12a --- /dev/null +++ b/viewport/src/tree_block.rs @@ -0,0 +1,198 @@ +use iced_wgpu::core::text::LineHeight; +use iced_wgpu::core::{ + alignment, Element, Font, Length, Pixels, Point, Rectangle, Theme, +}; +use iced_widget::canvas; + +use crate::palette; + +const NODE_HEIGHT: f32 = 20.0; +const INDENT_PX: f32 = 20.0; +const FONT_SIZE: f32 = 13.0; +const BRANCH_INSET: f32 = 12.0; + +#[derive(Debug, Clone)] +pub enum TreeMessage {} + +struct TreeNode { + label: String, + depth: usize, + is_last: bool, +} + +fn flatten_tree(val: &serde_json::Value, depth: usize, is_last: bool, out: &mut Vec) { + match val { + serde_json::Value::Array(items) => { + if depth > 0 { + out.push(TreeNode { + label: "[array]".into(), + depth, + is_last, + }); + } + let len = items.len(); + for (i, item) in items.iter().enumerate() { + flatten_tree(item, depth + 1, i == len - 1, out); + } + } + serde_json::Value::Object(_) => { + out.push(TreeNode { + label: "{object}".into(), + depth, + is_last, + }); + } + serde_json::Value::String(s) => { + out.push(TreeNode { + label: format!("\"{}\"", s), + depth, + is_last, + }); + } + serde_json::Value::Number(n) => { + out.push(TreeNode { + label: n.to_string(), + depth, + is_last, + }); + } + serde_json::Value::Bool(b) => { + out.push(TreeNode { + label: b.to_string(), + depth, + is_last, + }); + } + serde_json::Value::Null => { + out.push(TreeNode { + label: "null".into(), + depth, + is_last, + }); + } + } +} + +pub struct TreeProgram { + nodes: Vec, + total_height: f32, +} + +impl TreeProgram { + pub fn from_json(val: &serde_json::Value) -> Self { + let mut nodes = Vec::new(); + match val { + serde_json::Value::Array(items) => { + let len = items.len(); + for (i, item) in items.iter().enumerate() { + flatten_tree(item, 0, i == len - 1, &mut nodes); + } + } + _ => { + flatten_tree(val, 0, true, &mut nodes); + } + } + let total_height = (nodes.len() as f32 * NODE_HEIGHT).max(NODE_HEIGHT); + Self { nodes, total_height } + } + + pub fn height(&self) -> f32 { + self.total_height + } +} + +impl canvas::Program for TreeProgram { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced_wgpu::Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: iced_wgpu::core::mouse::Cursor, + ) -> Vec> { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let p = palette::current(); + let connector_color = p.surface2; + let label_color = p.text; + let array_color = p.overlay1; + + for (i, node) in self.nodes.iter().enumerate() { + let y = i as f32 * NODE_HEIGHT; + let indent_x = node.depth as f32 * INDENT_PX + 8.0; + + // vertical connector from parent + if node.depth > 0 { + let parent_x = (node.depth - 1) as f32 * INDENT_PX + 8.0; + let connector = canvas::Path::new(|b| { + b.move_to(Point::new(parent_x, y)); + b.line_to(Point::new(parent_x, y + NODE_HEIGHT / 2.0)); + b.line_to(Point::new(parent_x + BRANCH_INSET, y + NODE_HEIGHT / 2.0)); + }); + frame.stroke( + &connector, + canvas::Stroke::default() + .with_width(1.0) + .with_color(connector_color), + ); + + // extend vertical line upward to connect siblings + if !node.is_last { + let vert = canvas::Path::line( + Point::new(indent_x - INDENT_PX, y + NODE_HEIGHT / 2.0), + Point::new(indent_x - INDENT_PX, y + NODE_HEIGHT), + ); + frame.stroke( + &vert, + canvas::Stroke::default() + .with_width(1.0) + .with_color(connector_color), + ); + } + } + + let text_color = if node.label.starts_with('[') || node.label.starts_with('{') { + array_color + } else { + label_color + }; + + let branch_char = if node.depth == 0 { + String::new() + } else if node.is_last { + "\u{2514}\u{2500} ".into() // └─ + } else { + "\u{251C}\u{2500} ".into() // ├─ + }; + + let display = format!("{}{}", branch_char, node.label); + + frame.fill_text(canvas::Text { + content: display, + position: Point::new(indent_x, y + 2.0), + max_width: bounds.width - indent_x, + color: text_color, + size: Pixels(FONT_SIZE), + line_height: LineHeight::Relative(1.3), + font: Font::MONOSPACE, + align_x: alignment::Horizontal::Left.into(), + align_y: alignment::Vertical::Top, + shaping: iced_wgpu::core::text::Shaping::Basic, + }); + } + + vec![frame.into_geometry()] + } +} + +pub fn view<'a, Message: Clone + 'a>( + data: &serde_json::Value, +) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let program = TreeProgram::from_json(data); + let h = program.height(); + canvas::Canvas::new(program) + .width(Length::Fill) + .height(Length::Fixed(h)) + .into() +}