From ef675c340721652f0d75b999f32e06663446d31a Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Sun, 10 May 2026 22:01:02 +0200 Subject: [PATCH 01/23] Live-reload: 3-way merge keeps external box-position edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External edits (e.g. AI-driven Write/Edit on a .grafli file) were silently discarded for box positions because _on_file_changed unconditionally restored the in-memory positions over the new disk ones. Notes/images/arrows worked fine — only boxes had this override, intended to preserve in-app drags. Now we 3-way merge: in-memory position survives only when the disk position itself didn't change. --- grafli/app.py | 17 +++++++------ grafli/format.py | 28 +++++++++++++++++++++ tests/test_format.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/grafli/app.py b/grafli/app.py index ae07aaf..d3c06ff 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -28,7 +28,7 @@ from grafli.buffers import BufferManager, BufferState, ViewState from grafli.constants import Mode from grafli.filewatcher import JsonSafeWatcher -from grafli.format import Board, parse, serialize +from grafli.format import Board, merge_box_positions, parse, serialize from grafli.fuzzy import FuzzyItem, FuzzyOverlay from grafli.sidepanel import PanelToggleButton, SidePanel from grafli.view import GrafliView @@ -621,13 +621,16 @@ def _on_file_changed(self): new_board = parse(text) + # External edits (e.g. AI tools writing the file) must update box + # positions on screen. The in-memory positions may differ from + # disk because the user dragged boxes in-app — keep those drags + # only when the disk position itself didn't change. if self.board: - old_positions = { - b.id: (b.x, b.y) for b in self.board.boxes - } - for box in new_board.boxes: - if box.id in old_positions: - box.x, box.y = old_positions[box.id] + try: + prev_disk = parse(self._last_written) if self._last_written else None + except Exception: + prev_disk = None + merge_box_positions(new_board, prev_disk, self.board) self._view.load_board(new_board) self._view.mark_clean() diff --git a/grafli/format.py b/grafli/format.py index b49094e..349a619 100644 --- a/grafli/format.py +++ b/grafli/format.py @@ -578,3 +578,31 @@ def serialize_to_file(board: Board, path: str) -> None: """Write a Board to a .grafli file on disk.""" with open(path, "w", encoding="utf-8") as f: f.write(serialize(board)) + + +def merge_box_positions( + new_board: Board, prev_disk: Board | None, in_memory: Board, +) -> Board: + """3-way merge of box positions for live-reload after external edits. + + For each box in ``new_board``, keep the ``in_memory`` position only + when the previous disk content had it at the same place as the new + disk content (user dragged in-app, external edit didn't touch the + position). When the disk position changed externally, accept it — + otherwise external position edits are silently discarded. + + Mutates and returns ``new_board``. Notes / images / arrows are + untouched (their positions already round-trip cleanly via the + file watcher). + """ + prev_pos = ( + {b.id: (b.x, b.y) for b in prev_disk.boxes} if prev_disk else {} + ) + mem_pos = {b.id: (b.x, b.y) for b in in_memory.boxes} + for box in new_board.boxes: + was = prev_pos.get(box.id) + now = (box.x, box.y) + mem = mem_pos.get(box.id) + if was is not None and was == now and mem is not None and mem != was: + box.x, box.y = mem + return new_board diff --git a/tests/test_format.py b/tests/test_format.py index 0490e94..fe14e99 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -9,6 +9,7 @@ Board, Box, Note, + merge_box_positions, parse, parse_file, serialize, @@ -1367,3 +1368,62 @@ def test_note_wrap_chars_explicit_default_is_emitted(): board = parse('@ note n1 0,0 "hello" ~width=80') assert board.notes[0].wrap_chars_explicit is True assert "~width=80" in serialize(board) + + +# ── merge_box_positions: live-reload 3-way merge ────────────────── + + +def _board_with_box(bid: str, x: float, y: float) -> Board: + return Board(boxes=[Box(id=bid, label="X", x=x, y=y, w=100, h=50)]) + + +def test_merge_external_position_change_wins(): + """External edit moved a box. In-mem still has the previous disk pos. + The new disk position must survive the merge — this is the bug fix.""" + prev_disk = _board_with_box("a", 0, 0) + in_memory = _board_with_box("a", 0, 0) # never dragged + new_disk = _board_with_box("a", 500, 500) # external edit + merged = merge_box_positions(new_disk, prev_disk, in_memory) + assert (merged.boxes[0].x, merged.boxes[0].y) == (500, 500) + + +def test_merge_in_app_drag_preserved_when_disk_unchanged(): + """User dragged a box in-app. External edit only changed something + else (e.g. label). The in-app drag must NOT be reverted.""" + prev_disk = _board_with_box("a", 0, 0) + in_memory = _board_with_box("a", 200, 100) # user drag + new_disk = _board_with_box("a", 0, 0) # disk pos unchanged + merged = merge_box_positions(new_disk, prev_disk, in_memory) + assert (merged.boxes[0].x, merged.boxes[0].y) == (200, 100) + + +def test_merge_disk_wins_when_both_changed(): + """Conflict: user dragged AND external edit moved the box. + Disk wins (deterministic, matches 'external editor wins').""" + prev_disk = _board_with_box("a", 0, 0) + in_memory = _board_with_box("a", 200, 100) + new_disk = _board_with_box("a", 999, 999) + merged = merge_box_positions(new_disk, prev_disk, in_memory) + assert (merged.boxes[0].x, merged.boxes[0].y) == (999, 999) + + +def test_merge_handles_no_prev_disk(): + """First load — no prior disk content. new disk wins by default.""" + in_memory = _board_with_box("a", 200, 100) + new_disk = _board_with_box("a", 0, 0) + merged = merge_box_positions(new_disk, None, in_memory) + assert (merged.boxes[0].x, merged.boxes[0].y) == (0, 0) + + +def test_merge_handles_new_box_added_externally(): + """A new box appears on disk that wasn't in memory before. + It should land at the disk position (no in-mem to merge from).""" + prev_disk = _board_with_box("a", 0, 0) + in_memory = _board_with_box("a", 0, 0) + new_disk = Board(boxes=[ + Box(id="a", label="X", x=0, y=0, w=100, h=50), + Box(id="b", label="Y", x=300, y=300, w=100, h=50), + ]) + merged = merge_box_positions(new_disk, prev_disk, in_memory) + new_b = next(b for b in merged.boxes if b.id == "b") + assert (new_b.x, new_b.y) == (300, 300) From b2f34b7e197827c91744b671dde2ef2df29f4bc2 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Sun, 10 May 2026 22:01:20 +0200 Subject: [PATCH 02/23] Minimap: paint parent boxes before children so nested boxes are visible --- grafli/minimap.py | 30 ++++++++++++++++++++++++-- tests/test_minimap.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/test_minimap.py diff --git a/grafli/minimap.py b/grafli/minimap.py index 4ffb380..8bbd20b 100644 --- a/grafli/minimap.py +++ b/grafli/minimap.py @@ -33,6 +33,31 @@ _TIER_LABELS = ("Simple", "Moderate", "Intricate", "Dense") +def _box_depth_order(boxes): + """Return ``boxes`` ordered by parent-chain depth (top-level first). + + Parents must paint before children so the minimap's solid fill + doesn't hide nested boxes (parents are usually declared after + their children in `.grafli` files). + + Cyclic parent refs are tolerated — the chain walk bails on + revisits and the offending box gets a stable but arbitrary depth. + """ + by_id = {b.id: b for b in boxes} + + def depth(box): + d = 0 + cur = box + seen = {cur.id} + while cur.parent and cur.parent in by_id and cur.parent not in seen: + cur = by_id[cur.parent] + seen.add(cur.id) + d += 1 + return d + + return sorted(boxes, key=depth) + + class MinimapMixin: """Mixin providing minimap rendering and click-to-navigate. @@ -225,9 +250,10 @@ def _draw_minimap(self, painter: QPainter): dimmed_ids: set[str] = getattr(self, "_search_dimmed_ids", set()) or set() dim_alpha = 50 - # Draw boxes + # Draw boxes — top-level parents first so nested children + # render on top instead of being covered by the parent's fill. painter.setPen(Qt.PenStyle.NoPen) - for box in self._board.boxes: + for box in _box_depth_order(self._board.boxes): color_hex = _resolve_color(box.color) if box.color else "" if color_hex: c = QColor(color_hex) diff --git a/tests/test_minimap.py b/tests/test_minimap.py new file mode 100644 index 0000000..0ff4c3b --- /dev/null +++ b/tests/test_minimap.py @@ -0,0 +1,49 @@ +"""Tests for grafli.minimap helpers (Qt-free portion).""" + +from grafli.format import Box +from grafli.minimap import _box_depth_order + + +def _b(bid: str, parent: str = "") -> Box: + return Box(id=bid, label=bid, x=0, y=0, w=10, h=10, parent=parent) + + +def test_top_level_only_keeps_input_order_stable(): + a, b, c = _b("a"), _b("b"), _b("c") + assert _box_depth_order([a, b, c]) == [a, b, c] + + +def test_parent_drawn_before_children_regardless_of_input_order(): + """Parent declared AFTER children in the file (typical grafli) must + still be drawn first so the minimap doesn't paint over them.""" + child1 = _b("c1", parent="p") + child2 = _b("c2", parent="p") + parent = _b("p") + ordered = _box_depth_order([child1, child2, parent]) + assert ordered[0].id == "p" + assert {b.id for b in ordered[1:]} == {"c1", "c2"} + + +def test_nested_parents_ordered_by_depth(): + grandparent = _b("g") + parent = _b("p", parent="g") + leaf = _b("l", parent="p") + ordered = _box_depth_order([leaf, parent, grandparent]) + assert [b.id for b in ordered] == ["g", "p", "l"] + + +def test_cyclic_parent_refs_do_not_loop_forever(): + a = _b("a", parent="b") + b = _b("b", parent="a") + # Should return some order, not hang. + ordered = _box_depth_order([a, b]) + assert {x.id for x in ordered} == {"a", "b"} + + +def test_dangling_parent_ref_treated_as_top_level(): + """A box pointing at a non-existent parent should be at depth 0.""" + a = _b("a", parent="ghost") + b = _b("b") + ordered = _box_depth_order([a, b]) + # Both depth 0 — order preserved. + assert ordered == [a, b] From 0176660bce891dccb956bc677a007bb60f41534f Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Sun, 10 May 2026 22:01:29 +0200 Subject: [PATCH 03/23] App: pin window to primary screen so a sleeping external display can't swallow it --- grafli/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/grafli/app.py b/grafli/app.py index d3c06ff..b93b0a6 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -853,6 +853,11 @@ def main(): tick.timeout.connect(lambda: None) window = MainWindow(args.file, debug=args.debug) + # Pin to primary screen so a sleeping/disconnected external display + # cannot swallow the window via macOS's cached window frame. + primary = app.primaryScreen() + if primary is not None: + window.setGeometry(primary.availableGeometry()) window.showMaximized() # Single-instance server From aae71601a685fe50cee37d8e1c91c360b5f1d13a Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Sun, 10 May 2026 22:01:54 +0200 Subject: [PATCH 04/23] Diagnose: add 'grafli diagnose' static layout checks; SKILL.md workflow update --- grafli/app.py | 72 ++++++++- grafli/diagnostics.py | 267 ++++++++++++++++++++++++++++++++++ grafli/skills/grafli/SKILL.md | 17 +++ tests/test_diagnostics.py | 241 ++++++++++++++++++++++++++++++ 4 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 grafli/diagnostics.py create mode 100644 tests/test_diagnostics.py diff --git a/grafli/app.py b/grafli/app.py index b93b0a6..db91528 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -820,18 +820,84 @@ def _cmd_render(argv: list[str]) -> int: return 0 +def _make_note_rect_provider(): + """Return a callable that computes a note's rendered scene rect. + + Initializes a headless Qt app and registers bundled fonts so + ``QFontMetrics`` returns accurate widths for Patrick Hand. The + provider mirrors what ``NoteItem.boundingRect()`` would produce + on screen, so geometric checks see the rect users actually see. + """ + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + QApplication.instance() or QApplication([]) + _register_bundled_fonts() + from grafli.items import NoteItem + + def provider(note): + item = NoteItem(note) + br = item.boundingRect() + return (note.x, note.y, note.x + br.width(), note.y + br.height()) + + return provider + + +def _cmd_diagnose(argv: list[str]) -> int: + import json as _json + from grafli.diagnostics import run_all + + parser = argparse.ArgumentParser( + prog="grafli diagnose", + description=( + "Run static layout diagnostics on a .grafli file. " + "Surfaces children outside parents, sibling overlaps, cramped " + "containers, likely-truncated labels, and missing linked resources." + ), + ) + parser.add_argument("input", type=Path, help="Input .grafli file") + parser.add_argument( + "--json", action="store_true", + help="Emit JSON instead of human-readable text", + ) + args = parser.parse_args(argv) + + if not args.input.exists(): + print(f"Input not found: {args.input}", file=sys.stderr) + return 2 + + text = args.input.read_text(encoding="utf-8") + board = parse(text) + note_rect = _make_note_rect_provider() + diags = run_all(board, args.input.resolve().parent, note_rect=note_rect) + + if args.json: + print(_json.dumps([d.to_dict() for d in diags], indent=2)) + return 0 + + if not diags: + print("No findings.") + return 0 + + for d in diags: + suffix = "" if d.fixable else " (may be intentional)" + print(f"[{d.severity}] {d.code}: {d.message}{suffix}") + print(f"\n{len(diags)} finding(s).") + return 0 + + def main(): # Subcommand dispatch — keep the bare `grafli ` form unchanged. - if len(sys.argv) >= 2 and sys.argv[1] in ("skill", "render"): + if len(sys.argv) >= 2 and sys.argv[1] in ("skill", "render", "diagnose"): sub = sys.argv[1] rest = sys.argv[2:] if sub == "skill": sys.exit(_cmd_skill(rest)) - sys.exit(_cmd_render(rest)) + if sub == "render": + sys.exit(_cmd_render(rest)) + sys.exit(_cmd_diagnose(rest)) parser = argparse.ArgumentParser( prog="grafli", - description="Grafli whiteboard. Subcommands: skill, render.", + description="Grafli whiteboard. Subcommands: skill, render, diagnose.", ) parser.add_argument("file", nargs="?", default=None, help="File to open") parser.add_argument("--debug", action="store_true", help="Enable debug overlay") diff --git a/grafli/diagnostics.py b/grafli/diagnostics.py new file mode 100644 index 0000000..987b70e --- /dev/null +++ b/grafli/diagnostics.py @@ -0,0 +1,267 @@ +"""Static layout diagnostics for `.grafli` files. + +Pure functions that take a parsed `Board` (and optionally the source +file's directory for resource resolution) and return a list of +`Diagnostic` records. Used by `grafli diagnose` and exposed for tests. + +The checks here are intentionally heuristic. They surface likely +layout problems but cannot be perfectly precise without running Qt's +layout engine — agents should treat findings as guidance, not gates. +""" + +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Callable, Optional + +from grafli.constants import BOX_FONT_SIZES, LAYOUT_PADDING +from grafli.format import Board, Box, Image, Note + + +# Optional callable that returns a note's rendered scene rect +# (x1, y1, x2, y2). When supplied (typically from the CLI, which has +# Qt available), notes participate in geometric checks. When None, +# notes are skipped — keeps the pure-function tests fast. +NoteRectFn = Callable[[Note], Optional[tuple]] + + +ERROR = "error" +WARNING = "warning" +INFO = "info" + +_SEVERITY_ORDER = {ERROR: 0, WARNING: 1, INFO: 2} + + +@dataclass +class Diagnostic: + code: str + severity: str + message: str + item_ids: list[str] = field(default_factory=list) + fixable: bool = True + + def to_dict(self) -> dict: + return asdict(self) + + +def _box_rect(b: Box) -> tuple[float, float, float, float]: + return (b.x, b.y, b.x + b.w, b.y + b.h) + + +def _image_rect(im: Image) -> tuple[float, float, float, float]: + return (im.x, im.y, im.x + im.w, im.y + im.h) + + +def _rect_contains(outer: tuple, inner: tuple) -> bool: + ox1, oy1, ox2, oy2 = outer + ix1, iy1, ix2, iy2 = inner + return ix1 >= ox1 and iy1 >= oy1 and ix2 <= ox2 and iy2 <= oy2 + + +def _rects_overlap(a: tuple, b: tuple) -> bool: + ax1, ay1, ax2, ay2 = a + bx1, by1, bx2, by2 = b + return ax1 < bx2 and ax2 > bx1 and ay1 < by2 and ay2 > by1 + + +def _items_with_rects(board: Board, note_rect: NoteRectFn | None = None): + """Yield (item, id, rect) for items with computable geometry. + + Notes are included only when ``note_rect`` is provided — typically + from the CLI, which has Qt available to measure actual font + metrics. Without it, notes are skipped (a heuristic rect would + create false positives). + """ + for b in board.boxes: + yield b, b.id, _box_rect(b) + for im in board.images: + yield im, im.id, _image_rect(im) + if note_rect is not None: + for n in board.notes: + r = note_rect(n) + if r is not None: + yield n, n.id, r + + +def check_child_outside_parent( + board: Board, note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + diags: list[Diagnostic] = [] + boxes_by_id = {b.id: b for b in board.boxes} + for item, iid, rect in _items_with_rects(board, note_rect): + parent_id = getattr(item, "parent", "") + if not parent_id: + continue + parent = boxes_by_id.get(parent_id) + if parent is None: + diags.append(Diagnostic( + code="invalid-parent-ref", + severity=ERROR, + message=f"{iid!r} declares parent {parent_id!r} which does not exist", + item_ids=[iid], + fixable=True, + )) + continue + if not _rect_contains(_box_rect(parent), rect): + diags.append(Diagnostic( + code="child-outside-parent", + severity=WARNING, + message=f"{iid!r} is positioned outside its parent {parent_id!r}", + item_ids=[iid, parent_id], + fixable=True, + )) + return diags + + +def check_sibling_overlap( + board: Board, note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + diags: list[Diagnostic] = [] + items = list(_items_with_rects(board, note_rect)) + groups: dict[str, list] = {} + for item, iid, rect in items: + groups.setdefault(getattr(item, "parent", ""), []).append((iid, rect)) + for _parent_id, group in groups.items(): + for i in range(len(group)): + ai, ar = group[i] + for j in range(i + 1, len(group)): + bi, br = group[j] + if _rects_overlap(ar, br): + diags.append(Diagnostic( + code="sibling-overlap", + severity=WARNING, + message=f"{ai!r} overlaps {bi!r} (same containment level)", + item_ids=[ai, bi], + fixable=True, + )) + return diags + + +def check_cramped_container( + board: Board, + min_padding: float = LAYOUT_PADDING, + note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + """Flag parent boxes whose children sit tight against the inner edge.""" + diags: list[Diagnostic] = [] + children_by_parent: dict[str, list] = {} + for _item, _iid, rect in _items_with_rects(board, note_rect): + pid = getattr(_item, "parent", "") + if pid: + children_by_parent.setdefault(pid, []).append(rect) + boxes_by_id = {b.id: b for b in board.boxes} + for pid, child_rects in children_by_parent.items(): + parent = boxes_by_id.get(pid) + if parent is None or not child_rects: + continue + cx1 = min(r[0] for r in child_rects) + cy1 = min(r[1] for r in child_rects) + cx2 = max(r[2] for r in child_rects) + cy2 = max(r[3] for r in child_rects) + px1, py1, px2, py2 = _box_rect(parent) + worst = min(cx1 - px1, px2 - cx2, cy1 - py1, py2 - cy2) + if 0 <= worst < min_padding: + diags.append(Diagnostic( + code="cramped-container", + severity=INFO, + message=( + f"{pid!r} children are tight against the edge " + f"(min padding ~{worst:.0f}px, recommended >= {min_padding:.0f}px)" + ), + item_ids=[pid], + fixable=True, + )) + return diags + + +def check_label_truncated(board: Board) -> list[Diagnostic]: + """Heuristic: estimate text width and warn when it likely won't fit.""" + diags: list[Diagnostic] = [] + AVG_CHAR_FACTOR = 0.6 # JetBrains Mono is ~0.6em per char + H_PADDING = 16 # left+right padding inside box + TOLERANCE = 1.05 # 5% slack — only complain when clearly over + for b in board.boxes: + if not b.label: + continue + font_px = BOX_FONT_SIZES.get(b.textsize, BOX_FONT_SIZES[""]) + longest = max((len(line) for line in b.label.split("\n")), default=0) + if longest == 0: + continue + est_w = longest * font_px * AVG_CHAR_FACTOR + avail_w = b.w - H_PADDING + if avail_w > 0 and est_w > avail_w * TOLERANCE: + diags.append(Diagnostic( + code="label-truncated", + severity=INFO, + message=( + f"{b.id!r} label ({longest} chars at size " + f"{b.textsize or 'default'}) may not fit width " + f"{b.w:.0f}px (estimate ~{est_w:.0f}px)" + ), + item_ids=[b.id], + fixable=True, + )) + return diags + + +# Captures `@:` where path looks like a file (has an +# extension). Trailing `:line` / `:anchor` is consumed to keep matching +# tight, but the anchor itself is not validated. +_REF_RE = re.compile(r"@([^\s@]+\.[A-Za-z0-9]+)(?::[^\s]+)?") + + +def check_missing_resource( + board: Board, base_dir: Path | None +) -> list[Diagnostic]: + """Check `Image.image_path` and `@path[:anchor]` refs in text content.""" + diags: list[Diagnostic] = [] + if base_dir is None: + return diags + base = Path(base_dir) + for im in board.images: + if not im.image_path: + continue + if not (base / im.image_path).exists(): + diags.append(Diagnostic( + code="missing-resource", + severity=WARNING, + message=f"image {im.id!r} -> {im.image_path!r} not found", + item_ids=[im.id], + fixable=False, + )) + sources: list[tuple[str, str]] = [] + for b in board.boxes: + sources.append((b.id, b.label or "")) + for n in board.notes: + sources.append((n.id, n.text or "")) + for a in board.arrows: + sources.append((f"{a.from_id}->{a.to_id}", a.label or "")) + for iid, text in sources: + for m in _REF_RE.finditer(text): + ref_path = m.group(1) + if not (base / ref_path).exists(): + diags.append(Diagnostic( + code="missing-resource", + severity=INFO, + message=f"{iid!r} references {ref_path!r} which does not exist", + item_ids=[iid], + fixable=False, + )) + return diags + + +def run_all( + board: Board, + base_dir: Path | None = None, + note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + diags: list[Diagnostic] = [] + diags.extend(check_child_outside_parent(board, note_rect=note_rect)) + diags.extend(check_sibling_overlap(board, note_rect=note_rect)) + diags.extend(check_cramped_container(board, note_rect=note_rect)) + diags.extend(check_label_truncated(board)) + diags.extend(check_missing_resource(board, base_dir)) + diags.sort(key=lambda d: (_SEVERITY_ORDER.get(d.severity, 99), d.code, d.item_ids)) + return diags diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md index 59cb6c4..d1755f5 100644 --- a/grafli/skills/grafli/SKILL.md +++ b/grafli/skills/grafli/SKILL.md @@ -53,6 +53,23 @@ any `@ box` / `@ arrow` / `@ note` lines: obvious in the rendered image but invisible in the source. Render after every non-trivial edit, fix what you see, render again. Don't declare a diagram done without at least one render-and-look pass. +10. **Diagnose.** Run `grafli diagnose .grafli` (add `--json` + for machine-readable output) for static checks the eye misses: + children outside parents, sibling overlaps, cramped containers, + likely-truncated labels, missing `@path` / image refs. Each + finding carries a `fixable` flag and a `severity`: + + * `severity: error` (e.g. `invalid-parent-ref`) — always fix. + * `fixable: true` — usually a real geometry mistake. Try to fix. + * `fixable: false` — heuristic or possibly-intentional (a + placeholder reference, an artistic crowding choice). + Acknowledge once and move on. + + **One pass, then stop.** Run diagnose, address the obvious + findings, run it once more to confirm. If the same warnings + persist, accept them as known limitations and ship — do not + keep reshuffling the diagram trying to drive the count to zero. + Diagnostics are guidance, not a gate. ## File format quick reference diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..af8568e --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,241 @@ +"""Tests for grafli.diagnostics — static layout checks.""" + +import os +import tempfile +from pathlib import Path + +from grafli.diagnostics import ( + Diagnostic, + check_child_outside_parent, + check_cramped_container, + check_label_truncated, + check_missing_resource, + check_sibling_overlap, + run_all, +) +from grafli.format import Arrow, Board, Box, Image, Note + + +def _make_board(boxes=(), notes=(), arrows=(), images=()): + return Board( + boxes=list(boxes), + notes=list(notes), + arrows=list(arrows), + images=list(images), + ) + + +# ── child-outside-parent ─────────────────────────────────────── + +def test_child_inside_parent_is_clean(): + parent = Box(id="p", label="P", x=0, y=0, w=400, h=400) + child = Box(id="c", label="C", x=50, y=50, w=100, h=100, parent="p") + diags = check_child_outside_parent(_make_board(boxes=[parent, child])) + assert diags == [] + + +def test_child_outside_parent_flagged(): + parent = Box(id="p", label="P", x=0, y=0, w=200, h=200) + child = Box(id="c", label="C", x=300, y=300, w=50, h=50, parent="p") + diags = check_child_outside_parent(_make_board(boxes=[parent, child])) + codes = [d.code for d in diags] + assert "child-outside-parent" in codes + flagged = next(d for d in diags if d.code == "child-outside-parent") + assert flagged.item_ids == ["c", "p"] + + +def test_invalid_parent_ref_is_error(): + child = Box(id="c", label="C", x=0, y=0, w=10, h=10, parent="ghost") + diags = check_child_outside_parent(_make_board(boxes=[child])) + assert len(diags) == 1 + assert diags[0].code == "invalid-parent-ref" + assert diags[0].severity == "error" + + +# ── sibling-overlap ──────────────────────────────────────────── + +def test_non_overlapping_siblings_are_clean(): + a = Box(id="a", label="A", x=0, y=0, w=100, h=100) + b = Box(id="b", label="B", x=200, y=0, w=100, h=100) + diags = check_sibling_overlap(_make_board(boxes=[a, b])) + assert diags == [] + + +def test_overlapping_siblings_flagged(): + a = Box(id="a", label="A", x=0, y=0, w=100, h=100) + b = Box(id="b", label="B", x=50, y=50, w=100, h=100) + diags = check_sibling_overlap(_make_board(boxes=[a, b])) + assert len(diags) == 1 + assert diags[0].code == "sibling-overlap" + assert set(diags[0].item_ids) == {"a", "b"} + + +def test_overlap_only_within_same_parent(): + p1 = Box(id="p1", label="P1", x=0, y=0, w=100, h=100) + p2 = Box(id="p2", label="P2", x=200, y=0, w=100, h=100) + # Children that would overlap *if* they shared a parent, but don't. + a = Box(id="a", label="A", x=10, y=10, w=20, h=20, parent="p1") + b = Box(id="b", label="B", x=210, y=10, w=20, h=20, parent="p2") + diags = check_sibling_overlap(_make_board(boxes=[p1, p2, a, b])) + # p1, p2 don't overlap; a/b are in different parents. + assert diags == [] + + +# ── cramped-container ────────────────────────────────────────── + +def test_roomy_container_is_clean(): + parent = Box(id="p", label="P", x=0, y=0, w=400, h=400) + child = Box(id="c", label="C", x=100, y=100, w=100, h=100, parent="p") + diags = check_cramped_container(_make_board(boxes=[parent, child])) + assert diags == [] + + +def test_cramped_container_flagged(): + parent = Box(id="p", label="P", x=0, y=0, w=200, h=200) + # child sits 5px from each inner edge — well below LAYOUT_PADDING (20) + child = Box(id="c", label="C", x=5, y=5, w=190, h=190, parent="p") + diags = check_cramped_container(_make_board(boxes=[parent, child])) + assert len(diags) == 1 + assert diags[0].code == "cramped-container" + assert diags[0].item_ids == ["p"] + + +# ── label-truncated ──────────────────────────────────────────── + +def test_short_label_is_clean(): + b = Box(id="b", label="OK", x=0, y=0, w=160, h=80) + diags = check_label_truncated(_make_board(boxes=[b])) + assert diags == [] + + +def test_long_label_in_narrow_box_flagged(): + long_label = "this is a very long label that will not fit a tiny box" + b = Box(id="b", label=long_label, x=0, y=0, w=60, h=40) + diags = check_label_truncated(_make_board(boxes=[b])) + assert len(diags) == 1 + assert diags[0].code == "label-truncated" + assert diags[0].item_ids == ["b"] + + +def test_label_uses_longest_line_for_multiline(): + # Short first line, long second line — should still flag. + b = Box(id="b", label="ok\n" + "x" * 200, x=0, y=0, w=80, h=80) + diags = check_label_truncated(_make_board(boxes=[b])) + assert any(d.code == "label-truncated" for d in diags) + + +# ── missing-resource ─────────────────────────────────────────── + +def test_missing_image_path_flagged(tmp_path): + im = Image(id="i", image_path="nope.png", x=0, y=0, w=10, h=10) + diags = check_missing_resource(_make_board(images=[im]), tmp_path) + assert len(diags) == 1 + assert diags[0].code == "missing-resource" + assert diags[0].fixable is False + + +def test_existing_image_is_clean(tmp_path): + (tmp_path / "ok.png").write_bytes(b"\x89PNG fake") + im = Image(id="i", image_path="ok.png", x=0, y=0, w=10, h=10) + diags = check_missing_resource(_make_board(images=[im]), tmp_path) + assert diags == [] + + +def test_at_path_ref_flagged_when_missing(tmp_path): + n = Note(id="n", x=0, y=0, text="see @docs/missing.md:42 for details") + diags = check_missing_resource(_make_board(notes=[n]), tmp_path) + assert any(d.code == "missing-resource" and "n" in d.item_ids for d in diags) + + +def test_at_path_ref_clean_when_exists(tmp_path): + target = tmp_path / "docs" + target.mkdir() + (target / "guide.md").write_text("hello") + n = Note(id="n", x=0, y=0, text="see @docs/guide.md:42") + diags = check_missing_resource(_make_board(notes=[n]), tmp_path) + assert diags == [] + + +def test_resource_check_is_noop_without_base_dir(): + n = Note(id="n", x=0, y=0, text="see @does/not/matter.md") + assert check_missing_resource(_make_board(notes=[n]), None) == [] + + +# ── run_all ──────────────────────────────────────────────────── + +def test_run_all_returns_sorted_by_severity(tmp_path): + # An error (invalid parent) and a warning (overlap) — error first. + parent = Box(id="p", label="P", x=0, y=0, w=100, h=100) + a = Box(id="a", label="A", x=0, y=0, w=50, h=50) + b = Box(id="b", label="B", x=20, y=20, w=50, h=50) + ghost = Box(id="g", label="G", x=300, y=300, w=10, h=10, parent="ghost") + diags = run_all(_make_board(boxes=[parent, a, b, ghost]), tmp_path) + severities = [d.severity for d in diags] + # First diagnostic should be the error. + assert severities[0] == "error" + # Sorted: errors before warnings. + assert severities == sorted(severities, key=lambda s: {"error": 0, "warning": 1, "info": 2}[s]) + + +def test_diagnostic_to_dict_is_json_safe(): + d = Diagnostic(code="x", severity="info", message="m", item_ids=["a"]) + assert d.to_dict() == { + "code": "x", + "severity": "info", + "message": "m", + "item_ids": ["a"], + "fixable": True, + } + + +# ── Qt-backed: note rect provider ────────────────────────────── + +def _qt_note_rect_provider(): + """Real provider using NoteItem — matches CLI behavior.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication([]) + from grafli.app import _register_bundled_fonts + from grafli.items import NoteItem + _register_bundled_fonts() + + def provider(note): + item = NoteItem(note) + br = item.boundingRect() + return (note.x, note.y, note.x + br.width(), note.y + br.height()) + + return provider + + +def test_note_overlap_with_box_detected_via_provider(): + # Note placed on top of a box. Without a provider, overlap is + # invisible to the checker; with the provider, it's flagged. + box = Box(id="b", label="B", x=0, y=0, w=200, h=200) + note = Note(id="n", x=50, y=50, text="T: a sticky on the box") + board = _make_board(boxes=[box], notes=[note]) + + # Without provider — note is skipped. + assert check_sibling_overlap(board) == [] + + # With provider — note rect is real, overlap surfaces. + diags = check_sibling_overlap(board, note_rect=_qt_note_rect_provider()) + assert any( + d.code == "sibling-overlap" and set(d.item_ids) == {"b", "n"} + for d in diags + ) + + +def test_note_outside_parent_detected_via_provider(): + parent = Box(id="p", label="P", x=0, y=0, w=100, h=100) + # Note positioned at (500, 500) — clearly outside the parent box. + note = Note(id="n", x=500, y=500, text="far away", parent="p") + board = _make_board(boxes=[parent], notes=[note]) + + # Without provider — invisible. + assert check_child_outside_parent(board) == [] + + # With provider — flagged. + diags = check_child_outside_parent( + board, note_rect=_qt_note_rect_provider() + ) + assert any(d.code == "child-outside-parent" and "n" in d.item_ids for d in diags) From 97120c3f7c4be90b95ea60eb49fe10c1cba5f035 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Sun, 10 May 2026 22:01:59 +0200 Subject: [PATCH 05/23] Showcase: tighten positions and add ~width on discussion/code notes --- examples/showcase.grafli | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/showcase.grafli b/examples/showcase.grafli index 3f18de8..e1f4cdf 100644 --- a/examples/showcase.grafli +++ b/examples/showcase.grafli @@ -38,7 +38,7 @@ @ note bake_code 433.6683666702927,217.99831588774094 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread" @ arrow bake_code -- bake -@ note qa_flour 1425.0361010670406,379.3377553014274 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day." +@ note qa_flour 1400,380 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day." ~width=61 # ============================================================ # Region 2 — Threat reaction (annotations) @@ -66,18 +66,18 @@ @ note todo -5.769576575522848,1340.638539578403 "T: add audible cue when sense triggers" @ note q1 340,1340 "Q: should fear scale with NPC level?" -@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." -@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." -@ note assess_code 73.40171656199334,1133.8915636581614 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88" +@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54 +@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47 +@ note assess_code 20,1140 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88" ~width=38 @ arrow assess_code -- assess # ============================================================ # Region 3 — Town life (heatmap + jump-labels) # ============================================================ -@ note title3 -440,1740 "Town life" ~xxlarge +@ note title3 -440,1640 "Town life" ~xxlarge ~width=10 -@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small +@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39 # NPCs (column 1) @ box baker "Baker" -31.225890102333537,1830.8493366059504 180x80 %accent @@ -91,7 +91,7 @@ @ box smithy "Smithy" 169.56183325412547,2126.6826719758637 180x80 %tertiary @ box inn "Inn" -259.275277523341,2183.4636336263507 180x80 %tertiary @ box gate "Town gate" 43.616533258049714,2475.10969764214 180x80 %tertiary -@ box temple "Temple" -397.0434479605445,2585.274276046725 180x80 %tertiary +@ box temple "Temple" -400,2600 180x80 %tertiary # Shared spaces (column 3) @ box market "Market" -455.0965933225317,1916.1598782874626 180x80 %highlight @@ -129,5 +129,6 @@ @ arrow trade -> smith "event: stock_tools" @ arrow trade -> innkeep "event: extra_rooms" -@ note qa_busiest -223.07146245985234,1742.9126091537087 "Q: who is the busiest NPC?\nA: Priest — temple shift + well + square + festival + raid. \nFive edges, the brightest hotspot in the heatmap." +@ note qa_busiest -220,1720 "Q: who is the busiest NPC?\nA: Priest — temple shift + well + square + festival + raid. \nFive edges, the brightest hotspot in the heatmap." ~width=106 @ arrow disc -> fight +@ note n1 1400,300 "This is some interesting text which is quite long but I want to have it wrapping among multiple lines, does this work?" ~width=61 From 05c0dfe2564ab952cab64c5f724ab5cbbc1da27a Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Mon, 11 May 2026 09:52:30 +0200 Subject: [PATCH 06/23] Diagnose: arrow-label-crowded and arrow-label-covers-head checks --- grafli/app.py | 32 ++++++- grafli/diagnostics.py | 170 +++++++++++++++++++++++++++++++++- grafli/skills/grafli/SKILL.md | 5 +- tests/test_diagnostics.py | 109 ++++++++++++++++++++++ 4 files changed, 311 insertions(+), 5 deletions(-) diff --git a/grafli/app.py b/grafli/app.py index db91528..294fea8 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -841,6 +841,30 @@ def provider(note): return provider +def _make_arrow_label_size_provider(): + """Return a callable that returns an arrow label's rendered size.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + QApplication.instance() or QApplication([]) + _register_bundled_fonts() + from PySide6.QtGui import QFont, QFontMetricsF + from grafli.constants import ARROW_LABEL_FONT_SIZES, FONT_FAMILY + + def provider(arrow): + font = QFont( + FONT_FAMILY, + ARROW_LABEL_FONT_SIZES.get(arrow.textsize, ARROW_LABEL_FONT_SIZES[""]), + ) + fm = QFontMetricsF(font) + text = arrow.label or "" + if not text: + return (0.0, 0.0) + longest_w = max(fm.horizontalAdvance(line) for line in text.split("\n")) + height = fm.height() * max(1, len(text.split("\n"))) + return (longest_w, height) + + return provider + + def _cmd_diagnose(argv: list[str]) -> int: import json as _json from grafli.diagnostics import run_all @@ -867,7 +891,13 @@ def _cmd_diagnose(argv: list[str]) -> int: text = args.input.read_text(encoding="utf-8") board = parse(text) note_rect = _make_note_rect_provider() - diags = run_all(board, args.input.resolve().parent, note_rect=note_rect) + arrow_label_size = _make_arrow_label_size_provider() + diags = run_all( + board, + args.input.resolve().parent, + note_rect=note_rect, + arrow_label_size=arrow_label_size, + ) if args.json: print(_json.dumps([d.to_dict() for d in diags], indent=2)) diff --git a/grafli/diagnostics.py b/grafli/diagnostics.py index 987b70e..2677334 100644 --- a/grafli/diagnostics.py +++ b/grafli/diagnostics.py @@ -11,13 +11,14 @@ from __future__ import annotations +import math import re from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Callable, Optional -from grafli.constants import BOX_FONT_SIZES, LAYOUT_PADDING -from grafli.format import Board, Box, Image, Note +from grafli.constants import ARROWHEAD_SIZE, BOX_FONT_SIZES, LAYOUT_PADDING +from grafli.format import Arrow, Board, Box, Image, Note # Optional callable that returns a note's rendered scene rect @@ -26,6 +27,10 @@ # notes are skipped — keeps the pure-function tests fast. NoteRectFn = Callable[[Note], Optional[tuple]] +# Optional callable returning an arrow label's rendered size (w, h) in +# scene units, using real font metrics. Needed for arrow-label checks. +ArrowLabelSizeFn = Callable[[Arrow], tuple] + ERROR = "error" WARNING = "warning" @@ -206,6 +211,160 @@ def check_label_truncated(board: Board) -> list[Diagnostic]: return diags +def _id_rect_map(board: Board, note_rect: NoteRectFn | None = None) -> dict: + """Return id -> rect for everything an arrow could connect to.""" + out: dict = {} + for _item, iid, rect in _items_with_rects(board, note_rect): + out[iid] = rect + return out + + +def _ray_exits_rect(rect: tuple, target: tuple) -> tuple: + """Where does a ray from rect's center toward ``target`` leave the rect? + + Mirrors what the renderer's ``_rect_edge_point`` does for arrow + endpoints — start of the visible arrow segment. + """ + x1, y1, x2, y2 = rect + cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 + tx, ty = target + dx, dy = tx - cx, ty - cy + if dx == 0 and dy == 0: + return (cx, cy) + hw, hh = (x2 - x1) / 2.0, (y2 - y1) / 2.0 + tx_factor = math.inf if dx == 0 else hw / abs(dx) + ty_factor = math.inf if dy == 0 else hh / abs(dy) + t = min(tx_factor, ty_factor) + return (cx + dx * t, cy + dy * t) + + +def check_arrow_label_crowded( + board: Board, + arrow_label_size: ArrowLabelSizeFn | None = None, + note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + """Flag arrow labels that bleed into an endpoint shape. + + The renderer centers the label on the arrow's visible midpoint, so + when the gap between endpoints is shorter than the label, the label + sits on top of one of the boxes. Detection is grounded in real Qt + font metrics via ``arrow_label_size``; without it, the check is + skipped (heuristic would create false positives). + """ + diags: list[Diagnostic] = [] + if arrow_label_size is None: + return diags + + rects = _id_rect_map(board, note_rect) + + for a in board.arrows: + if not a.label or a.from_id == a.to_id: + continue + src = rects.get(a.from_id) + dst = rects.get(a.to_id) + if src is None or dst is None: + continue + + s_cx, s_cy = (src[0] + src[2]) / 2.0, (src[1] + src[3]) / 2.0 + d_cx, d_cy = (dst[0] + dst[2]) / 2.0, (dst[1] + dst[3]) / 2.0 + start = _ray_exits_rect(src, (d_cx, d_cy)) + end = _ray_exits_rect(dst, (s_cx, s_cy)) + + seg_len = math.hypot(end[0] - start[0], end[1] - start[1]) + if seg_len < 1: + # Endpoints overlap — sibling-overlap will flag this. + continue + + label_w, label_h = arrow_label_size(a) + mid_x = (start[0] + end[0]) / 2.0 + a.label_dx + mid_y = (start[1] + end[1]) / 2.0 + a.label_dy + # Inflate by 4px to match the renderer's line-clip "gap" rect — + # captures visual crowding, not just strict glyph overlap. + inflate = 4.0 + label_rect = ( + mid_x - label_w / 2.0 - inflate, + mid_y - label_h / 2.0 - inflate, + mid_x + label_w / 2.0 + inflate, + mid_y + label_h / 2.0 + inflate, + ) + + for endpoint_id, endpoint_rect in ( + (a.from_id, src), (a.to_id, dst), + ): + if _rects_overlap(label_rect, endpoint_rect): + diags.append(Diagnostic( + code="arrow-label-crowded", + severity=WARNING, + message=( + f"arrow {a.from_id!r} -> {a.to_id!r} label " + f"({a.label!r}) overlaps {endpoint_id!r} — " + f"shorten the label, widen the gap, or offset " + f"the label via @dx,dy" + ), + item_ids=[a.from_id, a.to_id], + fixable=True, + )) + break # one finding per arrow + return diags + + +def check_arrow_label_covers_head( + board: Board, + arrow_label_size: ArrowLabelSizeFn | None = None, + note_rect: NoteRectFn | None = None, +) -> list[Diagnostic]: + """Info: label wider than the visible arrow segment — direction lost. + + When the label fills (or exceeds) the arrow length, the renderer + splits the line around the label rect and the arrowhead disappears + behind the label. The endpoint-overlap check is the primary signal; + this one catches the remaining cases (offset labels, longer gaps + that still aren't long enough for the label). + """ + diags: list[Diagnostic] = [] + if arrow_label_size is None: + return diags + + rects = _id_rect_map(board, note_rect) + + for a in board.arrows: + if not a.label or a.from_id == a.to_id: + continue + if not (a.head_to or a.head_from): + continue # no head → nothing to obscure + src = rects.get(a.from_id) + dst = rects.get(a.to_id) + if src is None or dst is None: + continue + + s_cx, s_cy = (src[0] + src[2]) / 2.0, (src[1] + src[3]) / 2.0 + d_cx, d_cy = (dst[0] + dst[2]) / 2.0, (dst[1] + dst[3]) / 2.0 + start = _ray_exits_rect(src, (d_cx, d_cy)) + end = _ray_exits_rect(dst, (s_cx, s_cy)) + seg_len = math.hypot(end[0] - start[0], end[1] - start[1]) + if seg_len < 1: + continue + + label_w, _label_h = arrow_label_size(a) + # Reserve one arrowhead worth of clearance on each head end. + head_clearance = ARROWHEAD_SIZE * ( + int(bool(a.head_to)) + int(bool(a.head_from)) + ) + if label_w >= seg_len - head_clearance: + diags.append(Diagnostic( + code="arrow-label-covers-head", + severity=INFO, + message=( + f"arrow {a.from_id!r} -> {a.to_id!r} label is wider " + f"than the visible segment ({seg_len:.0f}px) — the " + f"arrowhead may be hidden behind it" + ), + item_ids=[a.from_id, a.to_id], + fixable=True, + )) + return diags + + # Captures `@:` where path looks like a file (has an # extension). Trailing `:line` / `:anchor` is consumed to keep matching # tight, but the anchor itself is not validated. @@ -256,12 +415,19 @@ def run_all( board: Board, base_dir: Path | None = None, note_rect: NoteRectFn | None = None, + arrow_label_size: ArrowLabelSizeFn | None = None, ) -> list[Diagnostic]: diags: list[Diagnostic] = [] diags.extend(check_child_outside_parent(board, note_rect=note_rect)) diags.extend(check_sibling_overlap(board, note_rect=note_rect)) diags.extend(check_cramped_container(board, note_rect=note_rect)) diags.extend(check_label_truncated(board)) + diags.extend(check_arrow_label_crowded( + board, arrow_label_size=arrow_label_size, note_rect=note_rect, + )) + diags.extend(check_arrow_label_covers_head( + board, arrow_label_size=arrow_label_size, note_rect=note_rect, + )) diags.extend(check_missing_resource(board, base_dir)) diags.sort(key=lambda d: (_SEVERITY_ORDER.get(d.severity, 99), d.code, d.item_ids)) return diags diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md index d1755f5..5220b0a 100644 --- a/grafli/skills/grafli/SKILL.md +++ b/grafli/skills/grafli/SKILL.md @@ -56,8 +56,9 @@ any `@ box` / `@ arrow` / `@ note` lines: 10. **Diagnose.** Run `grafli diagnose .grafli` (add `--json` for machine-readable output) for static checks the eye misses: children outside parents, sibling overlaps, cramped containers, - likely-truncated labels, missing `@path` / image refs. Each - finding carries a `fixable` flag and a `severity`: + likely-truncated labels, arrow labels crowding endpoints or hiding + arrowheads, missing `@path` / image refs. Each finding carries a + `fixable` flag and a `severity`: * `severity: error` (e.g. `invalid-parent-ref`) — always fix. * `fixable: true` — usually a real geometry mistake. Try to fix. diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index af8568e..c249469 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -6,6 +6,8 @@ from grafli.diagnostics import ( Diagnostic, + check_arrow_label_covers_head, + check_arrow_label_crowded, check_child_outside_parent, check_cramped_container, check_label_truncated, @@ -239,3 +241,110 @@ def test_note_outside_parent_detected_via_provider(): board, note_rect=_qt_note_rect_provider() ) assert any(d.code == "child-outside-parent" and "n" in d.item_ids for d in diags) + + +# ── arrow label crowding ─────────────────────────────────────── + +def _fixed_size(w: float, h: float): + """Stub size provider — returns the same (w, h) for any arrow.""" + return lambda _arrow: (w, h) + + +def test_arrow_label_crowded_with_adjacent_boxes(): + """The Stage1→Stage2 case: 20px gap, label wider than gap.""" + a = Box(id="a", label="A", x=0, y=0, w=160, h=80) + b = Box(id="b", label="B", x=180, y=0, w=160, h=80) # 20px gap + arr = Arrow(from_id="a", to_id="b", label="in") + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12)) + assert len(diags) == 1 + assert diags[0].code == "arrow-label-crowded" + assert set(diags[0].item_ids) == {"a", "b"} + + +def test_arrow_label_clean_with_wide_gap(): + a = Box(id="a", label="A", x=0, y=0, w=100, h=80) + b = Box(id="b", label="B", x=400, y=0, w=100, h=80) # 300px gap + arr = Arrow(from_id="a", to_id="b", label="in") + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12)) + assert diags == [] + + +def test_arrow_label_checks_skipped_without_provider(): + a = Box(id="a", label="A", x=0, y=0, w=160, h=80) + b = Box(id="b", label="B", x=180, y=0, w=160, h=80) + arr = Arrow(from_id="a", to_id="b", label="in") + board = _make_board(boxes=[a, b], arrows=[arr]) + assert check_arrow_label_crowded(board) == [] + assert check_arrow_label_covers_head(board) == [] + + +def test_arrow_label_unlabeled_arrows_skipped(): + a = Box(id="a", label="A", x=0, y=0, w=160, h=80) + b = Box(id="b", label="B", x=180, y=0, w=160, h=80) + arr = Arrow(from_id="a", to_id="b", label="") + board = _make_board(boxes=[a, b], arrows=[arr]) + assert check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12)) == [] + + +def test_arrow_label_offset_via_dx_dy_clears_overlap(): + """Author used @dx,dy to push the label off the line — no warning.""" + a = Box(id="a", label="A", x=0, y=0, w=160, h=80) + b = Box(id="b", label="B", x=180, y=0, w=160, h=80) + arr = Arrow(from_id="a", to_id="b", label="in", label_dy=-100) + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_crowded(board, arrow_label_size=_fixed_size(30, 12)) + assert diags == [] + + +def test_arrow_label_covers_head_long_label_short_arrow(): + """Label wider than the visible segment but doesn't overlap endpoints + (e.g. offset above) — still flags the lost arrowhead.""" + a = Box(id="a", label="A", x=0, y=0, w=80, h=80) + b = Box(id="b", label="B", x=120, y=0, w=80, h=80) # 40px gap + # Label offset above, so it doesn't overlap endpoints; but its width + # still exceeds the visible 40px segment. + arr = Arrow(from_id="a", to_id="b", label="long", label_dy=-80) + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_covers_head( + board, arrow_label_size=_fixed_size(60, 12), + ) + assert len(diags) == 1 + assert diags[0].code == "arrow-label-covers-head" + + +def test_arrow_label_covers_head_skipped_when_no_head(): + """No arrowhead → no direction to obscure.""" + a = Box(id="a", label="A", x=0, y=0, w=80, h=80) + b = Box(id="b", label="B", x=120, y=0, w=80, h=80) + arr = Arrow( + from_id="a", to_id="b", label="long", + head_to=False, head_from=False, + ) + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_covers_head( + board, arrow_label_size=_fixed_size(60, 12), + ) + assert diags == [] + + +def test_qt_arrow_label_provider_catches_test_grafli_pipeline_case(): + """End-to-end: replicate the user's 160px Stage1/2 with 20px gap and + a real Qt-measured 'in' label — must surface arrow-label-crowded.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication([]) + from grafli.app import ( + _register_bundled_fonts, _make_arrow_label_size_provider, + ) + _register_bundled_fonts() + + a = Box(id="s1", label="Stage 1", x=-720, y=290, w=160, h=80) + b = Box(id="s2", label="Stage 2", x=-540, y=290, w=160, h=80) + arr = Arrow(from_id="s1", to_id="s2", label="in") + board = _make_board(boxes=[a, b], arrows=[arr]) + diags = check_arrow_label_crowded( + board, arrow_label_size=_make_arrow_label_size_provider(), + ) + assert any(d.code == "arrow-label-crowded" for d in diags) From 4700da320d9e6a2bfe705b43edcefc0e87ec9f7b Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Mon, 11 May 2026 12:12:37 +0200 Subject: [PATCH 07/23] Format: quantize coordinates to integers on save (#20) --- grafli/format.py | 37 ++++++++----- tests/test_format.py | 128 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 20 deletions(-) diff --git a/grafli/format.py b/grafli/format.py index 349a619..645102a 100644 --- a/grafli/format.py +++ b/grafli/format.py @@ -432,11 +432,22 @@ def parse_file(path: str) -> Board: # ── Serializer ────────────────────────────────────────────────── +def _q(value: float) -> int: + """Quantize a coordinate / size to integer pixels. + + Why: full float precision in saved files produces noisy diffs (~14 + digits per moved element) that obscure real edits. Integer pixels + are visually indistinguishable in the UI but keep diffs readable + and round-trips byte-stable. + """ + return round(value) + + def _serialize_box(box: Box) -> str: - x = int(box.x) if box.x == int(box.x) else box.x - y = int(box.y) if box.y == int(box.y) else box.y - w = int(box.w) if box.w == int(box.w) else box.w - h = int(box.h) if box.h == int(box.h) else box.h + x = _q(box.x) + y = _q(box.y) + w = _q(box.w) + h = _q(box.h) escaped_label = box.label.replace("\n", "\\n") s = f'@ box {box.id} "{escaped_label}" {x},{y} {w}x{h}' if box.color: @@ -466,9 +477,9 @@ def _serialize_arrow(arrow: Arrow) -> str: base = f"@ arrow {arrow.from_id} {op} {arrow.to_id}" if arrow.label: base += f' "{arrow.label}"' - if arrow.label_dx or arrow.label_dy: - dx = int(arrow.label_dx) if arrow.label_dx == int(arrow.label_dx) else arrow.label_dx - dy = int(arrow.label_dy) if arrow.label_dy == int(arrow.label_dy) else arrow.label_dy + dx = _q(arrow.label_dx) + dy = _q(arrow.label_dy) + if dx or dy: base += f" @{dx},{dy}" if arrow.style: base += f" !{arrow.style}" @@ -480,8 +491,8 @@ def _serialize_arrow(arrow: Arrow) -> str: def _serialize_note(note: Note) -> str: - x = int(note.x) if note.x == int(note.x) else note.x - y = int(note.y) if note.y == int(note.y) else note.y + x = _q(note.x) + y = _q(note.y) use_block = note.block_text or '"' in note.text if use_block: parts = [f'@ note {note.id} {x},{y} """'] @@ -520,10 +531,10 @@ def _serialize_note(note: Note) -> str: def _serialize_image(image: Image) -> str: - x = int(image.x) if image.x == int(image.x) else image.x - y = int(image.y) if image.y == int(image.y) else image.y - w = int(image.w) if image.w == int(image.w) else image.w - h = int(image.h) if image.h == int(image.h) else image.h + x = _q(image.x) + y = _q(image.y) + w = _q(image.w) + h = _q(image.h) s = f'@ image {image.id} "{image.image_path}" {x},{y} {w}x{h}' if image.parent: s += f" >{image.parent}" diff --git a/tests/test_format.py b/tests/test_format.py index fe14e99..e05b1f5 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -1,5 +1,6 @@ """Tests for grafli.format — .grafli file parsing and serialization.""" +import re import tempfile from pathlib import Path @@ -123,14 +124,20 @@ def test_box_by_id_missing(): def test_float_coordinates(): + """Float coords parse losslessly but serialize quantized to integers. + + Issue #20: the auto-save must not leak ~14 digits of float noise into + diffs. Quantization on save makes drags produce 1-line diffs.""" text = '@ box f "Float" 10.5,20.3 100.0x50.0\n' board = parse(text) + # parser preserves the input precision assert board.boxes[0].x == 10.5 assert board.boxes[0].y == 20.3 - # integer-like floats should serialize without decimals - assert "100x50" in serialize(board) - # true floats should keep decimals - assert "10.5,20.3" in serialize(board) + out = serialize(board) + # integer-like floats serialize without decimals + assert "100x50" in out + # fractional coords round to integers (no decimals reach the file) + assert "." not in out.split('"Float"')[1].split('\n')[0] def test_negative_coordinates(): @@ -1169,11 +1176,11 @@ def test_arrow_label_offset_zero_omitted(): def test_arrow_all_fields_with_offset_roundtrip(): - text = '@ arrow a <-> b "data" @-5,12.5 !dashed ~xxlarge # check latency\n' + text = '@ arrow a <-> b "data" @-5,12 !dashed ~xxlarge # check latency\n' board = parse(text) arrow = board.arrows[0] assert arrow.label_dx == -5.0 - assert arrow.label_dy == 12.5 + assert arrow.label_dy == 12.0 assert arrow.style == "dashed" assert arrow.textsize == "xxlarge" assert arrow.annotation == "check latency" @@ -1181,7 +1188,7 @@ def test_arrow_all_fields_with_offset_roundtrip(): assert arrow.head_to is True # Annotation is parsed but not serialized result = serialize(board) - assert '@ arrow a <-> b "data" @-5,12.5 !dashed ~xxlarge\n' in result + assert '@ arrow a <-> b "data" @-5,12 !dashed ~xxlarge\n' in result def test_arrow_label_offset_negative(): @@ -1427,3 +1434,110 @@ def test_merge_handles_new_box_added_externally(): merged = merge_box_positions(new_disk, prev_disk, in_memory) new_b = next(b for b in merged.boxes if b.id == "b") assert (new_b.x, new_b.y) == (300, 300) + + +# ── Coordinate quantization on save (issue #20) ─────────────────── + + +def _no_decimals_in_coords(text: str) -> bool: + """No fractional coordinates in serialized output. + + Strips quoted string content from each @ line first so decimals in + labels (e.g. "ratio > 2.0") don't trip the check — we only care + about coordinate / size tokens like 12.5 in `100.5,200`. + """ + for line in text.splitlines(): + if not line.startswith("@"): + continue + outside_quotes = re.sub(r'"[^"]*"', "", line) + if re.search(r"\d+\.\d+", outside_quotes): + return False + return True + + +def test_serialize_quantizes_box_floats(): + """Issue #20: full-precision floats from drags are rounded on save.""" + board = Board() + board.add_box(Box( + id="b1", label="X", + x=1476.537054409133, y=217.99831588774094, + w=160.0, h=89.5001, + )) + out = serialize(board) + assert "1477,218" in out + assert "160x90" in out # 89.5 → 90 via banker's rounding + assert _no_decimals_in_coords(out) + + +def test_serialize_quantizes_note_floats(): + board = Board() + board.add_note(Note(id="n1", x=300.72953746, y=-108.89470385, text="hi")) + out = serialize(board) + assert "301,-109" in out + assert _no_decimals_in_coords(out) + + +def test_serialize_quantizes_image_floats(): + from grafli.format import Image + board = Board() + board.add_image(Image( + id="img1", image_path="x.png", + x=10.4, y=20.6, w=100.49, h=50.51, + )) + out = serialize(board) + assert "10,21" in out + assert "100x51" in out + assert _no_decimals_in_coords(out) + + +def test_serialize_quantizes_arrow_label_offset(): + board = Board() + board.add_arrow(Arrow( + from_id="a", to_id="b", label="x", + label_dx=10.7, label_dy=-20.3, + )) + out = serialize(board) + assert "@11,-20" in out + assert _no_decimals_in_coords(out) + + +def test_serialize_drops_offset_when_both_round_to_zero(): + """A label_dx/dy that rounds to (0, 0) is omitted entirely.""" + board = Board() + board.add_arrow(Arrow( + from_id="a", to_id="b", label="x", + label_dx=0.3, label_dy=-0.4, + )) + out = serialize(board) + assert "@0,0" not in out + assert "@-0,0" not in out + + +def test_serialize_no_float_noise_in_showcase(): + """Round-trip on the showcase example produces no float noise.""" + from pathlib import Path + src = Path(__file__).parent.parent / "examples" / "showcase.grafli" + if not src.exists(): + return # examples are optional in some checkouts + board = parse_file(str(src)) + out = serialize(board) + assert _no_decimals_in_coords(out), \ + "Serialized showcase still contains fractional coords" + + +def test_double_roundtrip_byte_stable(): + """parse → serialize → parse → serialize is byte-stable. + + The first serialize is allowed to quantize; the second must produce + an identical byte sequence.""" + text = ( + '#!grafli v1\n' + '@ box auth "Auth" 1476.537054409133,217.99831588774094 160x89.5\n' + '@ note n1 -300.72,-108.89 "hi"\n' + '@ image img1 "p.png" 10.4,20.6 100.49x50.51\n' + '@ arrow auth -> auth "loop" @10.7,-20.3\n' + ) + once = serialize(parse(text)) + twice = serialize(parse(once)) + assert once == twice + assert _no_decimals_in_coords(once) From 898fce60e3d5016587f9bad32013fff31055ed14 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Mon, 11 May 2026 12:12:42 +0200 Subject: [PATCH 08/23] Examples: re-save with quantized coords (#20) --- examples/architecture.grafli | 68 ++++++++++++++++----------------- examples/oauth-callback.grafli | 12 +++--- examples/showcase.grafli | 68 ++++++++++++++++----------------- examples/skill-explained.grafli | 18 ++++----- 4 files changed, 83 insertions(+), 83 deletions(-) diff --git a/examples/architecture.grafli b/examples/architecture.grafli index 00ad732..a09f0ab 100644 --- a/examples/architecture.grafli +++ b/examples/architecture.grafli @@ -5,33 +5,33 @@ # nesting, child text notes, block text, markdown resources, and sub-graflis. # --- Title / legend --- -@ note title -142.64805769219618,-93.30944319793657 "Architecture explanation demo" ~xlarge -@ note legend -335.4432297006256,26.190871260717813 "Edge label prefixes render as chips and color the edge: call:, data:, event:, state:, step:, verify:, owns:, depends:, risk:, note:" ~small +@ note title -143,-93 "Architecture explanation demo" ~xlarge +@ note legend -335,26 "Edge label prefixes render as chips and color the edge: call:, data:, event:, state:, step:, verify:, owns:, depends:, risk:, note:" ~small # --- Visible nesting containers --- -@ box frontend_layer "Frontend clients" 10.216128384960427,101.7784935756502 500x230 %secondary ^topleft ~small !flat -@ box api_layer "API boundary" 684.9675628779783,94.83264817843568 500x230 %primary ^topleft ~small !flat -@ box core_layer "Core domain services" 60.56713475112224,638.0582416010043 760x360 %tertiary ^topleft ~small !flat -@ box data_layer "Data and external stores" 914.9518738695019,545.2624693673604 560x360 %subtle ^topleft ~small !flat -@ box observability_layer "Verification / operations" 6.176285612556256,1057.2802719016136 760x280 %muted ^topleft ~small !flat +@ box frontend_layer "Frontend clients" 10,102 500x230 %secondary ^topleft ~small !flat +@ box api_layer "API boundary" 685,95 500x230 %primary ^topleft ~small !flat +@ box core_layer "Core domain services" 61,638 760x360 %tertiary ^topleft ~small !flat +@ box data_layer "Data and external stores" 915,545 560x360 %subtle ^topleft ~small !flat +@ box observability_layer "Verification / operations" 6,1057 760x280 %muted ^topleft ~small !flat # --- Frontend children --- -@ box web "Web App\nReact + Next.js" 572.12552841975,-85.54731796255707 190x80 %secondary -@ box mobile "Mobile App\nReact Native" -236.37555387268583,176.75070675159537 190x80 %secondary -@ note n_frontend_q 50.21612838496043,266.7784935756502 "Q: Should admin UI bypass mobile-oriented caching?" ~small >frontend_layer +@ box web "Web App\nReact + Next.js" 572,-86 190x80 %secondary +@ box mobile "Mobile App\nReact Native" -236,177 190x80 %secondary +@ note n_frontend_q 50,267 "Q: Should admin UI bypass mobile-oriented caching?" ~small >frontend_layer # --- API children --- -@ box gateway "API Gateway\nrate limits + auth" 170.21612838496043,171.7784935756502 200x90 %primary >frontend_layer -@ box graphql "GraphQL Federation\nschema stitching" 802.0935549525444,151.9225791656238 200x90 %primary &architecture-res/graphql.md >api_layer -@ note n_api_task 830.3447930125569,268.56281561682783 "T: Add p95 latency budget to gateway -> graphql edge" ~small >api_layer +@ box gateway "API Gateway\nrate limits + auth" 170,172 200x90 %primary >frontend_layer +@ box graphql "GraphQL Federation\nschema stitching" 802,152 200x90 %primary &architecture-res/graphql.md >api_layer +@ note n_api_task 830,269 "T: Add p95 latency budget to gateway -> graphql edge" ~small >api_layer # --- Core service children --- @ box auth "Auth Service\nOAuth2 + JWT" 0,400 200x85 %tertiary @ box users "User Profile\nService" 240,400 180x85 %tertiary -@ box orders "Order Processing\n& Fulfillment" 485.17863020914706,414.05558603437254 210x85 %tertiary &architecture-res/orders-flow.grafli -@ box catalog "Product Catalog\nService" 889.4722050150008,414.0502305973303 210x85 %tertiary +@ box orders "Order Processing\n& Fulfillment" 485,414 210x85 %tertiary &architecture-res/orders-flow.grafli +@ box catalog "Product Catalog\nService" 889,414 210x85 %tertiary -@ note n_orders_code -455.416501911354,363.81958174053847 """ +@ note n_orders_code -455,364 """ code: fn: placeOrder(req) -> OrderId pre: req.user is authenticated @@ -45,20 +45,20 @@ verify: test_order_happy_path @tests/orders_test.py:41 risk: reservation leak if payment timeout is not compensated """ ~small !mono -@ note n_core_discussion 115.63943407462125,897.5439704114971 "AI: The graph shows service relationships.\nReviewer: The code note keeps order internals local.\nAI: Deeper order flow is linked as a sub-grafli." ~small >core_layer +@ note n_core_discussion 116,898 "AI: The graph shows service relationships.\nReviewer: The code note keeps order internals local.\nAI: Deeper order flow is linked as a sub-grafli." ~small >core_layer # --- Data layer children --- -@ box redis "Redis\nsessions/cache" -172.66632124352293,694.1818652849742 180x85 %subtle -@ box postgres "PostgreSQL\nprimary store" 85.34646327677254,711.4393552051313 200x85 %subtle >core_layer -@ box queue "Kafka\norder events" 489.0295519305754,731.7723570382295 180x85 %subtle >core_layer -@ box search "Search Index\nElasticsearch" 1110.8393706318195,697.8052904232359 200x85 %subtle >data_layer -@ note n_data_note 954.9518738695019,875.2624693673604 "Plain child notes move with their parent box; use them for local context." ~small >data_layer +@ box redis "Redis\nsessions/cache" -173,694 180x85 %subtle +@ box postgres "PostgreSQL\nprimary store" 85,711 200x85 %subtle >core_layer +@ box queue "Kafka\norder events" 489,732 180x85 %subtle >core_layer +@ box search "Search Index\nElasticsearch" 1111,698 200x85 %subtle >data_layer +@ note n_data_note 955,875 "Plain child notes move with their parent box; use them for local context." ~small >data_layer # --- Verification / operations children --- -@ box ci "CI Pipeline\nGitHub Actions" 800.6491973589921,1137.5854113154567 200x85 %muted -@ box tests "Integration Tests\ncontract + flow" 59.55739921668311,1130.5155290241248 210x85 %muted >observability_layer -@ box monitor "Prometheus + Grafana\nruntime signals" 368.0875138889694,1195.3690436252004 200x85 %muted >observability_layer -@ note n_verify_code 46.176285612556256,1242.2802719016136 "code:\nfn: verifyChange(pr)\ncall: pytest(contract_tests)\ncall: smoke(gateway -> graphql)\nassert: no runtime warnings\nverify: dashboard shows p95 below target\nreturn: ready_for_review" ~small !mono >observability_layer +@ box ci "CI Pipeline\nGitHub Actions" 801,1138 200x85 %muted +@ box tests "Integration Tests\ncontract + flow" 60,1131 210x85 %muted >observability_layer +@ box monitor "Prometheus + Grafana\nruntime signals" 368,1195 200x85 %muted >observability_layer +@ note n_verify_code 46,1242 "code:\nfn: verifyChange(pr)\ncall: pytest(contract_tests)\ncall: smoke(gateway -> graphql)\nassert: no runtime warnings\nverify: dashboard shows p95 below target\nreturn: ready_for_review" ~small !mono >observability_layer # --- Frontend to API --- @ arrow web -> gateway "call: HTTPS /api" @@ -93,20 +93,20 @@ Block text note: Use triple quotes when the text itself contains "quoted strings". The canvas still edits this as normal note text. """ ~small -@ note n_subgraph_hint 913.8237143874437,1133.0152145654704 "Sub-grafli demo: open the linked Order Processing box to inspect the detailed order flow." ~small +@ note n_subgraph_hint 914,1133 "Sub-grafli demo: open the linked Order Processing box to inspect the detailed order flow." ~small @ arrow n_orders_code -> auth -@ box box1 "test" -1500,-160 495.03133382484134x516.9951555758935 +@ box box1 "test" -1500,-160 495x517 @ box box2 "3" -1220,200 160x80 %subtle >box1 -@ box box3 "1" -1464.0125969060239,15.924418563856193 160x80 %subtle >box1 -@ box box4 "2" -1436.0503876240962,175.92441856385616 160x80 %subtle >box1 -@ box box5 "5" -1220.06298453012,1.2115632417683173 160x80 %subtle >box1 +@ box box3 "1" -1464,16 160x80 %subtle >box1 +@ box box4 "2" -1436,176 160x80 %subtle >box1 +@ box box5 "5" -1220,1 160x80 %subtle >box1 @ box box6 "4" -1320,-120 160x80 %subtle >box1 @ arrow box3 -> box6 @ arrow box6 -> box5 @ arrow box5 -> box2 @ arrow box5 -> box4 -@ image img1 "architecture-res/img-20260501-190455.png" -1280.852069082695,632.8103352658841 320x120.06279434850863 -@ note n1 -833.2826241509773,552.4008407292996 """ +@ image img1 "architecture-res/img-20260501-190455.png" -1281,633 320x120 +@ note n1 -833,552 """ code: fn: placeOrder(req) -> OrderId pre: req.user is authenticated @@ -119,4 +119,4 @@ post: audit trail includes "order.created" verify: test_order_happy_path @tests/orders_test.py:41 risk: reservation leak if payment timeout is not compensated """ ~small !mono -@ box box7 "3" -791.5671846682503,175.34270062328628 160x80 %subtle +@ box box7 "3" -792,175 160x80 %subtle diff --git a/examples/oauth-callback.grafli b/examples/oauth-callback.grafli index 292ad12..010adab 100644 --- a/examples/oauth-callback.grafli +++ b/examples/oauth-callback.grafli @@ -20,7 +20,7 @@ @ box provider "OAuth Provider\nGoogle / GitHub" 460,140 220x80 %muted # ── Service boxes (horizontal flow) ──────────────── -@ box cb "Callback Handler\nPOST /auth/callback" 37.62564878892732,297.6458693771626 220x80 %primary +@ box cb "Callback Handler\nPOST /auth/callback" 38,298 220x80 %primary @ box xchg "Token Exchange\noauth client" 460,300 220x80 %tertiary @ box users "User Upsert\nidentity store" 840,300 220x80 %tertiary @ box sess "Session Mint\ncookie + redis" 1220,300 220x80 %tertiary @@ -35,7 +35,7 @@ # ── Code notes — one function per service ────────── -@ note n_cb -25.925767733563987,411.76054282006925 """ +@ note n_cb -26,412 """ code: handle_callback(req) -> Response pre req.method == POST @@ -47,7 +47,7 @@ risk timing leak — use constant_time_eq return exchange(req.query.code) @auth/callback.py:23 """ -@ note n_xchg 429.4064121972318,414.1146734429065 """ +@ note n_xchg 429,414 """ code: exchange(code) -> Session token = call provider.post(token_url, code) @@ -57,7 +57,7 @@ profile = call provider.get(userinfo_url, token) return upsert_user(profile) @auth/oauth.py:88 """ -@ note n_users 836.4789143598614,415.2917387543254 """ +@ note n_users 836,415 """ code: upsert_user(profile) -> User existing = find_by_provider_id(profile.id) @@ -69,7 +69,7 @@ emit UserCreated(new.id) return new @users/store.py:14 """ -@ note n_sess 1196.4688040657438,417.6458693771626 """ +@ note n_sess 1196,418 """ code: mint_session(user) -> SessionId sid = random_token(32) @@ -90,7 +90,7 @@ return 302 redirect_to=return_to @auth/callback.py:78 """ # ── Cross-cutting concerns: security tests ───────── -@ box tests "Security Tests\ncontract suite" 892.9477184256054,825.9358780276815 220x80 %muted +@ box tests "Security Tests\ncontract suite" 893,826 220x80 %muted @ arrow tests -> cb "verify: csrf" !dashed @ arrow tests -> sess "verify: fixation" !dashed diff --git a/examples/showcase.grafli b/examples/showcase.grafli index e1f4cdf..00141f7 100644 --- a/examples/showcase.grafli +++ b/examples/showcase.grafli @@ -17,16 +17,16 @@ # Region 1 — Baker's day (hero) # ============================================================ -@ note title1 1476.537054409133,19.010918447672285 "Baker's day" ~xxlarge +@ note title1 1477,19 "Baker's day" ~xxlarge -@ note caption1 -300.72953746032147,-108.89470384589161 "A daily routine as a state machine.\nEach arrow's `step:` / `state:` / `event:` prefix renders as a colored chip on the edge." ~small +@ note caption1 -301,-109 "A daily routine as a state machine.\nEach arrow's `step:` / `state:` / `event:` prefix renders as a colored chip on the edge." ~small -@ box sleep "Sleep" 140,142.19573710323735 160x90 %subtle -@ box bake "Bake bread" 126.08540922305644,389.9602261378962 180x90 %accent -@ box shop "Open shop" 479.1980244720821,505.5838972527878 180x90 %tertiary -@ box lunch "Lunch" 874.0607783666509,391.19624798664864 160x90 %highlight -@ box deliver "Deliver" 846.494085510205,146.77996554352328 200x90 %tertiary -@ box dinner "Dinner" 481.5442312766063,28.774266762025334 160x90 %accent +@ box sleep "Sleep" 140,142 160x90 %subtle +@ box bake "Bake bread" 126,390 180x90 %accent +@ box shop "Open shop" 479,506 180x90 %tertiary +@ box lunch "Lunch" 874,391 160x90 %highlight +@ box deliver "Deliver" 846,147 200x90 %tertiary +@ box dinner "Dinner" 482,29 160x90 %accent @ arrow sleep -> bake "step: 06:00" @ arrow bake -> shop "state: ready" @@ -35,7 +35,7 @@ @ arrow deliver -> dinner "step: 20:00" @ arrow dinner -> sleep "step: 22:00" -@ note bake_code 433.6683666702927,217.99831588774094 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread" +@ note bake_code 434,218 "code:\nbake() -> Bread\nif flour < 1\n err OutOfFlour\ndough = mix(flour, water, yeast)\nstate rising -> baking\nemit BatchReady\nreturn bread" @ arrow bake_code -- bake @ note qa_flour 1400,380 "Q: what if flour runs out mid-bake?\nA: the `code:` note bails — `if: flour < 1` raises OutOfFlour and ends the routine for the day." ~width=61 @@ -44,16 +44,16 @@ # Region 2 — Threat reaction (annotations) # ============================================================ -@ note title2 -38.010045408300755,719.0583777077526 "Threat reaction" ~xxlarge +@ note title2 -38,719 "Threat reaction" ~xxlarge -@ note caption2 -43.35091625087598,781.8723275865401 "Behavior under review. `T:` tasks, `Q:` questions, threaded discussions, and `code:` notes live next to the boxes they describe." ~small +@ note caption2 -43,782 "Behavior under review. `T:` tasks, `Q:` questions, threaded discussions, and `code:` notes live next to the boxes they describe." ~small -@ box sense "Sense threat" -15.943060517962408,1006.7008582809965 220x90 %subtle +@ box sense "Sense threat" -16,1007 220x90 %subtle @ box assess "Assess danger" 340,1000 240x90 %accent &showcase-res/assess.grafli -@ box flee "Flee" 878.2093078937904,1001.3971219715272 180x90 %muted -@ box fight "Fight" 894.4274649918939,1204.877538225958 180x90 %accent -@ box guards "Call guards" 425.3673853221261,1213.1965668760133 200x90 %tertiary -@ box hide "Hide" 874.2108017465225,819.0768264130897 180x90 %muted &showcase-res/hide.md +@ box flee "Flee" 878,1001 180x90 %muted +@ box fight "Fight" 894,1205 180x90 %accent +@ box guards "Call guards" 425,1213 200x90 %tertiary +@ box hide "Hide" 874,819 180x90 %muted &showcase-res/hide.md @ arrow sense -> assess "call: classify" @ arrow assess -> flee "event: weak" @@ -64,10 +64,10 @@ @ arrow fight -> flee "risk: outnumbered" !dashed @ arrow hide -> assess "verify: clear" !dashed -@ note todo -5.769576575522848,1340.638539578403 "T: add audible cue when sense triggers" +@ note todo -6,1341 "T: add audible cue when sense triggers" @ note q1 340,1340 "Q: should fear scale with NPC level?" -@ note disc 683.8763271382929,1345.2969292077053 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54 -@ note qa_recover 410.20212342126865,840.4205841046205 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47 +@ note disc 684,1345 "Designer: Should child NPCs ever pick fight?\nReviewer: No — clamp to flee/hide when AgeTag is child.\nDesigner: Will add the tag check inside assess." ~width=54 +@ note qa_recover 410,840 "Q: should Hide auto-recover after a timer?\nA: no — only on `verify: clear` from assess. \nA timer risks re-engaging an active threat." ~width=47 @ note assess_code 20,1140 "code:\nassessDanger(npc, threat) -> Action\nratio = threat.power / npc.power\nif ratio > 2.0\n return flee\nif threat.count > npc.allies\n return guards\nverify tests/test_assess.py:matrix\nreturn fight @ai/threat.py:88" ~width=38 @ arrow assess_code -- assess @@ -77,30 +77,30 @@ @ note title3 -440,1640 "Town life" ~xxlarge ~width=10 -@ note caption3 -435.1967712668878,1804.434821320418 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39 +@ note caption3 -435,1804 "NPCs `owns:` a shift,\n`step:` through routines,\nreact to world `event:`s.\nPress A for the heatmap." ~small ~width=39 # NPCs (column 1) -@ box baker "Baker" -31.225890102333537,1830.8493366059504 180x80 %accent -@ box smith "Smith" -20.7041979645699,2000.605628319995 180x80 %accent -@ box innkeep "Innkeeper" 43.49729606155151,2224.013491138159 180x80 %accent -@ box guard "Guard" 41.20404947985378,2346.9226694018726 180x80 %accent -@ box priest "Priest" 39.09406489545282,2595.5938856643634 180x80 %accent +@ box baker "Baker" -31,1831 180x80 %accent +@ box smith "Smith" -21,2001 180x80 %accent +@ box innkeep "Innkeeper" 43,2224 180x80 %accent +@ box guard "Guard" 41,2347 180x80 %accent +@ box priest "Priest" 39,2596 180x80 %accent # Workplaces (column 2) -@ box bakery "Bakery" 289.19600001982565,1847.071447383281 160.854356394798x62.76892075531828 %tertiary -@ box smithy "Smithy" 169.56183325412547,2126.6826719758637 180x80 %tertiary -@ box inn "Inn" -259.275277523341,2183.4636336263507 180x80 %tertiary -@ box gate "Town gate" 43.616533258049714,2475.10969764214 180x80 %tertiary +@ box bakery "Bakery" 289,1847 161x63 %tertiary +@ box smithy "Smithy" 170,2127 180x80 %tertiary +@ box inn "Inn" -259,2183 180x80 %tertiary +@ box gate "Town gate" 44,2475 180x80 %tertiary @ box temple "Temple" -400,2600 180x80 %tertiary # Shared spaces (column 3) -@ box market "Market" -455.0965933225317,1916.1598782874626 180x80 %highlight -@ box square "Town square" -480.42581859675676,2386.1247142033103 180x80 %highlight -@ box well "Village well" 502.3714955819046,2583.084750790608 180x80 %highlight +@ box market "Market" -455,1916 180x80 %highlight +@ box square "Town square" -480,2386 180x80 %highlight +@ box well "Village well" 502,2583 180x80 %highlight # Events (column 4) -@ box festival "Festival" 652.4330966119542,2331.586373897622 180x80 %primary -@ box raid "Bandit raid" -479.02697596886605,2220.197687009605 180x80 %primary +@ box festival "Festival" 652,2332 180x80 %primary +@ box raid "Bandit raid" -479,2220 180x80 %primary @ box trade "Trade day" 580,2020 180x80 %primary # Each NPC owns a daily shift at their workplace diff --git a/examples/skill-explained.grafli b/examples/skill-explained.grafli index 30a7a5d..04caa2b 100644 --- a/examples/skill-explained.grafli +++ b/examples/skill-explained.grafli @@ -6,16 +6,16 @@ @ note title 350,-80 "How the grafli skill works" ~xxlarge # === Pipeline (left to right) === -@ box prompt "User prompt" 20,80 220x80 %secondary -@ box trigger "Skill triggers?" 280,80 220x80 %accent -@ box plan "Plan" 540,80 220x80 %accent -@ box author "Author + verify" 800,80 220x80 %accent -@ box output "Clean .grafli" 1060,80 220x80 %primary +@ box prompt "User prompt" 20,80 220x80 %secondary +@ box trigger "Skill triggers?" 280,80 220x80 %accent +@ box plan "Plan" 540,80 220x80 %accent +@ box author "Author + verify" 800,80 220x80 %accent +@ box output "Clean .grafli" 1060,80 220x80 %primary -@ arrow prompt -> trigger -@ arrow trigger -> plan "yes" -@ arrow plan -> author -@ arrow author -> output +@ arrow prompt -> trigger +@ arrow trigger -> plan "yes" +@ arrow plan -> author +@ arrow author -> output # === Notes describing each step === @ note prompt_text 20,200 """ From 7e7de8fc71c33b0ae6cc0694bc2c3b1dc4ab7d92 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Mon, 11 May 2026 14:55:37 +0200 Subject: [PATCH 09/23] Skill: add 'grafli skill install/check/uninstall' subcommands (#37) --- grafli/app.py | 300 +++++++++++++++++++++++++++++++++++- grafli/skill_install.py | 175 +++++++++++++++++++++ tests/test_skill_install.py | 158 +++++++++++++++++++ 3 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 grafli/skill_install.py create mode 100644 tests/test_skill_install.py diff --git a/grafli/app.py b/grafli/app.py index 294fea8..f0e5dde 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -718,14 +718,25 @@ def _try_send_to_existing(file_path: str | None) -> bool: SKILL_DOCS = """\ -After extracting the skill, install it for your AI tool: - - Claude Code — copy / symlink to ~/.claude/skills/grafli/SKILL.md - https://code.claude.com/docs/en/skills - OpenCode — copy / symlink to ~/.config/opencode/skills/grafli/SKILL.md - https://opencode.ai/docs/skills - Codex CLI — append the body (frontmatter optional) to ~/.codex/AGENTS.md - https://agents.md/ +Subcommands: + install Install the bundled grafli skill for one or more AI tools. + check Report install status per tool (and whether a newer version + is available). + uninstall Remove the installed grafli skill from one or more tools. + +Supported targets (user-level paths follow the agentskills.io convention): + + claude ~/.claude/skills/grafli/SKILL.md + https://code.claude.com/docs/en/skills + codex ~/.agents/skills/grafli/SKILL.md + https://developers.openai.com/codex/skills + opencode ~/.config/opencode/skills/grafli/SKILL.md + https://opencode.ai/docs/skills + +(OpenCode also reads from `~/.claude/skills/` and `~/.agents/skills/`, so +installing for `claude` or `codex` is automatically picked up by OpenCode.) + +Without a subcommand, `grafli skill` prints the bundled SKILL.md to stdout. """ @@ -735,7 +746,24 @@ def _skill_path() -> Path: return Path(str(files("grafli.skills.grafli") / "SKILL.md")) +def _grafli_version() -> str: + from grafli._version import __version__ + return __version__ + + def _cmd_skill(argv: list[str]) -> int: + # Dispatch sub-subcommands (install / check / uninstall). The bare + # `grafli skill` form (print SKILL.md to stdout) and its existing + # flags (`-o`, `--where`) are preserved for backwards compatibility. + if argv and argv[0] in ("install", "check", "uninstall"): + sub = argv[0] + rest = argv[1:] + if sub == "install": + return _cmd_skill_install(rest) + if sub == "check": + return _cmd_skill_check(rest) + return _cmd_skill_uninstall(rest) + parser = argparse.ArgumentParser( prog="grafli skill", description="Print the bundled grafli AI skill (SKILL.md).", @@ -766,6 +794,262 @@ def _cmd_skill(argv: list[str]) -> int: return 0 +# ── grafli skill install / check / uninstall ───────────────────── + + +def _resolve_targets(positional: str | None) -> list[str]: + """Map a positional ('all', 'claude', 'codex', 'opencode', or None + when called from `check` where None means all) to a target list. + """ + from grafli.skill_install import ALL_TARGETS + if positional is None or positional == "all": + return list(ALL_TARGETS) + if positional not in ALL_TARGETS: + raise SystemExit( + f"unknown target: {positional!r} " + f"(valid: all, {', '.join(ALL_TARGETS)})" + ) + return [positional] + + +def _prompt_yes_no(question: str, *, default_yes: bool) -> bool: + """Tiny y/n prompt. Default is signalled with capital letter.""" + suffix = "[Y/n]" if default_yes else "[y/N]" + while True: + try: + ans = input(f"{question} {suffix} ").strip().lower() + except EOFError: + return default_yes + if not ans: + return default_yes + if ans in ("y", "yes"): + return True + if ans in ("n", "no"): + return False + + +def _cmd_skill_install(argv: list[str]) -> int: + from grafli.skill_install import ( + compute_status, write_skill, parent_dir_exists, + OK, STALE, MODIFIED, UNKNOWN, MISSING, + ) + + parser = argparse.ArgumentParser( + prog="grafli skill install", + description="Install the bundled grafli skill for one or more AI tools.", + ) + parser.add_argument( + "target", nargs="?", default=None, + help="Target tool (all | claude | codex | opencode). " + "Omit to be prompted per target.", + ) + parser.add_argument( + "--force", action="store_true", + help="Skip all prompts; overwrite existing installs and create " + "missing parent directories without asking.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Show planned actions without writing any files.", + ) + args = parser.parse_args(argv) + + if args.dry_run and args.force: + # Harmless combo, but explicit so users don't expect writes. + print("--dry-run set; --force has no effect on writes.", file=sys.stderr) + + interactive_per_target = args.target is None + targets = _resolve_targets(args.target) + + if not args.force and not args.dry_run and not sys.stdin.isatty(): + print( + "grafli skill install: stdin is not a TTY; pass --force to " + "install non-interactively, or --dry-run to preview.", + file=sys.stderr, + ) + return 2 + + packaged = _skill_path().read_text(encoding="utf-8") + version = _grafli_version() + + any_drift = False + any_action = False + + for t in targets: + st = compute_status(t, packaged, version) + # Show context line so the user always sees the destination. + if st.status == OK: + print(f"[ok] {t}: already current at {st.path} (grafli {version})") + continue + if st.status == MISSING: + note = f"will install to {st.path} (grafli {version})" + elif st.status == STALE: + note = ( + f"installed {st.installed_version} -> packaged {version}; " + f"will update {st.path}" + ) + elif st.status == MODIFIED: + note = ( + f"local changes detected at {st.path}; " + f"overwriting will discard them" + ) + else: # UNKNOWN + note = ( + f"existing file at {st.path} was not installed by " + f"`grafli skill install`; cannot determine source" + ) + print(f"[{st.status}] {t}: {note}") + + if args.dry_run: + any_drift = any_drift or st.status != OK + continue + + # Decide whether to write. + if args.force: + do_write = True + elif interactive_per_target or st.status != MISSING: + default_yes = st.status in (MISSING, STALE) + verb = "Install" if st.status == MISSING else "Overwrite" + do_write = _prompt_yes_no(f" {verb}?", default_yes=default_yes) + else: + do_write = True + + if not do_write: + print(f" skipped {t}") + continue + + # Parent dir check (skip when --force). + if not args.force and not parent_dir_exists(t): + print( + f" note: parent directory {st.path.parent.parent} does " + f"not exist (the target tool may not be installed)." + ) + if not _prompt_yes_no(" create and install anyway?", default_yes=False): + print(f" skipped {t}") + continue + + path = write_skill(t, packaged, version) + print(f" wrote {path}") + any_action = True + + if args.dry_run and any_drift: + return 1 + return 0 + + +def _cmd_skill_check(argv: list[str]) -> int: + import json as _json + from grafli.skill_install import ( + compute_status, DRIFT_STATES, OK, STALE, MODIFIED, UNKNOWN, MISSING, + ) + + parser = argparse.ArgumentParser( + prog="grafli skill check", + description="Report install status of the grafli skill per target.", + ) + parser.add_argument( + "target", nargs="?", default="all", + help="Target tool (all | claude | codex | opencode). " + "Default: all targets.", + ) + parser.add_argument( + "--json", action="store_true", + help="Emit machine-readable JSON instead of a human-readable table.", + ) + args = parser.parse_args(argv) + + targets = _resolve_targets(args.target) + packaged = _skill_path().read_text(encoding="utf-8") + version = _grafli_version() + + statuses = [compute_status(t, packaged, version) for t in targets] + + if args.json: + print(_json.dumps([s.to_dict() for s in statuses], indent=2)) + else: + for s in statuses: + tag = f"[{s.status}]" + if s.status == OK: + extra = f"(grafli {s.packaged_version})" + elif s.status == STALE: + extra = ( + f"(installed {s.installed_version} -> " + f"packaged {s.packaged_version})" + ) + elif s.status == MODIFIED: + extra = f"(installed {s.installed_version}; locally modified)" + elif s.status == UNKNOWN: + extra = "(no version marker; unknown provenance)" + else: # MISSING + extra = "" + print(f"{s.target:<9} {tag:<11} {s.path} {extra}".rstrip()) + + has_drift = any(s.status in DRIFT_STATES for s in statuses) + return 1 if has_drift else 0 + + +def _cmd_skill_uninstall(argv: list[str]) -> int: + from grafli.skill_install import remove_skill, compute_status, MISSING + + parser = argparse.ArgumentParser( + prog="grafli skill uninstall", + description="Remove the installed grafli skill from one or more AI tools.", + ) + parser.add_argument( + "target", nargs="?", default=None, + help="Target tool (all | claude | codex | opencode). " + "Omit to be prompted per target.", + ) + parser.add_argument( + "--force", action="store_true", + help="Skip all prompts.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Show planned actions without removing any files.", + ) + args = parser.parse_args(argv) + + interactive_per_target = args.target is None + targets = _resolve_targets(args.target) + + if not args.force and not args.dry_run and not sys.stdin.isatty(): + print( + "grafli skill uninstall: stdin is not a TTY; pass --force to " + "uninstall non-interactively, or --dry-run to preview.", + file=sys.stderr, + ) + return 2 + + packaged = _skill_path().read_text(encoding="utf-8") + version = _grafli_version() + + for t in targets: + st = compute_status(t, packaged, version) + if st.status == MISSING: + print(f"[missing] {t}: nothing to remove at {st.path.parent}") + continue + print(f"[present] {t}: {st.path.parent} (status: {st.status})") + + if args.dry_run: + continue + + do_remove = ( + args.force + or _prompt_yes_no(f" remove?", default_yes=False) + ) + if not do_remove: + print(f" skipped {t}") + continue + + removed = remove_skill(t) + if removed: + print(f" removed {st.path.parent}") + else: + print(f" nothing was removed (already gone)") + return 0 + + def _cmd_render(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="grafli render", diff --git a/grafli/skill_install.py b/grafli/skill_install.py new file mode 100644 index 0000000..5f144a6 --- /dev/null +++ b/grafli/skill_install.py @@ -0,0 +1,175 @@ +"""Install / check / uninstall the bundled grafli skill into AI tools. + +Pure logic lives here; the CLI orchestrator in ``app.py`` adds prompts, +TTY detection, and exit codes. Each target maps to a user-level skill +directory following the agentskills.io convention — one directory per +skill, named ``grafli``, containing a ``SKILL.md``. +""" + +from __future__ import annotations + +import re +import shutil +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +TARGETS: dict[str, Path] = { + "claude": Path("~/.claude/skills/grafli").expanduser(), + "codex": Path("~/.agents/skills/grafli").expanduser(), + "opencode": Path("~/.config/opencode/skills/grafli").expanduser(), +} + +ALL_TARGETS: tuple[str, ...] = tuple(TARGETS) + +_VERSION_LINE_RE = re.compile( + r"^\n?", flags=re.MULTILINE, +) + + +# ── status model ────────────────────────────────────────────────── + +OK = "ok" +STALE = "stale" +MODIFIED = "modified" +UNKNOWN = "unknown" +MISSING = "missing" + +DRIFT_STATES = frozenset({STALE, MODIFIED, UNKNOWN}) + + +@dataclass +class TargetStatus: + target: str + path: Path # the SKILL.md path (file) + status: str # one of: ok / stale / modified / unknown / missing + installed_version: str | None + packaged_version: str + + def to_dict(self) -> dict: + d = asdict(self) + d["path"] = str(self.path) + return d + + +# ── content helpers ─────────────────────────────────────────────── + +def stamp_skill(content: str, version: str) -> str: + """Return ``content`` with a version-comment line injected right after + the YAML frontmatter (or at the top if no frontmatter is present). + + Idempotent: if a version line is already present, it is replaced. + """ + stamp = f"" + # Replace any existing line first so we never end up with two. + if _VERSION_LINE_RE.search(content): + return _VERSION_LINE_RE.sub(stamp + "\n", content, count=1) + + if content.startswith("---\n"): + end = content.find("\n---\n", 4) + if end != -1: + head = content[: end + 5] + body = content[end + 5:].lstrip("\n") + return f"{head}{stamp}\n\n{body}" + return f"{stamp}\n\n{content.lstrip()}" + + +def strip_version_line(content: str) -> str: + """Remove the version-comment line so two files can be compared + on canonical content alone. + """ + return _VERSION_LINE_RE.sub("", content, count=1) + + +def extract_version(content: str) -> str | None: + m = _VERSION_LINE_RE.search(content) + return m.group(1) if m else None + + +def _canonical(content: str) -> str: + """Normalize for comparison — strip version line and collapse the + leading blank line that follows it. + """ + out = strip_version_line(content) + # Drop up to one leading blank line that the version-line removal + # may have left behind so a freshly-stamped file canonicalizes back + # to the packaged source. + if out.startswith("\n"): + out = out[1:] + return out + + +def target_path(target: str) -> Path: + """Return the SKILL.md file path for a target.""" + return TARGETS[target] / "SKILL.md" + + +# ── status detection ────────────────────────────────────────────── + +def compute_status( + target: str, packaged_content: str, packaged_version: str, +) -> TargetStatus: + path = target_path(target) + if not path.exists(): + return TargetStatus( + target=target, path=path, status=MISSING, + installed_version=None, packaged_version=packaged_version, + ) + + installed = path.read_text(encoding="utf-8") + installed_ver = extract_version(installed) + if _canonical(installed) == _canonical(packaged_content): + status = OK + elif installed_ver is None: + status = UNKNOWN + elif installed_ver == packaged_version: + status = MODIFIED + else: + status = STALE + return TargetStatus( + target=target, path=path, status=status, + installed_version=installed_ver, packaged_version=packaged_version, + ) + + +def compute_all( + targets: Iterable[str], packaged_content: str, packaged_version: str, +) -> list[TargetStatus]: + return [compute_status(t, packaged_content, packaged_version) for t in targets] + + +# ── write / remove primitives ───────────────────────────────────── + +def write_skill( + target: str, packaged_content: str, packaged_version: str, +) -> Path: + """Write the stamped SKILL.md for ``target``. Creates parent dirs. + Returns the written path. + """ + path = target_path(target) + path.parent.mkdir(parents=True, exist_ok=True) + stamped = stamp_skill(packaged_content, packaged_version) + path.write_text(stamped, encoding="utf-8") + return path + + +def remove_skill(target: str) -> bool: + """Remove ``target``'s SKILL.md and the enclosing ``grafli/`` dir. + Returns True if anything was removed. + """ + dir_path = TARGETS[target] + if not dir_path.exists(): + return False + if dir_path.is_symlink(): + dir_path.unlink() + return True + shutil.rmtree(dir_path) + return True + + +def parent_dir_exists(target: str) -> bool: + """True if the parent skills directory exists (e.g. ``~/.claude/skills``). + Used to detect "tool isn't installed" so we can prompt before creating. + """ + return TARGETS[target].parent.exists() diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py new file mode 100644 index 0000000..fa4a235 --- /dev/null +++ b/tests/test_skill_install.py @@ -0,0 +1,158 @@ +"""Tests for grafli.skill_install — install / check / uninstall logic.""" + +from pathlib import Path + +import pytest + +from grafli import skill_install +from grafli.skill_install import ( + MISSING, + MODIFIED, + OK, + STALE, + UNKNOWN, + compute_status, + extract_version, + remove_skill, + stamp_skill, + strip_version_line, + write_skill, +) + + +SAMPLE = """\ +--- +name: grafli +description: short +--- + +# Body + +Hello. +""" + + +# ── stamping helpers ────────────────────────────────────────────── + + +def test_stamp_inserts_after_frontmatter(): + stamped = stamp_skill(SAMPLE, "1.2.3") + assert "" in stamped + # Must follow the frontmatter and precede the body. + fm_end = stamped.index("---", 4) + body_start = stamped.index("# Body") + stamp_pos = stamped.index("" in twice + # And exactly one line — never two. + assert twice.count("grafli skill version:") == 1 + + +def test_stamp_handles_no_frontmatter(): + bare = "# Just a heading\n\nbody" + stamped = stamp_skill(bare, "0.1.0") + assert stamped.startswith("") + assert "# Just a heading" in stamped + + +def test_strip_returns_canonical(): + stamped = stamp_skill(SAMPLE, "1.2.3") + assert strip_version_line(stamped) != stamped + # Stripping then re-stamping must round-trip to the same content. + re_stamped = stamp_skill(strip_version_line(stamped), "1.2.3") + assert re_stamped == stamped + + +def test_extract_version_roundtrip(): + stamped = stamp_skill(SAMPLE, "0.4.0") + assert extract_version(stamped) == "0.4.0" + assert extract_version(SAMPLE) is None + + +# ── target redirection fixture ──────────────────────────────────── + + +@pytest.fixture +def isolated_targets(tmp_path, monkeypatch): + """Redirect skill_install.TARGETS at the per-test tmp_path so we + never touch the developer's real ~/.claude, ~/.agents, etc.""" + targets = { + "claude": tmp_path / "claude/skills/grafli", + "codex": tmp_path / "agents/skills/grafli", + "opencode": tmp_path / "opencode/skills/grafli", + } + monkeypatch.setattr(skill_install, "TARGETS", targets) + return targets + + +# ── compute_status across all 5 states ──────────────────────────── + + +def test_status_missing(isolated_targets): + st = compute_status("claude", SAMPLE, "0.4.0") + assert st.status == MISSING + assert st.installed_version is None + assert st.packaged_version == "0.4.0" + + +def test_status_ok_matches_packaged(isolated_targets): + write_skill("claude", SAMPLE, "0.4.0") + st = compute_status("claude", SAMPLE, "0.4.0") + assert st.status == OK + assert st.installed_version == "0.4.0" + + +def test_status_stale_when_version_older(isolated_targets): + write_skill("claude", SAMPLE, "0.3.0") + new_packaged = SAMPLE + "\n## New section\n" + st = compute_status("claude", new_packaged, "0.4.0") + assert st.status == STALE + assert st.installed_version == "0.3.0" + + +def test_status_modified_when_version_matches_but_content_differs(isolated_targets): + write_skill("claude", SAMPLE, "0.4.0") + # Manually edit the installed file (user added a note). + path = isolated_targets["claude"] / "SKILL.md" + text = path.read_text() + "\n\n\n" + path.write_text(text) + st = compute_status("claude", SAMPLE, "0.4.0") + assert st.status == MODIFIED + assert st.installed_version == "0.4.0" + + +def test_status_unknown_when_no_version_marker(isolated_targets): + # File exists but was hand-installed without the version comment. + path = isolated_targets["claude"] / "SKILL.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# Old hand-installed body\n\nstuff that differs.") + st = compute_status("claude", SAMPLE, "0.4.0") + assert st.status == UNKNOWN + assert st.installed_version is None + + +# ── write / remove primitives ───────────────────────────────────── + + +def test_write_creates_parents_and_stamps(isolated_targets): + path = write_skill("codex", SAMPLE, "0.4.0") + assert path.exists() + assert "" in path.read_text() + assert path == isolated_targets["codex"] / "SKILL.md" + + +def test_remove_deletes_skill_directory(isolated_targets): + write_skill("claude", SAMPLE, "0.4.0") + assert isolated_targets["claude"].exists() + assert remove_skill("claude") is True + assert not isolated_targets["claude"].exists() + + +def test_remove_when_already_missing_returns_false(isolated_targets): + assert remove_skill("claude") is False From 99770271db286cf7e2b0cb2f161b73429b18567b Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:06:42 +0200 Subject: [PATCH 10/23] Skill: boxes are identifiers, multi-line detail goes in code: notes --- grafli/skills/grafli/SKILL.md | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/grafli/skills/grafli/SKILL.md b/grafli/skills/grafli/SKILL.md index 5220b0a..9aadbf7 100644 --- a/grafli/skills/grafli/SKILL.md +++ b/grafli/skills/grafli/SKILL.md @@ -40,9 +40,14 @@ any `@ box` / `@ arrow` / `@ note` lines: 6. **Arrows last.** Every arrow gets a label unless its meaning is obvious from context. Use semantic edge prefixes (`call:`, `data:`, `event:`, etc.) where they fit. -7. **Notes for the human.** Add `T:` tasks, `Q:` questions, `code:` - notes for review-oriented detail. Don't dump prose on the canvas — - notes are headlines, not paragraphs. +7. **Notes for the human.** Add `T:` tasks and `Q:` questions as + short headlines next to the relevant node. For pseudocode, + assertion lists, behavioral specs, sequence sketches, or any + other multi-line detail, use a `code:` note — that's the right + home for content too long for a box label. **Never inline that + detail into a box label itself.** Boxes are identifiers; notes + are bodies. If you find yourself writing more than a short phrase + inside a `@ box` label, stop and move it to a note. 8. **Re-read.** Pretend you're the user opening the file: does the eye land on the right entry point? Are arrows crossing? If yes, reposition before saving — repositioning beats decoration. @@ -160,6 +165,16 @@ explicitly on top-level containers for a more prominent heading. Child positions use absolute coordinates — see the container layout model in the design principles below. +**Boxes are identifiers, not bodies.** A box label should be the +shortest phrase that names the node — typically one line, two at +most. If you're tempted to add bullet points, pseudocode, +behavioral detail, or a multi-paragraph description inside a box +label, that content belongs in a `code:` or plain note next to the +box — not inside it. Notes carry distinct visual affordances (badge +colours for `T:` / `Q:`, handwriting font, syntax-styled `code:` +rendering) that you forfeit by inlining the detail. The shape of +your diagram is the graph; the detail lives in notes adjacent to it. + ## Arrow syntax ``` @@ -230,6 +245,14 @@ Goal: a reviewer can verify in seconds that an implementation covers the expected steps, branches, and side effects — without opening the source. +`code:` notes are the right home for any multi-line detail you might +otherwise be tempted to cram into a box label: assertion lists, +phase checklists, configuration snippets, behavioral specs, sequence +sketches. The syntax-styled rendering (bold signature line, indent +guides, coloured keywords) gives that content a distinct visual voice +the diagram needs — flat text inside a box label is a regression in +both readability and review value. + #### Layout * **First body line is the function signature** — rendered bold with @@ -782,6 +805,11 @@ viewer follows the link. * **Orphaned elements**: boxes floating far from their logical group look accidental — keep related items close and aligned. * **Undersized boxes**: a box should comfortably fit its label. +* **Text bodies inside box labels**: bullet lists, pseudocode, + assertion checklists, multi-paragraph descriptions belong in a + `code:` or plain note next to the box, not crammed into the box + label. If a box label needs more than a short phrase to identify + the node, the extra content is a note opportunity. --- From b2b9bd7d864a753624dbe553d26eccde86dbef94 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:06:42 +0200 Subject: [PATCH 11/23] =?UTF-8?q?Cheatsheet:=20move=20Dim=20notes=20(?= =?UTF-8?q?=E2=87=A7N)=20under=20Focus=20&=20Analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- grafli/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafli/view.py b/grafli/view.py index 90619c2..e28e4aa 100644 --- a/grafli/view.py +++ b/grafli/view.py @@ -4869,7 +4869,6 @@ def _show_cheatsheet(self): ("N", "Create node (\u21e7click stays in mode)"), ("T", "Create note (\u21e7click stays in mode)"), ("C", "Connect arrow (one-shot)"), - ("\u21e7N", "Dim notes \u2014 concentrate on the graph"), ]), ("Navigate", [ ("Arrow keys", "Pan viewport"), @@ -4917,6 +4916,7 @@ def _show_cheatsheet(self): ]), ("Focus & Analysis", [ (",", "Dim arrows"), + ("\u21e7N", "Dim notes \u2014 concentrate on the graph"), ("A", "Complexity analysis heatmap"), ("B", "Subgraph focus (cycle direction)"), ("\u21e7B", "Toggle focus depth (full/1-hop)"), From 55232f77d716283d8f1e49cd4286d386a941266b Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:25:01 +0200 Subject: [PATCH 12/23] Link badge: smaller plate (14x11) mirroring label style for legibility --- grafli/items.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/grafli/items.py b/grafli/items.py index 0696271..53c0b75 100644 --- a/grafli/items.py +++ b/grafli/items.py @@ -90,13 +90,24 @@ def note_prefix(text: str) -> tuple[str, str] | None: def _paint_link_glyph(painter: QPainter, rect: QRectF): - """Paint a subtle link icon at the top-right corner of *rect*.""" - color = QColor("#D4804E") - color.setAlphaF(0.6) - painter.setPen(QPen(color, 1.2)) + """Paint a link icon at the top-right of *rect*, on a small label-style + plate so it stays legible regardless of box fill color. + """ + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Plate — mirrors BoxLabelItem's background plate. + plate = QRectF(rect.right() - 17, rect.top() + 2, 14, 11) + bg = QColor("#F2F0EB") + bg.setAlphaF(0.6) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(bg)) + painter.drawRoundedRect(plate, 4, 4) + + # Chain glyph — same color as label text, full alpha (plate carries contrast). + painter.setPen(QPen(QColor("#2F3437"), 1.2)) painter.setBrush(Qt.BrushStyle.NoBrush) - cx = rect.right() - 8 - cy = rect.top() + 8 + cx = plate.center().x() + cy = plate.center().y() r1 = QRectF(cx - 4, cy - 3, 6, 4) r2 = QRectF(cx - 2, cy - 1, 6, 4) painter.drawRoundedRect(r1, 1.5, 1.5) From 3f495a837c9374240f77ad5ef62bde5baa9d3faf Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:32:59 +0200 Subject: [PATCH 13/23] Zen editor: render as 80% modal card with dim wash over graph --- grafli/constants.py | 6 +++++ grafli/zen_md.py | 54 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/grafli/constants.py b/grafli/constants.py index bf77993..601538b 100644 --- a/grafli/constants.py +++ b/grafli/constants.py @@ -156,6 +156,12 @@ def _resolve_color(color: str) -> str: ZEN_MD_FONT_SIZE = 16 ZEN_MD_FONT_SIZE_MIN = 10 ZEN_MD_FONT_SIZE_MAX = 32 +# Modal card occupies 80% × 80% of the parent; rest is dim wash so the +# graph stays faintly visible behind. +ZEN_MD_CARD_W_RATIO = 0.80 +ZEN_MD_CARD_H_RATIO = 0.80 +ZEN_MD_CARD_RADIUS = 12 +ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha # ── Side panel ─────────────────────────────────────────────────── SIDE_PANEL_WIDTH = 180 diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 408367e..89dafe7 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -5,8 +5,8 @@ import re from pathlib import Path -from PySide6.QtCore import QEvent, QFileSystemWatcher, QSettings, Qt, Signal, QTimer -from PySide6.QtGui import QFont, QKeyEvent, QPainter +from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer +from PySide6.QtGui import QBrush, QFont, QKeyEvent, QPainter from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget @@ -14,6 +14,10 @@ FONT_FAMILY, ZEN_HINT_COLOR, ZEN_MD_BG, + ZEN_MD_CARD_H_RATIO, + ZEN_MD_CARD_RADIUS, + ZEN_MD_CARD_W_RATIO, + ZEN_MD_DIM_COLOR, ZEN_MD_FONT_SIZE, ZEN_MD_FONT_SIZE_MAX, ZEN_MD_FONT_SIZE_MIN, @@ -44,6 +48,10 @@ def __init__( ): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + # Translucent so the dim wash painted in paintEvent composites over + # the parent's content (the graph) instead of obscuring it. + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAutoFillBackground(False) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self._file_path = file_path self._original_text = text @@ -71,9 +79,7 @@ def __init__( def _build_ui(self, title: str, text: str): layout = QVBoxLayout(self) - h_margin = max((self.width() - ZEN_MD_MAX_WIDTH) // 2, 60) - v_margin = max(self.height() // 8, 40) - layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin) + self._apply_card_margins(layout) layout.setSpacing(8) # Title @@ -145,9 +151,9 @@ def _build_hint_text(self) -> str: parts = [] if self._file_path: if self._read_only: - parts.append("[READ-ONLY]") + parts.append("[READ-ONLY \u00b7 Ctrl+W to edit]") else: - parts.append("[EDITING]") + parts.append("[EDITING \u00b7 Ctrl+W to lock]") mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL" parts.append(f"-- {mode_name} --") parts.append("Esc to save \u00b7 Shift+Esc to cancel") @@ -278,11 +284,39 @@ def _print(self): self._editor.print_(printer) self._highlighter.set_focus_enabled(True) + # ── Modal card geometry ── + + def _card_rect(self) -> QRectF: + """80% × 80% rect centered in the widget — the writing surface.""" + w = self.width() * ZEN_MD_CARD_W_RATIO + h = self.height() * ZEN_MD_CARD_H_RATIO + x = (self.width() - w) / 2 + y = (self.height() - h) / 2 + return QRectF(x, y, w, h) + + def _apply_card_margins(self, layout): + """Anchor layout margins inside the modal card with comfortable + padding, while keeping content width ≤ ZEN_MD_MAX_WIDTH.""" + card = self._card_rect() + side = self.width() - card.right() # symmetric outer gap to card right + inner_h = max((card.width() - ZEN_MD_MAX_WIDTH) / 2, 24) + h_margin = int(side + inner_h) + v_margin = int((self.height() - card.height()) / 2 + 32) + layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin) + # ── Paint ── def paintEvent(self, event): p = QPainter(self) - p.fillRect(self.rect(), ZEN_MD_BG) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + # Dim wash across the full widget — graph stays faintly visible. + p.fillRect(self.rect(), ZEN_MD_DIM_COLOR) + # Solid writing card centered at 80% × 80%. + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(ZEN_MD_BG)) + p.drawRoundedRect( + self._card_rect(), ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS, + ) p.end() # ── Resize tracking ── @@ -291,9 +325,7 @@ def resizeEvent(self, event): super().resizeEvent(event) layout = self.layout() if layout: - h_margin = max((self.width() - ZEN_MD_MAX_WIDTH) // 2, 60) - v_margin = max(self.height() // 8, 40) - layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin) + self._apply_card_margins(layout) def _parent_resized(self): parent = self.parentWidget() From 7a1ab345b4e9d2ef69aaa6861d706ac9cbd9477b Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:32:59 +0200 Subject: [PATCH 14/23] Help: Markdown Editor tab + hint bar shows Ctrl+W toggle --- grafli/view.py | 104 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/grafli/view.py b/grafli/view.py index e28e4aa..df7ffcb 100644 --- a/grafli/view.py +++ b/grafli/view.py @@ -5060,6 +5060,17 @@ def _render_column(group_names): notes_browser.setHtml(self._notes_help_html()) tabs.addTab(notes_browser, "Text Annotations") + # ── Tab 3: markdown editor ── + md_browser = QTextBrowser(tabs) + md_browser.setOpenLinks(False) + md_browser.setFont(font) + md_browser.setStyleSheet( + "QTextBrowser { background: #2A2A2A; color: #E0E0E0; border: none;" + " padding: 8px; }" + ) + md_browser.setHtml(self._md_editor_help_html()) + tabs.addTab(md_browser, "Markdown Editor") + btn = QPushButton("Close", dlg) btn.clicked.connect(dlg.accept) @@ -5207,6 +5218,99 @@ def _notes_help_html(self) -> str: canvas this is still just an ordinary editable note.

""" + def _md_editor_help_html(self) -> str: + hdr = ( + "color:#6A9FB5;font-weight:bold;" + "padding-top:10px;padding-bottom:4px" + ) + kw = "color:#6A9FB5;font-weight:bold" + mono = "font-family:monospace" + cell = "padding:4px 8px;vertical-align:top" + key_cell = ( + "padding:4px 8px;font-family:monospace;" + "white-space:nowrap;vertical-align:top" + ) + return f""" +

MARKDOWN EDITOR (ZEN MODE)

+

Opens when you follow a link to a local .md + file from a node URL, or when you edit an annotation. Read-only for + files by default so you can browse without accidental edits; toggle + write mode with Ctrl+W. Annotation edits start in write mode.

+ +

Session

+ + + + + + + + + + + + + +
EscSave & close + (annotation mode emits the new text; file mode just + closes — writes happen via autosave).
Shift+EscCancel — discard pending changes + in annotation mode.
Ctrl+WToggle read-only / write + (file mode only). Write mode autosaves after 500 ms + of idle typing; read-only re-attaches the file watcher + so external edits reload.
Ctrl+POpen the native print dialog.
Ctrl++ / Ctrl+- / Ctrl+0Bigger / smaller / reset font size + (persists across sessions).
Ctrl+JActivate word-jump overlay + (Easymotion-style two-key jump to any visible word).
+ +

Vim Motion (NORMAL mode)

+ + + + + + + + + +
h j k lLeft / down / up / right.
w / b / eNext word start / previous word / + word end.
0 / $Line start / line end.
gg / GDocument start / end.
+ +

Entering INSERT mode

+ + + + + + + + + +
i / aInsert before / after the cursor.
I / AInsert at line start / line end.
o / OOpen new line below / above.
EscBack to NORMAL mode + (cursor steps left, vim convention).
+ +

Edits (NORMAL mode)

+ + + + + + + +
xDelete character under cursor.
ddDelete line.
dwDelete to next word.
+ +

Display

+

iA Writer-inspired: the current paragraph stays at full opacity, + surrounding text is muted to keep focus on what you're writing. + Headings, lists, links, inline code, + and code fences get light syntax highlighting; muted in read-only + mode (no focus paragraph) so the whole document reads as one piece.

+ +

Layout

+

The editor opens as a modal card occupying 80% of the window + with the graph dimly visible behind — you stay in grafli, + just focused on the text. Content is centered with a max width of + about 700 px regardless of window size, so lines stay readable.

+ """ + def _show_graph_stats_dialog(self): hdr = "color:#6A9FB5;font-weight:bold;font-size:13px" cell = "padding:4px 8px" From b13d4a4ca566876918dfa746268c273a5c1f4313 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:41:47 +0200 Subject: [PATCH 15/23] Zen editor: subtle READ-ONLY pill in card corner; repaint on toggle --- grafli/zen_md.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 89dafe7..dfe361f 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -6,7 +6,7 @@ from pathlib import Path from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer -from PySide6.QtGui import QBrush, QFont, QKeyEvent, QPainter +from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget @@ -262,6 +262,7 @@ def _toggle_write_mode(self): self._editor.textChanged.connect(self._schedule_autosave) self._vim._set_mode(VimMode.NORMAL) self._hint.setText(self._build_hint_text()) + self.update() # repaint to add/remove the READ-ONLY badge def _schedule_autosave(self): if self._autosave_timer: @@ -312,13 +313,37 @@ def paintEvent(self, event): # Dim wash across the full widget — graph stays faintly visible. p.fillRect(self.rect(), ZEN_MD_DIM_COLOR) # Solid writing card centered at 80% × 80%. + card = self._card_rect() p.setPen(Qt.PenStyle.NoPen) p.setBrush(QBrush(ZEN_MD_BG)) - p.drawRoundedRect( - self._card_rect(), ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS, - ) + p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS) + # Read-only indicator in the corner — quiet but always visible. + if self._read_only and self._file_path: + self._paint_readonly_badge(p, card) p.end() + def _paint_readonly_badge(self, painter: QPainter, card: QRectF): + """Subtle READ-ONLY pill in the card's top-right corner.""" + badge_font = QFont(FONT_FAMILY, 9, QFont.Weight.DemiBold) + fm = QFontMetricsF(badge_font) + text = "READ-ONLY" + text_w = fm.horizontalAdvance(text) + pad_h, pad_v = 10, 3 + plate_w = text_w + pad_h * 2 + plate_h = fm.height() + pad_v * 2 + plate = QRectF( + card.right() - plate_w - 14, + card.top() + 14, + plate_w, + plate_h, + ) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(QColor("#E0DBD2"))) + painter.drawRoundedRect(plate, plate_h / 2, plate_h / 2) + painter.setFont(badge_font) + painter.setPen(QPen(ZEN_HINT_COLOR)) + painter.drawText(plate, int(Qt.AlignmentFlag.AlignCenter), text) + # ── Resize tracking ── def resizeEvent(self, event): From 778349e4c2221ac3d278db9694dbdba4cf69c78f Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 20:52:55 +0200 Subject: [PATCH 16/23] Zen editor: hug text column, spare canvas from dim, READ/EDIT pill, drop shadow --- grafli/constants.py | 10 ++- grafli/view.py | 19 +++-- grafli/zen_md.py | 192 +++++++++++++++++++++++++++++++++----------- 3 files changed, 163 insertions(+), 58 deletions(-) diff --git a/grafli/constants.py b/grafli/constants.py index 601538b..ae144af 100644 --- a/grafli/constants.py +++ b/grafli/constants.py @@ -156,10 +156,12 @@ def _resolve_color(color: str) -> str: ZEN_MD_FONT_SIZE = 16 ZEN_MD_FONT_SIZE_MIN = 10 ZEN_MD_FONT_SIZE_MAX = 32 -# Modal card occupies 80% × 80% of the parent; rest is dim wash so the -# graph stays faintly visible behind. -ZEN_MD_CARD_W_RATIO = 0.80 -ZEN_MD_CARD_H_RATIO = 0.80 +# Modal card: width hugs the text column, height takes most of the window. +# Card chrome strips (the area outside the canvas) get the dim wash so the +# graph canvas itself stays fully saturated. +ZEN_MD_CARD_INNER_PAD_H = 64 +ZEN_MD_CARD_INNER_PAD_V = 40 +ZEN_MD_CARD_H_RATIO = 0.85 ZEN_MD_CARD_RADIUS = 12 ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha diff --git a/grafli/view.py b/grafli/view.py index df7ffcb..8ed6c05 100644 --- a/grafli/view.py +++ b/grafli/view.py @@ -2006,7 +2006,7 @@ def _open_md_zen(self, path: Path, anchor: str = ""): text = path.read_text(encoding="utf-8") self._zen_editor = ZenMarkdownEditor( parent=self.window(), text=text, title=path.name, - file_path=path, anchor=anchor, + file_path=path, anchor=anchor, canvas=self, ) self._zen_editor.cancelled.connect(self._cancel_zen_edit) @@ -5233,9 +5233,10 @@ def _md_editor_help_html(self) -> str: return f"""

MARKDOWN EDITOR (ZEN MODE)

Opens when you follow a link to a local .md - file from a node URL, or when you edit an annotation. Read-only for - files by default so you can browse without accidental edits; toggle - write mode with Ctrl+W. Annotation edits start in write mode.

+ file from a node URL, or when you edit an annotation. The corner pill + shows the current perspective (READ or EDIT); files start + read-only so browsing never edits by accident. Toggle with Ctrl+W. + Annotation edits start in write mode.

Session

@@ -5305,10 +5306,12 @@ def _md_editor_help_html(self) -> str: mode (no focus paragraph) so the whole document reads as one piece.

Layout

-

The editor opens as a modal card occupying 80% of the window - with the graph dimly visible behind — you stay in grafli, - just focused on the text. Content is centered with a max width of - about 700 px regardless of window size, so lines stay readable.

+

The editor opens as a centered modal card with a drop shadow. + The dim wash falls over grafli's chrome (toolbars, side panel, + minimap) but spares the graph canvas, so the diagram you're + annotating stays fully saturated behind the card. Card width hugs + the text column (max ≈700 px) so lines stay readable + regardless of window size.

""" def _show_graph_stats_dialog(self): diff --git a/grafli/zen_md.py b/grafli/zen_md.py index dfe361f..233fa98 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -5,7 +5,17 @@ import re from pathlib import Path -from PySide6.QtCore import QEvent, QFileSystemWatcher, QRectF, QSettings, Qt, Signal, QTimer +from PySide6.QtCore import ( + QEvent, + QFileSystemWatcher, + QPoint, + QRect, + QRectF, + QSettings, + Qt, + Signal, + QTimer, +) from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget @@ -15,8 +25,9 @@ ZEN_HINT_COLOR, ZEN_MD_BG, ZEN_MD_CARD_H_RATIO, + ZEN_MD_CARD_INNER_PAD_H, + ZEN_MD_CARD_INNER_PAD_V, ZEN_MD_CARD_RADIUS, - ZEN_MD_CARD_W_RATIO, ZEN_MD_DIM_COLOR, ZEN_MD_FONT_SIZE, ZEN_MD_FONT_SIZE_MAX, @@ -45,6 +56,7 @@ def __init__( title: str = "", file_path: Path | None = None, anchor: str = "", + canvas: QWidget | None = None, ): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -58,6 +70,9 @@ def __init__( self._read_only = file_path is not None self._watcher = None self._autosave_timer: QTimer | None = None + # The graph canvas widget — the dim wash skips over this rect so + # the graph itself stays fully saturated while UI chrome dims. + self._canvas = canvas # Load persisted font size preference settings = QSettings("Grafli", "Grafli") @@ -148,16 +163,8 @@ def _build_ui(self, title: str, text: str): self._update_focus() def _build_hint_text(self) -> str: - parts = [] - if self._file_path: - if self._read_only: - parts.append("[READ-ONLY \u00b7 Ctrl+W to edit]") - else: - parts.append("[EDITING \u00b7 Ctrl+W to lock]") mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL" - parts.append(f"-- {mode_name} --") - parts.append("Esc to save \u00b7 Shift+Esc to cancel") - return " ".join(parts) + return f"-- {mode_name} -- Esc to save \u00b7 Shift+Esc to cancel" def _on_mode_changed(self, mode: VimMode): self._hint.setText(self._build_hint_text()) @@ -288,61 +295,154 @@ def _print(self): # ── Modal card geometry ── def _card_rect(self) -> QRectF: - """80% × 80% rect centered in the widget — the writing surface.""" - w = self.width() * ZEN_MD_CARD_W_RATIO - h = self.height() * ZEN_MD_CARD_H_RATIO + """Card width hugs the text column (ZEN_MD_MAX_WIDTH + padding); + height takes most of the window. Centered. + """ + desired_w = ZEN_MD_MAX_WIDTH + 2 * ZEN_MD_CARD_INNER_PAD_H + max_w = max(self.width() - 80, 320) + w = min(desired_w, max_w) + h = min(self.height() * ZEN_MD_CARD_H_RATIO, self.height() - 60) x = (self.width() - w) / 2 y = (self.height() - h) / 2 return QRectF(x, y, w, h) def _apply_card_margins(self, layout): - """Anchor layout margins inside the modal card with comfortable - padding, while keeping content width ≤ ZEN_MD_MAX_WIDTH.""" + """Anchor layout margins inside the card with comfortable padding.""" card = self._card_rect() - side = self.width() - card.right() # symmetric outer gap to card right - inner_h = max((card.width() - ZEN_MD_MAX_WIDTH) / 2, 24) - h_margin = int(side + inner_h) - v_margin = int((self.height() - card.height()) / 2 + 32) - layout.setContentsMargins(h_margin, v_margin, h_margin, v_margin) + h_outside = (self.width() - card.width()) / 2 + v_outside = (self.height() - card.height()) / 2 + layout.setContentsMargins( + int(h_outside + ZEN_MD_CARD_INNER_PAD_H), + int(v_outside + ZEN_MD_CARD_INNER_PAD_V), + int(h_outside + ZEN_MD_CARD_INNER_PAD_H), + int(v_outside + ZEN_MD_CARD_INNER_PAD_V), + ) + + def _canvas_rect_in_self(self) -> QRect | None: + """Return the canvas widget's geometry in this widget's coord space, + or None if no canvas was supplied / it isn't visible. + """ + if not self._canvas or not self._canvas.isVisible(): + return None + top_left = self.mapFromGlobal(self._canvas.mapToGlobal(QPoint(0, 0))) + return QRect(top_left, self._canvas.size()) # ── Paint ── def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) - # Dim wash across the full widget — graph stays faintly visible. - p.fillRect(self.rect(), ZEN_MD_DIM_COLOR) - # Solid writing card centered at 80% × 80%. + + # Dim wash — but skip the canvas rect so the graph stays saturated. + canvas = self._canvas_rect_in_self() + full = self.rect() + if canvas is None or not full.intersects(canvas): + p.fillRect(full, ZEN_MD_DIM_COLOR) + else: + clipped = canvas.intersected(full) + # Four strips around the canvas — only the chrome dims. + if clipped.top() > full.top(): + p.fillRect( + QRect(full.left(), full.top(), + full.width(), clipped.top() - full.top()), + ZEN_MD_DIM_COLOR, + ) + if clipped.bottom() < full.bottom(): + p.fillRect( + QRect(full.left(), clipped.bottom() + 1, + full.width(), full.bottom() - clipped.bottom()), + ZEN_MD_DIM_COLOR, + ) + if clipped.left() > full.left(): + p.fillRect( + QRect(full.left(), clipped.top(), + clipped.left() - full.left(), clipped.height()), + ZEN_MD_DIM_COLOR, + ) + if clipped.right() < full.right(): + p.fillRect( + QRect(clipped.right() + 1, clipped.top(), + full.right() - clipped.right(), clipped.height()), + ZEN_MD_DIM_COLOR, + ) + + # Drop shadow, then the solid writing card on top. card = self._card_rect() + self._paint_card_shadow(p, card) p.setPen(Qt.PenStyle.NoPen) p.setBrush(QBrush(ZEN_MD_BG)) p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS) - # Read-only indicator in the corner — quiet but always visible. - if self._read_only and self._file_path: - self._paint_readonly_badge(p, card) + + # Mode pill (READ / EDIT) in the corner — always shown in file mode. + if self._file_path: + self._paint_mode_badge(p, card) p.end() - def _paint_readonly_badge(self, painter: QPainter, card: QRectF): - """Subtle READ-ONLY pill in the card's top-right corner.""" - badge_font = QFont(FONT_FAMILY, 9, QFont.Weight.DemiBold) - fm = QFontMetricsF(badge_font) - text = "READ-ONLY" - text_w = fm.horizontalAdvance(text) - pad_h, pad_v = 10, 3 - plate_w = text_w + pad_h * 2 - plate_h = fm.height() + pad_v * 2 - plate = QRectF( - card.right() - plate_w - 14, - card.top() + 14, - plate_w, - plate_h, - ) + def _paint_card_shadow(self, painter: QPainter, card: QRectF): + """Soft drop shadow around the card. Painted before the card; the + opaque card covers the inside, so only the spillover at the edges + shows. Layers stack outward with decreasing alpha, biased downward + for gravity. + """ + drop = 6 # downward bias + painter.setPen(Qt.PenStyle.NoPen) + for i in range(1, 14): + alpha = 20 - i * 2 + if alpha <= 0: + break + painter.setBrush(QBrush(QColor(0, 0, 0, alpha))) + shadow = QRectF( + card.left() - i, + card.top() - i + drop // 2, + card.width() + 2 * i, + card.height() + 2 * i + drop // 2, + ) + painter.drawRoundedRect( + shadow, ZEN_MD_CARD_RADIUS + i, ZEN_MD_CARD_RADIUS + i, + ) + + def _paint_mode_badge(self, painter: QPainter, card: QRectF): + """Mode pill (READ or EDIT) plus a Ctrl+W toggle hint underneath.""" + is_edit = not self._read_only + if is_edit: + plate_color = QColor("#D8E0EA") + text_color = ZEN_TITLE_COLOR + label = "EDIT" + else: + plate_color = QColor("#E0DBD2") + text_color = ZEN_HINT_COLOR + label = "READ" + + pill_font = QFont(FONT_FAMILY, 10, QFont.Weight.Bold) + fm_pill = QFontMetricsF(pill_font) + pad_h, pad_v = 14, 4 + plate_w = max(fm_pill.horizontalAdvance(label) + pad_h * 2, 64) + plate_h = fm_pill.height() + pad_v * 2 + pill_x = card.right() - plate_w - 14 + pill_y = card.top() + 14 + pill = QRectF(pill_x, pill_y, plate_w, plate_h) + painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(QColor("#E0DBD2"))) - painter.drawRoundedRect(plate, plate_h / 2, plate_h / 2) - painter.setFont(badge_font) + painter.setBrush(QBrush(plate_color)) + painter.drawRoundedRect(pill, plate_h / 2, plate_h / 2) + painter.setFont(pill_font) + painter.setPen(QPen(text_color)) + painter.drawText(pill, int(Qt.AlignmentFlag.AlignCenter), label) + + # Subtitle: small "Ctrl+W toggle" centered under the pill. + sub_font = QFont(FONT_FAMILY, 8) + fm_sub = QFontMetricsF(sub_font) + sub_label = "Ctrl+W toggle" + sub_w = fm_sub.horizontalAdvance(sub_label) + sub_rect = QRectF( + pill_x + (plate_w - sub_w) / 2, + pill_y + plate_h + 3, + sub_w, + fm_sub.height(), + ) + painter.setFont(sub_font) painter.setPen(QPen(ZEN_HINT_COLOR)) - painter.drawText(plate, int(Qt.AlignmentFlag.AlignCenter), text) + painter.drawText(sub_rect, int(Qt.AlignmentFlag.AlignCenter), sub_label) # ── Resize tracking ── From 9d6c1eac0718b4f22a14cf7ba3039f83cad71ee7 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:05:53 +0200 Subject: [PATCH 17/23] =?UTF-8?q?Zen=20editor:=20strip=20all=20chrome=20?= =?UTF-8?q?=E2=80=94=20pure=20text,=20shortcuts=20via=20F1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- grafli/view.py | 14 ++++---- grafli/zen_md.py | 85 ++++-------------------------------------------- 2 files changed, 13 insertions(+), 86 deletions(-) diff --git a/grafli/view.py b/grafli/view.py index 8ed6c05..9f9e698 100644 --- a/grafli/view.py +++ b/grafli/view.py @@ -5233,10 +5233,10 @@ def _md_editor_help_html(self) -> str: return f"""

MARKDOWN EDITOR (ZEN MODE)

Opens when you follow a link to a local .md - file from a node URL, or when you edit an annotation. The corner pill - shows the current perspective (READ or EDIT); files start - read-only so browsing never edits by accident. Toggle with Ctrl+W. - Annotation edits start in write mode.

+ file from a node URL, or when you edit an annotation. Pure text, no + chrome — the shortcuts below are the controls. Files open + read-only so browsing never edits by accident; toggle with + Ctrl+W. Annotation edits start in write mode.

Session

@@ -5309,9 +5309,9 @@ def _md_editor_help_html(self) -> str:

The editor opens as a centered modal card with a drop shadow. The dim wash falls over grafli's chrome (toolbars, side panel, minimap) but spares the graph canvas, so the diagram you're - annotating stays fully saturated behind the card. Card width hugs - the text column (max ≈700 px) so lines stay readable - regardless of window size.

+ annotating stays fully saturated behind the card. The card holds + just the text — no title, no hint bar, no badges. Card width + hugs the text column (max ≈700 px).

""" def _show_graph_stats_dialog(self): diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 233fa98..84826a3 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -16,13 +16,12 @@ Signal, QTimer, ) -from PySide6.QtGui import QBrush, QColor, QFont, QFontMetricsF, QKeyEvent, QPainter, QPen +from PySide6.QtGui import QBrush, QColor, QFont, QKeyEvent, QPainter from PySide6.QtPrintSupport import QPrintDialog, QPrinter -from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget +from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget from grafli.constants import ( FONT_FAMILY, - ZEN_HINT_COLOR, ZEN_MD_BG, ZEN_MD_CARD_H_RATIO, ZEN_MD_CARD_INNER_PAD_H, @@ -34,7 +33,6 @@ ZEN_MD_FONT_SIZE_MIN, ZEN_MD_MAX_WIDTH, ZEN_TEXT_COLOR, - ZEN_TITLE_COLOR, _CTRL_MOD, ) from grafli.zen_md_highlight import MarkdownHighlighter, compute_focus_range @@ -95,20 +93,10 @@ def __init__( def _build_ui(self, title: str, text: str): layout = QVBoxLayout(self) self._apply_card_margins(layout) - layout.setSpacing(8) - - # Title - if title: - self._title = QLabel(title) - self._title.setFont(QFont(FONT_FAMILY, 11, QFont.Weight.Bold)) - self._title.setStyleSheet( - f"color: {ZEN_TITLE_COLOR.name()}; background: transparent;" - ) - layout.addWidget(self._title) - else: - self._title = None + layout.setSpacing(0) - # Text editor + # Pure text — no title, no hint bar, no badges. Discoverability + # lives in F1 help; the card is just the writing surface. self._editor = QPlainTextEdit(text) self._editor.setFont(QFont(FONT_FAMILY, self._font_size)) self._editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) @@ -116,7 +104,7 @@ def _build_ui(self, title: str, text: str): self._editor.setStyleSheet( f"QPlainTextEdit {{" f" background: {ZEN_MD_BG.name()}; color: {ZEN_TEXT_COLOR.name()};" - f" border: none; padding: 16px;" + f" border: none; padding: 0px;" f" selection-background-color: #B8D4E8;" f"}}" ) @@ -125,15 +113,6 @@ def _build_ui(self, title: str, text: str): ) layout.addWidget(self._editor, stretch=1) - # Hint bar - self._hint = QLabel(self._build_hint_text()) - self._hint.setFont(QFont(FONT_FAMILY, 10)) - self._hint.setStyleSheet( - f"color: {ZEN_HINT_COLOR.name()}; background: transparent;" - ) - self._hint.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self._hint) - # Markdown highlighter + paragraph focus (disabled in read-only mode) self._highlighter = MarkdownHighlighter(self._editor.document()) self._highlighter.set_base_size(self._font_size) @@ -162,12 +141,7 @@ def _build_ui(self, title: str, text: str): self._editor.setTextCursor(cursor) self._update_focus() - def _build_hint_text(self) -> str: - mode_name = self._vim.mode.value if hasattr(self, "_vim") else "NORMAL" - return f"-- {mode_name} -- Esc to save \u00b7 Shift+Esc to cancel" - def _on_mode_changed(self, mode: VimMode): - self._hint.setText(self._build_hint_text()) # Disable macOS input method in normal mode to prevent IMK # interference with auto-repeat key events. self._editor.setAttribute( @@ -268,8 +242,6 @@ def _toggle_write_mode(self): self._autosave_timer.timeout.connect(self._autosave) self._editor.textChanged.connect(self._schedule_autosave) self._vim._set_mode(VimMode.NORMAL) - self._hint.setText(self._build_hint_text()) - self.update() # repaint to add/remove the READ-ONLY badge def _schedule_autosave(self): if self._autosave_timer: @@ -373,9 +345,6 @@ def paintEvent(self, event): p.setBrush(QBrush(ZEN_MD_BG)) p.drawRoundedRect(card, ZEN_MD_CARD_RADIUS, ZEN_MD_CARD_RADIUS) - # Mode pill (READ / EDIT) in the corner — always shown in file mode. - if self._file_path: - self._paint_mode_badge(p, card) p.end() def _paint_card_shadow(self, painter: QPainter, card: QRectF): @@ -401,48 +370,6 @@ def _paint_card_shadow(self, painter: QPainter, card: QRectF): shadow, ZEN_MD_CARD_RADIUS + i, ZEN_MD_CARD_RADIUS + i, ) - def _paint_mode_badge(self, painter: QPainter, card: QRectF): - """Mode pill (READ or EDIT) plus a Ctrl+W toggle hint underneath.""" - is_edit = not self._read_only - if is_edit: - plate_color = QColor("#D8E0EA") - text_color = ZEN_TITLE_COLOR - label = "EDIT" - else: - plate_color = QColor("#E0DBD2") - text_color = ZEN_HINT_COLOR - label = "READ" - - pill_font = QFont(FONT_FAMILY, 10, QFont.Weight.Bold) - fm_pill = QFontMetricsF(pill_font) - pad_h, pad_v = 14, 4 - plate_w = max(fm_pill.horizontalAdvance(label) + pad_h * 2, 64) - plate_h = fm_pill.height() + pad_v * 2 - pill_x = card.right() - plate_w - 14 - pill_y = card.top() + 14 - pill = QRectF(pill_x, pill_y, plate_w, plate_h) - - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(plate_color)) - painter.drawRoundedRect(pill, plate_h / 2, plate_h / 2) - painter.setFont(pill_font) - painter.setPen(QPen(text_color)) - painter.drawText(pill, int(Qt.AlignmentFlag.AlignCenter), label) - - # Subtitle: small "Ctrl+W toggle" centered under the pill. - sub_font = QFont(FONT_FAMILY, 8) - fm_sub = QFontMetricsF(sub_font) - sub_label = "Ctrl+W toggle" - sub_w = fm_sub.horizontalAdvance(sub_label) - sub_rect = QRectF( - pill_x + (plate_w - sub_w) / 2, - pill_y + plate_h + 3, - sub_w, - fm_sub.height(), - ) - painter.setFont(sub_font) - painter.setPen(QPen(ZEN_HINT_COLOR)) - painter.drawText(sub_rect, int(Qt.AlignmentFlag.AlignCenter), sub_label) # ── Resize tracking ── From cd4e9fbfe3a72e1f8260aacf5dc251643d162644 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:07:24 +0200 Subject: [PATCH 18/23] Zen editor: hang heading # markers in left gutter; body text aligns --- grafli/zen_md.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 84826a3..c0c8b58 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -16,7 +16,16 @@ Signal, QTimer, ) -from PySide6.QtGui import QBrush, QColor, QFont, QKeyEvent, QPainter +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QFontMetricsF, + QKeyEvent, + QPainter, + QTextBlockFormat, + QTextCursor, +) from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget @@ -120,6 +129,11 @@ def _build_ui(self, title: str, text: str): if self._read_only: self._highlighter.set_focus_enabled(False) + # Heading gutter — `#` markers hang to the left of body text. + self._applying_layout = False + self._apply_heading_layout() + self._editor.textChanged.connect(self._on_text_changed_layout) + # Vim key handler self._vim = VimKeyHandler( editor=self._editor, @@ -190,6 +204,55 @@ def _jump_to_anchor(self, anchor: str): return block = block.next() + # ── Heading-gutter layout ── + + _RE_HEADING_PREFIX = re.compile(r"^(#{1,3})\s+") + + def _gutter_metrics(self) -> tuple[float, float]: + """Return (char_width, gutter_width). Gutter fits the longest + heading marker (`### ` = 4 chars). + """ + char_w = QFontMetricsF(self._editor.font()).horizontalAdvance(" ") + return char_w, char_w * 4 + + def _apply_block_layout(self, block) -> None: + """Set the block's leftMargin/textIndent so heading `#`s hang in + the gutter and heading text aligns with body text. + """ + char_w, gutter = self._gutter_metrics() + m = self._RE_HEADING_PREFIX.match(block.text()) + fmt = QTextBlockFormat() + fmt.setLeftMargin(gutter) + if m: + level = len(m.group(1)) + fmt.setTextIndent(-char_w * (level + 1)) + else: + fmt.setTextIndent(0) + current = block.blockFormat() + if (current.leftMargin() == fmt.leftMargin() + and current.textIndent() == fmt.textIndent()): + return + cursor = QTextCursor(block) + self._applying_layout = True + try: + cursor.setBlockFormat(fmt) + finally: + self._applying_layout = False + + def _apply_heading_layout(self) -> None: + """Apply heading-gutter layout to every block in the document.""" + doc = self._editor.document() + block = doc.firstBlock() + while block.isValid(): + self._apply_block_layout(block) + block = block.next() + + def _on_text_changed_layout(self) -> None: + """Re-apply layout to the block under the cursor on every edit.""" + if self._applying_layout: + return + self._apply_block_layout(self._editor.textCursor().block()) + # ── File watching & autosave ── def _setup_file_watcher(self): @@ -209,6 +272,7 @@ def _on_file_changed(self, path: str): cursor_pos = self._editor.textCursor().position() text = p.read_text(encoding="utf-8") self._editor.setPlainText(text) + self._apply_heading_layout() cursor = self._editor.textCursor() cursor.setPosition(min(cursor_pos, len(text))) self._editor.setTextCursor(cursor) @@ -462,6 +526,8 @@ def _change_font_size(self, delta: int): self._font_size = new_size self._editor.setFont(QFont(FONT_FAMILY, self._font_size)) self._highlighter.set_base_size(self._font_size) + # Gutter width is char-based; re-apply after font change. + self._apply_heading_layout() QSettings("Grafli", "Grafli").setValue( "zen_md/font_size", self._font_size ) From bd2cd03c429a96cae8f61e9718aefc5dc96bc9f6 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:08:32 +0200 Subject: [PATCH 19/23] Zen editor: fade in (180ms) on open, fade out (140ms) on close --- grafli/zen_md.py | 53 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/grafli/zen_md.py b/grafli/zen_md.py index c0c8b58..2c3e242 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -6,9 +6,11 @@ from pathlib import Path from PySide6.QtCore import ( + QEasingCurve, QEvent, QFileSystemWatcher, QPoint, + QPropertyAnimation, QRect, QRectF, QSettings, @@ -27,7 +29,12 @@ QTextCursor, ) from PySide6.QtPrintSupport import QPrintDialog, QPrinter -from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGraphicsOpacityEffect, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) from grafli.constants import ( FONT_FAMILY, @@ -90,12 +97,19 @@ def __init__( ZEN_MD_FONT_SIZE_MIN, min(ZEN_MD_FONT_SIZE_MAX, self._font_size) ) + # Opacity effect for fade in/out. + self._opacity = QGraphicsOpacityEffect(self) + self._opacity.setOpacity(0.0) + self.setGraphicsEffect(self._opacity) + self._closing = False + self.resize(parent.size()) self._build_ui(title, text) self._setup_file_watcher() if anchor: self._jump_to_anchor(anchor) self.show() + self._start_fade_in() # ── UI construction ── @@ -163,19 +177,46 @@ def _on_mode_changed(self, mode: VimMode): mode == VimMode.INSERT, ) + def _start_fade_in(self): + anim = QPropertyAnimation(self._opacity, b"opacity", self) + anim.setDuration(180) + anim.setStartValue(0.0) + anim.setEndValue(1.0) + anim.setEasingCurve(QEasingCurve.Type.OutCubic) + anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped) + self._fade_in_anim = anim # hold ref so it doesn't get GC'd mid-run + def _close_save(self): if self._file_path: - # File mode: just close (autosave handles writes) - self.cancelled.emit() + self._fade_out_and_close(self._emit_cancelled) else: - # Annotation mode: emit finished with text - self.finished.emit(self._editor.toPlainText()) - self.close() + captured = self._editor.toPlainText() + self._fade_out_and_close(lambda: self._emit_finished(captured)) def _close_cancel(self): + self._fade_out_and_close(self._emit_cancelled) + + def _emit_cancelled(self): self.cancelled.emit() self.close() + def _emit_finished(self, text: str): + self.finished.emit(text) + self.close() + + def _fade_out_and_close(self, callback): + if self._closing: + return + self._closing = True + anim = QPropertyAnimation(self._opacity, b"opacity", self) + anim.setDuration(140) + anim.setStartValue(self._opacity.opacity()) + anim.setEndValue(0.0) + anim.setEasingCurve(QEasingCurve.Type.InCubic) + anim.finished.connect(callback) + anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped) + self._fade_out_anim = anim + def _update_focus(self): if self._read_only: return From e74f3488958d83f76475d77534c93a8102f69d73 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:12:03 +0200 Subject: [PATCH 20/23] Zen editor: slower fade (320/240ms) + gentle canvas dim that fades with it --- grafli/constants.py | 3 ++- grafli/zen_md.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/grafli/constants.py b/grafli/constants.py index ae144af..de7fbcd 100644 --- a/grafli/constants.py +++ b/grafli/constants.py @@ -163,7 +163,8 @@ def _resolve_color(color: str) -> str: ZEN_MD_CARD_INNER_PAD_V = 40 ZEN_MD_CARD_H_RATIO = 0.85 ZEN_MD_CARD_RADIUS = 12 -ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # ≈ 0.45 alpha +ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash +ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 55) # canvas — gentle, keeps graph readable # ── Side panel ─────────────────────────────────────────────────── SIDE_PANEL_WIDTH = 180 diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 2c3e242..e4fbb1c 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -39,6 +39,7 @@ from grafli.constants import ( FONT_FAMILY, ZEN_MD_BG, + ZEN_MD_CANVAS_DIM_COLOR, ZEN_MD_CARD_H_RATIO, ZEN_MD_CARD_INNER_PAD_H, ZEN_MD_CARD_INNER_PAD_V, @@ -179,7 +180,7 @@ def _on_mode_changed(self, mode: VimMode): def _start_fade_in(self): anim = QPropertyAnimation(self._opacity, b"opacity", self) - anim.setDuration(180) + anim.setDuration(320) anim.setStartValue(0.0) anim.setEndValue(1.0) anim.setEasingCurve(QEasingCurve.Type.OutCubic) @@ -209,7 +210,7 @@ def _fade_out_and_close(self, callback): return self._closing = True anim = QPropertyAnimation(self._opacity, b"opacity", self) - anim.setDuration(140) + anim.setDuration(240) anim.setStartValue(self._opacity.opacity()) anim.setEndValue(0.0) anim.setEasingCurve(QEasingCurve.Type.InCubic) @@ -410,14 +411,16 @@ def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) - # Dim wash — but skip the canvas rect so the graph stays saturated. + # Dim wash — chrome gets the full wash; canvas gets a gentler dim + # so the graph stays readable but visibly steps back. Both fade + # together with the widget's opacity effect. canvas = self._canvas_rect_in_self() full = self.rect() if canvas is None or not full.intersects(canvas): p.fillRect(full, ZEN_MD_DIM_COLOR) else: clipped = canvas.intersected(full) - # Four strips around the canvas — only the chrome dims. + # Four chrome strips — full dim. if clipped.top() > full.top(): p.fillRect( QRect(full.left(), full.top(), @@ -442,6 +445,8 @@ def paintEvent(self, event): full.right() - clipped.right(), clipped.height()), ZEN_MD_DIM_COLOR, ) + # Canvas — gentler dim, animates with the editor's opacity. + p.fillRect(clipped, ZEN_MD_CANVAS_DIM_COLOR) # Drop shadow, then the solid writing card on top. card = self._card_rect() From daec7da749bda1460fda05f1052bbb01d6009dc5 Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:14:40 +0200 Subject: [PATCH 21/23] Zen editor: stronger canvas dim; remember window geometry across runs --- grafli/app.py | 27 +++++++++++++++++++++------ grafli/constants.py | 4 ++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/grafli/app.py b/grafli/app.py index f0e5dde..941c803 100644 --- a/grafli/app.py +++ b/grafli/app.py @@ -684,6 +684,9 @@ def closeEvent(self, event): elif reply == QMessageBox.StandardButton.Cancel: event.ignore() return + QSettings("Grafli", "Grafli").setValue( + "window/geometry", self.saveGeometry(), + ) self._stop_watching() super().closeEvent(event) @@ -1233,12 +1236,24 @@ def main(): tick.timeout.connect(lambda: None) window = MainWindow(args.file, debug=args.debug) - # Pin to primary screen so a sleeping/disconnected external display - # cannot swallow the window via macOS's cached window frame. - primary = app.primaryScreen() - if primary is not None: - window.setGeometry(primary.availableGeometry()) - window.showMaximized() + # Restore saved geometry if it lands on a currently-attached screen; + # otherwise fall back to maximized on the primary screen so a sleeping + # or disconnected external display can't swallow the window. + restored = False + saved_geom = QSettings("Grafli", "Grafli").value("window/geometry") + if saved_geom is not None and window.restoreGeometry(saved_geom): + frame = window.frameGeometry() + if any( + s.availableGeometry().intersects(frame) for s in app.screens() + ): + restored = True + if not restored: + primary = app.primaryScreen() + if primary is not None: + window.setGeometry(primary.availableGeometry()) + window.showMaximized() + else: + window.show() # Single-instance server server = QLocalServer() diff --git a/grafli/constants.py b/grafli/constants.py index de7fbcd..d2227b0 100644 --- a/grafli/constants.py +++ b/grafli/constants.py @@ -163,8 +163,8 @@ def _resolve_color(color: str) -> str: ZEN_MD_CARD_INNER_PAD_V = 40 ZEN_MD_CARD_H_RATIO = 0.85 ZEN_MD_CARD_RADIUS = 12 -ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash -ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 55) # canvas — gentle, keeps graph readable +ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash +ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 120) # canvas — strong step-back # ── Side panel ─────────────────────────────────────────────────── SIDE_PANEL_WIDTH = 180 From f448d126934581abf27b8f27516fc046add134db Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Tue, 12 May 2026 21:20:53 +0200 Subject: [PATCH 22/23] Zen editor: warmer paper, softer text, neutral headings, stronger canvas dim --- grafli/constants.py | 6 +++--- grafli/zen_md_highlight.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/grafli/constants.py b/grafli/constants.py index d2227b0..c82dae7 100644 --- a/grafli/constants.py +++ b/grafli/constants.py @@ -141,13 +141,13 @@ def _resolve_color(color: str) -> str: ZEN_PANEL_BG = QColor("#F5F2ED") ZEN_PANEL_BORDER = QColor("#CDC8BF") ZEN_PANEL_WIDTH = 480 -ZEN_TEXT_COLOR = QColor("#2F3437") +ZEN_TEXT_COLOR = QColor("#403A30") ZEN_TITLE_COLOR = QColor("#004578") ZEN_HINT_COLOR = QColor("#8A8580") # ── Zen markdown editor ────────────────────────────────────────── ZEN_MD_MAX_WIDTH = 700 -ZEN_MD_BG = QColor("#F5F2ED") +ZEN_MD_BG = QColor("#EEE5D0") ZEN_MD_HEADING_SIZES = {1: 22, 2: 18, 3: 15} ZEN_MD_CODE_BG = QColor("#EDE9E3") ZEN_MD_LINK_COLOR = QColor("#004578") @@ -164,7 +164,7 @@ def _resolve_color(color: str) -> str: ZEN_MD_CARD_H_RATIO = 0.85 ZEN_MD_CARD_RADIUS = 12 ZEN_MD_DIM_COLOR = QColor(0, 0, 0, 115) # chrome — full wash -ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 120) # canvas — strong step-back +ZEN_MD_CANVAS_DIM_COLOR = QColor(0, 0, 0, 165) # canvas — strong step-back # ── Side panel ─────────────────────────────────────────────────── SIDE_PANEL_WIDTH = 180 diff --git a/grafli/zen_md_highlight.py b/grafli/zen_md_highlight.py index 654084e..2a4c4a7 100644 --- a/grafli/zen_md_highlight.py +++ b/grafli/zen_md_highlight.py @@ -112,10 +112,10 @@ def highlightBlock(self, text: str): m.start(1), len(m.group(1)), _fmt(color=self._alpha(ZEN_MD_SYNTAX_COLOR, focused), size=size), ) - # Heading text + # Heading text — same color as body, bold + larger size only. self.setFormat( m.start(2), len(m.group(2)), - _fmt(color=self._alpha(ZEN_TITLE_COLOR, focused), size=size, bold=True), + _fmt(color=self._alpha(ZEN_TEXT_COLOR, focused), size=size, bold=True), ) return From 1417efa55e681f5d00f0a7a33dce457ba2b0cb8d Mon Sep 17 00:00:00 2001 From: Serein Pfeiffer Date: Wed, 13 May 2026 10:18:06 +0200 Subject: [PATCH 23/23] Nav: f jumps to item (alt to Ctrl+J); first-child moves to gc to pair with gp --- grafli/view.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/grafli/view.py b/grafli/view.py index 9f9e698..63870df 100644 --- a/grafli/view.py +++ b/grafli/view.py @@ -2640,6 +2640,9 @@ def keyPressEvent(self, event): if event.key() == Qt.Key.Key_P and no_mod: self._record_shortcut("gp → parent") self._select_parent_and_zoom() + elif event.key() == Qt.Key.Key_C and no_mod: + self._record_shortcut("gc → first child") + self._select_first_child() event.accept() return @@ -3076,9 +3079,10 @@ def keyPressEvent(self, event): event.accept() return - # F — select first child of current box + # f — jump mode (alternative to Ctrl+J). First-child now lives on `gc`. if event.key() == Qt.Key.Key_F and no_mod: - self._select_first_child() + self._clear_box_mode() + self._start_jump_mode() event.accept() return @@ -4877,9 +4881,9 @@ def _show_cheatsheet(self): ("z", "Zoom in: 25 → 50 → 100 → 150 % (cycle)"), ("⇧Z", "Zoom to fit (whole graph)"), ("gp", "Select parent (zoom if needed)"), - ("F", "Select first child"), + ("gc", "Select first child"), ("Tab / \u21e7Tab", "Cycle siblings (or search matches)"), - ("Ctrl+J", "Jump to any item (global)"), + ("f / Ctrl+J", "Jump to any item (global)"), ("Ctrl+O / Ctrl+I", "Nav history back / forward"), ("Alt (hold)", "Graph nav: follow connectors"), ("/", "Search dim-filter \u2014 Tab/\u21e7Tab cycle, Esc clears"),