Version 1
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4571
File diff suppressed because it is too large
Load Diff
+16
@@ -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"] }
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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
File diff suppressed because it is too large
Load Diff
+266
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 ``.
|
||||
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 `` 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("");
|
||||
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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user