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 3f18de8..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,25 +35,25 @@
@ 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 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)
# ============================================================
-@ 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,43 +64,43 @@
@ 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."
-@ 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 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
# ============================================================
# 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,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 temple "Temple" -397.0434479605445,2585.274276046725 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
@@ -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
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 """
diff --git a/grafli/app.py b/grafli/app.py
index ae07aaf..941c803 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()
@@ -681,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)
@@ -715,14 +721,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.
"""
@@ -732,7 +749,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).",
@@ -763,6 +797,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",
@@ -817,18 +1107,114 @@ 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 _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
+
+ 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()
+ 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))
+ 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
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. 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
+| Esc | +Save & close + (annotation mode emits the new text; file mode just + closes — writes happen via autosave). |
| Shift+Esc | +Cancel — discard pending changes + in annotation mode. |
| Ctrl+W | +Toggle 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+P | +Open the native print dialog. |
| Ctrl++ / Ctrl+- / Ctrl+0 | +Bigger / smaller / reset font size + (persists across sessions). |
| Ctrl+J | +Activate word-jump overlay + (Easymotion-style two-key jump to any visible word). |
Vim Motion (NORMAL mode)
+| h j k l | +Left / down / up / right. |
| w / b / e | +Next word start / previous word / + word end. |
| 0 / $ | +Line start / line end. |
| gg / G | +Document start / end. |
Entering INSERT mode
+| i / a | +Insert before / after the cursor. |
| I / A | +Insert at line start / line end. |
| o / O | +Open new line below / above. |
| Esc | +Back to NORMAL mode + (cursor steps left, vim convention). |
Edits (NORMAL mode)
+| x | +Delete character under cursor. |
| dd | +Delete line. |
| dw | +Delete 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 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. 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): hdr = "color:#6A9FB5;font-weight:bold;font-size:13px" cell = "padding:4px 8px" diff --git a/grafli/zen_md.py b/grafli/zen_md.py index 408367e..e4fbb1c 100644 --- a/grafli/zen_md.py +++ b/grafli/zen_md.py @@ -5,21 +5,51 @@ 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 ( + QEasingCurve, + QEvent, + QFileSystemWatcher, + QPoint, + QPropertyAnimation, + QRect, + QRectF, + QSettings, + Qt, + Signal, + QTimer, +) +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QFontMetricsF, + QKeyEvent, + QPainter, + QTextBlockFormat, + QTextCursor, +) from PySide6.QtPrintSupport import QPrintDialog, QPrinter -from PySide6.QtWidgets import QLabel, QPlainTextEdit, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QGraphicsOpacityEffect, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) from grafli.constants import ( FONT_FAMILY, - ZEN_HINT_COLOR, 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, + ZEN_MD_CARD_RADIUS, + ZEN_MD_DIM_COLOR, ZEN_MD_FONT_SIZE, ZEN_MD_FONT_SIZE_MAX, 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 @@ -41,15 +71,23 @@ def __init__( title: str = "", file_path: Path | None = None, anchor: str = "", + canvas: QWidget | None = None, ): 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 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") @@ -60,34 +98,29 @@ 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 ── 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) - 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 + self._apply_card_margins(layout) + 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) @@ -95,7 +128,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"}}" ) @@ -104,15 +137,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) @@ -120,6 +144,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, @@ -141,20 +170,7 @@ def _build_ui(self, title: str, text: str): self._editor.setTextCursor(cursor) self._update_focus() - def _build_hint_text(self) -> str: - parts = [] - if self._file_path: - if self._read_only: - parts.append("[READ-ONLY]") - else: - parts.append("[EDITING]") - 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) - 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( @@ -162,19 +178,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(320) + 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(240) + 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 @@ -203,6 +246,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): @@ -222,6 +314,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) @@ -255,7 +348,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()) def _schedule_autosave(self): if self._autosave_timer: @@ -278,22 +370,124 @@ def _print(self): self._editor.print_(printer) self._highlighter.set_focus_enabled(True) + # ── Modal card geometry ── + + def _card_rect(self) -> QRectF: + """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 card with comfortable padding.""" + card = self._card_rect() + 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.fillRect(self.rect(), ZEN_MD_BG) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 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 chrome strips — full dim. + 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, + ) + # 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() + 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) + p.end() + 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, + ) + + # ── Resize tracking ── 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() @@ -378,6 +572,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 ) 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 diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..c249469 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,350 @@ +"""Tests for grafli.diagnostics — static layout checks.""" + +import os +import tempfile +from pathlib import Path + +from grafli.diagnostics import ( + Diagnostic, + check_arrow_label_covers_head, + check_arrow_label_crowded, + 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) + + +# ── 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) diff --git a/tests/test_format.py b/tests/test_format.py index 0490e94..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 @@ -9,6 +10,7 @@ Board, Box, Note, + merge_box_positions, parse, parse_file, serialize, @@ -122,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(): @@ -1168,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" @@ -1180,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(): @@ -1367,3 +1375,169 @@ 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) + + +# ── 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) 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] 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