pinstar 1.1.3

Terminal-based canvas editor for .canvas files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# Pinstar Library API

Host-agnostic canvas/diagram engine for Obsidian-compatible `.canvas` files,
Mermaid, DOT, and PlantUML flowcharts. Hosts own keybinds, status lines,
dialogs, and the system clipboard; pinstar owns everything canvas-related.

## Dependency

```toml
# Cargo.toml
[dependencies]
pinstar = { version = "1.1.2", features = ["images"] }
```

The `images` feature enables image-node support (`pinstar::image` module,
`ratatui-image`, `lru`). Set `default-features = false` for a lighter build
without inline images; the default feature set includes images.

## Read-only Mermaid previews

Enable `mermaid-preview` to use `pinstar::preview::render_mermaid(source, columns)`.
It returns styled Ratatui `Line` rows and explicit substitution notices, or an
error. Hosts should retain fenced source with a diagnostic on errors. Pass only
the code-block literal, not its Markdown fences. The host owns source positions,
caching, theming defaults and scroll synchronization.

Parsing uses `merman-core-clin 0.6.0`, the MIT/Apache-2.0 compatibility package
with the upstream 0.6 parser API and a maintained YAML backend. No JavaScript
runtime or browser is required. Rust 1.90 or newer is required.
The interactive `.canvas` model, parser, serializer and editing behavior are
unchanged. Unknown or newer syntax is rejected rather than partially rendered.

Terminal output preserves nodes, connections, edge labels and markers, nested
subgraphs and directions, titles, accessibility text, classes and basic colors /
text modifiers. Cycles and parallel edges are supported. Labels containing HTML
become safe text; Markdown formatting may remain textual. Specialized shapes use
labelled compact outlines. Curves/link lengths use orthogonal routing, animations
are static, and unsupported CSS/configuration is reported. Icons/images are text
placeholders; remote assets are never fetched. Links/tooltips are text, callbacks
never execute. This is syntax-aware terminal approximation, not browser/SVG parity.

Limits: 64 KiB source, 128 nodes, 256 edges, 32 groups, 16 nesting levels, 240
columns and 512 diagram rows. Before native parsing, chart-body budgets cap raw `&` characters at 32,
opening delimiters (`[`, `(`, `{`) at 256, and `subgraph` words at 32.
Labels, directive contents and comments also count: these conservative budgets
cannot be bypassed by quote-state tricks. YAML frontmatter uses a non-expanding
scanner, caps structure depth at 16, and rejects anchors/aliases before native
loading. Literal `&`/`*` in YAML strings and block scalars remain supported. These cases return diagnostics
with source fallback, not partial diagrams. Diagrams that cannot fit the supplied
width return an error instead of clipping nodes or silently losing labels. Render/cache in a
background worker for live previews; never persist decrypted note text to render.

## Architecture Overview

```
┌─────────────────────────────────────────────────────────┐
│  Host application (e.g. clin-rs)                        │
│  ┌───────────┐  ┌──────────┐  ┌──────────────────────┐  │
│  │ Keybinds  │  │ Clipboard│  │ Dialogs / Status bar │  │
│  └─────┬─────┘  └────┬─────┘  └──────────┬───────────┘  │
│        │              │                   │              │
│        ▼              ▼                   ▼              │
│  ┌─────────────────────────────────────────────────┐     │
│  │            pinstar library                      │     │
│  │  PinstarState ◄─── apply_action(PinstarAction)  │     │
│  │       │       ◄─── handle_pinstar_mouse()       │     │
│  │       ▼                                         │     │
│  │  draw_pinstar_view(frame, state, theme, area)   │     │
│  └─────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────┘
```

Hosts resolve their own key events to `PinstarAction` variants and feed them
through `apply_action`. Mouse events go through `handle_pinstar_mouse`. The
renderer `draw_pinstar_view` paints the canvas into any ratatui `Rect`,
reserving one bottom row for the host's footer.

## Quick Start

```rust
use pinstar::{
    PinstarState, Settings, ThemeColors,
    PinstarAction, ActionCtx, ActionOutcome,
    apply_action, draw_pinstar_view, handle_pinstar_mouse,
    execute_menu_action,
};

// 1. Load a canvas file
let mut state = PinstarState::load(Path::new("my_diagram.canvas"))?;

// 2. Configure host-specific settings
state.settings = Settings {
    enable_image_nodes: true,  // show "Add Image Node" in context menus
    image_cache_size: 64,      // LRU slots for decoded images
    rename_uses_id: false,     // false = rename title (clin), true = rename id (standalone)
};

// 3. In your render loop
fn render(frame: &mut Frame, state: &mut PinstarState, area: Rect) {
    let theme = ThemeColors::default(); // or map from your app's theme
    let mouse_pos = state.mouse_pos;    // updated by your mouse handler
    draw_pinstar_view(frame, state, &theme, area, mouse_pos);
}

// 4. Dispatch actions from your keybind system
let outcome = apply_action(
    &mut state,
    PinstarAction::ZoomIn,
    &ActionCtx { area, count: 1 },
);

// 5. Handle host-level requests from the outcome
if let Some(host_action) = outcome.host_action {
    match host_action {
        PinstarAction::Save => { let _ = state.save(); }
        PinstarAction::Quit => { /* exit canvas view */ }
        PinstarAction::Help => { /* open your help screen */ }
        PinstarAction::AddImageNode => { /* open file picker, then: */ }
        PinstarAction::ToggleOrthogonal => { /* persist preference */ }
        _ => {}
    }
}
if let Some(notice) = outcome.notice {
    // display transient status message
}
```

## Core Types

### `PinstarState`

Central mutable state for one canvas session. Created via `PinstarState::load(path)`.

```rust
let mut state = PinstarState::load(Path::new("diagram.canvas"))?;
```

Key fields accessible to hosts:

| Field | Type | Description |
|---|---|---|
| `path` | `PathBuf` | File being edited |
| `format` | `SupportedFormat` | Detected file format |
| `data` | `CanvasData` | Live node/edge data |
| `settings` | `Settings` | Host behavior switches |
| `selection` | `Selection<String>` | Multi-select state (primary + extras) |
| `selected_edge_id` | `Option<String>` | Currently selected edge |
| `floating_editor` | `Option<TextArea>` | Inline node text editor |
| `raw_editor` | `TextArea` | Source-view text editor |
| `editor_focus` | `bool` | Whether raw editor has focus |
| `context_menu` | `Option<PinstarContextMenu>` | Active context menu |
| `mouse_pos` | `Option<(u16, u16)>` | Current mouse position (host must set) |
| `locked` | `bool` | Editing locked (auto-true for flowcharts) |
| `show_grid` | `bool` | Grid dots visible |
| `show_help` | `bool` | Help overlay active |
| `orthogonal_connections` | `bool` | Right-angle edges |
| `rename_popup` | `Option<TextArea>` | Active rename popup |
| `trigger_ext_editor` | `bool` | Flag: host should open external editor |
| `trigger_image_picker` | `bool` | Flag: host should open image file dialog |
| `footer_hint` | `String` | Context-sensitive hint text for host footer |
| `last_area` | `Rect` | Last rendered area |

Key methods:

```rust
state.save()?;                           // serialize + atomic write
state.sync_to_raw_editor();              // data → raw editor text
state.sync_from_raw_editor()?;           // raw editor text → data
state.rename_node_title(new_title);      // rename selected node
state.add_image_node_with(path, x, y);   // add image node at position
state.toggle_editor();                   // toggle raw source editor
state.finish_connection(&target_id);     // complete edge creation
state.finish_delete_connection(&target_id); // complete edge deletion
state.active_mode_message();             // status text for connection/resize modes
state.select_edge_of_selected_node(idx); // select Nth edge of current node
state.open_edge_menu_centered(area);     // open edge context menu
```

### `Settings`

```rust
pub struct Settings {
    pub enable_image_nodes: bool,  // default: false
    pub image_cache_size: usize,   // default: 32
    pub rename_uses_id: bool,      // default: false
}
```

### `PinstarAction`

All possible canvas actions. The host maps its own keybinds onto these.

**Host-level actions** (returned via `ActionOutcome::host_action` for the host to handle):

| Variant | Host responsibility |
|---|---|
| `Quit` | Exit canvas view |
| `Save` | Call `state.save()` |
| `Help` | Open help screen |
| `AddImageNode` | Open file dialog → `state.add_image_node_with(path, x, y)` |
| `ToggleOrthogonal` | Persist the preference per vault/project |

**Stateful actions** (consumed by `apply_action`, no host handling needed):

`Undo`, `Redo`, `ZoomIn`, `ZoomOut`, `ZoomFineIn`, `ZoomFineOut`,
`MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown`, `EditOrConnect`,
`OpenContextMenu`, `MenuUp`, `MenuDown`, `MenuSelect`, `MenuClose`,
`CreateConnection`, `DeleteConnection`, `RenameNode`, `ResizeMode`,
`SetColor`, `DeleteNode`, `DeleteAllConnections`, `AddTextNode`, `AddGroup`,
`ToggleGrid`, `ToggleEditorPane`, `CycleFocus`, `RenameConfirm`,
`RenameCancel`, `ConfirmResize`, `CancelResize`, `EditorUnfocus`,
`CloseEditor`, `CloseEditorAlt`

**Standalone-only** (used by the pinstar binary's default keymap, ignored by hosted use):

`PickShape`, `PickOrientation`, `ToggleLock`, `OpenExternalEditor`,
`OpenWholeFileEditor`, `ShowHelp`

### `ActionCtx`

```rust
pub struct ActionCtx {
    pub area: Rect,    // terminal area the canvas occupies
    pub count: usize,  // repeat count from host's prefix resolver (1-based)
}
```

### `ActionOutcome`

```rust
pub struct ActionOutcome {
    pub host_action: Option<PinstarAction>,  // host-level request
    pub notice: Option<&'static str>,        // transient status message
}
```

### `MouseOutcome`

```rust
pub struct MouseOutcome {
    pub consumed: bool,                // event was handled
    pub notice: Option<&'static str>,  // transient status message
    pub clipboard: Option<String>,     // text to write to system clipboard
}
```

## Input Handling

### Keyboard: `apply_action`

```rust
pub fn apply_action(
    state: &mut PinstarState,
    action: PinstarAction,
    ctx: &ActionCtx,
) -> ActionOutcome;
```

Host resolves key events → `PinstarAction`, calls `apply_action`. Stateful
actions are consumed. Host-level actions are echoed back via
`outcome.host_action` for the host to fulfill.

### Mouse: `handle_pinstar_mouse`

```rust
pub fn handle_pinstar_mouse(
    state: &mut PinstarState,
    mouse: crossterm::event::MouseEvent,
    area: Rect,
) -> MouseOutcome;
```

Handles click, drag, scroll, marquee selection, context menus, resize
handles, and text selection. The host must:

1. Set `state.mouse_pos` before calling
2. Handle `outcome.clipboard` (write to system clipboard)
3. Display `outcome.notice` if present
4. Check `state.trigger_image_picker` after the call

### Context Menus: `execute_menu_action`

```rust
pub fn execute_menu_action(
    state: &mut PinstarState,
    label: &str,
    menu_type: PinstarMenuType,
    menu_x: u16,
    menu_y: u16,
) -> ActionOutcome;
```

Execute a context menu item by its label string. Used when the host intercepts
menu selection through its own keybind system rather than letting the engine
handle `MenuSelect`.

### Standalone Keymap: `handle_pinstar_event`

```rust
pub fn handle_pinstar_event(
    state: &mut PinstarState,
    key: KeyEvent,
    running: &mut bool,
    area: Rect,
) -> bool;
```

Default hardcoded keymap used by the standalone `pinstar` binary. Hosts with
their own keybind system (like clin-rs) should use `apply_action` instead.

## Rendering

```rust
pub fn draw_pinstar_view(
    frame: &mut Frame,
    state: &mut PinstarState,
    theme: &ThemeColors,
    area: Rect,
    mouse_pos: Option<(u16, u16)>,
);
```

Renders the full canvas view into `area`. Reserves the bottom row for the
host's footer/statusline. Handles both canvas-format rendering (node blocks,
edges, marquee) and flowchart-format rendering (shapes, braille borders,
box-drawing orthogonal edges).

### `ThemeColors`

Map your app's theme to pinstar's theme struct:

```rust
pub struct ThemeColors {
    pub accent: Color,
    pub heading: Color,
    pub success: Color,
    pub warning: Color,
    pub destructive: Color,
    pub muted: Color,
    pub text: Color,
    pub fg: Color,
    pub bg: Color,
    pub border: Color,
    pub tag: Color,
    pub folder: Color,
    pub highlight_fg: Color,
    pub highlight_bg: Color,
}
```

All fields have sensible defaults via `ThemeColors::default()`.

## Data Model (`pinstar::data`)

Obsidian-compatible `.canvas` JSON format. All types derive `Serialize` +
`Deserialize`.

### `CanvasData`

```rust
pub struct CanvasData {
    pub nodes: Vec<CanvasNode>,
    pub edges: Vec<CanvasEdge>,
    pub orientation: DiagramOrientation,  // default: TopDown
}
```

### `CanvasNode`

```rust
pub enum CanvasNode {
    Text(TextNode),
    File(FileNode),
    Link(LinkNode),
    Group(GroupNode),
}
```

Common accessors on `CanvasNode`: `id()`, `pos()`, `size()`, `set_pos()`,
`set_size()`, `color()`, `title()`.

### Node Types

```rust
pub struct TextNode {
    pub id: String,
    pub x: f64, pub y: f64,
    pub width: f64, pub height: f64,
    pub text: String,
    pub title: Option<String>,
    pub color: Option<String>,
    pub shape: NodeShape,  // Rectangle, Diamond, Circle, Cylinder, Stadium
}

pub struct FileNode {
    pub id: String,
    pub x: f64, pub y: f64,
    pub width: f64, pub height: f64,
    pub file: String,
    pub subpath: Option<String>,
    pub title: Option<String>,
    pub color: Option<String>,
}

pub struct LinkNode {
    pub id: String,
    pub x: f64, pub y: f64,
    pub width: f64, pub height: f64,
    pub url: String,
    pub title: Option<String>,
    pub color: Option<String>,
}

pub struct GroupNode {
    pub id: String,
    pub x: f64, pub y: f64,
    pub width: f64, pub height: f64,
    pub label: Option<String>,
    pub color: Option<String>,
}
```

### `CanvasEdge`

```rust
pub struct CanvasEdge {
    pub id: String,
    pub from_node: String,
    pub from_side: Option<String>,
    pub to_node: String,
    pub to_side: Option<String>,
    pub label: Option<String>,
    pub color: Option<String>,
    pub style: EdgeStyle,  // Solid, Dashed, Dotted, Thick
}
```

### Creating Canvas Data Programmatically

```rust
use pinstar::data::*;

let data = CanvasData {
    nodes: vec![
        CanvasNode::Text(TextNode {
            id: "node1".into(),
            x: 0.0, y: 0.0,
            width: 250.0, height: 60.0,
            text: "Hello".into(),
            title: None,
            color: Some("1".into()),  // palette index or "#rrggbb"
            shape: NodeShape::default(),
        }),
    ],
    edges: vec![],
    orientation: DiagramOrientation::default(),
};

// Serialize to .canvas JSON
let json = serde_json::to_string_pretty(&data)?;
```

## Format Support (`pinstar::formats`)

```rust
pub enum SupportedFormat {
    Canvas,    // .canvas (Obsidian JSON)
    Mermaid,   // .md, .mmd, .mermaid
    Dot,       // .dot, .gv
    PlantUml,  // .puml, .plantuml, .iuml
}
```

```rust
// Auto-detect from file extension
let format = pinstar::formats::detect_format(path);

// Load any supported format into CanvasData
let data = pinstar::formats::load_from_format(path, &content, format)?;

// Save back (round-trips flowchart source, serializes canvas as JSON)
let output = pinstar::formats::save_to_format(&data, &original, format, write_layout)?;
```

Flowchart formats auto-apply hierarchical layout when no existing positions
are found. Canvas format uses force-directed layout for unpositioned nodes.

## Image Support (`pinstar::image`, feature = `"images"`)

Background image decode worker + LRU cache. Wire it up during init:

```rust
use pinstar::image::{spawn_worker, ImageCache, DecodedImage};

// Spawn the decode worker thread
let (tx, rx) = spawn_worker();
state.image_decode_tx = Some(tx);
state.image_cache = ImageCache::new(state.settings.image_cache_size);

// If using ratatui-image, provide a picker
state.image_picker = Some(ratatui_image::picker::Picker::from_query_stdio()?);

// In your event loop, poll for completed decodes
while let Ok(result) = rx.try_recv() {
    if let (Ok(img), Some(picker)) = (result, state.image_picker.as_ref()) {
        state.image_cache.install_decoded(img, picker);
    }
}
```

## Additional Public Modules

| Module | Key exports | Purpose |
|---|---|---|
| `camera` | `zoom_step`, `pan_centered`, `clamp_world`, `nearest_in_dir` | Viewport math |
| `grid` | `CanvasGridProjection`, `draw_canvas_grid` | Adaptive dot grid |
| `selection` | `Selection<Id>` | Multi-select with primary + extras |
| `overlay` | `MarqueeState`, `draw_canvas_rect_filled`, `muted_canvas_selection_fill` | Selection visuals |
| `menu` | `PinstarContextMenu`, `PinstarMenuType`, `MenuItemSpec`, `menu_specs` | Context menu definitions |
| `help` | Help text content | Keyboard/mouse/format help tabs |
| `textsel` | `MouseTextSelection` | Mouse-driven text selection |
| `theme` | `ThemeColors`, `parse_hex_color`, `get_node_color`, `get_edge_color` | Theme + color utilities |

## Constants

```rust
// Color picker palette shared between canvas and draw views
pub const COLOR_PICKER_PALETTE: &[(&str, &str, Color)] = &[...];

// Delay before re-rendering pixel images after zoom/pan/resize
pub const TRANSFORM_SETTLE: Duration = Duration::from_millis(150);
```

## Utilities

```rust
// Atomic file write (temp + rename) to prevent corruption
pub fn atomic_write(path: &Path, content: &str) -> Result<()>;
```

## Integration Pattern (clin-rs reference)

clin-rs wraps pinstar in a `PinstarPlugin` struct that owns:
- `PinstarState` — the engine state
- Host keybinds + sequence matcher
- Image decode receiver

The integration flow:

1. **Init**: `PinstarState::load(path)` → configure `settings` → `spawn_worker()` → wire `image_decode_tx` and `image_picker`
2. **Render**: call `draw_pinstar_view` → paint host footer over the reserved bottom row
3. **Key events**: map host `CanvasAction` → `PinstarAction` via a 1:1 mapping function → `apply_action` → handle `ActionOutcome`
4. **Mouse events**: set `state.mouse_pos` → `handle_pinstar_mouse` → handle `MouseOutcome` (clipboard, notices, image picker trigger)
5. **Save**: on `ActionOutcome::host_action == Save`, call `state.save()`
6. **Host dialogs**: when `AddImageNode` returned or `trigger_image_picker` set, open file picker → `state.add_image_node_with(path, x, y)` → `state.sync_to_raw_editor()`

```rust
// Minimal host action → lib action mapping (shared subset)
fn to_lib_action(action: CanvasAction) -> PinstarAction {
    match action {
        CanvasAction::Quit       => PinstarAction::Quit,
        CanvasAction::Save       => PinstarAction::Save,
        CanvasAction::Help       => PinstarAction::Help,
        CanvasAction::Undo       => PinstarAction::Undo,
        CanvasAction::Redo       => PinstarAction::Redo,
        CanvasAction::ZoomIn     => PinstarAction::ZoomIn,
        CanvasAction::ZoomOut    => PinstarAction::ZoomOut,
        CanvasAction::MoveUp     => PinstarAction::MoveUp,
        CanvasAction::MoveDown   => PinstarAction::MoveDown,
        CanvasAction::MoveLeft   => PinstarAction::MoveLeft,
        CanvasAction::MoveRight  => PinstarAction::MoveRight,
        CanvasAction::AddTextNode => PinstarAction::AddTextNode,
        CanvasAction::DeleteNode => PinstarAction::DeleteNode,
        // ... remaining 1:1 mappings
    }
}
```