1 block compositor: heading, HR, table widgets with view_blocks integration

This commit is contained in:
jess 2026-04-08 05:18:45 -07:00
parent 9c4c359056
commit 80fd148280
8 changed files with 1772 additions and 127 deletions

476
viewport/src/blocks.rs Normal file
View File

@ -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<iced_wgpu::Renderer>,
pub start_line: usize,
pub line_count: usize,
pub heading_level: u8,
pub heading_text: String,
pub table_rows: Vec<Vec<String>>,
pub tree_data: Option<serde_json::Value>,
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<Vec<String>>, 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<Vec<String>>, usize) {
let parse_row = |line: &str| -> Vec<String> {
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<Vec<String>>,
}
fn classify_spans(lines: &[&str]) -> Vec<BlockSpan> {
let mut spans = Vec::new();
let mut i = 0;
let mut text_start: Option<usize> = None;
let flush_text = |text_start: &mut Option<usize>, i: usize, spans: &mut Vec<BlockSpan>| {
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<Block> {
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<Block>, 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::<Vec<_>>()
.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<usize> {
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
}

View File

@ -17,10 +17,13 @@ use iced_wgpu::core::text::highlighter::Format;
use iced_wgpu::core::widget::Id as WidgetId; use iced_wgpu::core::widget::Id as WidgetId;
use crate::palette; use crate::palette;
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors}; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors};
use crate::table_block::TableMessage;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub enum Message { pub enum Message {
BlockAction(usize, text_editor::Action),
FocusBlock(usize),
EditorAction(text_editor::Action), EditorAction(text_editor::Action),
TogglePreview, TogglePreview,
MarkdownLink(markdown::Uri), MarkdownLink(markdown::Uri),
@ -42,6 +45,7 @@ pub enum Message {
ReplaceQueryChanged(String), ReplaceQueryChanged(String),
ReplaceOne, ReplaceOne,
ReplaceAll, ReplaceAll,
TableMsg(usize, TableMessage),
} }
pub const RESULT_PREFIX: &str = ""; pub const RESULT_PREFIX: &str = "";
@ -91,7 +95,8 @@ impl FindState {
} }
pub struct EditorState { pub struct EditorState {
pub content: text_editor::Content<iced_wgpu::Renderer>, pub blocks: Vec<crate::blocks::Block>,
pub focused_block: usize,
pub font_size: f32, pub font_size: f32,
pub preview: bool, pub preview: bool,
pub parsed: Vec<markdown::Item>, pub parsed: Vec<markdown::Item>,
@ -107,6 +112,7 @@ pub struct EditorState {
pub find: FindState, pub find: FindState,
pub pending_focus: Option<WidgetId>, pub pending_focus: Option<WidgetId>,
pub table_states: std::collections::HashMap<u64, crate::table_block::TableState>,
} }
fn md_style() -> markdown::Style { fn md_style() -> markdown::Style {
@ -128,32 +134,22 @@ fn md_style() -> markdown::Style {
impl EditorState { impl EditorState {
pub fn new() -> Self { pub fn new() -> Self {
let sample = concat!( let sample = concat!(
"use std::collections::HashMap;\n\n", "# Block Compositor\n",
"/// A simple key-value store.\n", "Acord renders structured documents with mixed content.\n\n",
"pub struct Store {\n", "## Data Table\n",
" data: HashMap<String, i64>,\n", "| Name | Age | Role |\n",
"}\n\n", "|-------|-----|----------|\n",
"impl Store {\n", "| Alice | 30 | Engineer |\n",
" pub fn new() -> Self {\n", "| Bob | 25 | Designer |\n",
" Self { data: HashMap::new() }\n", "| Carol | 35 | Manager |\n\n",
" }\n\n", "---\n\n",
" pub fn insert(&mut self, key: &str, value: i64) {\n", "### Code Section\n",
" self.data.insert(key.to_string(), value);\n", "let x = 42\n",
" }\n\n", "/= x * 2\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",
); );
Self { let mut s = Self {
content: text_editor::Content::with_text(sample), blocks: crate::blocks::parse_blocks(sample),
focused_block: 0,
font_size: 14.0, font_size: 14.0,
preview: false, preview: false,
parsed: Vec::new(), parsed: Vec::new(),
@ -167,19 +163,206 @@ impl EditorState {
last_edit_time: Instant::now(), last_edit_time: Instant::now(),
find: FindState::new(), find: FindState::new(),
pending_focus: None, pending_focus: None,
table_states: std::collections::HashMap::new(),
};
s.sync_table_states();
s
}
fn content(&self) -> &text_editor::Content<iced_wgpu::Renderer> {
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<iced_wgpu::Renderer> {
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<iced_wgpu::Renderer>> {
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<iced_wgpu::Renderer>> {
self.blocks.get_mut(idx)
.filter(|b| b.kind == crate::blocks::BlockKind::Text)
.map(|b| &mut b.content)
}
fn line_height(&self) -> f32 { fn line_height(&self) -> f32 {
self.font_size * 1.3 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) { 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.scroll_offset = 0.0;
self.sync_table_states();
self.reparse(); self.reparse();
} }
fn sync_table_states(&mut self) {
use crate::blocks::BlockKind;
let active_ids: std::collections::HashSet<u64> = 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) { pub fn set_lang_from_ext(&mut self, ext: &str) {
self.lang = lang_from_extension(ext); self.lang = lang_from_extension(ext);
} }
@ -192,17 +375,17 @@ impl EditorState {
} }
fn strip_results_in_place(&mut self) { 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)) { if !text.lines().any(|l| is_result_line(l)) {
return; return;
} }
let cursor = self.content.cursor(); let cursor = self.content().cursor();
let clean_line = to_clean_line(&text, cursor.position.line); let clean_line = to_clean_line(&text, cursor.position.line);
let clean_col = cursor.position.column; let clean_col = cursor.position.column;
let clean = strip_result_lines(&text); 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); 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 }, position: Position { line: restored_line, column: clean_col },
selection: None, selection: None,
}); });
@ -213,28 +396,51 @@ impl EditorState {
self.parsed = markdown::parse(&text).collect(); 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) { fn toggle_wrap(&mut self, marker: &str) {
let mlen = marker.len(); 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 => { Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => {
let inner = &sel[mlen..sel.len() - mlen]; 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())), text_editor::Edit::Paste(Arc::new(inner.to_string())),
)); ));
} }
Some(sel) => { Some(sel) => {
let wrapped = format!("{marker}{sel}{marker}"); 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)), text_editor::Edit::Paste(Arc::new(wrapped)),
)); ));
} }
None => { None => {
let empty = format!("{marker}{marker}"); 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)), text_editor::Edit::Paste(Arc::new(empty)),
)); ));
for _ in 0..mlen { 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 { pub fn get_clean_text(&self) -> String {
strip_result_lines(&self.content.text()) strip_result_lines(&self.full_text())
} }
fn run_eval(&mut self) { fn run_eval(&mut self) {
let old_cursor = self.content.cursor(); let old_cursor = self.content().cursor();
let old_text = self.content.text(); let old_text = self.content().text();
let clean_line = to_clean_line(&old_text, old_cursor.position.line); let clean_line = to_clean_line(&old_text, old_cursor.position.line);
let clean_col = old_cursor.position.column; 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.lines().any(|l| l.trim_start().starts_with("/=")) {
if clean != old_text { 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); 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 }, position: Position { line: restored, column: clean_col },
selection: None, selection: None,
}); });
@ -280,9 +487,10 @@ impl EditorState {
if insertions.is_empty() { if insertions.is_empty() {
if clean != old_text { 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); 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 }, position: Position { line: restored, column: clean_col },
selection: None, selection: None,
}); });
@ -308,8 +516,9 @@ impl EditorState {
let new_text = out_lines.join("\n"); let new_text = out_lines.join("\n");
let new_line = from_clean_line(&new_text, clean_line); let new_line = from_clean_line(&new_text, clean_line);
self.content = text_editor::Content::with_text(&new_text); let idx = self.focused_block;
self.content.move_to(Cursor { self.set_block_text(idx, &new_text);
self.content_mut().move_to(Cursor {
position: Position { line: new_line, column: clean_col }, position: Position { line: new_line, column: clean_col },
selection: None, selection: None,
}); });
@ -320,7 +529,7 @@ impl EditorState {
} }
fn snapshot(&self) -> UndoSnapshot { fn snapshot(&self) -> UndoSnapshot {
let cursor = self.content.cursor(); let cursor = self.content().cursor();
UndoSnapshot { UndoSnapshot {
text: self.get_clean_text(), text: self.get_clean_text(),
cursor_line: cursor.position.line, cursor_line: cursor.position.line,
@ -370,9 +579,9 @@ impl EditorState {
fn restore_snapshot(&mut self, snap: &UndoSnapshot) { fn restore_snapshot(&mut self, snap: &UndoSnapshot) {
self.set_text(&snap.text); self.set_text(&snap.text);
self.run_eval(); self.run_eval();
let text = self.content.text(); let text = self.content().text();
let display_line = from_clean_line(&text, snap.cursor_line); 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 }, position: Position { line: display_line, column: snap.cursor_col },
selection: None, selection: None,
}); });
@ -436,9 +645,9 @@ impl EditorState {
} }
let idx = self.find.current.min(self.find.matches.len() - 1); let idx = self.find.current.min(self.find.matches.len() - 1);
let (line, col) = self.find.matches[idx]; 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); 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 }, position: Position { line: display_line, column: col },
selection: None, selection: None,
}); });
@ -459,7 +668,7 @@ impl EditorState {
let lh = self.line_height(); let lh = self.line_height();
self.scroll_offset += *lines as f32 * lh; self.scroll_offset += *lines as f32 * lh;
self.scroll_offset = self.scroll_offset.max(0.0); 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)); self.scroll_offset = self.scroll_offset.min(max.max(0.0));
} }
@ -468,8 +677,8 @@ impl EditorState {
} }
let auto_indent = if is_enter { let auto_indent = if is_enter {
let cursor = self.content.cursor(); let cursor = self.content().cursor();
let line_text = self.content.line(cursor.position.line) let line_text = self.content().line(cursor.position.line)
.map(|l| l.text.to_string()) .map(|l| l.text.to_string())
.unwrap_or_default(); .unwrap_or_default();
let base = leading_whitespace(&line_text).to_string(); 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 { let dedent = if let text_editor::Action::Edit(text_editor::Edit::Insert(ch)) = &action {
matches!(ch, '}' | ']' | ')').then(|| { matches!(ch, '}' | ']' | ')').then(|| {
let cursor = self.content.cursor(); let cursor = self.content().cursor();
let line_text = self.content.line(cursor.position.line) let line_text = self.content().line(cursor.position.line)
.map(|l| l.text.to_string()) .map(|l| l.text.to_string())
.unwrap_or_default(); .unwrap_or_default();
let prefix = &line_text[..cursor.position.column]; let prefix = &line_text[..cursor.position.column];
@ -500,11 +709,15 @@ impl EditorState {
} else { } else {
None 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 let Some(indent) = auto_indent {
if !indent.is_empty() { 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)), text_editor::Edit::Paste(Arc::new(indent)),
)); ));
} }
@ -512,22 +725,26 @@ impl EditorState {
if let Some(col) = dedent { if let Some(col) = dedent {
let remove = col.min(4); 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 { for _ in 0..remove {
self.content.perform(text_editor::Action::Edit( self.content_mut().perform(text_editor::Action::Edit(
text_editor::Edit::Backspace, 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 { if is_edit {
self.last_edit = Instant::now(); self.last_edit = Instant::now();
if self.lang.is_none() { 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(); 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 { if is_enter || is_paste {
self.check_block_structure();
self.eval_dirty = false; self.eval_dirty = false;
self.run_eval(); self.run_eval();
} else { } else {
@ -537,7 +754,7 @@ impl EditorState {
} }
Message::InsertTable => { Message::InsertTable => {
let table = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| | | |\n| | | |\n"; 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())), text_editor::Edit::Paste(Arc::new(table.to_string())),
)); ));
self.reparse(); self.reparse();
@ -553,16 +770,16 @@ impl EditorState {
self.run_eval(); self.run_eval();
} }
Message::SmartEval => { Message::SmartEval => {
let cursor = self.content.cursor(); let cursor = self.content().cursor();
let text = self.content.text(); let text = self.content().text();
let lines: Vec<&str> = text.lines().collect(); let lines: Vec<&str> = text.lines().collect();
let line_idx = cursor.position.line; let line_idx = cursor.position.line;
if line_idx < lines.len() { if line_idx < lines.len() {
let line = lines[line_idx].trim(); let line = lines[line_idx].trim();
if let Some(varname) = parse_let_binding(line) { if let Some(varname) = parse_let_binding(line) {
let insert = format!("\n/= {varname}"); let insert = format!("\n/= {varname}");
self.content.perform(text_editor::Action::Move(Motion::End)); self.content_mut().perform(text_editor::Action::Move(Motion::End));
self.content.perform(text_editor::Action::Edit( self.content_mut().perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(insert)), text_editor::Edit::Paste(Arc::new(insert)),
)); ));
self.reparse(); self.reparse();
@ -687,6 +904,24 @@ impl EditorState {
self.run_eval(); self.run_eval();
self.update_find_matches(); 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() .into()
} else { } else {
let top_pad = 38.0_f32; self.view_blocks()
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::<SyntaxHighlighter>(
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<bool> = 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()
}; };
let mode_label = if self.preview { "Preview" } else { "Edit" }; let mode_label = if self.preview { "Preview" } else { "Edit" };
let cursor = self.content.cursor(); let cursor = self.content().cursor();
let text = self.content.text(); let text = self.content().text();
let line = to_clean_line(&text, cursor.position.line) + 1; let line = to_clean_line(&text, cursor.position.line) + 1;
let col = cursor.position.column + 1; let col = cursor.position.column + 1;
@ -821,6 +998,119 @@ impl EditorState {
.into() .into()
} }
fn view_blocks(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
use crate::blocks::BlockKind;
let mut block_elements: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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::<SyntaxHighlighter>(
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<bool> = 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> { fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current(); let p = palette::current();
@ -899,6 +1189,124 @@ impl EditorState {
}) })
.into() .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<BlockFindMatch> {
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<String> {
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<String> = 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 { fn find_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style {

View File

@ -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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for EvalResultProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: iced_wgpu::core::mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
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()
}

View File

@ -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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for HeadingProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
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()
}

42
viewport/src/hr_block.rs Normal file
View File

@ -0,0 +1,42 @@
use iced_wgpu::core::{mouse, Element, Length, Point, Rectangle, Theme};
use iced_widget::canvas;
use crate::palette;
struct HRProgram;
impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for HRProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
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()
}

View File

@ -1,10 +1,16 @@
use std::ffi::{c_char, c_void, CStr, CString}; use std::ffi::{c_char, c_void, CStr, CString};
pub mod blocks;
mod bridge; mod bridge;
mod editor; mod editor;
pub mod eval_block;
mod handle; mod handle;
pub mod heading_block;
pub mod hr_block;
pub mod palette; pub mod palette;
mod syntax; mod syntax;
pub mod table_block;
pub mod tree_block;
pub use acord_core::*; pub use acord_core::*;

329
viewport/src/table_block.rs Normal file
View File

@ -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<Vec<String>>,
pub col_widths: Vec<f32>,
pub focused_cell: Option<(usize, usize)>,
}
impl TableState {
pub fn new(rows: Vec<Vec<String>>, col_widths: Option<Vec<f32>>) -> 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<Self> {
let lines: Vec<&str> = text.lines().collect();
if lines.len() < 2 {
return None;
}
let parse_row = |line: &str| -> Vec<String> {
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<f32> = (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<Vec<String>>) -> Self {
let col_count = data.iter().map(|r| r.len()).max().unwrap_or(0);
let widths: Vec<f32> = (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<WidgetId> {
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<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (ri, row) in state.rows.iter().enumerate() {
let is_header = ri == 0;
let mut row_cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = 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()
}

198
viewport/src/tree_block.rs Normal file
View File

@ -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<TreeNode>) {
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<TreeNode>,
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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for TreeProgram {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced_wgpu::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: iced_wgpu::core::mouse::Cursor,
) -> Vec<canvas::Geometry<iced_wgpu::Renderer>> {
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()
}