1 block compositor: heading, HR, table widgets with view_blocks integration
This commit is contained in:
parent
9c4c359056
commit
80fd148280
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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::*;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue