Compare commits
11 Commits
01f34a4f34
...
5b4abcf3e5
| Author | SHA1 | Date |
|---|---|---|
|
|
5b4abcf3e5 | |
|
|
a390a2cc4a | |
|
|
80fd148280 | |
|
|
9c4c359056 | |
|
|
81fdb9146c | |
|
|
d50f463ebb | |
|
|
908a69ec88 | |
|
|
6f36f9c3df | |
|
|
36895cd548 | |
|
|
57488a2861 | |
|
|
cdd243c8ff |
|
|
@ -6,11 +6,6 @@ import UniformTypeIdentifiers
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let focusEditor = Notification.Name("focusEditor")
|
static let focusEditor = Notification.Name("focusEditor")
|
||||||
static let focusTitle = Notification.Name("focusTitle")
|
static let focusTitle = Notification.Name("focusTitle")
|
||||||
static let formatDocument = Notification.Name("formatDocument")
|
|
||||||
static let insertTable = Notification.Name("insertTable")
|
|
||||||
static let boldSelection = Notification.Name("boldSelection")
|
|
||||||
static let italicizeSelection = Notification.Name("italicizeSelection")
|
|
||||||
static let smartEval = Notification.Name("smartEval")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class WindowController {
|
class WindowController {
|
||||||
|
|
@ -63,6 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
observeDocumentTitle()
|
observeDocumentTitle()
|
||||||
|
|
||||||
observeDocumentText()
|
observeDocumentText()
|
||||||
|
syncThemeToViewport()
|
||||||
|
|
||||||
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||||
|
|
||||||
|
|
@ -281,19 +277,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func boldSelection() {
|
@objc private func boldSelection() {
|
||||||
NotificationCenter.default.post(name: .boldSelection, object: nil)
|
viewport?.sendCommand(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func italicizeSelection() {
|
@objc private func italicizeSelection() {
|
||||||
NotificationCenter.default.post(name: .italicizeSelection, object: nil)
|
viewport?.sendCommand(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func insertTable() {
|
@objc private func insertTable() {
|
||||||
NotificationCenter.default.post(name: .insertTable, object: nil)
|
viewport?.sendCommand(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func smartEval() {
|
@objc private func smartEval() {
|
||||||
NotificationCenter.default.post(name: .smartEval, object: nil)
|
viewport?.sendCommand(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openNote() {
|
@objc private func openNote() {
|
||||||
|
|
@ -412,7 +408,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@objc private func formatDocument() {
|
@objc private func formatDocument() {
|
||||||
NotificationCenter.default.post(name: .formatDocument, object: nil)
|
viewport?.sendCommand(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openSettings() {
|
@objc private func openSettings() {
|
||||||
|
|
@ -421,9 +417,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@objc private func settingsDidChange() {
|
@objc private func settingsDidChange() {
|
||||||
window.backgroundColor = Theme.current.base
|
window.backgroundColor = Theme.current.base
|
||||||
|
syncThemeToViewport()
|
||||||
window.contentView?.needsDisplay = true
|
window.contentView?.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncThemeToViewport() {
|
||||||
|
let mode = ConfigManager.shared.themeMode
|
||||||
|
let name: String
|
||||||
|
switch mode {
|
||||||
|
case "dark": name = "mocha"
|
||||||
|
case "light": name = "latte"
|
||||||
|
default:
|
||||||
|
let appearance = NSApp.effectiveAppearance
|
||||||
|
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||||
|
name = isDark ? "mocha" : "latte"
|
||||||
|
}
|
||||||
|
viewport?.setTheme(name)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func toggleBrowser() {
|
@objc private func toggleBrowser() {
|
||||||
DocumentBrowserController.shared?.toggle()
|
DocumentBrowserController.shared?.toggle()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ class IcedViewportView: NSView {
|
||||||
|
|
||||||
if cmd && !shift {
|
if cmd && !shift {
|
||||||
switch chars {
|
switch chars {
|
||||||
case "a", "b", "c", "i", "v", "x", "z", "p", "t",
|
case "a", "b", "c", "e", "f", "g", "i", "v", "x", "z", "p", "t",
|
||||||
"=", "+", "-", "0":
|
"=", "+", "-", "0":
|
||||||
keyDown(with: event)
|
keyDown(with: event)
|
||||||
return true
|
return true
|
||||||
|
|
@ -168,7 +168,7 @@ class IcedViewportView: NSView {
|
||||||
}
|
}
|
||||||
if cmd && shift {
|
if cmd && shift {
|
||||||
switch chars {
|
switch chars {
|
||||||
case "z":
|
case "g", "z":
|
||||||
keyDown(with: event)
|
keyDown(with: event)
|
||||||
return true
|
return true
|
||||||
default: break
|
default: break
|
||||||
|
|
@ -214,4 +214,16 @@ class IcedViewportView: NSView {
|
||||||
viewport_free_string(cstr)
|
viewport_free_string(cstr)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendCommand(_ command: UInt32) {
|
||||||
|
guard let h = viewportHandle else { return }
|
||||||
|
viewport_send_command(h, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTheme(_ name: String) {
|
||||||
|
guard let h = viewportHandle else { return }
|
||||||
|
name.withCString { cstr in
|
||||||
|
viewport_set_theme(h, cstr)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,8 @@ char *viewport_get_text(struct ViewportHandle *handle);
|
||||||
|
|
||||||
void viewport_free_string(char *s);
|
void viewport_free_string(char *s);
|
||||||
|
|
||||||
|
void viewport_set_theme(struct ViewportHandle *_handle, const char *name);
|
||||||
|
|
||||||
|
void viewport_send_command(struct ViewportHandle *handle, uint32_t command);
|
||||||
|
|
||||||
#endif /* ACORD_VIEWPORT_H */
|
#endif /* ACORD_VIEWPORT_H */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ use raw_window_handle::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::editor::{EditorState, Message};
|
use crate::editor::{EditorState, Message};
|
||||||
|
use crate::palette;
|
||||||
use crate::ViewportHandle;
|
use crate::ViewportHandle;
|
||||||
|
|
||||||
struct MacClipboard;
|
struct MacClipboard;
|
||||||
|
|
@ -158,22 +159,35 @@ pub fn render(handle: &mut ViewportHandle) {
|
||||||
let mut messages: Vec<Message> = Vec::new();
|
let mut messages: Vec<Message> = Vec::new();
|
||||||
|
|
||||||
for event in &handle.events {
|
for event in &handle.events {
|
||||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
match event {
|
||||||
key: keyboard::Key::Character(c),
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
modifiers,
|
key: keyboard::Key::Character(c),
|
||||||
..
|
modifiers,
|
||||||
}) = event
|
..
|
||||||
{
|
}) if modifiers.logo() => {
|
||||||
if modifiers.logo() {
|
|
||||||
match c.as_str() {
|
match c.as_str() {
|
||||||
"p" => messages.push(Message::TogglePreview),
|
"p" => messages.push(Message::TogglePreview),
|
||||||
"t" => messages.push(Message::InsertTable),
|
"t" => messages.push(Message::InsertTable),
|
||||||
"b" => messages.push(Message::ToggleBold),
|
"b" => messages.push(Message::ToggleBold),
|
||||||
"i" => messages.push(Message::ToggleItalic),
|
"i" => messages.push(Message::ToggleItalic),
|
||||||
"e" => messages.push(Message::SmartEval),
|
"e" => messages.push(Message::SmartEval),
|
||||||
|
"z" if modifiers.shift() => messages.push(Message::Redo),
|
||||||
|
"z" => messages.push(Message::Undo),
|
||||||
|
"f" => messages.push(Message::ToggleFind),
|
||||||
|
"g" if modifiers.shift() => messages.push(Message::FindPrev),
|
||||||
|
"g" => messages.push(Message::FindNext),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
|
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
if handle.state.find.visible {
|
||||||
|
messages.push(Message::HideFind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +206,9 @@ pub fn render(handle: &mut ViewportHandle) {
|
||||||
handle.state.update(msg);
|
handle.state.update(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle.state.tick();
|
||||||
|
let pending_focus = handle.state.take_pending_focus();
|
||||||
|
|
||||||
let theme = Theme::Dark;
|
let theme = Theme::Dark;
|
||||||
let style = Style {
|
let style = Style {
|
||||||
text_color: Color::WHITE,
|
text_color: Color::WHITE,
|
||||||
|
|
@ -204,13 +221,18 @@ pub fn render(handle: &mut ViewportHandle) {
|
||||||
&mut handle.renderer,
|
&mut handle.renderer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if let Some(focus_id) = pending_focus {
|
||||||
|
use iced_wgpu::core::widget::operation::focusable;
|
||||||
|
let mut op = focusable::focus(focus_id);
|
||||||
|
ui.operate(&handle.renderer, &mut op);
|
||||||
|
}
|
||||||
|
|
||||||
ui.draw(&mut handle.renderer, &theme, &style, handle.cursor);
|
ui.draw(&mut handle.renderer, &theme, &style, handle.cursor);
|
||||||
handle.cache = ui.into_cache();
|
handle.cache = ui.into_cache();
|
||||||
|
|
||||||
let bg = Color::from_rgb(0.08, 0.08, 0.10);
|
|
||||||
handle
|
handle
|
||||||
.renderer
|
.renderer
|
||||||
.present(Some(bg), handle.format, &view, &handle.viewport);
|
.present(Some(palette::current().base), handle.format, &view, &handle.viewport);
|
||||||
|
|
||||||
frame.present();
|
frame.present();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +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;
|
||||||
mod syntax;
|
mod syntax;
|
||||||
|
pub mod table_block;
|
||||||
|
pub mod tree_block;
|
||||||
|
|
||||||
pub use acord_core::*;
|
pub use acord_core::*;
|
||||||
|
|
||||||
|
|
@ -172,3 +179,34 @@ pub extern "C" fn viewport_free_string(s: *mut c_char) {
|
||||||
if s.is_null() { return; }
|
if s.is_null() { return; }
|
||||||
unsafe { drop(CString::from_raw(s)); }
|
unsafe { drop(CString::from_raw(s)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn viewport_set_theme(_handle: *mut ViewportHandle, name: *const c_char) {
|
||||||
|
let s = if name.is_null() {
|
||||||
|
"mocha"
|
||||||
|
} else {
|
||||||
|
unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("mocha")
|
||||||
|
};
|
||||||
|
palette::set_theme(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u32) {
|
||||||
|
let h = match unsafe { handle.as_mut() } {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let msg = match command {
|
||||||
|
1 => editor::Message::ToggleBold,
|
||||||
|
2 => editor::Message::ToggleItalic,
|
||||||
|
3 => editor::Message::InsertTable,
|
||||||
|
4 => editor::Message::SmartEval,
|
||||||
|
5 => editor::Message::Evaluate,
|
||||||
|
6 => editor::Message::TogglePreview,
|
||||||
|
7 => editor::Message::ZoomIn,
|
||||||
|
8 => editor::Message::ZoomOut,
|
||||||
|
9 => editor::Message::ZoomReset,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
h.state.update(msg);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
use iced_wgpu::core::Color;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Palette {
|
||||||
|
pub rosewater: Color,
|
||||||
|
pub flamingo: Color,
|
||||||
|
pub pink: Color,
|
||||||
|
pub mauve: Color,
|
||||||
|
pub red: Color,
|
||||||
|
pub maroon: Color,
|
||||||
|
pub peach: Color,
|
||||||
|
pub yellow: Color,
|
||||||
|
pub green: Color,
|
||||||
|
pub teal: Color,
|
||||||
|
pub sky: Color,
|
||||||
|
pub sapphire: Color,
|
||||||
|
pub blue: Color,
|
||||||
|
pub lavender: Color,
|
||||||
|
pub text: Color,
|
||||||
|
pub subtext1: Color,
|
||||||
|
pub subtext0: Color,
|
||||||
|
pub overlay2: Color,
|
||||||
|
pub overlay1: Color,
|
||||||
|
pub overlay0: Color,
|
||||||
|
pub surface2: Color,
|
||||||
|
pub surface1: Color,
|
||||||
|
pub surface0: Color,
|
||||||
|
pub base: Color,
|
||||||
|
pub mantle: Color,
|
||||||
|
pub crust: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static MOCHA: Palette = Palette {
|
||||||
|
rosewater: Color::from_rgb(0.961, 0.878, 0.863),
|
||||||
|
flamingo: Color::from_rgb(0.949, 0.804, 0.804),
|
||||||
|
pink: Color::from_rgb(0.961, 0.761, 0.906),
|
||||||
|
mauve: Color::from_rgb(0.796, 0.651, 0.969),
|
||||||
|
red: Color::from_rgb(0.953, 0.545, 0.659),
|
||||||
|
maroon: Color::from_rgb(0.922, 0.627, 0.675),
|
||||||
|
peach: Color::from_rgb(0.980, 0.702, 0.529),
|
||||||
|
yellow: Color::from_rgb(0.976, 0.886, 0.686),
|
||||||
|
green: Color::from_rgb(0.651, 0.890, 0.631),
|
||||||
|
teal: Color::from_rgb(0.580, 0.886, 0.835),
|
||||||
|
sky: Color::from_rgb(0.537, 0.863, 0.922),
|
||||||
|
sapphire: Color::from_rgb(0.455, 0.780, 0.925),
|
||||||
|
blue: Color::from_rgb(0.537, 0.706, 0.980),
|
||||||
|
lavender: Color::from_rgb(0.706, 0.745, 0.996),
|
||||||
|
text: Color::from_rgb(0.804, 0.839, 0.957),
|
||||||
|
subtext1: Color::from_rgb(0.729, 0.761, 0.871),
|
||||||
|
subtext0: Color::from_rgb(0.651, 0.678, 0.784),
|
||||||
|
overlay2: Color::from_rgb(0.576, 0.600, 0.698),
|
||||||
|
overlay1: Color::from_rgb(0.498, 0.518, 0.612),
|
||||||
|
overlay0: Color::from_rgb(0.424, 0.439, 0.525),
|
||||||
|
surface2: Color::from_rgb(0.345, 0.357, 0.439),
|
||||||
|
surface1: Color::from_rgb(0.271, 0.278, 0.353),
|
||||||
|
surface0: Color::from_rgb(0.192, 0.196, 0.267),
|
||||||
|
base: Color::from_rgb(0.118, 0.118, 0.180),
|
||||||
|
mantle: Color::from_rgb(0.094, 0.094, 0.145),
|
||||||
|
crust: Color::from_rgb(0.067, 0.067, 0.106),
|
||||||
|
};
|
||||||
|
|
||||||
|
pub static LATTE: Palette = Palette {
|
||||||
|
rosewater: Color::from_rgb(0.863, 0.541, 0.471),
|
||||||
|
flamingo: Color::from_rgb(0.867, 0.471, 0.471),
|
||||||
|
pink: Color::from_rgb(0.918, 0.463, 0.796),
|
||||||
|
mauve: Color::from_rgb(0.533, 0.224, 0.937),
|
||||||
|
red: Color::from_rgb(0.824, 0.059, 0.224),
|
||||||
|
maroon: Color::from_rgb(0.902, 0.271, 0.325),
|
||||||
|
peach: Color::from_rgb(0.996, 0.392, 0.043),
|
||||||
|
yellow: Color::from_rgb(0.875, 0.557, 0.114),
|
||||||
|
green: Color::from_rgb(0.251, 0.627, 0.169),
|
||||||
|
teal: Color::from_rgb(0.090, 0.573, 0.600),
|
||||||
|
sky: Color::from_rgb(0.016, 0.647, 0.898),
|
||||||
|
sapphire: Color::from_rgb(0.125, 0.624, 0.710),
|
||||||
|
blue: Color::from_rgb(0.118, 0.400, 0.961),
|
||||||
|
lavender: Color::from_rgb(0.447, 0.529, 0.992),
|
||||||
|
text: Color::from_rgb(0.298, 0.310, 0.412),
|
||||||
|
subtext1: Color::from_rgb(0.361, 0.373, 0.467),
|
||||||
|
subtext0: Color::from_rgb(0.424, 0.435, 0.522),
|
||||||
|
overlay2: Color::from_rgb(0.486, 0.498, 0.576),
|
||||||
|
overlay1: Color::from_rgb(0.549, 0.561, 0.631),
|
||||||
|
overlay0: Color::from_rgb(0.612, 0.627, 0.690),
|
||||||
|
surface2: Color::from_rgb(0.675, 0.690, 0.745),
|
||||||
|
surface1: Color::from_rgb(0.737, 0.753, 0.800),
|
||||||
|
surface0: Color::from_rgb(0.800, 0.816, 0.855),
|
||||||
|
base: Color::from_rgb(0.937, 0.945, 0.961),
|
||||||
|
mantle: Color::from_rgb(0.902, 0.914, 0.937),
|
||||||
|
crust: Color::from_rgb(0.863, 0.878, 0.910),
|
||||||
|
};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CURRENT: RefCell<&'static Palette> = const { RefCell::new(&MOCHA) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current() -> &'static Palette {
|
||||||
|
CURRENT.with(|c| *c.borrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_theme(name: &str) {
|
||||||
|
let pal = match name {
|
||||||
|
"latte" | "light" => &LATTE,
|
||||||
|
_ => &MOCHA,
|
||||||
|
};
|
||||||
|
CURRENT.with(|c| *c.borrow_mut() = pal);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,36 @@
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use iced_wgpu::core::text::highlighter;
|
use iced_wgpu::core::text::highlighter;
|
||||||
use iced_wgpu::core::Color;
|
use iced_wgpu::core::{Color, Font};
|
||||||
|
use iced_wgpu::core::font::{Weight, Style as FontStyle};
|
||||||
use acord_core::highlight::{highlight_source, HighlightSpan};
|
use acord_core::highlight::{highlight_source, HighlightSpan};
|
||||||
|
use acord_core::doc::{classify_document, LineKind};
|
||||||
use crate::editor::{RESULT_PREFIX, ERROR_PREFIX};
|
use crate::editor::{RESULT_PREFIX, ERROR_PREFIX};
|
||||||
|
use crate::palette;
|
||||||
|
|
||||||
pub const EVAL_RESULT_KIND: u8 = 24;
|
pub const EVAL_RESULT_KIND: u8 = 24;
|
||||||
pub const EVAL_ERROR_KIND: u8 = 25;
|
pub const EVAL_ERROR_KIND: u8 = 25;
|
||||||
|
|
||||||
|
const MD_HEADING_MARKER: u8 = 26;
|
||||||
|
const MD_H1: u8 = 27;
|
||||||
|
const MD_H2: u8 = 28;
|
||||||
|
const MD_H3: u8 = 29;
|
||||||
|
const MD_BOLD: u8 = 30;
|
||||||
|
const MD_ITALIC: u8 = 31;
|
||||||
|
const MD_INLINE_CODE: u8 = 32;
|
||||||
|
const MD_FORMAT_MARKER: u8 = 33;
|
||||||
|
const MD_LINK_TEXT: u8 = 34;
|
||||||
|
const MD_LINK_URL: u8 = 35;
|
||||||
|
const MD_BLOCKQUOTE_MARKER: u8 = 36;
|
||||||
|
const MD_BLOCKQUOTE: u8 = 37;
|
||||||
|
const MD_LIST_MARKER: u8 = 38;
|
||||||
|
const MD_FENCE_MARKER: u8 = 39;
|
||||||
|
const MD_CODE_BLOCK: u8 = 40;
|
||||||
|
const MD_HR: u8 = 41;
|
||||||
|
const MD_TASK_OPEN: u8 = 42;
|
||||||
|
const MD_TASK_DONE: u8 = 43;
|
||||||
|
const MD_BOLD_ITALIC: u8 = 44;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct SyntaxSettings {
|
pub struct SyntaxSettings {
|
||||||
pub lang: String,
|
pub lang: String,
|
||||||
|
|
@ -19,11 +42,23 @@ pub struct SyntaxHighlight {
|
||||||
pub kind: u8,
|
pub kind: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum LineDecor {
|
||||||
|
None,
|
||||||
|
CodeBlock,
|
||||||
|
Blockquote,
|
||||||
|
HorizontalRule,
|
||||||
|
FenceMarker,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SyntaxHighlighter {
|
pub struct SyntaxHighlighter {
|
||||||
lang: String,
|
lang: String,
|
||||||
spans: Vec<HighlightSpan>,
|
spans: Vec<HighlightSpan>,
|
||||||
line_offsets: Vec<usize>,
|
line_offsets: Vec<usize>,
|
||||||
|
line_kinds: Vec<LineKind>,
|
||||||
|
in_fenced_code: bool,
|
||||||
current_line: usize,
|
current_line: usize,
|
||||||
|
line_decors: Vec<LineDecor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyntaxHighlighter {
|
impl SyntaxHighlighter {
|
||||||
|
|
@ -35,8 +70,323 @@ impl SyntaxHighlighter {
|
||||||
self.line_offsets.push(offset);
|
self.line_offsets.push(offset);
|
||||||
offset += line.len() + 1;
|
offset += line.len() + 1;
|
||||||
}
|
}
|
||||||
|
let classified = classify_document(source);
|
||||||
|
self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect();
|
||||||
|
|
||||||
|
self.line_decors.clear();
|
||||||
|
let mut in_fence = false;
|
||||||
|
for (i, raw_line) in source.split('\n').enumerate() {
|
||||||
|
let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown;
|
||||||
|
if is_md {
|
||||||
|
let trimmed = raw_line.trim_start();
|
||||||
|
if trimmed.starts_with("```") {
|
||||||
|
in_fence = !in_fence;
|
||||||
|
self.line_decors.push(LineDecor::FenceMarker);
|
||||||
|
} else if in_fence {
|
||||||
|
self.line_decors.push(LineDecor::CodeBlock);
|
||||||
|
} else if is_horizontal_rule(trimmed) {
|
||||||
|
self.line_decors.push(LineDecor::HorizontalRule);
|
||||||
|
} else if trimmed.starts_with("> ") || trimmed == ">" {
|
||||||
|
self.line_decors.push(LineDecor::Blockquote);
|
||||||
|
} else {
|
||||||
|
self.line_decors.push(LineDecor::None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if in_fence { in_fence = false; }
|
||||||
|
self.line_decors.push(LineDecor::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.in_fenced_code = false;
|
||||||
self.current_line = 0;
|
self.current_line = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn highlight_markdown(&self, line: &str) -> Vec<(Range<usize>, SyntaxHighlight)> {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let leading = line.len() - trimmed.len();
|
||||||
|
|
||||||
|
if is_horizontal_rule(trimmed) {
|
||||||
|
return vec![(0..line.len(), SyntaxHighlight { kind: MD_HR })];
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(level) = heading_level(trimmed) {
|
||||||
|
let marker_end = leading + level + 1;
|
||||||
|
let kind = match level {
|
||||||
|
1 => MD_H1,
|
||||||
|
2 => MD_H2,
|
||||||
|
_ => MD_H3,
|
||||||
|
};
|
||||||
|
let mut spans = vec![
|
||||||
|
(0..marker_end, SyntaxHighlight { kind: MD_HEADING_MARKER }),
|
||||||
|
];
|
||||||
|
if marker_end < line.len() {
|
||||||
|
spans.push((marker_end..line.len(), SyntaxHighlight { kind }));
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.starts_with("> ") || trimmed == ">" {
|
||||||
|
let marker_end = leading + if trimmed.len() > 1 { 2 } else { 1 };
|
||||||
|
let mut spans = vec![
|
||||||
|
(0..marker_end, SyntaxHighlight { kind: MD_BLOCKQUOTE_MARKER }),
|
||||||
|
];
|
||||||
|
if marker_end < line.len() {
|
||||||
|
let content = &line[marker_end..];
|
||||||
|
let inner = parse_inline(content, marker_end);
|
||||||
|
if inner.is_empty() {
|
||||||
|
spans.push((marker_end..line.len(), SyntaxHighlight { kind: MD_BLOCKQUOTE }));
|
||||||
|
} else {
|
||||||
|
spans.extend(inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(list_info) = list_marker_info(trimmed) {
|
||||||
|
let (marker_len, marker_kind) = match list_info {
|
||||||
|
ListKind::TaskOpen(n) => (n, MD_TASK_OPEN),
|
||||||
|
ListKind::TaskDone(n) => (n, MD_TASK_DONE),
|
||||||
|
ListKind::Plain(n) => (n, MD_LIST_MARKER),
|
||||||
|
};
|
||||||
|
let marker_end = leading + marker_len;
|
||||||
|
let mut spans = vec![
|
||||||
|
(0..marker_end, SyntaxHighlight { kind: marker_kind }),
|
||||||
|
];
|
||||||
|
if marker_end < line.len() {
|
||||||
|
let content = &line[marker_end..];
|
||||||
|
let inner = parse_inline(content, marker_end);
|
||||||
|
if inner.is_empty() {
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
spans.extend(inner);
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_inline(line, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heading_level(trimmed: &str) -> Option<usize> {
|
||||||
|
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 { return None; }
|
||||||
|
if level < bytes.len() && bytes[level] == b' ' {
|
||||||
|
Some(level)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_horizontal_rule(trimmed: &str) -> bool {
|
||||||
|
if trimmed.len() < 3 { return false; }
|
||||||
|
let first = trimmed.as_bytes()[0];
|
||||||
|
if !matches!(first, b'-' | b'*' | b'_') { return false; }
|
||||||
|
trimmed.bytes().all(|b| b == first || b == b' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum ListKind {
|
||||||
|
Plain(usize),
|
||||||
|
TaskOpen(usize),
|
||||||
|
TaskDone(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_marker_info(trimmed: &str) -> Option<ListKind> {
|
||||||
|
let bytes = trimmed.as_bytes();
|
||||||
|
if bytes.is_empty() { return None; }
|
||||||
|
|
||||||
|
if matches!(bytes[0], b'-' | b'*' | b'+') && bytes.get(1) == Some(&b' ') {
|
||||||
|
if trimmed.starts_with("- [ ] ") {
|
||||||
|
return Some(ListKind::TaskOpen(6));
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("- [x] ") || trimmed.starts_with("- [X] ") {
|
||||||
|
return Some(ListKind::TaskDone(6));
|
||||||
|
}
|
||||||
|
return Some(ListKind::Plain(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
|
||||||
|
if i > 0 && i < bytes.len() && matches!(bytes[i], b'.' | b')') {
|
||||||
|
if bytes.get(i + 1) == Some(&b' ') {
|
||||||
|
return Some(ListKind::Plain(i + 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_inline(text: &str, base: usize) -> Vec<(Range<usize>, SyntaxHighlight)> {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let len = bytes.len();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
if bytes[i] == b'\\' && i + 1 < len && is_md_punctuation(bytes[i + 1]) {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' {
|
||||||
|
if let Some(end) = find_triple_star(bytes, i + 3) {
|
||||||
|
spans.push((base + i..base + i + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if i + 3 < end {
|
||||||
|
spans.push((base + i + 3..base + end, SyntaxHighlight { kind: MD_BOLD_ITALIC }));
|
||||||
|
}
|
||||||
|
spans.push((base + end..base + end + 3, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
i = end + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' {
|
||||||
|
if let Some(end) = find_closing(bytes, i + 2, b'*', b'*') {
|
||||||
|
spans.push((base + i..base + i + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if i + 2 < end {
|
||||||
|
let inner = parse_inline(&text[i + 2..end], base + i + 2);
|
||||||
|
if inner.is_empty() {
|
||||||
|
spans.push((base + i + 2..base + end, SyntaxHighlight { kind: MD_BOLD }));
|
||||||
|
} else {
|
||||||
|
for (r, h) in inner {
|
||||||
|
let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind };
|
||||||
|
spans.push((r, SyntaxHighlight { kind }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes[i] == b'*' && (i + 1 >= len || bytes[i + 1] != b'*') {
|
||||||
|
if let Some(end) = find_single_closing(bytes, i + 1, b'*') {
|
||||||
|
if end > i + 1 && bytes[end - 1] != b'*' {
|
||||||
|
spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if i + 1 < end {
|
||||||
|
spans.push((base + i + 1..base + end, SyntaxHighlight { kind: MD_ITALIC }));
|
||||||
|
}
|
||||||
|
spans.push((base + end..base + end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes[i] == b'`' {
|
||||||
|
let tick_count = count_backticks(bytes, i);
|
||||||
|
if let Some(end) = find_backtick_close(bytes, i + tick_count, tick_count) {
|
||||||
|
spans.push((base + i..base + i + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if i + tick_count < end {
|
||||||
|
spans.push((base + i + tick_count..base + end, SyntaxHighlight { kind: MD_INLINE_CODE }));
|
||||||
|
}
|
||||||
|
spans.push((base + end..base + end + tick_count, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
i = end + tick_count;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes[i] == b'[' {
|
||||||
|
if let Some((text_end, url_end)) = find_link(bytes, i) {
|
||||||
|
spans.push((base + i..base + i + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if i + 1 < text_end {
|
||||||
|
spans.push((base + i + 1..base + text_end, SyntaxHighlight { kind: MD_LINK_TEXT }));
|
||||||
|
}
|
||||||
|
spans.push((base + text_end..base + text_end + 2, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
if text_end + 2 < url_end {
|
||||||
|
spans.push((base + text_end + 2..base + url_end, SyntaxHighlight { kind: MD_LINK_URL }));
|
||||||
|
}
|
||||||
|
spans.push((base + url_end..base + url_end + 1, SyntaxHighlight { kind: MD_FORMAT_MARKER }));
|
||||||
|
i = url_end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_md_punctuation(b: u8) -> bool {
|
||||||
|
matches!(b, b'\\' | b'`' | b'*' | b'_' | b'{' | b'}' | b'[' | b']'
|
||||||
|
| b'(' | b')' | b'#' | b'+' | b'-' | b'.' | b'!' | b'|')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_triple_star(bytes: &[u8], start: usize) -> Option<usize> {
|
||||||
|
let mut i = start;
|
||||||
|
while i + 2 < bytes.len() {
|
||||||
|
if bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_backticks(bytes: &[u8], start: usize) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
while start + n < bytes.len() && bytes[start + n] == b'`' { n += 1; }
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_backtick_close(bytes: &[u8], start: usize, count: usize) -> Option<usize> {
|
||||||
|
if count == 0 { return None; }
|
||||||
|
let mut i = start;
|
||||||
|
while i + count <= bytes.len() {
|
||||||
|
if count_backticks(bytes, i) == count {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_closing(bytes: &[u8], start: usize, c1: u8, c2: u8) -> Option<usize> {
|
||||||
|
let mut i = start;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
if bytes[i] == c1 && bytes[i + 1] == c2 {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_single_closing(bytes: &[u8], start: usize, ch: u8) -> Option<usize> {
|
||||||
|
let mut i = start;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == ch {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_link(bytes: &[u8], open: usize) -> Option<(usize, usize)> {
|
||||||
|
let mut i = open + 1;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b']' {
|
||||||
|
if i + 1 < bytes.len() && bytes[i + 1] == b'(' {
|
||||||
|
let text_end = i;
|
||||||
|
let mut j = i + 2;
|
||||||
|
while j < bytes.len() {
|
||||||
|
if bytes[j] == b')' {
|
||||||
|
return Some((text_end, j));
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if bytes[i] == b'\n' { return None; }
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
impl highlighter::Highlighter for SyntaxHighlighter {
|
impl highlighter::Highlighter for SyntaxHighlighter {
|
||||||
|
|
@ -49,7 +399,10 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
||||||
lang: settings.lang.clone(),
|
lang: settings.lang.clone(),
|
||||||
spans: Vec::new(),
|
spans: Vec::new(),
|
||||||
line_offsets: Vec::new(),
|
line_offsets: Vec::new(),
|
||||||
|
line_kinds: Vec::new(),
|
||||||
|
in_fenced_code: false,
|
||||||
current_line: 0,
|
current_line: 0,
|
||||||
|
line_decors: Vec::new(),
|
||||||
};
|
};
|
||||||
h.rebuild(&settings.source);
|
h.rebuild(&settings.source);
|
||||||
h
|
h
|
||||||
|
|
@ -62,18 +415,42 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
||||||
|
|
||||||
fn change_line(&mut self, line: usize) {
|
fn change_line(&mut self, line: usize) {
|
||||||
self.current_line = self.current_line.min(line);
|
self.current_line = self.current_line.min(line);
|
||||||
|
if line == 0 {
|
||||||
|
self.in_fenced_code = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> {
|
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||||
let ln = self.current_line;
|
let ln = self.current_line;
|
||||||
self.current_line += 1;
|
self.current_line += 1;
|
||||||
|
|
||||||
let trimmed = _line.trim_start();
|
let trimmed = line.trim_start();
|
||||||
if trimmed.starts_with(RESULT_PREFIX) {
|
if trimmed.starts_with(RESULT_PREFIX) {
|
||||||
return vec![(0.._line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter();
|
return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_RESULT_KIND })].into_iter();
|
||||||
}
|
}
|
||||||
if trimmed.starts_with(ERROR_PREFIX) {
|
if trimmed.starts_with(ERROR_PREFIX) {
|
||||||
return vec![(0.._line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter();
|
return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter();
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_markdown = ln < self.line_kinds.len()
|
||||||
|
&& self.line_kinds[ln] == LineKind::Markdown;
|
||||||
|
|
||||||
|
if is_markdown {
|
||||||
|
if trimmed.starts_with("```") {
|
||||||
|
self.in_fenced_code = !self.in_fenced_code;
|
||||||
|
return vec![(0..line.len(), SyntaxHighlight { kind: MD_FENCE_MARKER })].into_iter();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.in_fenced_code {
|
||||||
|
return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter();
|
||||||
|
}
|
||||||
|
|
||||||
|
let md_spans = self.highlight_markdown(line);
|
||||||
|
if !md_spans.is_empty() {
|
||||||
|
return md_spans.into_iter();
|
||||||
|
}
|
||||||
|
} else if self.in_fenced_code {
|
||||||
|
self.in_fenced_code = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ln >= self.line_offsets.len() {
|
if ln >= self.line_offsets.len() {
|
||||||
|
|
@ -84,7 +461,7 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
||||||
let line_end = if ln + 1 < self.line_offsets.len() {
|
let line_end = if ln + 1 < self.line_offsets.len() {
|
||||||
self.line_offsets[ln + 1] - 1
|
self.line_offsets[ln + 1] - 1
|
||||||
} else {
|
} else {
|
||||||
line_start + _line.len()
|
line_start + line.len()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
@ -107,33 +484,101 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn highlight_color(kind: u8) -> Color {
|
pub fn highlight_color(kind: u8) -> Color {
|
||||||
|
let p = palette::current();
|
||||||
match kind {
|
match kind {
|
||||||
0 => Color::from_rgb(0.804, 0.569, 0.945), // keyword - mauve
|
0 => p.mauve,
|
||||||
1 => Color::from_rgb(0.537, 0.706, 0.980), // function - blue
|
1 => p.blue,
|
||||||
2 => Color::from_rgb(0.604, 0.831, 0.898), // function.builtin - teal
|
2 => p.teal,
|
||||||
3 => Color::from_rgb(0.976, 0.827, 0.522), // type - yellow
|
3 => p.yellow,
|
||||||
4 => Color::from_rgb(0.976, 0.827, 0.522), // type.builtin - yellow
|
4 => p.yellow,
|
||||||
5 => Color::from_rgb(0.569, 0.878, 0.800), // constructor - teal
|
5 => p.teal,
|
||||||
6 => Color::from_rgb(0.988, 0.702, 0.529), // constant - peach
|
6 => p.peach,
|
||||||
7 => Color::from_rgb(0.988, 0.702, 0.529), // constant.builtin - peach
|
7 => p.peach,
|
||||||
8 => Color::from_rgb(0.651, 0.890, 0.631), // string - green
|
8 => p.green,
|
||||||
9 => Color::from_rgb(0.988, 0.702, 0.529), // number - peach
|
9 => p.peach,
|
||||||
10 => Color::from_rgb(0.424, 0.443, 0.537), // comment - overlay0
|
10 => p.overlay0,
|
||||||
11 => Color::from_rgb(0.804, 0.839, 0.957), // variable - text
|
11 => p.text,
|
||||||
12 => Color::from_rgb(0.949, 0.604, 0.584), // variable.builtin - red
|
12 => p.red,
|
||||||
13 => Color::from_rgb(0.949, 0.773, 0.584), // variable.parameter - flamingo
|
13 => p.flamingo,
|
||||||
14 => Color::from_rgb(0.604, 0.831, 0.898), // operator - sky
|
14 => p.sky,
|
||||||
15 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation - overlay2
|
15 => p.overlay2,
|
||||||
16 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.bracket - overlay2
|
16 => p.overlay2,
|
||||||
17 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.delimiter - overlay2
|
17 => p.overlay2,
|
||||||
18 => Color::from_rgb(0.537, 0.706, 0.980), // property - blue
|
18 => p.blue,
|
||||||
19 => Color::from_rgb(0.804, 0.569, 0.945), // tag - mauve
|
19 => p.mauve,
|
||||||
20 => Color::from_rgb(0.976, 0.827, 0.522), // attribute - yellow
|
20 => p.yellow,
|
||||||
21 => Color::from_rgb(0.569, 0.878, 0.800), // label - teal
|
21 => p.teal,
|
||||||
22 => Color::from_rgb(0.949, 0.604, 0.584), // escape - red
|
22 => p.red,
|
||||||
23 => Color::from_rgb(0.804, 0.839, 0.957), // embedded - text
|
23 => p.text,
|
||||||
24 => Color::from_rgb(0.651, 0.890, 0.631), // eval result - green
|
24 => p.green,
|
||||||
25 => Color::from_rgb(0.890, 0.400, 0.400), // eval error - muted red
|
25 => p.maroon,
|
||||||
_ => Color::from_rgb(0.804, 0.839, 0.957), // default text
|
MD_HEADING_MARKER => p.overlay0,
|
||||||
|
MD_H1 => p.rosewater,
|
||||||
|
MD_H2 => p.peach,
|
||||||
|
MD_H3 => p.yellow,
|
||||||
|
MD_BOLD => p.peach,
|
||||||
|
MD_ITALIC => p.mauve,
|
||||||
|
MD_INLINE_CODE => p.green,
|
||||||
|
MD_FORMAT_MARKER => p.overlay0,
|
||||||
|
MD_LINK_TEXT => p.blue,
|
||||||
|
MD_LINK_URL => p.overlay1,
|
||||||
|
MD_BLOCKQUOTE_MARKER => p.overlay0,
|
||||||
|
MD_BLOCKQUOTE => p.sky,
|
||||||
|
MD_LIST_MARKER => p.sky,
|
||||||
|
MD_FENCE_MARKER => p.overlay0,
|
||||||
|
MD_CODE_BLOCK => p.text,
|
||||||
|
MD_HR => p.overlay1,
|
||||||
|
MD_TASK_OPEN => p.overlay2,
|
||||||
|
MD_TASK_DONE => p.green,
|
||||||
|
MD_BOLD_ITALIC => p.peach,
|
||||||
|
_ => p.text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn highlight_font(kind: u8) -> Option<Font> {
|
||||||
|
match kind {
|
||||||
|
MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..Font::MONOSPACE }),
|
||||||
|
MD_H1 => Some(Font { weight: Weight::Black, ..Font::DEFAULT }),
|
||||||
|
MD_H2 => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }),
|
||||||
|
MD_H3 => Some(Font { weight: Weight::Semibold, ..Font::DEFAULT }),
|
||||||
|
MD_BOLD => Some(Font { weight: Weight::Bold, ..Font::DEFAULT }),
|
||||||
|
MD_ITALIC => Some(Font { style: FontStyle::Italic, ..Font::DEFAULT }),
|
||||||
|
MD_BOLD_ITALIC => Some(Font { weight: Weight::Bold, style: FontStyle::Italic, ..Font::DEFAULT }),
|
||||||
|
MD_INLINE_CODE => Some(Font::MONOSPACE),
|
||||||
|
MD_FORMAT_MARKER => Some(Font::MONOSPACE),
|
||||||
|
MD_BLOCKQUOTE => Some(Font { style: FontStyle::Italic, ..Font::DEFAULT }),
|
||||||
|
MD_FENCE_MARKER => Some(Font::MONOSPACE),
|
||||||
|
MD_CODE_BLOCK => Some(Font::MONOSPACE),
|
||||||
|
MD_TASK_DONE => Some(Font { weight: Weight::Bold, ..Font::MONOSPACE }),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_line_decors(source: &str) -> Vec<LineDecor> {
|
||||||
|
let classified = classify_document(source);
|
||||||
|
let line_kinds: Vec<LineKind> = classified.into_iter().map(|cl| cl.kind).collect();
|
||||||
|
let mut decors = Vec::new();
|
||||||
|
let mut in_fence = false;
|
||||||
|
for (i, raw_line) in source.split('\n').enumerate() {
|
||||||
|
let is_md = i < line_kinds.len() && line_kinds[i] == LineKind::Markdown;
|
||||||
|
if is_md {
|
||||||
|
let trimmed = raw_line.trim_start();
|
||||||
|
if trimmed.starts_with("```") {
|
||||||
|
in_fence = !in_fence;
|
||||||
|
decors.push(LineDecor::FenceMarker);
|
||||||
|
} else if in_fence {
|
||||||
|
decors.push(LineDecor::CodeBlock);
|
||||||
|
} else if is_horizontal_rule(trimmed) {
|
||||||
|
decors.push(LineDecor::HorizontalRule);
|
||||||
|
} else if trimmed.starts_with("> ") || trimmed == ">" {
|
||||||
|
decors.push(LineDecor::Blockquote);
|
||||||
|
} else {
|
||||||
|
decors.push(LineDecor::None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if in_fence { in_fence = false; }
|
||||||
|
decors.push(LineDecor::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decors
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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