Version 1

This commit is contained in:
2026-09-09 21:19:44 +02:00
commit f35e5c82e5
16 changed files with 11148 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+4571
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "jsoncanvas-desktop"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.36.2"
egui_extras = { version = "0.36.2", features = ["image", "file"] }
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
open = "5.4.3"
rfd = "0.17.2"
serde = { version = "1.0.229", features = ["derive"] }
# `preserve_order` keeps node/edge attributes in the order the spec lists them
# and keeps unknown attributes where they were, so saving a file written by
# another tool produces a small diff instead of a reshuffle.
serde_json = { version = "1.0.151", features = ["preserve_order"] }
+120
View File
@@ -0,0 +1,120 @@
# JSON Canvas Editor
A native desktop editor for the [JSON Canvas](https://jsoncanvas.org) 1.0 file
format, written in Rust. It runs on Windows and Linux from a single binary and
reads and writes plain `.canvas` files on the local disk — there is no server,
no account and no hidden state.
![The editor with a canvas open](docs/screenshot.png)
## What it does
**The whole spec.** All four node types (`text`, `file`, `link`, `group`) and
every edge attribute (`fromSide`/`toSide`, `fromEnd`/`toEnd`, `label`, `color`),
plus both `canvasColor` forms — the six presets and `#rrggbb` hex.
**It does not damage files it does not fully understand.** Unknown top-level
keys, unknown node/edge attributes and even unknown node `type`s are carried
through a load/save cycle untouched, and attributes are written back in the
order the spec lists them, so a save produces a small diff rather than a
reshuffle.
**Direct manipulation.**
| Gesture | Result |
| --- | --- |
| Double-click empty canvas | New text note, ready to type in |
| Double-click a note | Edit its Markdown in place |
| Double-click a file/link node | Open it with the system default application |
| Drag a dot on a node's edge | Draw a connection; drop it on empty canvas to create the node too |
| Drag a corner or side handle | Resize |
| Drag a group | Moves the group and everything inside it |
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
| Middle- or right-drag | Pan |
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
fenced code, quotes, bullet, numbered and task lists, rules, images and links.
Links are clickable; a link to another `.canvas` file opens it in the editor.
**File node previews**: images are rendered inline, text files are previewed as
Markdown, and a `subpath` such as `#Design` shows just that section of the file.
Group `background` images are drawn with the fitting their `backgroundStyle`
asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the
canvas, so a canvas plus its files stays portable.
**The usual editor comforts**: undo/redo of every change, copy/paste/duplicate,
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
dark themes, and a warning before you lose unsaved work.
## Building and running
```sh
cargo run --release # start with an empty canvas
cargo run --release -- board.canvas
```
The release binary is self-contained:
```sh
cargo build --release # target/release/jsoncanvas-desktop[.exe]
```
* **Windows** — nothing beyond a Rust toolchain (MSVC or GNU).
* **Linux** — the usual desktop development packages for windowing and
rendering, e.g. on Debian/Ubuntu:
```sh
sudo apt install build-essential pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libgl1-mesa-dev libwayland-dev libxkbcommon-dev
```
File dialogs use the XDG desktop portal, so no GTK development packages are
needed; install `xdg-desktop-portal` and a backend for your desktop if it is
not already present.
Run the tests with `cargo test` (the model, geometry, Markdown and editing logic
are covered headless — no display required).
## Keyboard shortcuts
| | |
| --- | --- |
| <kbd>Ctrl</kbd>+<kbd>N</kbd> / <kbd>O</kbd> / <kbd>S</kbd> | New, open, save |
| <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> | Save as |
| <kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> | Undo, redo |
| <kbd>Ctrl</kbd>+<kbd>C</kbd> / <kbd>V</kbd> / <kbd>D</kbd> | Copy, paste, duplicate |
| <kbd>Ctrl</kbd>+<kbd>A</kbd> | Select everything |
| <kbd>Ctrl</kbd>+<kbd>G</kbd> | Put the selection in a group |
| <kbd>Ctrl</kbd>+<kbd>I</kbd> | Show or hide the inspector |
| <kbd>Delete</kbd> | Delete the selection |
| <kbd>F2</kbd> | Edit the selected node |
| <kbd>F</kbd> | Zoom to the selection |
| <kbd>Escape</kbd> | Stop editing, clear the selection |
| Arrow keys | Nudge by 1 (<kbd>Shift</kbd>: 10) |
| <kbd>Ctrl</kbd>+<kbd>0</kbd> / <kbd>1</kbd> | Zoom to fit, actual size |
## How it is put together
The UI toolkit is [egui](https://github.com/emilk/egui) via `eframe`. An
infinite canvas is essentially one large custom-painted widget, which is what
immediate-mode GUI is good at, and it builds to a single binary on both targets
without a GTK or Qt runtime. Pan and zoom are a transform on a dedicated egui
layer, so real widgets (the in-place Markdown editor, links) keep working while
the canvas is scaled.
| Module | Responsibility |
| --- | --- |
| `model.rs` | The file format: parsing, writing, round-trip fidelity |
| `document.rs` | Open document: path, dirty state, undo history |
| `geometry.rs` | Node boxes, edge anchors, Bézier routing, hit testing |
| `view.rs` | The canvas: painting, selection, and every gesture |
| `nodes.rs` | Node bodies: Markdown, file previews, links |
| `markdown.rs` | The Markdown subset used by text nodes |
| `inspector.rs` | The properties panel |
| `theme.rs` | Palettes and the six preset colours |
| `app.rs` | Menus, toolbar, dialogs, file handling, shortcuts |
## Licence
MIT.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"nodes": [
{"id": "group-basics", "type": "group", "label": "The four node types", "x": -520, "y": -320, "width": 1100, "height": 480, "color": "6"},
{"id": "text", "type": "text", "text": "## text\n\nMarkdown: **bold**, *italic*, `code`,\n[links](https://jsoncanvas.org) and lists.\n\n- [x] renders inline\n- [ ] edit with a double-click", "x": -480, "y": -250, "width": 300, "height": 200, "color": "5"},
{"id": "file", "type": "file", "file": "../README.md", "subpath": "#What it does", "x": -140, "y": -250, "width": 320, "height": 380},
{"id": "link", "type": "link", "url": "https://jsoncanvas.org/spec/1.0/", "x": 220, "y": -250, "width": 320, "height": 140},
{"id": "note", "type": "text", "text": "### group\n\nThe box around all of this is a group node. Drag it and everything inside comes along.", "x": 220, "y": -60, "width": 320, "height": 190, "color": "3"},
{"id": "edges", "type": "text", "text": "## Edges\n\nSides, arrow heads, labels and colours are all part of the format — set them in the inspector.", "x": -480, "y": 240, "width": 320, "height": 160, "color": "4"}
],
"edges": [
{"id": "edge-1", "fromNode": "text", "fromSide": "right", "toNode": "file", "toSide": "left", "label": "previews"},
{"id": "edge-2", "fromNode": "file", "fromSide": "right", "toNode": "link", "toSide": "left", "color": "2"},
{"id": "edge-3", "fromNode": "text", "fromSide": "bottom", "toNode": "edges", "toSide": "top", "fromEnd": "arrow", "toEnd": "arrow", "label": "both ends"}
]
}
+1050
View File
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
//! The open document: a [`Canvas`] plus its file location, dirty state and
//! undo history.
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::Canvas;
/// How many undo steps are kept.
const UNDO_LIMIT: usize = 256;
/// A canvas together with everything the editor needs to know about it.
pub struct Document {
pub canvas: Canvas,
path: Option<PathBuf>,
dirty: bool,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
/// Label of the change currently being recorded, used to coalesce the many
/// small mutations of a single drag into one undo step.
open_change: Option<String>,
}
struct Snapshot {
canvas: Canvas,
label: String,
}
impl Default for Document {
fn default() -> Self {
Self::new()
}
}
impl Document {
pub fn new() -> Self {
Self {
canvas: Canvas::default(),
path: None,
dirty: false,
undo: Vec::new(),
redo: Vec::new(),
open_change: None,
}
}
/// Reads a `.canvas` file from disk.
pub fn open(path: &Path) -> Result<Self, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("Could not read {}: {e}", path.display()))?;
let canvas = Canvas::from_json(&text)
.map_err(|e| format!("{} is not a valid JSON Canvas file: {e}", path.display()))?;
Ok(Self {
canvas,
path: Some(path.to_path_buf()),
dirty: false,
undo: Vec::new(),
redo: Vec::new(),
open_change: None,
})
}
/// Writes the canvas to `path` and makes it the document's location.
pub fn save_as(&mut self, path: &Path) -> Result<(), String> {
fs::write(path, self.canvas.to_json())
.map_err(|e| format!("Could not write {}: {e}", path.display()))?;
self.path = Some(path.to_path_buf());
self.dirty = false;
Ok(())
}
/// Writes the canvas back to the file it was loaded from.
///
/// Returns `Ok(false)` when the document has no path yet and the caller
/// needs to ask the user for one.
pub fn save(&mut self) -> Result<bool, String> {
let Some(path) = self.path.clone() else {
return Ok(false);
};
self.save_as(&path)?;
Ok(true)
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// The directory the document lives in, used to resolve relative node paths.
pub fn base_dir(&self) -> Option<PathBuf> {
self.path.as_ref()?.parent().map(Path::to_path_buf)
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Name shown in the window title and the status bar.
pub fn display_name(&self) -> String {
self.path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Untitled.canvas".to_owned())
}
// -- undo -------------------------------------------------------------
/// Records the state before a change. Call this *before* mutating.
///
/// Repeated calls with the same `label` while a gesture is open (see
/// [`Document::end_change`]) only record the first state, so an entire drag
/// undoes in one step.
pub fn begin_change(&mut self, label: &str) {
if self.open_change.as_deref() == Some(label) {
self.dirty = true;
return;
}
self.push_undo(label);
self.open_change = Some(label.to_owned());
}
/// Records a single, self-contained change.
pub fn change(&mut self, label: &str) {
self.end_change();
self.push_undo(label);
}
/// Closes the current gesture so the next change starts a new undo step.
pub fn end_change(&mut self) {
self.open_change = None;
}
fn push_undo(&mut self, label: &str) {
self.undo.push(Snapshot {
canvas: self.canvas.clone(),
label: label.to_owned(),
});
if self.undo.len() > UNDO_LIMIT {
self.undo.remove(0);
}
self.redo.clear();
self.dirty = true;
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo_label(&self) -> Option<&str> {
self.undo.last().map(|s| s.label.as_str())
}
pub fn redo_label(&self) -> Option<&str> {
self.redo.last().map(|s| s.label.as_str())
}
/// Steps back one change, returning its label.
pub fn undo(&mut self) -> Option<String> {
let snapshot = self.undo.pop()?;
self.redo.push(Snapshot {
canvas: std::mem::replace(&mut self.canvas, snapshot.canvas),
label: snapshot.label.clone(),
});
self.open_change = None;
self.dirty = true;
Some(snapshot.label)
}
/// Steps forward one change, returning its label.
pub fn redo(&mut self) -> Option<String> {
let snapshot = self.redo.pop()?;
self.undo.push(Snapshot {
canvas: std::mem::replace(&mut self.canvas, snapshot.canvas),
label: snapshot.label.clone(),
});
self.open_change = None;
self.dirty = true;
Some(snapshot.label)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Node, NodeKind};
fn text_node(id: &str) -> Node {
Node::new(id, NodeKind::Text { text: id.into() }, 0, 0, 250, 60)
}
#[test]
fn undo_and_redo_restore_the_canvas() {
let mut doc = Document::new();
doc.change("Add node");
doc.canvas.nodes.push(text_node("a"));
assert!(doc.is_dirty());
assert_eq!(doc.undo().as_deref(), Some("Add node"));
assert!(doc.canvas.nodes.is_empty());
assert_eq!(doc.redo().as_deref(), Some("Add node"));
assert_eq!(doc.canvas.nodes.len(), 1);
}
#[test]
fn a_gesture_collapses_into_one_undo_step() {
let mut doc = Document::new();
doc.canvas.nodes.push(text_node("a"));
for step in 1..=10 {
doc.begin_change("Move");
doc.canvas.nodes[0].x = step;
}
doc.end_change();
doc.undo();
assert_eq!(doc.canvas.nodes[0].x, 0);
assert!(!doc.can_undo());
}
#[test]
fn a_new_change_clears_the_redo_stack() {
let mut doc = Document::new();
doc.change("One");
doc.canvas.nodes.push(text_node("a"));
doc.undo();
doc.change("Two");
doc.canvas.nodes.push(text_node("b"));
assert!(!doc.can_redo());
}
#[test]
fn saving_and_loading_round_trips(
) -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id()));
fs::create_dir_all(&dir)?;
let path = dir.join("test.canvas");
let mut doc = Document::new();
doc.canvas.nodes.push(text_node("a"));
doc.save_as(&path)?;
assert!(!doc.is_dirty());
let loaded = Document::open(&path)?;
assert_eq!(loaded.canvas, doc.canvas);
assert_eq!(loaded.display_name(), "test.canvas");
fs::remove_dir_all(&dir)?;
Ok(())
}
#[test]
fn opening_a_broken_file_reports_an_error() {
let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("bad.canvas");
fs::write(&path, "{ not json").unwrap();
let err = match Document::open(&path) {
Ok(_) => panic!("expected the load to fail"),
Err(err) => err,
};
assert!(err.contains("not a valid JSON Canvas file"), "{err}");
fs::remove_dir_all(&dir).unwrap();
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Geometry shared by the renderer and the interaction code: node rectangles,
//! edge anchor points and the cubic Bézier curves edges are drawn with.
use eframe::egui::{Pos2, Rect, Vec2, pos2, vec2};
use crate::model::{Node, Side};
/// The node's box in canvas coordinates.
pub fn node_rect(node: &Node) -> Rect {
Rect::from_min_size(
pos2(node.x as f32, node.y as f32),
vec2(node.width as f32, node.height as f32),
)
}
/// The point on `rect` where an edge attached to `side` starts or ends.
pub fn anchor(rect: Rect, side: Side) -> Pos2 {
match side {
Side::Top => pos2(rect.center().x, rect.top()),
Side::Bottom => pos2(rect.center().x, rect.bottom()),
Side::Left => pos2(rect.left(), rect.center().y),
Side::Right => pos2(rect.right(), rect.center().y),
}
}
/// The outward pointing unit normal of a side.
pub fn normal(side: Side) -> Vec2 {
match side {
Side::Top => vec2(0.0, -1.0),
Side::Bottom => vec2(0.0, 1.0),
Side::Left => vec2(-1.0, 0.0),
Side::Right => vec2(1.0, 0.0),
}
}
/// Picks the sides for an edge whose `fromSide`/`toSide` the file leaves out.
///
/// The dominant axis between the two node centres wins, which is what the
/// reference implementations do.
pub fn auto_sides(from: Rect, to: Rect) -> (Side, Side) {
let delta = to.center() - from.center();
// Compare the gaps rather than the raw centre distance so that a wide node
// sitting just above a narrow one still connects top-to-bottom.
let gap_x = (to.left() - from.right()).max(from.left() - to.right());
let gap_y = (to.top() - from.bottom()).max(from.top() - to.bottom());
let horizontal = if gap_x >= 0.0 && gap_y >= 0.0 {
gap_x >= gap_y
} else if gap_x >= 0.0 {
true
} else if gap_y >= 0.0 {
false
} else {
delta.x.abs() >= delta.y.abs()
};
if horizontal {
if delta.x >= 0.0 {
(Side::Right, Side::Left)
} else {
(Side::Left, Side::Right)
}
} else if delta.y >= 0.0 {
(Side::Bottom, Side::Top)
} else {
(Side::Top, Side::Bottom)
}
}
/// The four control points of the cubic Bézier used to draw an edge.
pub fn edge_curve(from: Rect, from_side: Side, to: Rect, to_side: Side) -> [Pos2; 4] {
let start = anchor(from, from_side);
let end = anchor(to, to_side);
curve_between(start, from_side, end, to_side)
}
/// Same as [`edge_curve`] but for free endpoints (used while dragging a new edge).
pub fn curve_between(start: Pos2, from_side: Side, end: Pos2, to_side: Side) -> [Pos2; 4] {
let distance = (end - start).length();
let strength = (distance * 0.45).clamp(30.0, 250.0);
[
start,
start + normal(from_side) * strength,
end + normal(to_side) * strength,
end,
]
}
/// Point on a cubic Bézier at `t` in `0..=1`.
pub fn bezier_point(p: [Pos2; 4], t: f32) -> Pos2 {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
pos2(
a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x,
a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y,
)
}
/// Derivative of a cubic Bézier at `t`; the direction the curve travels in.
pub fn bezier_tangent(p: [Pos2; 4], t: f32) -> Vec2 {
let u = 1.0 - t;
let d = (p[1] - p[0]) * (3.0 * u * u) + (p[2] - p[1]) * (6.0 * u * t) + (p[3] - p[2]) * (3.0 * t * t);
if d.length() > f32::EPSILON {
d.normalized()
} else {
(p[3] - p[0]).normalized()
}
}
/// How many samples are used when approximating a curve by a polyline.
const SAMPLES: usize = 24;
/// Shortest distance from `point` to the curve, used for hit testing edges.
pub fn distance_to_curve(p: [Pos2; 4], point: Pos2) -> f32 {
let mut best = f32::INFINITY;
let mut previous = p[0];
for i in 1..=SAMPLES {
let current = bezier_point(p, i as f32 / SAMPLES as f32);
best = best.min(distance_to_segment(point, previous, current));
previous = current;
}
best
}
/// Axis aligned bounds of the curve, approximated from samples.
pub fn curve_bounds(p: [Pos2; 4]) -> Rect {
let mut rect = Rect::from_points(&[p[0], p[3]]);
for i in 1..SAMPLES {
rect = rect.union(Rect::from_points(&[bezier_point(p, i as f32 / SAMPLES as f32)]));
}
rect
}
fn distance_to_segment(point: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let len_sq = ab.length_sq();
if len_sq <= f32::EPSILON {
return (point - a).length();
}
let t = ((point - a).dot(ab) / len_sq).clamp(0.0, 1.0);
(point - (a + ab * t)).length()
}
/// The bounding box of everything in `rects`, or `None` when there is nothing.
pub fn bounds_of(rects: impl IntoIterator<Item = Rect>) -> Option<Rect> {
rects.into_iter().reduce(|acc, r| acc.union(r))
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect::from_min_size(pos2(x, y), vec2(w, h))
}
#[test]
fn anchors_sit_on_the_middle_of_each_side() {
let r = rect(0.0, 0.0, 100.0, 50.0);
assert_eq!(anchor(r, Side::Top), pos2(50.0, 0.0));
assert_eq!(anchor(r, Side::Bottom), pos2(50.0, 50.0));
assert_eq!(anchor(r, Side::Left), pos2(0.0, 25.0));
assert_eq!(anchor(r, Side::Right), pos2(100.0, 25.0));
}
#[test]
fn auto_sides_follow_the_dominant_axis() {
let a = rect(0.0, 0.0, 100.0, 100.0);
assert_eq!(
auto_sides(a, rect(400.0, 0.0, 100.0, 100.0)),
(Side::Right, Side::Left)
);
assert_eq!(
auto_sides(a, rect(-400.0, 0.0, 100.0, 100.0)),
(Side::Left, Side::Right)
);
assert_eq!(
auto_sides(a, rect(0.0, 400.0, 100.0, 100.0)),
(Side::Bottom, Side::Top)
);
assert_eq!(
auto_sides(a, rect(0.0, -400.0, 100.0, 100.0)),
(Side::Top, Side::Bottom)
);
}
#[test]
fn a_curve_starts_and_ends_on_its_anchors() {
let from = rect(0.0, 0.0, 100.0, 100.0);
let to = rect(300.0, 0.0, 100.0, 100.0);
let curve = edge_curve(from, Side::Right, to, Side::Left);
assert_eq!(bezier_point(curve, 0.0), anchor(from, Side::Right));
assert_eq!(bezier_point(curve, 1.0), anchor(to, Side::Left));
// Leaving a right side means travelling to the right.
assert!(bezier_tangent(curve, 0.0).x > 0.9);
}
#[test]
fn hit_testing_measures_distance_to_the_curve() {
let curve = [
pos2(0.0, 0.0),
pos2(50.0, 0.0),
pos2(50.0, 0.0),
pos2(100.0, 0.0),
];
assert!(distance_to_curve(curve, pos2(50.0, 0.0)) < 0.5);
assert!((distance_to_curve(curve, pos2(50.0, 20.0)) - 20.0).abs() < 0.5);
}
#[test]
fn bounds_cover_every_rect() {
let all = bounds_of([rect(0.0, 0.0, 10.0, 10.0), rect(90.0, 40.0, 10.0, 10.0)]).unwrap();
assert_eq!(all, rect(0.0, 0.0, 100.0, 50.0));
assert!(bounds_of([]).is_none());
}
}
+817
View File
@@ -0,0 +1,817 @@
//! The properties panel: everything about the current selection that is easier
//! to change with a widget than by dragging it on the canvas.
use std::path::{Path, PathBuf};
use eframe::egui::{self, Color32, ComboBox, DragValue, RichText, TextEdit, Ui};
use crate::document::Document;
use crate::model::{BackgroundStyle, Color, EndShape, Node, NodeKind, Side};
use crate::theme::{self, Palette};
use crate::view::CanvasView;
#[derive(Default)]
pub struct InspectorOutput {
pub status: Option<String>,
pub open_target: Option<String>,
}
pub fn show(
ui: &mut Ui,
doc: &mut Document,
view: &mut CanvasView,
palette: &Palette,
) -> InspectorOutput {
let mut out = InspectorOutput::default();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(4.0);
if let Some(id) = view.selection.only_node().map(str::to_owned) {
node_section(ui, doc, view, palette, &id, &mut out);
} else if let Some(id) = view.selection.only_edge().map(str::to_owned) {
edge_section(ui, doc, view, palette, &id, &mut out);
} else if !view.selection.is_empty() {
multi_section(ui, doc, view, palette, &mut out);
} else {
canvas_section(ui, doc, palette, &mut out);
}
});
out
}
// ---------------------------------------------------------------------------
// A single node
// ---------------------------------------------------------------------------
fn node_section(
ui: &mut Ui,
doc: &mut Document,
view: &mut CanvasView,
palette: &Palette,
id: &str,
out: &mut InspectorOutput,
) {
let Some(node) = doc.canvas.node(id).cloned() else {
return;
};
heading(ui, palette, &format!("{} node", capitalise(node.kind.type_name())));
match &node.kind {
NodeKind::Text { text } => {
let mut buffer = text.clone();
let response = ui.add(
TextEdit::multiline(&mut buffer)
.desired_width(f32::INFINITY)
.desired_rows(6)
.hint_text("Markdown"),
);
if response.changed() {
doc.begin_change("Edit text");
if let Some(NodeKind::Text { text }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) {
*text = buffer;
}
}
if response.lost_focus() {
doc.end_change();
}
}
NodeKind::File { file, subpath } => {
let mut path = file.clone();
let mut sub = subpath.clone().unwrap_or_default();
ui.label(field_label(palette, "File"));
let changed = ui
.add(TextEdit::singleline(&mut path).desired_width(f32::INFINITY))
.changed();
ui.horizontal(|ui| {
if ui.button("Browse…").clicked()
&& let Some(picked) = pick_any_file(doc)
{
path = picked;
doc.change("Set file");
set_file(doc, id, Some(path.clone()), None);
}
if ui.button("Open").on_hover_text("Open with the system default application").clicked() {
out.open_target = Some(file.clone());
}
});
ui.add_space(6.0);
ui.label(field_label(palette, "Subpath"));
let sub_changed = ui
.add(
TextEdit::singleline(&mut sub)
.hint_text("#heading")
.desired_width(f32::INFINITY),
)
.changed();
if changed || sub_changed {
doc.begin_change("Edit file node");
set_file(doc, id, Some(path), Some(sub));
}
}
NodeKind::Link { url } => {
let mut buffer = url.clone();
ui.label(field_label(palette, "URL"));
if ui
.add(TextEdit::singleline(&mut buffer).desired_width(f32::INFINITY))
.changed()
{
doc.begin_change("Edit URL");
if let Some(NodeKind::Link { url }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) {
*url = buffer;
}
}
if ui.button("Open in browser").clicked() {
out.open_target = Some(url.clone());
}
}
NodeKind::Group {
label,
background,
background_style,
} => {
let mut label_buffer = label.clone().unwrap_or_default();
ui.label(field_label(palette, "Label"));
if ui
.add(
TextEdit::singleline(&mut label_buffer)
.desired_width(f32::INFINITY)
.hint_text("Unnamed group"),
)
.changed()
{
doc.begin_change("Edit label");
set_group(doc, id, Some(label_buffer), None, None);
}
ui.add_space(6.0);
ui.label(field_label(palette, "Background image"));
let mut background_buffer = background.clone().unwrap_or_default();
if ui
.add(TextEdit::singleline(&mut background_buffer).desired_width(f32::INFINITY))
.changed()
{
doc.begin_change("Edit background");
set_group(doc, id, None, Some(background_buffer), None);
}
ui.horizontal(|ui| {
if ui.button("Browse…").clicked()
&& let Some(picked) = pick_image_file(doc)
{
doc.change("Set background");
set_group(doc, id, None, Some(picked), None);
}
if background.is_some() && ui.button("Clear").clicked() {
doc.change("Clear background");
set_group(doc, id, None, Some(String::new()), None);
}
});
ui.add_space(6.0);
let mut style = *background_style;
ComboBox::from_label("Fitting")
.selected_text(style.map_or("default", BackgroundStyle::label))
.show_ui(ui, |ui| {
ui.selectable_value(&mut style, None, "default");
for option in BackgroundStyle::ALL {
ui.selectable_value(&mut style, Some(option), option.label());
}
});
if style != *background_style {
doc.change("Set background fitting");
set_group(doc, id, None, None, Some(style));
}
let members = doc.canvas.nodes_in_group(id).len();
ui.add_space(4.0);
ui.label(
RichText::new(format!(
"Contains {members} node{}",
if members == 1 { "" } else { "s" }
))
.size(12.0)
.color(palette.text_muted),
);
}
NodeKind::Unknown { type_name } => {
ui.label(
RichText::new(format!(
"This node has the unsupported type “{type_name}”. Its attributes are kept as they are when saving."
))
.size(12.0)
.color(palette.text_muted),
);
}
}
ui.add_space(10.0);
geometry_section(ui, doc, palette, id, &node);
ui.add_space(10.0);
ui.label(field_label(palette, "Colour"));
if let Some(color) = color_picker(ui, node.color.as_ref(), palette, id) {
doc.change("Set colour");
if let Some(node) = doc.canvas.node_mut(id) {
node.color = color;
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("Bring to front").clicked() {
view.raise_selection(doc, true);
}
if ui.button("Send to back").clicked() {
view.raise_selection(doc, false);
}
});
ui.add_space(4.0);
if ui
.button(RichText::new("Delete node").color(palette.danger))
.clicked()
{
out.status = view.delete_selection(doc);
}
ui.add_space(10.0);
ui.label(
RichText::new(format!("id: {}", node.id))
.size(11.0)
.monospace()
.color(palette.text_muted),
);
}
fn geometry_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, id: &str, node: &Node) {
let (mut x, mut y, mut w, mut h) = (node.x, node.y, node.width, node.height);
ui.label(field_label(palette, "Position and size"));
let mut changed = false;
ui.horizontal(|ui| {
changed |= ui.add(DragValue::new(&mut x).speed(1).prefix("x ")).changed();
changed |= ui.add(DragValue::new(&mut y).speed(1).prefix("y ")).changed();
});
ui.horizontal(|ui| {
changed |= ui
.add(DragValue::new(&mut w).speed(1).range(1..=100_000).prefix("w "))
.changed();
changed |= ui
.add(DragValue::new(&mut h).speed(1).range(1..=100_000).prefix("h "))
.changed();
});
if changed {
doc.begin_change("Edit geometry");
if let Some(node) = doc.canvas.node_mut(id) {
node.x = x;
node.y = y;
node.width = w;
node.height = h;
}
}
}
// ---------------------------------------------------------------------------
// A single edge
// ---------------------------------------------------------------------------
fn edge_section(
ui: &mut Ui,
doc: &mut Document,
view: &mut CanvasView,
palette: &Palette,
id: &str,
out: &mut InspectorOutput,
) {
let Some(edge) = doc.canvas.edge(id).cloned() else {
return;
};
heading(ui, palette, "Edge");
let name_of = |node_id: &str| {
doc.canvas
.node(node_id)
.map(|n| n.title())
.unwrap_or_else(|| format!("missing node {node_id}"))
};
ui.label(
RichText::new(format!("{} → {}", name_of(&edge.from_node), name_of(&edge.to_node)))
.size(12.0)
.color(palette.text_muted),
);
ui.add_space(8.0);
let mut from_side = edge.from_side;
let mut to_side = edge.to_side;
let mut from_end = edge.from_end;
let mut to_end = edge.to_end;
ui.horizontal(|ui| {
side_combo(ui, "From", &mut from_side);
end_combo(ui, "Start", &mut from_end, EndShape::None);
});
ui.horizontal(|ui| {
side_combo(ui, "To", &mut to_side);
end_combo(ui, "End", &mut to_end, EndShape::Arrow);
});
if from_side != edge.from_side
|| to_side != edge.to_side
|| from_end != edge.from_end
|| to_end != edge.to_end
{
doc.change("Edit edge");
if let Some(edge) = doc.canvas.edge_mut(id) {
edge.from_side = from_side;
edge.to_side = to_side;
edge.from_end = from_end;
edge.to_end = to_end;
}
}
ui.add_space(8.0);
ui.label(field_label(palette, "Label"));
let mut label = edge.label.clone().unwrap_or_default();
if ui
.add(TextEdit::singleline(&mut label).desired_width(f32::INFINITY))
.changed()
{
doc.begin_change("Edit edge label");
if let Some(edge) = doc.canvas.edge_mut(id) {
edge.label = (!label.is_empty()).then_some(label);
}
}
ui.add_space(8.0);
ui.label(field_label(palette, "Colour"));
if let Some(color) = color_picker(ui, edge.color.as_ref(), palette, id) {
doc.change("Set colour");
if let Some(edge) = doc.canvas.edge_mut(id) {
edge.color = color;
}
}
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("Reverse direction").clicked() {
doc.change("Reverse edge");
if let Some(edge) = doc.canvas.edge_mut(id) {
std::mem::swap(&mut edge.from_node, &mut edge.to_node);
std::mem::swap(&mut edge.from_side, &mut edge.to_side);
std::mem::swap(&mut edge.from_end, &mut edge.to_end);
}
out.status = Some("Reversed the edge".to_owned());
}
if ui
.button(RichText::new("Delete edge").color(palette.danger))
.clicked()
{
out.status = view.delete_selection(doc);
}
});
}
fn side_combo(ui: &mut Ui, label: &str, side: &mut Option<Side>) {
ComboBox::from_id_salt(label)
.selected_text(format!(
"{label}: {}",
side.map_or("auto", Side::label)
))
.width(110.0)
.show_ui(ui, |ui| {
ui.selectable_value(side, None, "auto");
for option in Side::ALL {
ui.selectable_value(side, Some(option), option.label());
}
});
}
fn end_combo(ui: &mut Ui, label: &str, end: &mut Option<EndShape>, default: EndShape) {
let text = match end {
None => format!("default ({})", end_name(default)),
Some(value) => end_name(*value).to_owned(),
};
ComboBox::from_id_salt(label)
.selected_text(format!("{label}: {text}"))
.width(130.0)
.show_ui(ui, |ui| {
ui.selectable_value(end, None, format!("default ({})", end_name(default)));
ui.selectable_value(end, Some(EndShape::None), "none");
ui.selectable_value(end, Some(EndShape::Arrow), "arrow");
});
}
fn end_name(end: EndShape) -> &'static str {
match end {
EndShape::None => "none",
EndShape::Arrow => "arrow",
}
}
// ---------------------------------------------------------------------------
// Many things selected
// ---------------------------------------------------------------------------
fn multi_section(
ui: &mut Ui,
doc: &mut Document,
view: &mut CanvasView,
palette: &Palette,
out: &mut InspectorOutput,
) {
let nodes = view.selection.nodes.len();
let edges = view.selection.edges.len();
heading(ui, palette, "Multiple items");
ui.label(
RichText::new(format!(
"{nodes} node{} and {edges} edge{} selected",
plural(nodes),
plural(edges)
))
.size(12.0)
.color(palette.text_muted),
);
ui.add_space(10.0);
ui.label(field_label(palette, "Colour"));
if let Some(color) = color_picker(ui, None, palette, "multi") {
doc.change("Set colour");
for id in view.selection.nodes.clone() {
if let Some(node) = doc.canvas.node_mut(&id) {
node.color = color.clone();
}
}
for id in view.selection.edges.clone() {
if let Some(edge) = doc.canvas.edge_mut(&id) {
edge.color = color.clone();
}
}
}
if nodes >= 2 {
ui.add_space(10.0);
ui.label(field_label(palette, "Align"));
ui.horizontal(|ui| {
if ui.button("Left").clicked() {
align(doc, view, Align::Left);
}
if ui.button("Centre").clicked() {
align(doc, view, Align::CentreX);
}
if ui.button("Right").clicked() {
align(doc, view, Align::Right);
}
});
ui.horizontal(|ui| {
if ui.button("Top").clicked() {
align(doc, view, Align::Top);
}
if ui.button("Middle").clicked() {
align(doc, view, Align::CentreY);
}
if ui.button("Bottom").clicked() {
align(doc, view, Align::Bottom);
}
});
}
ui.add_space(10.0);
if ui
.button(RichText::new("Delete selection").color(palette.danger))
.clicked()
{
out.status = view.delete_selection(doc);
}
}
#[derive(Clone, Copy)]
enum Align {
Left,
CentreX,
Right,
Top,
CentreY,
Bottom,
}
fn align(doc: &mut Document, view: &CanvasView, how: Align) {
let ids = view.selection.nodes.clone();
let boxes: Vec<(i32, i32, i32, i32)> = ids
.iter()
.filter_map(|id| doc.canvas.node(id))
.map(|n| (n.x, n.y, n.width, n.height))
.collect();
if boxes.len() < 2 {
return;
}
let left = boxes.iter().map(|b| b.0).min().unwrap_or(0);
let right = boxes.iter().map(|b| b.0 + b.2).max().unwrap_or(0);
let top = boxes.iter().map(|b| b.1).min().unwrap_or(0);
let bottom = boxes.iter().map(|b| b.1 + b.3).max().unwrap_or(0);
doc.change("Align");
for id in ids {
let Some(node) = doc.canvas.node_mut(&id) else {
continue;
};
match how {
Align::Left => node.x = left,
Align::Right => node.x = right - node.width,
Align::CentreX => node.x = (left + right) / 2 - node.width / 2,
Align::Top => node.y = top,
Align::Bottom => node.y = bottom - node.height,
Align::CentreY => node.y = (top + bottom) / 2 - node.height / 2,
}
}
}
// ---------------------------------------------------------------------------
// Nothing selected
// ---------------------------------------------------------------------------
fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut InspectorOutput) {
heading(ui, palette, "Canvas");
let nodes = doc.canvas.nodes.len();
let edges = doc.canvas.edges.len();
let groups = doc.canvas.nodes.iter().filter(|n| n.is_group()).count();
for (label, value) in [
("Nodes", nodes.to_string()),
("Groups", groups.to_string()),
("Edges", edges.to_string()),
(
"File",
doc.path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "not saved yet".to_owned()),
),
] {
ui.horizontal(|ui| {
ui.label(field_label(palette, label));
ui.label(RichText::new(value).size(12.0));
});
}
let dangling = doc
.canvas
.edges
.iter()
.filter(|e| {
doc.canvas.node(&e.from_node).is_none() || doc.canvas.node(&e.to_node).is_none()
})
.count();
if dangling > 0 {
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"{dangling} edge{} point at nodes that do not exist.",
plural(dangling)
))
.size(12.0)
.color(palette.danger),
);
if ui.button("Remove them").clicked() {
doc.change("Remove dangling edges");
let removed = doc.canvas.drop_dangling_edges();
out.status = Some(format!("Removed {removed} dangling edge{}", plural(removed)));
}
}
ui.add_space(12.0);
ui.label(field_label(palette, "Tips"));
for tip in [
"Double-click the canvas to add a note",
"Drag a dot on a node's edge to connect it",
"Middle- or right-drag to pan, Ctrl+wheel to zoom",
"Hold Shift to add to the selection",
] {
ui.label(RichText::new(format!("• {tip}")).size(12.0).color(palette.text_muted));
}
}
// ---------------------------------------------------------------------------
// Shared widgets
// ---------------------------------------------------------------------------
/// A row of the six preset colours plus a hex field. Returns the new value when
/// the user picked one.
fn color_picker(
ui: &mut Ui,
current: Option<&Color>,
palette: &Palette,
salt: &str,
) -> Option<Option<Color>> {
let mut picked = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (index, color) in theme::PRESETS.iter().enumerate() {
let preset = Color::Preset(index as u8 + 1);
let selected = current == Some(&preset);
if swatch(ui, *color, selected, palette).clicked() {
picked = Some(Some(preset));
}
}
if swatch(ui, palette.surface, current.is_none(), palette)
.on_hover_text("No colour")
.clicked()
{
picked = Some(None);
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
let mut hex = match current {
Some(Color::Hex(hex)) => hex.clone(),
_ => String::new(),
};
let response = ui.add(
TextEdit::singleline(&mut hex)
.id_salt(format!("hex-{salt}"))
.hint_text("#rrggbb")
.desired_width(90.0),
);
if response.changed()
&& let Some(color) = Color::parse(&hex)
{
picked = Some(Some(color));
}
if let Some(color) = current {
ui.label(
RichText::new(color.display_name())
.size(11.0)
.color(palette.text_muted),
);
}
});
picked
}
fn swatch(ui: &mut Ui, color: Color32, selected: bool, palette: &Palette) -> egui::Response {
let size = egui::vec2(20.0, 20.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
let stroke = if selected {
egui::Stroke::new(2.0, palette.accent)
} else if response.hovered() {
egui::Stroke::new(1.5, palette.text_muted)
} else {
egui::Stroke::new(1.0, palette.border)
};
ui.painter().rect(
rect,
egui::CornerRadius::same(4),
color,
stroke,
egui::StrokeKind::Inside,
);
response
}
fn heading(ui: &mut Ui, palette: &Palette, text: &str) {
ui.label(RichText::new(text).size(15.0).strong().color(palette.text));
ui.add_space(6.0);
}
fn field_label(palette: &Palette, text: &str) -> RichText {
RichText::new(text).size(11.0).color(palette.text_muted)
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
fn capitalise(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn set_file(doc: &mut Document, id: &str, file: Option<String>, subpath: Option<String>) {
if let Some(NodeKind::File {
file: current,
subpath: current_sub,
}) = doc.canvas.node_mut(id).map(|n| &mut n.kind)
{
if let Some(file) = file {
*current = file;
}
if let Some(sub) = subpath {
*current_sub = (!sub.is_empty()).then_some(sub);
}
}
}
fn set_group(
doc: &mut Document,
id: &str,
label: Option<String>,
background: Option<String>,
style: Option<Option<BackgroundStyle>>,
) {
if let Some(NodeKind::Group {
label: current_label,
background: current_background,
background_style,
}) = doc.canvas.node_mut(id).map(|n| &mut n.kind)
{
if let Some(label) = label {
*current_label = (!label.is_empty()).then_some(label);
}
if let Some(background) = background {
*current_background = (!background.is_empty()).then_some(background);
}
if let Some(style) = style {
*background_style = style;
}
}
}
fn pick_any_file(doc: &Document) -> Option<String> {
let mut dialog = rfd::FileDialog::new();
if let Some(dir) = doc.base_dir() {
dialog = dialog.set_directory(dir);
}
dialog
.pick_file()
.map(|path| relative_to(doc.base_dir().as_deref(), &path))
}
fn pick_image_file(doc: &Document) -> Option<String> {
let mut dialog = rfd::FileDialog::new().add_filter(
"Images",
&["png", "jpg", "jpeg", "gif", "bmp", "webp"],
);
if let Some(dir) = doc.base_dir() {
dialog = dialog.set_directory(dir);
}
dialog
.pick_file()
.map(|path| relative_to(doc.base_dir().as_deref(), &path))
}
/// Paths are stored relative to the canvas when possible, which is what makes a
/// canvas plus its files portable.
pub fn relative_to(base: Option<&Path>, path: &Path) -> String {
let relative = base
.and_then(|base| path.strip_prefix(base).ok())
.map(PathBuf::from);
let path = relative.as_deref().unwrap_or(path);
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeKind;
use crate::view::CanvasView;
fn node(id: &str, x: i32, y: i32, w: i32, h: i32) -> Node {
Node::new(id, NodeKind::Text { text: String::new() }, x, y, w, h)
}
#[test]
fn relative_paths_are_used_when_the_file_is_below_the_canvas() {
let base = Path::new("/vault");
assert_eq!(
relative_to(Some(base), Path::new("/vault/img/a.png")),
"img/a.png"
);
assert_eq!(
relative_to(Some(base), Path::new("/elsewhere/a.png")),
"/elsewhere/a.png"
);
assert_eq!(relative_to(None, Path::new("/a/b.png")), "/a/b.png");
}
#[test]
fn aligning_moves_nodes_to_a_shared_edge() {
let mut doc = Document::new();
doc.canvas.nodes.push(node("a", 0, 0, 100, 100));
doc.canvas.nodes.push(node("b", 50, 200, 200, 50));
let mut view = CanvasView::new();
view.select_all(&doc.canvas);
align(&mut doc, &view, Align::Left);
assert_eq!(doc.canvas.node("b").unwrap().x, 0);
align(&mut doc, &view, Align::Right);
assert_eq!(doc.canvas.node("a").unwrap().x, 100);
assert_eq!(doc.canvas.node("b").unwrap().x, 0);
align(&mut doc, &view, Align::Top);
assert_eq!(doc.canvas.node("b").unwrap().y, 0);
}
#[test]
fn aligning_needs_at_least_two_nodes() {
let mut doc = Document::new();
doc.canvas.nodes.push(node("a", 7, 7, 100, 100));
let mut view = CanvasView::new();
view.select_node("a");
align(&mut doc, &view, Align::Left);
assert_eq!(doc.canvas.node("a").unwrap().x, 7);
assert!(!doc.can_undo(), "a no-op should not create an undo step");
}
#[test]
fn capitalises_type_names_for_the_heading() {
assert_eq!(capitalise("text"), "Text");
assert_eq!(capitalise(""), "");
}
}
+45
View File
@@ -0,0 +1,45 @@
//! A native editor for the JSON Canvas file format (<https://jsoncanvas.org>).
//!
//! Usage: `jsoncanvas-desktop [FILE.canvas]`
// Do not pop up a console window next to the app on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod document;
mod geometry;
mod inspector;
mod markdown;
mod model;
mod nodes;
mod theme;
mod view;
use std::path::PathBuf;
fn main() -> eframe::Result {
let mut args = std::env::args_os().skip(1);
let open = match args.next() {
Some(arg) if arg == "--help" || arg == "-h" => {
println!("Usage: jsoncanvas-desktop [FILE.canvas]");
return Ok(());
}
Some(arg) => Some(PathBuf::from(arg)),
None => None,
};
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("JSON Canvas Editor")
.with_inner_size([1280.0, 820.0])
.with_min_inner_size([640.0, 420.0])
.with_app_id("jsoncanvas-desktop"),
..Default::default()
};
eframe::run_native(
"JSON Canvas Editor",
options,
Box::new(|cc| Ok(Box::new(app::App::new(cc, open)))),
)
}
+750
View File
@@ -0,0 +1,750 @@
//! A small Markdown subset renderer for the contents of `text` nodes.
//!
//! The spec says a text node holds Markdown but does not pin down a flavour, so
//! this covers the parts that actually show up on canvases: headings, emphasis,
//! inline code, fenced code, quotes, bullet/numbered/task lists, rules, links
//! and images. Anything it does not recognise is rendered as plain text.
use eframe::egui::{self, Color32, RichText, Ui};
use crate::theme::Palette;
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Style {
pub bold: bool,
pub italic: bool,
pub code: bool,
pub strike: bool,
}
/// A run of text sharing one style, optionally a link.
#[derive(Debug, Clone, PartialEq)]
pub struct Span {
pub text: String,
pub style: Style,
/// Link target (`[text](target)`), or the URL of an autolink.
pub link: Option<String>,
/// `true` for `![alt](src)`.
pub image: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Marker {
Bullet,
Number(u64),
Task(bool),
}
/// One cell of a table.
pub type Cell = Vec<Span>;
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Heading { level: u8, spans: Vec<Span> },
Paragraph(Vec<Span>),
List { indent: usize, marker: Marker, spans: Vec<Span> },
Quote(Vec<Span>),
Code { language: String, text: String },
Table { header: Vec<Cell>, rows: Vec<Vec<Cell>> },
Rule,
}
// ---------------------------------------------------------------------------
// Block parsing
// ---------------------------------------------------------------------------
/// Splits Markdown source into renderable blocks.
pub fn parse(source: &str) -> Vec<Block> {
let mut blocks = Vec::new();
let mut paragraph: Vec<String> = Vec::new();
let mut quote: Vec<String> = Vec::new();
let mut lines = source.lines().peekable();
fn flush(buffer: &mut Vec<String>, blocks: &mut Vec<Block>, quoted: bool) {
if buffer.is_empty() {
return;
}
let text = buffer.join(" ");
buffer.clear();
let spans = parse_inline(&text);
blocks.push(if quoted {
Block::Quote(spans)
} else {
Block::Paragraph(spans)
});
}
while let Some(line) = lines.next() {
let trimmed = line.trim();
if let Some(fence) = fence_of(trimmed) {
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
let language = trimmed[fence.len()..].trim().to_owned();
let mut body = Vec::new();
for line in lines.by_ref() {
if line.trim().starts_with(fence) {
break;
}
body.push(line.to_owned());
}
blocks.push(Block::Code {
language,
text: body.join("\n"),
});
continue;
}
if trimmed.is_empty() {
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
continue;
}
if is_rule(trimmed) {
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
blocks.push(Block::Rule);
continue;
}
if let Some((level, rest)) = heading_of(trimmed) {
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
blocks.push(Block::Heading {
level,
spans: parse_inline(rest),
});
continue;
}
if let Some(rest) = trimmed.strip_prefix('>') {
flush(&mut paragraph, &mut blocks, false);
quote.push(rest.trim_start().to_owned());
continue;
}
// A pipe table: a header row, a `| --- |` separator, then the body.
if trimmed.contains('|')
&& lines.peek().is_some_and(|next| is_separator_row(next))
{
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
let header = split_row(trimmed);
lines.next();
let mut rows = Vec::new();
while let Some(row) = lines.peek() {
if !row.contains('|') || row.trim().is_empty() {
break;
}
rows.push(split_row(row.trim()));
lines.next();
}
blocks.push(Block::Table { header, rows });
continue;
}
if let Some((indent, marker, rest)) = list_item_of(line) {
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
blocks.push(Block::List {
indent,
marker,
spans: parse_inline(rest),
});
continue;
}
flush(&mut quote, &mut blocks, true);
paragraph.push(trimmed.to_owned());
}
flush(&mut paragraph, &mut blocks, false);
flush(&mut quote, &mut blocks, true);
blocks
}
fn fence_of(line: &str) -> Option<&'static str> {
if line.starts_with("```") {
Some("```")
} else if line.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn is_rule(line: &str) -> bool {
let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect();
compact.len() >= 3
&& (compact.chars().all(|c| c == '-')
|| compact.chars().all(|c| c == '*')
|| compact.chars().all(|c| c == '_'))
}
/// `| --- | :--: |`, the line that turns the row above it into a table header.
fn is_separator_row(line: &str) -> bool {
let line = line.trim();
if !line.contains('|') {
return false;
}
let cells = split_cells(line);
!cells.is_empty()
&& cells.iter().all(|cell| {
let body = cell.trim().trim_start_matches(':').trim_end_matches(':');
!body.is_empty() && body.chars().all(|c| c == '-')
})
}
fn split_cells(line: &str) -> Vec<&str> {
line.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(str::trim)
.collect()
}
fn split_row(line: &str) -> Vec<Cell> {
split_cells(line).into_iter().map(parse_inline).collect()
}
fn heading_of(line: &str) -> Option<(u8, &str)> {
let hashes = line.chars().take_while(|c| *c == '#').count();
if (1..=6).contains(&hashes) {
let rest = &line[hashes..];
// ATX headings need a space after the hashes.
rest.strip_prefix(' ').map(|rest| (hashes as u8, rest.trim()))
} else {
None
}
}
fn list_item_of(line: &str) -> Option<(usize, Marker, &str)> {
let indent_chars = line.len() - line.trim_start().len();
let indent = indent_chars / 2;
let trimmed = line.trim_start();
let (marker, rest) = if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
let rest_trimmed = rest.trim_start();
let lowered = rest_trimmed.to_ascii_lowercase();
if let Some(task) = lowered.strip_prefix("[ ] ") {
(Marker::Task(false), &rest_trimmed[rest_trimmed.len() - task.len()..])
} else if let Some(task) = lowered
.strip_prefix("[x] ")
.or_else(|| lowered.strip_prefix("[/] "))
{
(Marker::Task(true), &rest_trimmed[rest_trimmed.len() - task.len()..])
} else {
(Marker::Bullet, rest)
}
} else {
let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
let rest = &trimmed[digits.len()..];
let rest = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") "))?;
(Marker::Number(digits.parse().ok()?), rest)
};
Some((indent, marker, rest.trim_start()))
}
// ---------------------------------------------------------------------------
// Inline parsing
// ---------------------------------------------------------------------------
/// Parses emphasis, code, links and images inside one block of text.
pub fn parse_inline(text: &str) -> Vec<Span> {
let mut spans = Vec::new();
parse_inline_into(text, Style::default(), &mut spans);
spans.retain(|s| !s.text.is_empty());
spans
}
fn parse_inline_into(text: &str, style: Style, out: &mut Vec<Span>) {
let mut plain = String::new();
let mut rest = text;
macro_rules! flush {
() => {
if !plain.is_empty() {
out.push(Span {
text: std::mem::take(&mut plain),
style,
link: None,
image: false,
});
}
};
}
while !rest.is_empty() {
// Backslash escapes.
if let Some(tail) = rest.strip_prefix('\\') {
let mut chars = tail.chars();
if let Some(escaped) = chars.next() {
plain.push(escaped);
rest = chars.as_str();
continue;
}
}
// Inline code takes precedence and is never parsed further.
if let Some((inner, tail)) = delimited(rest, "`") {
flush!();
out.push(Span {
text: inner.to_owned(),
style: Style { code: true, ..style },
link: None,
image: false,
});
rest = tail;
continue;
}
if let Some((inner, tail)) = delimited(rest, "**").or_else(|| delimited(rest, "__")) {
flush!();
parse_inline_into(inner, Style { bold: true, ..style }, out);
rest = tail;
continue;
}
if let Some((inner, tail)) = delimited(rest, "~~") {
flush!();
parse_inline_into(inner, Style { strike: true, ..style }, out);
rest = tail;
continue;
}
if let Some((inner, tail)) = delimited(rest, "*").or_else(|| delimited(rest, "_")) {
flush!();
parse_inline_into(inner, Style { italic: true, ..style }, out);
rest = tail;
continue;
}
if let Some((label, target, tail, image)) = link_at(rest) {
flush!();
let mut link_spans = Vec::new();
parse_inline_into(label, style, &mut link_spans);
if link_spans.is_empty() {
link_spans.push(Span {
text: target.clone(),
style,
link: None,
image: false,
});
}
for mut span in link_spans {
span.link = Some(target.clone());
span.image = image;
out.push(span);
}
rest = tail;
continue;
}
if let Some((url, tail)) = autolink_at(rest) {
flush!();
out.push(Span {
text: url.clone(),
style,
link: Some(url),
image: false,
});
rest = tail;
continue;
}
let mut chars = rest.chars();
if let Some(c) = chars.next() {
plain.push(c);
}
rest = chars.as_str();
}
flush!();
}
/// If `text` opens with `delim`, returns the content up to the matching `delim`
/// and the remainder after it.
fn delimited<'a>(text: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
let after_open = text.strip_prefix(delim)?;
// `**bold**` must not be mistaken for the start of `*italic*`.
if delim.len() == 1 && after_open.starts_with(delim) {
return None;
}
let end = after_open.find(delim)?;
let inner = &after_open[..end];
if inner.is_empty() || inner.starts_with(char::is_whitespace) {
return None;
}
Some((inner, &after_open[end + delim.len()..]))
}
/// Parses `[label](target)` or `![alt](src)` at the start of `text`.
fn link_at(text: &str) -> Option<(&str, String, &str, bool)> {
let (image, body) = match text.strip_prefix('!') {
Some(body) if body.starts_with('[') => (true, body),
_ => (false, text),
};
let after_open = body.strip_prefix('[')?;
let close = after_open.find("](")?;
let label = &after_open[..close];
let after_label = &after_open[close + 2..];
let end = after_label.find(')')?;
let target = after_label[..end].trim().to_owned();
if target.is_empty() {
return None;
}
Some((label, target, &after_label[end + 1..], image))
}
/// Parses a bare `http(s)://…` URL at the start of `text`.
fn autolink_at(text: &str) -> Option<(String, &str)> {
if !(text.starts_with("http://") || text.starts_with("https://")) {
return None;
}
let end = text
.find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"'))
.unwrap_or(text.len());
let url = text[..end].trim_end_matches(['.', ',', ';', ':', '!']);
Some((url.to_owned(), &text[url.len()..]))
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
const HEADING_SCALE: [f32; 6] = [1.7, 1.45, 1.25, 1.12, 1.0, 0.95];
/// Draws the blocks and returns the target of a link the user clicked.
pub fn render(ui: &mut Ui, blocks: &[Block], palette: &Palette, base_size: f32) -> Option<String> {
let mut activated = None;
let mut table_index = 0usize;
ui.spacing_mut().item_spacing.y = base_size * 0.35;
for block in blocks {
match block {
Block::Heading { level, spans } => {
let size = base_size * HEADING_SCALE[(*level as usize - 1).min(5)];
if let Some(link) = spans_ui(ui, spans, palette, size, true) {
activated = Some(link);
}
}
Block::Paragraph(spans) => {
if let Some(link) = spans_ui(ui, spans, palette, base_size, false) {
activated = Some(link);
}
}
Block::List {
indent,
marker,
spans,
} => {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(base_size * (0.4 + 1.2 * *indent as f32));
let bullet = match marker {
Marker::Bullet => "• ".to_owned(),
Marker::Number(n) => format!("{n}. "),
Marker::Task(true) => "☑ ".to_owned(),
Marker::Task(false) => "☐ ".to_owned(),
};
ui.label(
RichText::new(bullet)
.size(base_size)
.color(palette.text_muted),
);
if let Some(link) = spans_inline(ui, spans, palette, base_size, false) {
activated = Some(link);
}
});
}
Block::Quote(spans) => {
let stripe = palette.text_muted.gamma_multiply(0.6);
egui::Frame::NONE
.inner_margin(egui::Margin {
left: (base_size * 0.7) as i8,
..Default::default()
})
.show(ui, |ui| {
let top = ui.cursor().min;
let result = spans_ui(ui, spans, palette, base_size, false);
let bottom = ui.min_rect().max.y;
let x = top.x - base_size * 0.45;
ui.painter().line_segment(
[egui::pos2(x, top.y), egui::pos2(x, bottom)],
egui::Stroke::new(2.0, stripe),
);
if let Some(link) = result {
activated = Some(link);
}
});
}
Block::Code { text, .. } => {
egui::Frame::NONE
.fill(palette.surface_muted)
.corner_radius(4.0)
.inner_margin(egui::Margin::symmetric(
(base_size * 0.5) as i8,
(base_size * 0.35) as i8,
))
.show(ui, |ui| {
ui.label(
RichText::new(text)
.monospace()
.size(base_size * 0.92)
.color(palette.text),
);
});
}
Block::Table { header, rows } => {
table_index += 1;
egui::Grid::new(("markdown-table", ui.id(), table_index))
.striped(true)
.spacing([base_size * 0.8, base_size * 0.25])
.show(ui, |ui| {
for cell in header {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
spans_inline(ui, cell, palette, base_size, true);
});
}
ui.end_row();
for row in rows {
for cell in row {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if let Some(link) =
spans_inline(ui, cell, palette, base_size, false)
{
activated = Some(link);
}
});
}
ui.end_row();
}
});
}
Block::Rule => {
let stroke = egui::Stroke::new(1.0, palette.border);
let width = ui.available_width();
let (rect, _) = ui.allocate_exact_size(
egui::vec2(width, base_size * 0.6),
egui::Sense::hover(),
);
ui.painter().line_segment(
[
egui::pos2(rect.left(), rect.center().y),
egui::pos2(rect.right(), rect.center().y),
],
stroke,
);
}
}
}
activated
}
fn spans_ui(
ui: &mut Ui,
spans: &[Span],
palette: &Palette,
size: f32,
heading: bool,
) -> Option<String> {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
spans_inline(ui, spans, palette, size, heading)
})
.inner
}
fn spans_inline(
ui: &mut Ui,
spans: &[Span],
palette: &Palette,
size: f32,
heading: bool,
) -> Option<String> {
let mut activated = None;
for span in spans {
let mut text = RichText::new(&span.text).size(size);
if span.style.bold || heading {
text = text.strong();
}
if span.style.italic {
text = text.italics();
}
if span.style.strike {
text = text.strikethrough();
}
if span.style.code {
text = text
.monospace()
.size(size * 0.92)
.background_color(palette.surface_muted);
}
match &span.link {
Some(target) => {
let label = if span.image {
RichText::new(format!("[image] {}", span.text)).size(size)
} else {
text
}
.color(link_color(palette));
if ui.link(label).on_hover_text(target).clicked() {
activated = Some(target.clone());
}
}
None => {
ui.label(text.color(palette.text));
}
}
}
activated
}
fn link_color(palette: &Palette) -> Color32 {
palette.accent
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_headings_and_paragraphs() {
let blocks = parse("# Title\n\nSome text\nwrapped over lines.\n");
assert_eq!(blocks.len(), 2);
assert!(matches!(blocks[0], Block::Heading { level: 1, .. }));
let Block::Paragraph(spans) = &blocks[1] else {
panic!("expected a paragraph");
};
assert_eq!(spans[0].text, "Some text wrapped over lines.");
}
#[test]
fn hash_without_a_space_is_not_a_heading() {
assert!(matches!(parse("#tag")[0], Block::Paragraph(_)));
}
#[test]
fn parses_emphasis() {
let spans = parse_inline("a **bold** and *italic* and `code` and ~~gone~~");
let styled: Vec<_> = spans
.iter()
.map(|s| (s.text.as_str(), s.style))
.filter(|(_, style)| *style != Style::default())
.collect();
assert_eq!(styled[0].0, "bold");
assert!(styled[0].1.bold);
assert!(styled[1].1.italic);
assert!(styled[2].1.code);
assert!(styled[3].1.strike);
}
#[test]
fn parses_links_images_and_autolinks() {
let spans = parse_inline("see [the spec](https://jsoncanvas.org) now");
let link = spans.iter().find(|s| s.link.is_some()).unwrap();
assert_eq!(link.text, "the spec");
assert_eq!(link.link.as_deref(), Some("https://jsoncanvas.org"));
let spans = parse_inline("![a picture](img/pic.png)");
assert!(spans[0].image);
assert_eq!(spans[0].link.as_deref(), Some("img/pic.png"));
let spans = parse_inline("go to https://example.com.");
let link = spans.iter().find(|s| s.link.is_some()).unwrap();
assert_eq!(link.link.as_deref(), Some("https://example.com"));
}
#[test]
fn parses_lists_including_tasks() {
let blocks = parse("- one\n- [ ] todo\n- [x] done\n1. first\n - nested");
let markers: Vec<Marker> = blocks
.iter()
.filter_map(|b| match b {
Block::List { marker, .. } => Some(*marker),
_ => None,
})
.collect();
assert_eq!(
markers,
vec![
Marker::Bullet,
Marker::Task(false),
Marker::Task(true),
Marker::Number(1),
Marker::Bullet
]
);
let Block::List { indent, spans, .. } = &blocks[4] else {
panic!("expected a list item");
};
assert_eq!(*indent, 1);
assert_eq!(spans[0].text, "nested");
}
#[test]
fn parses_fenced_code_and_rules() {
let blocks = parse("---\n```rust\nlet x = 1;\n```\n");
assert!(matches!(blocks[0], Block::Rule));
let Block::Code { language, text } = &blocks[1] else {
panic!("expected code");
};
assert_eq!(language, "rust");
assert_eq!(text, "let x = 1;");
}
#[test]
fn parses_pipe_tables() {
let blocks = parse("| Key | What it does |\n| --- | :---: |\n| `a` | first |\n| b | second |\n\nafter");
let Block::Table { header, rows } = &blocks[0] else {
panic!("expected a table, got {blocks:?}");
};
assert_eq!(header.len(), 2);
assert_eq!(header[1][0].text, "What it does");
assert_eq!(rows.len(), 2);
assert!(rows[0][0][0].style.code);
assert_eq!(rows[1][1][0].text, "second");
// The paragraph after the table is still its own block.
assert!(matches!(blocks[1], Block::Paragraph(_)));
}
#[test]
fn a_lone_pipe_is_not_a_table() {
assert!(matches!(parse("a | b")[0], Block::Paragraph(_)));
assert!(!is_separator_row("| not a separator |"));
assert!(is_separator_row("|---|:--:|"));
}
#[test]
fn parses_quotes() {
let blocks = parse("> quoted\n> again");
let Block::Quote(spans) = &blocks[0] else {
panic!("expected a quote");
};
assert_eq!(spans[0].text, "quoted again");
}
#[test]
fn escapes_are_honoured() {
let spans = parse_inline("literal \\*stars\\*");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "literal *stars*");
}
}
+755
View File
@@ -0,0 +1,755 @@
//! Data model for the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) file format.
//!
//! The model is deliberately lenient when reading and strict when writing:
//!
//! * unknown top-level keys, unknown node/edge attributes and even unknown node
//! `type`s are preserved verbatim so that round-tripping a file written by
//! another application never loses data,
//! * optional attributes that were absent stay absent when the file is written back.
//!
//! This module has no UI dependency so that it can be tested headless.
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
/// Unrecognised attributes of a canvas, node or edge, kept for round-tripping.
pub type Extra = Map<String, Value>;
/// Smallest width/height the editor will produce for a node.
pub const MIN_NODE_SIZE: i32 = 20;
// ---------------------------------------------------------------------------
// Small enums
// ---------------------------------------------------------------------------
/// The side of a node an edge attaches to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
Top,
Right,
Bottom,
Left,
}
impl Side {
pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left];
pub fn label(self) -> &'static str {
match self {
Self::Top => "top",
Self::Right => "right",
Self::Bottom => "bottom",
Self::Left => "left",
}
}
pub fn opposite(self) -> Self {
match self {
Self::Top => Self::Bottom,
Self::Bottom => Self::Top,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
/// The shape drawn at an end of an edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndShape {
None,
Arrow,
}
/// How a group's background image is rendered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BackgroundStyle {
Cover,
Ratio,
Repeat,
}
impl BackgroundStyle {
pub const ALL: [Self; 3] = [Self::Cover, Self::Ratio, Self::Repeat];
pub fn label(self) -> &'static str {
match self {
Self::Cover => "cover",
Self::Ratio => "ratio",
Self::Repeat => "repeat",
}
}
}
// ---------------------------------------------------------------------------
// Color
// ---------------------------------------------------------------------------
/// The spec's `canvasColor`: either one of the six presets or a hex string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Color {
/// Preset `"1"`..=`"6"` (red, orange, yellow, green, cyan, purple).
Preset(u8),
/// A `#RGB` or `#RRGGBB` string, stored including the leading `#`.
Hex(String),
}
impl Color {
pub const PRESET_NAMES: [&'static str; 6] =
["red", "orange", "yellow", "green", "cyan", "purple"];
/// Parses a `canvasColor`. Returns `None` for values the spec does not allow.
pub fn parse(raw: &str) -> Option<Self> {
let raw = raw.trim();
if let Some(hex) = raw.strip_prefix('#') {
let ok = matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit());
return ok.then(|| Self::Hex(format!("#{}", hex.to_ascii_lowercase())));
}
match raw.parse::<u8>() {
Ok(n @ 1..=6) => Some(Self::Preset(n)),
_ => None,
}
}
/// The value as it appears in the file.
pub fn as_string(&self) -> String {
match self {
Self::Preset(n) => n.to_string(),
Self::Hex(hex) => hex.clone(),
}
}
/// Human readable name, for the inspector.
pub fn display_name(&self) -> String {
match self {
Self::Preset(n) => Self::PRESET_NAMES[(*n as usize) - 1].to_owned(),
Self::Hex(hex) => hex.to_ascii_uppercase(),
}
}
}
impl Serialize for Color {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.as_string())
}
}
impl<'de> Deserialize<'de> for Color {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
// Tolerate `"color": 1` even though the spec asks for a string.
let raw = match Value::deserialize(d)? {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
other => return Err(D::Error::custom(format!("invalid canvasColor: {other}"))),
};
Color::parse(&raw).ok_or_else(|| D::Error::custom(format!("invalid canvasColor: {raw:?}")))
}
}
// ---------------------------------------------------------------------------
// Nodes
// ---------------------------------------------------------------------------
/// The type-specific part of a node.
#[derive(Debug, Clone, PartialEq)]
pub enum NodeKind {
Text {
text: String,
},
File {
file: String,
subpath: Option<String>,
},
Link {
url: String,
},
Group {
label: Option<String>,
background: Option<String>,
background_style: Option<BackgroundStyle>,
},
/// A node whose `type` this version of the spec does not know. Everything
/// about it lives in [`Node::extra`] and is written back unchanged.
Unknown {
type_name: String,
},
}
impl NodeKind {
pub fn type_name(&self) -> &str {
match self {
Self::Text { .. } => "text",
Self::File { .. } => "file",
Self::Link { .. } => "link",
Self::Group { .. } => "group",
Self::Unknown { type_name } => type_name,
}
}
}
/// A node of the canvas.
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub id: String,
pub kind: NodeKind,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub color: Option<Color>,
pub extra: Extra,
}
impl Node {
pub fn new(id: impl Into<String>, kind: NodeKind, x: i32, y: i32, w: i32, h: i32) -> Self {
Self {
id: id.into(),
kind,
x,
y,
width: w.max(MIN_NODE_SIZE),
height: h.max(MIN_NODE_SIZE),
color: None,
extra: Extra::new(),
}
}
pub fn is_group(&self) -> bool {
matches!(self.kind, NodeKind::Group { .. })
}
/// `true` when this node's box fully contains `other`'s box.
pub fn contains(&self, other: &Self) -> bool {
other.x >= self.x
&& other.y >= self.y
&& other.x + other.width <= self.x + self.width
&& other.y + other.height <= self.y + self.height
}
/// A short one-line description used in lists and the status bar.
pub fn title(&self) -> String {
match &self.kind {
NodeKind::Text { text } => {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let line = line.trim_start_matches(['#', '>', '-', '*', ' ']).trim();
if line.is_empty() {
"Empty note".to_owned()
} else {
truncate(line, 40)
}
}
NodeKind::File { file, .. } => file_name(file).to_owned(),
NodeKind::Link { url } => truncate(url.trim_start_matches("https://"), 40),
NodeKind::Group { label, .. } => label.clone().unwrap_or_else(|| "Group".to_owned()),
NodeKind::Unknown { type_name } => format!("<{type_name}>"),
}
}
}
impl Serialize for Node {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut map = Map::new();
map.insert("id".into(), Value::String(self.id.clone()));
map.insert(
"type".into(),
Value::String(self.kind.type_name().to_owned()),
);
match &self.kind {
NodeKind::Text { text } => {
map.insert("text".into(), Value::String(text.clone()));
}
NodeKind::File { file, subpath } => {
map.insert("file".into(), Value::String(file.clone()));
insert_opt_str(&mut map, "subpath", subpath.as_deref());
}
NodeKind::Link { url } => {
map.insert("url".into(), Value::String(url.clone()));
}
NodeKind::Group {
label,
background,
background_style,
} => {
insert_opt_str(&mut map, "label", label.as_deref());
insert_opt_str(&mut map, "background", background.as_deref());
insert_opt_str(
&mut map,
"backgroundStyle",
background_style.map(BackgroundStyle::label),
);
}
NodeKind::Unknown { .. } => {}
}
map.insert("x".into(), Value::from(self.x));
map.insert("y".into(), Value::from(self.y));
map.insert("width".into(), Value::from(self.width));
map.insert("height".into(), Value::from(self.height));
if let Some(color) = &self.color {
map.insert("color".into(), Value::String(color.as_string()));
}
for (k, v) in &self.extra {
map.entry(k.clone()).or_insert_with(|| v.clone());
}
map.serialize(s)
}
}
impl<'de> Deserialize<'de> for Node {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut map = Map::<String, Value>::deserialize(d)?;
let id = take_string(&mut map, "id")
.ok_or_else(|| D::Error::custom("node is missing a string \"id\""))?;
let type_name = take_string(&mut map, "type")
.ok_or_else(|| D::Error::custom(format!("node {id:?} is missing a \"type\"")))?;
let kind = match type_name.as_str() {
"text" => NodeKind::Text {
text: take_string(&mut map, "text").unwrap_or_default(),
},
"file" => NodeKind::File {
file: take_string(&mut map, "file").unwrap_or_default(),
subpath: take_string(&mut map, "subpath"),
},
"link" => NodeKind::Link {
url: take_string(&mut map, "url").unwrap_or_default(),
},
"group" => NodeKind::Group {
label: take_string(&mut map, "label"),
background: take_string(&mut map, "background"),
background_style: take_enum(&mut map, "backgroundStyle"),
},
_ => NodeKind::Unknown { type_name },
};
let color = take_color(&mut map);
Ok(Node {
id,
kind,
x: take_i32(&mut map, "x").unwrap_or(0),
y: take_i32(&mut map, "y").unwrap_or(0),
width: take_i32(&mut map, "width").unwrap_or(250).max(1),
height: take_i32(&mut map, "height").unwrap_or(60).max(1),
color,
extra: map,
})
}
}
// ---------------------------------------------------------------------------
// Edges
// ---------------------------------------------------------------------------
/// A connection between two nodes.
#[derive(Debug, Clone, PartialEq)]
pub struct Edge {
pub id: String,
pub from_node: String,
pub from_side: Option<Side>,
pub from_end: Option<EndShape>,
pub to_node: String,
pub to_side: Option<Side>,
pub to_end: Option<EndShape>,
pub color: Option<Color>,
pub label: Option<String>,
pub extra: Extra,
}
impl Edge {
pub fn new(
id: impl Into<String>,
from_node: impl Into<String>,
from_side: Side,
to_node: impl Into<String>,
to_side: Side,
) -> Self {
Self {
id: id.into(),
from_node: from_node.into(),
from_side: Some(from_side),
from_end: None,
to_node: to_node.into(),
to_side: Some(to_side),
to_end: None,
color: None,
label: None,
extra: Extra::new(),
}
}
/// Shape drawn where the edge leaves its source; the spec's default for
/// `fromEnd` is `none`.
pub fn start_shape(&self) -> EndShape {
self.from_end.unwrap_or(EndShape::None)
}
/// Shape drawn where the edge reaches its target; the spec's default for
/// `toEnd` is `arrow`.
pub fn end_shape(&self) -> EndShape {
self.to_end.unwrap_or(EndShape::Arrow)
}
}
impl Serialize for Edge {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut map = Map::new();
map.insert("id".into(), Value::String(self.id.clone()));
map.insert("fromNode".into(), Value::String(self.from_node.clone()));
insert_opt_str(&mut map, "fromSide", self.from_side.map(Side::label));
insert_opt_str(&mut map, "fromEnd", self.from_end.map(end_label));
map.insert("toNode".into(), Value::String(self.to_node.clone()));
insert_opt_str(&mut map, "toSide", self.to_side.map(Side::label));
insert_opt_str(&mut map, "toEnd", self.to_end.map(end_label));
if let Some(color) = &self.color {
map.insert("color".into(), Value::String(color.as_string()));
}
insert_opt_str(&mut map, "label", self.label.as_deref());
for (k, v) in &self.extra {
map.entry(k.clone()).or_insert_with(|| v.clone());
}
map.serialize(s)
}
}
impl<'de> Deserialize<'de> for Edge {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut map = Map::<String, Value>::deserialize(d)?;
let id = take_string(&mut map, "id")
.ok_or_else(|| D::Error::custom("edge is missing a string \"id\""))?;
let from_node = take_string(&mut map, "fromNode")
.ok_or_else(|| D::Error::custom(format!("edge {id:?} is missing \"fromNode\"")))?;
let to_node = take_string(&mut map, "toNode")
.ok_or_else(|| D::Error::custom(format!("edge {id:?} is missing \"toNode\"")))?;
let color = take_color(&mut map);
Ok(Edge {
id,
from_node,
from_side: take_enum(&mut map, "fromSide"),
from_end: take_enum(&mut map, "fromEnd"),
to_node,
to_side: take_enum(&mut map, "toSide"),
to_end: take_enum(&mut map, "toEnd"),
color,
label: take_string(&mut map, "label"),
extra: map,
})
}
}
fn end_label(end: EndShape) -> &'static str {
match end {
EndShape::None => "none",
EndShape::Arrow => "arrow",
}
}
// ---------------------------------------------------------------------------
// Canvas
// ---------------------------------------------------------------------------
/// A whole `.canvas` document.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Canvas {
#[serde(default)]
pub nodes: Vec<Node>,
#[serde(default)]
pub edges: Vec<Edge>,
#[serde(flatten, default)]
pub extra: Extra,
}
impl Canvas {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Serialises to pretty-printed JSON, always including `nodes` and `edges`.
pub fn to_json(&self) -> String {
let mut out = serde_json::to_string_pretty(&CanvasOut {
nodes: &self.nodes,
edges: &self.edges,
extra: &self.extra,
})
.expect("canvas serialization cannot fail");
out.push('\n');
out
}
pub fn node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id)
}
pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
self.nodes.iter_mut().find(|n| n.id == id)
}
pub fn edge(&self, id: &str) -> Option<&Edge> {
self.edges.iter().find(|e| e.id == id)
}
pub fn edge_mut(&mut self, id: &str) -> Option<&mut Edge> {
self.edges.iter_mut().find(|e| e.id == id)
}
/// Removes a node together with every edge attached to it.
pub fn remove_node(&mut self, id: &str) {
self.nodes.retain(|n| n.id != id);
self.edges.retain(|e| e.from_node != id && e.to_node != id);
}
/// Ids of the nodes fully contained in the group `group_id`.
pub fn nodes_in_group(&self, group_id: &str) -> Vec<String> {
let Some(group) = self.node(group_id) else {
return Vec::new();
};
self.nodes
.iter()
.filter(|n| n.id != group_id && group.contains(n))
.map(|n| n.id.clone())
.collect()
}
/// An id that is not used by any node or edge in this canvas.
pub fn fresh_id(&self) -> String {
loop {
let id = random_id();
let taken =
self.nodes.iter().any(|n| n.id == id) || self.edges.iter().any(|e| e.id == id);
if !taken {
return id;
}
}
}
/// Drops edges that point at nodes which do not exist, returning how many
/// were removed.
pub fn drop_dangling_edges(&mut self) -> usize {
let ids: std::collections::HashSet<&str> =
self.nodes.iter().map(|n| n.id.as_str()).collect();
let before = self.edges.len();
self.edges
.retain(|e| ids.contains(e.from_node.as_str()) && ids.contains(e.to_node.as_str()));
before - self.edges.len()
}
}
#[derive(Serialize)]
struct CanvasOut<'a> {
nodes: &'a Vec<Node>,
edges: &'a Vec<Edge>,
#[serde(flatten)]
extra: &'a Extra,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// A 16 character lowercase hex id, matching the style used by Obsidian Canvas.
pub fn random_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = nanos ^ counter.wrapping_mul(0x9e37_79b9_7f4a_7c15);
// splitmix64
let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^= z >> 31;
format!("{z:016x}")
}
fn take_string(map: &mut Map<String, Value>, key: &str) -> Option<String> {
match map.remove(key) {
Some(Value::String(s)) => Some(s),
// Not a string: put it back so it survives round-tripping.
Some(other) => {
map.insert(key.to_owned(), other);
None
}
None => None,
}
}
fn take_i32(map: &mut Map<String, Value>, key: &str) -> Option<i32> {
match map.remove(key) {
Some(Value::Number(n)) => n
.as_f64()
.map(|f| f.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32),
Some(other) => {
map.insert(key.to_owned(), other);
None
}
None => None,
}
}
fn take_enum<T: for<'a> Deserialize<'a>>(map: &mut Map<String, Value>, key: &str) -> Option<T> {
let value = map.remove(key)?;
match T::deserialize(value.clone()) {
Ok(v) => Some(v),
Err(_) => {
map.insert(key.to_owned(), value);
None
}
}
}
/// Reads `color`, keeping values the spec does not allow in `extra`.
fn take_color(map: &mut Map<String, Value>) -> Option<Color> {
let value = map.remove("color")?;
let raw = match &value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
_ => {
map.insert("color".into(), value);
return None;
}
};
match Color::parse(&raw) {
Some(color) => Some(color),
None => {
map.insert("color".into(), value);
None
}
}
}
fn insert_opt_str(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value {
map.insert(key.to_owned(), Value::String(value.to_owned()));
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let head: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{}…", head.trim_end())
}
}
/// The last component of a `/`- or `\`-separated path.
pub fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r##"{
"nodes": [
{"id":"a","type":"text","text":"# Hello","x":-100,"y":-40,"width":250,"height":60,"color":"3"},
{"id":"b","type":"file","file":"notes/todo.md","subpath":"#today","x":200,"y":0,"width":400,"height":400},
{"id":"c","type":"group","label":"Ideas","x":-200,"y":-200,"width":800,"height":800,"color":"#ff00aa"},
{"id":"d","type":"link","url":"https://jsoncanvas.org","x":0,"y":300,"width":400,"height":400}
],
"edges": [
{"id":"e1","fromNode":"a","fromSide":"right","toNode":"b","toSide":"left","label":"see also","color":"5"}
]
}"##;
#[test]
fn parses_every_node_type() {
let canvas = Canvas::from_json(SAMPLE).unwrap();
assert_eq!(canvas.nodes.len(), 4);
assert_eq!(canvas.edges.len(), 1);
assert!(matches!(canvas.nodes[0].kind, NodeKind::Text { .. }));
assert_eq!(canvas.nodes[0].color, Some(Color::Preset(3)));
assert_eq!(canvas.nodes[2].color, Some(Color::Hex("#ff00aa".into())));
let NodeKind::File { subpath, .. } = &canvas.nodes[1].kind else {
panic!("expected a file node");
};
assert_eq!(subpath.as_deref(), Some("#today"));
}
#[test]
fn round_trips_without_losing_data() {
let canvas = Canvas::from_json(SAMPLE).unwrap();
let again = Canvas::from_json(&canvas.to_json()).unwrap();
assert_eq!(canvas, again);
}
/// Guards the `preserve_order` feature of `serde_json`: without it the
/// attributes come out alphabetically and every save reshuffles the file.
#[test]
fn attributes_are_written_in_spec_order() {
let json = Canvas::from_json(SAMPLE).unwrap().to_json();
let first_node = json.split("\"edges\"").next().unwrap();
let at = |key: &str| first_node.find(key).unwrap_or(usize::MAX);
assert!(
at("\"id\"") < at("\"type\"")
&& at("\"type\"") < at("\"text\"")
&& at("\"text\"") < at("\"x\"")
&& at("\"x\"") < at("\"width\"")
&& at("\"width\"") < at("\"color\""),
"{json}"
);
}
#[test]
fn keeps_unknown_attributes_and_node_types() {
let json = r#"{"nodes":[{"id":"x","type":"quantum","x":0,"y":0,"width":10,"height":10,
"spin":"up"}],"edges":[],"appVersion":"1.2.3"}"#;
let canvas = Canvas::from_json(json).unwrap();
assert!(matches!(canvas.nodes[0].kind, NodeKind::Unknown { .. }));
let out = canvas.to_json();
assert!(out.contains("\"quantum\""), "{out}");
assert!(out.contains("\"spin\""), "{out}");
assert!(out.contains("\"appVersion\""), "{out}");
}
#[test]
fn edge_end_defaults_follow_the_spec() {
let edge = Edge::new("e", "a", Side::Right, "b", Side::Left);
assert_eq!(edge.start_shape(), EndShape::None);
assert_eq!(edge.end_shape(), EndShape::Arrow);
// Unset ends are not written back out.
let json = serde_json::to_string(&edge).unwrap();
assert!(!json.contains("fromEnd"), "{json}");
}
#[test]
fn rejects_invalid_colors_but_keeps_the_value() {
assert_eq!(Color::parse("7"), None);
assert_eq!(Color::parse("#12"), None);
assert_eq!(Color::parse("#AABBCC"), Some(Color::Hex("#aabbcc".into())));
let canvas =
Canvas::from_json(r#"{"nodes":[{"id":"a","type":"text","text":"","color":"nope"}]}"#)
.unwrap();
assert_eq!(canvas.nodes[0].color, None);
assert!(canvas.to_json().contains("nope"));
}
#[test]
fn removing_a_node_removes_its_edges() {
let mut canvas = Canvas::from_json(SAMPLE).unwrap();
canvas.remove_node("a");
assert!(canvas.edges.is_empty());
}
#[test]
fn groups_report_the_nodes_they_contain() {
let canvas = Canvas::from_json(SAMPLE).unwrap();
let mut inside = canvas.nodes_in_group("c");
inside.sort();
// "d" hangs out of the bottom of the group, so it is not a member.
assert_eq!(inside, vec!["a".to_owned(), "b".to_owned()]);
}
#[test]
fn ids_are_unique() {
let ids: std::collections::HashSet<String> = (0..1000).map(|_| random_id()).collect();
assert_eq!(ids.len(), 1000);
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Rendering the *body* of a node: markdown for text nodes, previews for file
//! nodes, the target of link nodes.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use eframe::egui::{self, Align, Layout, RichText, Ui};
use crate::markdown;
use crate::model::{Node, NodeKind, file_name};
use crate::theme::Palette;
/// Files larger than this are not previewed as text.
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
/// What a file node points at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileKind {
Image,
Text,
Other,
}
pub fn file_kind(path: &str) -> FileKind {
let ext = Path::new(path)
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" => FileKind::Image,
"md" | "markdown" | "txt" | "text" | "canvas" | "json" | "toml" | "yaml" | "yml"
| "csv" | "log" | "rs" | "py" | "js" | "ts" | "html" | "css" | "sh" => FileKind::Text,
_ => FileKind::Other,
}
}
/// Cache for the text previews of file nodes, so the disk is not hit per frame.
#[derive(Default)]
pub struct PreviewCache {
text: HashMap<PathBuf, Result<String, String>>,
}
impl PreviewCache {
pub fn clear(&mut self) {
self.text.clear();
}
fn text_preview(&mut self, path: &Path) -> &Result<String, String> {
self.text.entry(path.to_path_buf()).or_insert_with(|| {
match std::fs::read(path) {
Ok(bytes) => {
let truncated = bytes.len() > MAX_PREVIEW_BYTES;
let head = &bytes[..bytes.len().min(MAX_PREVIEW_BYTES)];
let mut text = String::from_utf8_lossy(head).into_owned();
if truncated {
text.push_str("\n\n…");
}
Ok(text)
}
Err(err) => Err(format!("{err}")),
}
})
}
}
/// Resolves a node path against the folder the canvas lives in.
pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf {
let path = Path::new(raw);
match base_dir {
Some(base) if path.is_relative() => base.join(path),
_ => path.to_path_buf(),
}
}
/// A `file://` URI for egui's image loader.
///
/// On Windows the loader wants the `file:///C:/dir/file.png` form: without the
/// third slash it reads the drive letter as a UNC host name. It does not
/// percent-decode, so nothing here is escaped.
pub fn file_uri(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
format!("file:///{}", text.trim_start_matches('/'))
} else {
format!("file://{text}")
}
}
/// Everything the body renderer needs from the outside world.
pub struct BodyContext<'a> {
pub palette: &'a Palette,
pub base_dir: Option<&'a Path>,
pub cache: &'a mut PreviewCache,
/// Current canvas scale, used to skip detail when zoomed far out.
pub scale: f32,
}
/// Draws the contents of `node` inside `ui`, returning a link/file the user
/// activated by clicking.
pub fn render_body(ui: &mut Ui, node: &Node, ctx: &mut BodyContext<'_>) -> Option<String> {
match &node.kind {
NodeKind::Text { text } => render_text(ui, text, ctx),
NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), ctx),
NodeKind::Link { url } => render_link(ui, url, ctx),
NodeKind::Group { .. } => None,
NodeKind::Unknown { type_name } => {
ui.label(
RichText::new(format!("Unsupported node type “{type_name}”"))
.size(13.0)
.color(ctx.palette.text_muted)
.italics(),
);
ui.label(
RichText::new("Its data is preserved when the file is saved.")
.size(11.0)
.color(ctx.palette.text_muted),
);
None
}
}
}
fn render_text(ui: &mut Ui, text: &str, ctx: &mut BodyContext<'_>) -> Option<String> {
if text.trim().is_empty() {
ui.label(
RichText::new("Empty note")
.size(13.0)
.italics()
.color(ctx.palette.text_muted),
);
return None;
}
let blocks = markdown::parse(text);
markdown::render(ui, &blocks, ctx.palette, 14.0)
}
fn render_file(
ui: &mut Ui,
file: &str,
subpath: Option<&str>,
ctx: &mut BodyContext<'_>,
) -> Option<String> {
let palette = ctx.palette;
let path = resolve_path(ctx.base_dir, file);
let kind = file_kind(file);
let mut activated = None;
// Header: the file name, plus the subpath when the node points inside a file.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let icon = match kind {
FileKind::Image => "IMG",
FileKind::Text => "DOC",
FileKind::Other => "FILE",
};
ui.label(RichText::new(icon).size(10.0).color(palette.text_muted));
if ui
.link(
RichText::new(file_name(file))
.size(13.0)
.strong()
.color(palette.text),
)
.on_hover_text(path.display().to_string())
.clicked()
{
activated = Some(file.to_owned());
}
if let Some(subpath) = subpath {
ui.label(
RichText::new(subpath)
.size(12.0)
.color(palette.text_muted),
);
}
});
if ctx.scale < 0.4 {
return activated;
}
if !path.exists() {
ui.add_space(4.0);
ui.label(
RichText::new("File not found")
.size(12.0)
.italics()
.color(palette.danger),
);
return activated;
}
ui.add_space(4.0);
match kind {
FileKind::Image => {
ui.add(
egui::Image::new(file_uri(&path))
.maintain_aspect_ratio(true)
.fit_to_exact_size(ui.available_size())
.corner_radius(4.0),
);
}
FileKind::Text => match ctx.cache.text_preview(&path) {
Ok(text) => {
let excerpt = excerpt_for(text, subpath);
let blocks = markdown::parse(&excerpt);
if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) {
activated = Some(link);
}
}
Err(err) => {
ui.label(
RichText::new(format!("Could not read the file: {err}"))
.size(12.0)
.color(palette.danger),
);
}
},
FileKind::Other => {
ui.label(
RichText::new("No preview available")
.size(12.0)
.italics()
.color(palette.text_muted),
);
}
}
activated
}
/// The part of a text file a file node shows: the whole file, or the section a
/// `subpath` (`#heading`) points at.
fn excerpt_for(text: &str, subpath: Option<&str>) -> String {
let Some(heading) = subpath.and_then(|s| s.strip_prefix('#')) else {
return text.to_owned();
};
let heading = heading.trim().to_ascii_lowercase();
let mut out = Vec::new();
let mut level = 0usize;
for line in text.lines() {
let hashes = line.chars().take_while(|c| *c == '#').count();
let is_heading = (1..=6).contains(&hashes) && line[hashes..].starts_with(' ');
if level == 0 {
if is_heading && line[hashes..].trim().to_ascii_lowercase() == heading {
level = hashes;
out.push(line.to_owned());
}
} else {
if is_heading && hashes <= level {
break;
}
out.push(line.to_owned());
}
}
if out.is_empty() {
format!("*Section “{heading}” not found*")
} else {
out.join("\n")
}
}
fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option<String> {
let palette = ctx.palette;
let mut activated = None;
ui.with_layout(Layout::top_down(Align::Min), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new("URL").size(10.0).color(palette.text_muted));
ui.label(
RichText::new(host_of(url))
.size(13.0)
.strong()
.color(palette.text),
);
});
ui.add_space(2.0);
if ui
.link(RichText::new(url).size(12.0).color(palette.accent))
.on_hover_text("Open in the default browser")
.clicked()
{
activated = Some(url.to_owned());
}
});
activated
}
/// The host part of a URL, for the link node header.
fn host_of(url: &str) -> &str {
let rest = url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(url);
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
if host.is_empty() { url } else { host }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_file_kinds() {
assert_eq!(file_kind("a/b/photo.PNG"), FileKind::Image);
assert_eq!(file_kind("notes.md"), FileKind::Text);
assert_eq!(file_kind("archive.zip"), FileKind::Other);
assert_eq!(file_kind("no-extension"), FileKind::Other);
}
#[test]
fn resolves_relative_paths_against_the_canvas_folder() {
let base = Path::new("/vault/boards");
assert_eq!(
resolve_path(Some(base), "notes/a.md"),
PathBuf::from("/vault/boards/notes/a.md")
);
// Absolute paths are left alone.
let absolute = if cfg!(windows) { "C:\\tmp\\a.md" } else { "/tmp/a.md" };
assert_eq!(
resolve_path(Some(base), absolute),
PathBuf::from(absolute)
);
}
#[test]
fn a_subpath_selects_one_section() {
let text = "# One\nalpha\n\n## Two\nbeta\n\n# Three\ngamma";
assert_eq!(excerpt_for(text, Some("#Two")), "## Two\nbeta\n");
assert!(excerpt_for(text, Some("#Missing")).contains("not found"));
assert_eq!(excerpt_for(text, None), text);
}
#[test]
fn image_uris_use_the_form_the_loader_expects() {
let uri = file_uri(Path::new(if cfg!(windows) {
"C:\\vault\\a b.png"
} else {
"/vault/a b.png"
}));
if cfg!(windows) {
assert_eq!(uri, "file:///C:/vault/a b.png");
} else {
assert_eq!(uri, "file:///vault/a b.png");
}
}
#[test]
fn shows_the_host_of_a_link() {
assert_eq!(host_of("https://jsoncanvas.org/spec/1.0/"), "jsoncanvas.org");
assert_eq!(host_of("example.com"), "example.com");
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Colours: the six `canvasColor` presets plus the light and dark palettes the
//! editor paints with.
use eframe::egui::Color32;
use crate::model::Color;
/// The six preset colours, in spec order (`"1"` … `"6"`).
pub const PRESETS: [Color32; 6] = [
Color32::from_rgb(0xfb, 0x46, 0x4c), // 1 red
Color32::from_rgb(0xe9, 0x97, 0x3f), // 2 orange
Color32::from_rgb(0xe0, 0xde, 0x71), // 3 yellow
Color32::from_rgb(0x44, 0xcf, 0x6e), // 4 green
Color32::from_rgb(0x53, 0xdf, 0xdd), // 5 cyan
Color32::from_rgb(0xa8, 0x82, 0xff), // 6 purple
];
/// Every colour the canvas is painted with, for one of the two themes.
#[derive(Clone, Copy)]
pub struct Palette {
pub dark: bool,
pub canvas_bg: Color32,
pub grid: Color32,
pub grid_strong: Color32,
pub surface: Color32,
pub surface_muted: Color32,
pub border: Color32,
pub text: Color32,
pub text_muted: Color32,
pub accent: Color32,
pub edge: Color32,
pub danger: Color32,
}
impl Palette {
pub fn dark() -> Self {
Self {
dark: true,
canvas_bg: Color32::from_rgb(0x1a, 0x1a, 0x1e),
grid: Color32::from_rgb(0x26, 0x26, 0x2c),
grid_strong: Color32::from_rgb(0x30, 0x30, 0x38),
surface: Color32::from_rgb(0x24, 0x24, 0x2a),
surface_muted: Color32::from_rgb(0x2c, 0x2c, 0x33),
border: Color32::from_rgb(0x44, 0x44, 0x4e),
text: Color32::from_rgb(0xe4, 0xe4, 0xe8),
text_muted: Color32::from_rgb(0x95, 0x95, 0xa0),
accent: Color32::from_rgb(0x70, 0x9c, 0xff),
edge: Color32::from_rgb(0x8a, 0x8a, 0x98),
danger: Color32::from_rgb(0xe5, 0x53, 0x53),
}
}
pub fn light() -> Self {
Self {
dark: false,
canvas_bg: Color32::from_rgb(0xf7, 0xf7, 0xf9),
grid: Color32::from_rgb(0xe6, 0xe6, 0xea),
grid_strong: Color32::from_rgb(0xd6, 0xd6, 0xdc),
surface: Color32::from_rgb(0xff, 0xff, 0xff),
surface_muted: Color32::from_rgb(0xf0, 0xf0, 0xf4),
border: Color32::from_rgb(0xcd, 0xcd, 0xd6),
text: Color32::from_rgb(0x1e, 0x1e, 0x24),
text_muted: Color32::from_rgb(0x6b, 0x6b, 0x78),
accent: Color32::from_rgb(0x2f, 0x6f, 0xed),
edge: Color32::from_rgb(0x76, 0x76, 0x84),
danger: Color32::from_rgb(0xc4, 0x2b, 0x2b),
}
}
/// Turns a `canvasColor` into a paintable colour.
pub fn resolve(&self, color: &Color) -> Color32 {
match color {
Color::Preset(n) => PRESETS[(*n as usize - 1).min(5)],
Color::Hex(hex) => parse_hex(hex).unwrap_or(self.accent),
}
}
/// Border and fill for a node with the given `canvasColor`.
pub fn node_colors(&self, color: Option<&Color>) -> (Color32, Color32) {
match color {
None => (self.surface, self.border),
Some(color) => {
let accent = self.resolve(color);
let strength = if self.dark { 0.16 } else { 0.10 };
(blend(accent, self.surface, strength), accent)
}
}
}
/// Fill and border for a group box.
pub fn group_colors(&self, color: Option<&Color>) -> (Color32, Color32) {
let border = color.map_or(self.border, |c| self.resolve(c));
let strength = if self.dark { 0.10 } else { 0.07 };
(blend(border, self.canvas_bg, strength), border)
}
/// Colour of an edge, honouring its optional `canvasColor`.
pub fn edge_color(&self, color: Option<&Color>) -> Color32 {
color.map_or(self.edge, |c| self.resolve(c))
}
}
/// Parses `#rgb` / `#rrggbb`.
pub fn parse_hex(hex: &str) -> Option<Color32> {
let hex = hex.trim().strip_prefix('#')?;
let expand = |c: u8| c * 17;
match hex.len() {
3 => {
let mut it = hex.chars().map(|c| c.to_digit(16).map(|d| expand(d as u8)));
Some(Color32::from_rgb(it.next()??, it.next()??, it.next()??))
}
6 => {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
_ => None,
}
}
/// Mixes `fg` into `bg` with the given strength in `0..=1`.
pub fn blend(fg: Color32, bg: Color32, strength: f32) -> Color32 {
let mix = |a: u8, b: u8| (a as f32 * strength + b as f32 * (1.0 - strength)).round() as u8;
Color32::from_rgb(
mix(fg.r(), bg.r()),
mix(fg.g(), bg.g()),
mix(fg.b(), bg.b()),
)
}
/// Black or white, whichever stays readable on `background`.
pub fn contrasting_text(background: Color32) -> Color32 {
let luma = 0.299 * background.r() as f32 + 0.587 * background.g() as f32
+ 0.114 * background.b() as f32;
if luma > 150.0 {
Color32::from_rgb(0x1e, 0x1e, 0x24)
} else {
Color32::from_rgb(0xf2, 0xf2, 0xf5)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_both_hex_lengths() {
assert_eq!(parse_hex("#fff"), Some(Color32::WHITE));
assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0)));
assert_eq!(parse_hex("nope"), None);
assert_eq!(parse_hex("#12345"), None);
}
#[test]
fn presets_resolve_by_number() {
let palette = Palette::dark();
assert_eq!(palette.resolve(&Color::Preset(1)), PRESETS[0]);
assert_eq!(palette.resolve(&Color::Preset(6)), PRESETS[5]);
assert_eq!(
palette.resolve(&Color::Hex("#ff00ff".into())),
Color32::from_rgb(255, 0, 255)
);
}
#[test]
fn a_coloured_node_keeps_a_readable_fill() {
let palette = Palette::dark();
let (fill, border) = palette.node_colors(Some(&Color::Preset(1)));
assert_eq!(border, PRESETS[0]);
// The fill is only tinted, it stays close to the surface colour.
assert!(fill.r() > palette.surface.r() && fill.r() < PRESETS[0].r());
}
#[test]
fn text_contrast_flips_with_the_background() {
assert_eq!(contrasting_text(Color32::WHITE).r(), 0x1e);
assert_eq!(contrasting_text(Color32::BLACK).r(), 0xf2);
}
}
+1996
View File
File diff suppressed because it is too large Load Diff