A act-by-keyboard move-by-mouse controlled, VIM-inspired desktop editor for small to medium graphs (thousands objects should be fine; no hardware acceleration).
Meant for convenient information storage - like mind maps. Ergomy is the main goal. No fighting with cumbersome UI. You can draft a tree or a dependency graph roughly as fast as you can describe it out loud.
Example of editing:
vor doubleclick to spawn a node.Space + <write> + escapeto edit a node.vorato create connected nodes.eto connect node under mouse from selected node.Eto connect in reverse direction.- ...
Selecting:
- Click to select,
Ctrl+clickto multi-select,Ctrl+dragto box-select. - Search-based:
/, type,enter. - Hit
tto select full subtree (of successors).Tfor predecessors. lto add one generation of neighbours.wto remove the oldest generation- ...
Registers, macros, mappings:
- record vim macros with
q+<letter>and replay with@+<letter>. - map stuff
map gh tih0<Esc>- gh hides everything except subtree of the current node and moves view to it. (Mapping ag<letter>shadows the built-in meaning —gffollows links — andlink-<letter>for that letter.) m<letter>+'<letter>mark graph subsets;m+<letter>adds the selection to a mark,m-<letter>takes it out of it.- some
:commands. - headless mode.
- ...
Saves in a DOT-like format, but full compatibility is not our goal.
Written in Kotlin + Swing, runs anywhere JDK 21 runs. (Yes, it is ugly, and hidpi support is bad.)
Originally written by me in 2022, finished with a heavy help of Claude Code.
End of Human-written README. The rest is AI-generated, but I hope it's accurate and useful.
Being revived from a long-dormant "forgotten changes" state.
Buildable, runnable, but rough around the edges. See
tasks/roadmap.md for the plan and
docs/developer/architecture.md for
the code-level picture.
git clone git@github.com:kareltucek/sane-graph-edit.git
cd sane-graph-edit
./gradlew runGradle downloads the right Kotlin compiler via the JVM toolchain
config; you only need a JDK 21 (or newer) on your $PATH or
managed by sdkman.
./gradlew run --args=examples/index.dot is a place to start: a graph
whose nodes lead to the examples, the built-in help, this manual and the
file format. Select a node and press gf; Ctrl+O goes back.
./gradlew fatJar
java -jar build/libs/sane-graph-edit-*-all.jarA single self-contained .jar you can copy to any machine with
JDK 21+. Rename it, put it on a USB stick, alias it in your
shell — whatever works.
./gradlew appimageProduces a single self-contained
build/dist/sane-graph-edit-<version>-x86_64.AppImage file.
chmod +x it and run — no installation needed, no Java needed on
the target machine.
chmod +x build/dist/sane-graph-edit-*-x86_64.AppImage
./build/dist/sane-graph-edit-*-x86_64.AppImageRequires
appimagetool on
$PATH or at ~/.local/bin/appimagetool:
mkdir -p ~/.local/bin
wget -O ~/.local/bin/appimagetool \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x ~/.local/bin/appimagetool./gradlew jpackageProduces build/dist/sane-graph-edit/ — a directory containing a
native bin/sane-graph-edit launcher and a bundled JRE. Copy the
whole directory to /opt, ~/bin, or wherever you keep local
apps. The user does not need Java installed.
# run it directly
build/dist/sane-graph-edit/bin/sane-graph-edit
# or install system-wide (also available via `make install-swing`)
sudo cp -r build/dist/sane-graph-edit /opt/
sudo ln -sf /opt/sane-graph-edit/bin/sane-graph-edit /usr/local/bin/sane-graph-editThat is the Swing client. The Electron client has no package yet:
make electron builds it and sudo make install installs a
sane-graph-edit command that starts it from this source tree.
For a .deb or .rpm installer instead of a plain directory:
./gradlew jpackage -Ptype=deb # Debian/Ubuntu (.deb)
./gradlew jpackage -Ptype=rpm # Fedora/RHEL (.rpm)These require the corresponding packaging tools (dpkg-deb /
rpmbuild) to be installed.
Run the editor without a window to automate exports and other batch operations:
# Export a DOT file to SVG, no window, no user interaction
sane-graph-edit input.dot -e ':export svg output.svg<Enter>'
# Recall a persistent mark (A-Z), then export just those nodes
sane-graph-edit input.dot -e "'A" -e ':export-selection svg a.svg<Enter>'Flags:
-e '<keys>'— execute a key sequence and exit. Repeatable. The argument is whatever you'd type interactively:':export svg foo.svg<Enter>',"'A",'dih', anything.-u— stay open with a window after running-e. Useful for pre-configuring a session.[file]— positional arg: DOT file to load on startup.-h,--help— usage.
./gradlew build # compile + test + assemble
./gradlew test # run unit tests only
./gradlew fatJar # rebuild the fat jar
./gradlew jpackage # rebuild the native app-image| Action | What it does |
|---|---|
| Click a node | Select it (replaces current selection) |
Ctrl + click node |
Toggle that node in/out of the multi-selection |
Ctrl + click empty + drag |
Selection box — rubber-band adds to selection |
| Drag a selected node | Move the selection |
…with Shift held |
The selection moves exactly; nodes it lands on make way and the descendants that hang from it alone follow, by springs, a step for each picture, at a speed that does not depend on how often pictures are drawn, until they rest; one undo step |
| Drag on empty space | Pan the view |
| Double-click empty | Create a new node |
| Double-click a node | Edit its text in place |
| Right-click | Open the colour / shape / size / content picker on hover node |
| Mouse wheel | Zoom in / out, centred on the cursor |
| Key | Action |
|---|---|
e / E |
Connect selection → node under cursor (forward / backward) |
v / V |
Spawn new node at cursor, connect from selection (fwd / bwd) |
a / A |
Append new node + edge from the last active node |
c |
Clear edges (within selection, or incident to selection) |
d |
Delete selected nodes |
D |
Delete selected nodes but reconnect predecessors to successors |
o |
Layout optimiser while held: the selected nodes (all seen, with one or none selected) move a spring pass a picture until the key is let go; one undo step |
O |
The same, around one selected node: it stays where it is and its children are laid out around it (several selected: as o) |
:set suns / :set nosuns |
Show / stop showing, while o settles the whole graph, the systems of the levels that move: a ring (3D: a wire dodecahedron, and a ring as far as it reaches laid straight) around each, as big as the room it wants, coloured by level, and the own room of each sun of the lowest systems (level 0); :set suns=<levels> (e.g. 0,1,2): o on the whole graph moves those levels only |
g |
Toggle "grab" mode — move the selection without holding a button |
| Key | Action |
|---|---|
Ctrl+A |
Select all / deselect all (toggle) |
Escape |
Clear selection |
t / T |
Add the forward / backward reachable closure to selection |
l / L |
Add one generation of neighbours (forward / backward) |
w / W |
Remove the "oldest" generation of the current selection |
i |
Invert selection (among visible nodes) |
h |
Hide the selected nodes (levelled; use ih to keep only selection) |
H |
Unhide one level (undo the most recent h) |
0 |
Fit view to all visible (or to selection if any) |
1 |
Centre view on selection |
Space |
Edit the text of the last active node |
Shift+Space |
Same, but replace selection with that node first |
Ctrl+Space |
Same as Space, the node shown maximized (below) meanwhile |
A node is as big as its text ("auto") unless it was given a size of
its own ("manual", in the picker): then it keeps that size whatever
is in it, and what does not fit is cut off. A selected manual node
shows its corners; dragging one resizes the node, the opposite corner
staying where it is (in 3D mode the node stays where it is, and grows
or shrinks around its middle). Back to "auto" in the picker.
In the file it is the attribute node-size="320x200", width and
height in the graph's units.
A node's text is plain unless its content-type attribute says
otherwise. The same types, and graph, directory, workspace and
file, say what a link points at (link-content-type-<letter>); the
file format's table of content types says which is allowed where:
content-type |
The text is | Shown, while not edited (Electron client) |
|---|---|---|
| (none) | plain text | as it is |
code-<lang> |
code, e.g. code-scala |
highlighted as neovim highlights that file type |
code |
code in no named language | as it is |
markdown |
markdown | formatted; ```mermaid and ```dot blocks are drawn as diagrams |
A terminal node (Electron client) is not text but a terminal with
your shell ($SHELL) in it, as big as the node; making a node one
gives it a size of 80 columns and 24 rows, to be changed by its
corners. The shell starts when the node is first entered (as a node
is edited: Space, …) and then runs on and shows what it prints,
whether the keys are its or the graph's. Shift+Escape, or a click
on the graph, gives the keys back to the graph; so does
Shift+Escape in a node's editor. While the keys are the shell's,
the mouse in the node is the program's that asks for it (a click, a
drag, the wheel), as the left button and the wheel are neovim's in a
node being edited; for one that does not ask, the wheel scrolls back
through what was printed (its last 10000 lines), or, in one that has the whole screen (less,
tmux), sends it ↑ and ↓. A drag with Shift selects text whatever
the program asks, and the selection is what the middle button pastes
(the primary selection); the middle button pastes it into the
terminal too, in one whose program does not ask for the mouse, or
with Shift. Entered with Ctrl+Space, a terminal, as any node
edited so, is shown over the whole window but a margin, with as many
columns and rows as fit there, until the keys go back to the graph;
then it is the node's size again, and the graph never changed.
Ctrl+Space in a node already entered maximizes it the same way, or a
maximized one back to its size (the key is then not the shell's or
neovim's).
Ctrl+wheel in it makes its text bigger or smaller; the zoom it was
left at is yours for the next one, kept in
~/.config/sane-graph-edit/global.properties. It ends when the
shell exits, the node is removed or the window closes. Nothing of it
is saved, and opening a file starts nothing: the document only says
that the node is a terminal. What the terminal shows is drawn in the
node, in the node's colours, wherever nodes are drawn: in 3D mode and
in a headset too, where the trigger on the node selected enters it (the keys
are still the window's: the keyboard of the computer the editor runs
on). A headset is told what a terminal shows as it changes, as text
(up to some 60 times a second), and draws it over the node in its
own monospaced font. The node's text shows while there is no shell.
:content-type code-scala (or markdown, plain, terminal, …) sets it for
the selected nodes; :content-type alone tells it. The picker
(right click) shows it too and sets it with a click; code takes
the language typed beside it. The language is
a neovim file type, and the node is edited as one. A node keeps the
size of its source text whatever is shown in it; a picture that
does not fit is scaled down. The Swing client keeps the attribute
and shows every node as text.
Text kept in a file. A type may be followed by : and where the
text is, written as a link is: code-kotlin:src/A.kt:10-20 (lines),
code-kotlin:src/A.kt:/^fun main/,/^}/ (lines found by patterns, as
git log -L names them; they stay right when the file changes),
markdown:README.md#Depth (a section of a markdown file),
plain:notes.txt (all of it). The node then shows what the file
holds, read when the graph is opened. All the nodes of all the open
graphs that speak of one file share one unsaved copy of it: nodes
that hold the same lines, or lines of one another, show one text,
and an edit of one is seen in the others when the editor is left. An
edit is a change of the graph like any other: it is undone like any
other, and saving the graph (:w) writes the file — all of it, with
what other open graphs changed in it. When a save changes how many
lines there are, the line numbers of the nodes below them follow, in
every graph that is open. A graph that is not open is not corrected:
name a section where you can. A file that another program changed
under an edit is not written over; :e! takes its text again.
The unsaved copy lives as long as the application: :e and closing
a tab leave it, :wa saves every graph and every such file, and
quitting asks about those that are still unsaved. gf on a text file opens such a node, selected (a click or Space edits it), and :w there
writes the file. This is a first version.
A node being edited in the Electron client is never bigger than the part of it that is on the screen; beyond that neovim scrolls.
A node can be a different thing at each level of detail: its label,
content type, size and colours are kept once per level (label-l1,
fillcolor-l2, … in the file; see docs/file-format.md).
The editor shows one level, 0 at first; = goes one up and - one
down (:detail-more, :detail-less), and the status line says
[detail 1]. Nothing is taken from another level: a node that has
neither a label nor a content type at the level shown is not shown,
with its edges. What is edited, and a node that is made, is of the
level shown; positions and edges are the same at all levels. =
goes as far as one above the highest level anything is at, which is
how a new level is started. The level is of the view and is not
saved.
A node can have a third coordinate, z, the third number of its
position (pos="100,50,200!"): how far it is behind the plane the
graph is drawn in, away from you, in the units of its position;
negative for a node in front of the plane. A node without one is in
the plane, and a graph without any looks as it always did. (An
attribute z="200" is read as well.)
Depth shows in 3D mode only (:set 3d), which turns itself on when
a graph with depth in it is opened or a node is given one; in 2D
mode every node is drawn as if in the plane. In 3D mode
the view looks straight at the graph. What is further away is drawn
smaller and nearer the middle of the view, what is nearer bigger;
what is behind you is not drawn, and an edge to it is drawn as far
as it can be seen. At zoom 1 you
are 1000 units in front of the plane you look at, so a node 1000
units behind it is half its size. Zooming is coming closer: the
wheel goes towards the node the pointer is on, or, over nothing, the
one nearest to it on the screen, whatever its depth, and past the
nodes in front of it; that node stays where it is. examples/cube.dot has one to try.
:z 300 puts the selected nodes at that depth, :z +50 and :z -50
move them by that much (away from you, towards you), :z =-300 puts
them at a negative depth, and
:z alone tells where they are. A new node is made at the depth of
the plane you look at.
:set 3d turns 3D mode on for the graph shown, not for the other
tabs (:set no3d or :set 2d off, :set 3d! toggles; the status line shows
[3d]). In the Electron client a graph in 3D mode is drawn by the
graphics card (WebGL), each node a picture in space. In 3D mode the middle button turns the
view around the same thing: the node it is pressed on, or the one
nearest to the pointer, which stays where it is on the screen; :view-front turns it back. Nodes always face you. What
you do with the mouse then happens across the view as you see it:
a dragged node, grab, rotate, scale and mirror move nodes left,
right, up and down on your screen, which seen from the side is into
the graph; new and pasted nodes land in the plane you look at. In 2D
mode, the default, nothing turns the view (the middle button pans
it), and a view that was turned is shown from the front. The layout optimizer and the SVG
export know nothing of depth.
These act on the selection, or on the last active node when nothing is selected.
| Key | Action |
|---|---|
zz / z1 |
Centre the view on it (as 1) |
z0 |
Fit the view to it (as 0) |
zt / zb |
Move the view up or down so that it is at the top / bottom edge |
zs / ze |
Move the view sideways so that it is at the left / right edge |
zf{motion} |
Fold: hide the nodes the motion reaches |
zd{motion} |
Unfold: show the nodes the motion reaches |
za{motion} |
Toggle: unfold if the motion reaches a hidden node, else fold |
zi / zI |
Toggle the children / everything downstream (zal, zat) |
zo / zO |
Toggle the parents / everything upstream (zaL, zaT) |
zm / zr |
Hide the selected nodes / unhide one level (h, H) |
zR |
Unhide everything |
The edge commands keep the zoom and leave 40 pixels to the edge.
A motion is one of t, T, l, L: what it would add to the
selection is what is folded, hidden nodes included; whatever is
selected stays visible. Each fold is one undo step. As commands:
:fold t, :unfold L, :toggle-fold select-closure-forward.
Two modes change how hiding interacts with selection. Turn them on
with :set <mode>, off with :set no<mode>, toggle with
:set <mode>!. They apply to all tabs and show in the status bar
while on:
unhide—t/T/l/L(andG) also step into hidden nodes, and unhide the ones they select (one undo step).show-parents/show-children— besides non-hidden nodes, also show selected nodes and every parent / child of a non-hidden or selected node. Display only; hide levels are untouched. A hidden node that is seen so is drawn faint, half as strong (not by the Swing client).explore(keyx;xor Escape leaves it) — explore mode, for walking through a graph; of the graph shown, not of all tabs. Only the pinned nodes are shown, at first the selected ones (with nothing selected: of each connected part of the graph the nodes that fewest edges come into, so the root of a tree and all of a cycle), and their parents and children faint. A click pins the node and its children, one generation (:explore-show). A right click folds: everything pinned below the node, but for what is also below another pinned node; with nothing to fold, the node itself (:explore-hide). A right click on a faint node does nothing. The pinned nodes are the mark!:m!pins the selection and nothing else,m+!/m-!add to it and take from it,'!selects them. Nodes that come to be seen are brought next to their neighbours, the faint ones on entering the mode too: each starts where the document has it from its parent and is brought to rest by the springs of the layout optimizer (the edges pull it in, the nodes around push it off) and by one that holds it to where it started from, direction and distance, but no harder than a fixed pull; so a node far away in the document comes near and stays in its direction. Several nodes to start from are themselves put together then, around the middle of the view: in columns side by side, each some 1500 units high, in the order they were in from top to bottom, with room to unfold between the columns; a faint node that is in the way of a new one is pushed aside, the pinned ones stay. All of that is only for as long as the mode is on: nothing is changed, saved or undone, the document is not modified by it, and leaving the mode shows what was shown before, every node where it was (also one that was moved by hand in the mode). A file saved in the mode has the nodes where the document does.dynamic— a node that becomes visible (by any of the above, orH) is moved next to its visible neighbours, left to right: a child in a column right of its parents, a parent left of its children, stacked vertically without overlapping anything visible. A real move: one undo step per batch, saved with the file.suns,suns=<levels>— the systemsosettles the whole graph by are drawn (seeo); with levels (0,1,2; 0 the nodes), only those, andoon the whole graph moves only those.wall-scale=<n>— withwalls, how many millimetres of a wall a unit of the graph is: 1 (a node 150 units wide is 15 cm) unless set; 0.5 shows everything half as big.room— in a headset (--vr), its room: the outlines of the flat surfaces it knows, and the shape it has scanned (furniture too) as a net of lines; off unless set. What of the graph is behind it is shown faint either way.walls— in a headset (--vr) the graph is on the walls of the room instead of before the eyes, in explore mode only (for now: out of it the graph stays before the eyes): flat as it is, bent around the room along the walls, its middle on the wall one looks at when explore mode is entered, at the height of the eyes, as big as it is; past the last wall it goes on over the first. Where it is on the walls is decided then (or when the walls are first known, orwall-scalechanges) and stays so: what is unfolded or moved goes onto the walls without shifting the rest. Doors and windows are not known of. Nothing in the graph is moved by this; a node taken and let go of somewhere in the room is put on the wall nearest to that place, as high as it was let go of. Needs walls: the headset's room must be set up (or:room-mock). The headset's page opened in a browser on the desktop shows the same with the walls as lines; the mouse goes around the room. What of the graph is behind something the headset has scanned of the room (furniture, a wall) is shown faint, withwallsor without.discard— never ask about unsaved changes: opening, following links, reloading, jumping, closing tabs and quitting discard them.
The left and the right button of the mouse can be mapped like keys,
for when they are used on a node: :map <RightMouse> zO makes the
node the selection and runs zO. A mapped right button does only
that (no style picker). A mapped <LeftMouse> is run after a click,
and the button still selects and drags as ever. :unmap undoes it.
| Key | Action |
|---|---|
F |
Copy style from the selected node to the "clipboard" |
f |
Paste the last-copied style onto the selected nodes |
| Key | Action |
|---|---|
u or Ctrl+Z |
Undo the last mutation |
U, Ctrl+Shift+Z, or Ctrl+R |
Redo |
Undo covers every user-visible change: spawning / deleting nodes, adding / removing edges, moving nodes (one step per drag, not per pixel), typing into a node (one step per edit session, not per keystroke), style / shape / size changes, and layout-optimiser runs. Selection changes and view transforms are not tracked — they're navigation, not data.
| Key | Action |
|---|---|
Tab |
Complete: commands, :set settings (:se works too), :help topics, :export formats, paths for :e / :w / :source / :browse / :cd. Several matches: first the common prefix, then each Tab cycles |
Shift+Tab |
Cycle backwards |
Ctrl+D |
List what Tab could complete to |
Up / Down |
Command history |
| Key | Action |
|---|---|
:open |
Open into this tab — file chooser dialog |
Ctrl+P |
Pick a file with an external command (:set picker=…; default: xterm + fzf over *.dot), open it here (more lines: new tabs) |
Ctrl+O / Ctrl+I |
Jump back / forward (vim jump list): to where a gf / g<letter> / :open in this tab started, restoring selection and view |
Ctrl+S |
Save — writes to the current file, prompts on first save |
Ctrl+Shift+S |
Save As — always prompts |
:w <path> |
Save As to path: the tab's file from then on |
F5 |
Reload the current file from disk (asks if unsaved). Undo brings back the graph as it was before the reload |
gf |
Follow the selected node's link in this tab: open the file (and focus :nodeid if given), or show the directory's file tree |
g<letter> |
Same for link-<letter>, unless g<letter> is mapped to something else. Without such a link it reports that and does nothing (it doesn't grab) — except gx / gy / gs / gg, which then grab |
gF |
Same as gf, but into a new tab |
:browse [dir] |
New tab with a file tree of dir (default: working directory), its graphs and markdown files; gf on a node opens it. Files are one column right of their directory, subdirectories two; the tree is cut to the depth that fits the window, the last level being name/ …. The help node above the directory opens the key and command reference; the node below it says that i d (select all, delete) empties the tab to start a graph: with a node added or deleted, the tab is an untitled graph of yours, to save. Bare :e reads the directory again. :tabnew is :browse of the workspace |
:e <path> |
Open a file in a new tab; a directory opens as its file tree (also from the open dialog and the command line); a .dot file that is not there yet opens as an empty graph, which :w makes |
:file |
Show the full path of the tab's file (or the browsed directory); with one node selected whose text is kept in a file, that file too, with its lines as they are now |
:cd <dir> |
Switch the workspace to dir (see Workspaces); bare :cd shows it |
:open-workspace |
New tab with the workspaces that have a session and the graphs open in each; gf on a workspace switches to it, on a graph opens it here |
Ctrl+E |
Export graph as SVG |
Ctrl+Shift+E |
Export only the selected nodes as SVG |
On close, the editor prompts Save / Discard / Cancel for every
unsaved tab. A graph with unsaved changes that a tab leaves — a
link followed, Ctrl+O, a file opened in its place — is not asked
about but kept, with or without a file: going back (Ctrl+O, the
link again) finds it as it was. Quitting asks about the kept ones,
and :wa saves those that have a file. What the application made
(the help, a file tree, the workspaces, the lines of a text file)
is left without asking. Closing a tab (:q) asks as before.
Last-used open/save directories are remembered in
~/.sanegrapedit.properties.
The Ctrl+P picker runs sh -c "<picker>" in the background with
SGE_DIR (the workspace directory) and SGE_FILE set,
and opens the path[:nodeid] lines it prints (relative to
SGE_DIR). Opening a terminal is the command's job; a terminal's
stdout isn't the picker's, hence the temp file. E.g. st + television
in the init file:
set picker=out=$(mktemp); st -c sge-picker -e sh -c 'cd "$SGE_DIR" && tv --source-command "find . -name \"*.dot\"" > "$1"' sh "$out"; cat "$out"; rm -f "$out"
With i3, for_window [class="sge-picker"] floating enable makes it
a popup.
The workspace is the working directory: the one the editor was
started in, until :cd <dir> switches it. Relative paths in the
: bar are relative to it, and each workspace has its own session.
:cd stores the open tabs in the session of the workspace being
left (asking about unsaved changes) and replaces them with those
of the new one. A workspace without open files in its session
starts with a file tree of its directory (:browse).
Windows are independent of each other, also when started in the
same directory. sane-graph-edit --attach instead joins the
session of the window already running in that directory (the
first one started there, if there are several): the two have their
own tabs, selection and view, but a file open in both is one
document — an edit, an undo or a save in one shows up in the other,
and undo steps back through the document's changes whoever made
them. While somebody else has the current document open too, the
status bar says [shared].
If two changes collide, the one that names something already deleted is refused, and where both touched the same node the owner's stands; the refused change disappears with a message. When the owner quits, the other windows carry on by themselves with what they had.
The window that has a directory's session is the one that answers
at its socket, whichever client it is: a window of the Electron
client has it, is joined with --attach and is scripted with ctl
the same way, and a Swing window and an Electron window can share
one session.
A script or an agent can read and change the documents of a running window (the first one started in the directory):
sane-graph-edit ctl documents # what is open
sane-graph-edit ctl open other.dot # show a file in a new tab (--here: in the current tab)
sane-graph-edit ctl state graph.dot # nodes and edges by id, as JSON
sane-graph-edit ctl dot graph.dot # the same as DOT, unsaved changes included
echo '{"setNodes": {"<id>": {"text": "New label"}}}' |
sane-graph-edit ctl patch graph.dot # change it; the window updates at once
sane-graph-edit ctl undo graph.dot
sane-graph-edit ctl save graph.dotRun it in the window's workspace directory, or name it with
--workspace <dir>. A patch adds, changes and removes nodes and
edges by id; sane-graph-edit ctl help shows its form. The same
messages can be spoken directly over the session's socket (ctl socket prints its path): JSON, one message per line — see
docs/developer/architecture.md.
For clients that cannot open a Unix socket, such as a browser,
sane-graph-edit --ws <port> also listens on a WebSocket (port 0:
any free one). It is off unless asked for, accepts connections from
this machine only, and only from clients that know the token in the
address. The window prints that address
(ws://127.0.0.1:<port>/?token=…) and keeps it, while it runs, in
the file beside the session's socket with .ws in place of
.sock. The messages are the same as on the socket, one per
WebSocket text message.
A first version, to look at a graph in a headset; nothing can be
changed from there yet. make run ARGS="--vr 8765 graph.dot" (the
Electron client) also answers a headset's browser at that port, and
says what to do next: with the headset on its USB cable,
adb reverse tcp:8765 tcp:8765and then the address it prints, in the headset's browser, and there the four-digit PIN it prints with it. The page shows the graph of the tab that is shown in the window, as that changes, and offers to enter with the room visible (passthrough) or hidden. Of each controller the trigger is its left button and the grip its right one; a press let go of with the controller still (moved less than 3 cm, turned less than 6°) and within 0.3 s is a click, else a hold. The right trigger on a node has no time limit: held still as long as one likes, it is a click.
The right controller points. Trigger click on a node: selects it (explore mode: unfolds it); on a terminal node already selected, goes into its shell; on no node, makes a new one there, selected, as far along the ray as the node nearest to the ray is (on the walls: where the ray meets one); a terminal when every node shown is one; held on a node, takes it along where the ray goes (explore mode: as a Shift-drag, the others making way), as far along the ray as it was, a thumbstick taking it further (forward) or nearer (back); with the graph on the walls, where the ray meets a wall. A selected node that has a size of its own shows its corners (white squares): the trigger held on one, or just outside it (within 1.2° as the eyes see it), resizes the node around its middle. Selected nodes have a blue band around them, 4 pixels wide however far. Grip click on a node: takes it out of the selection (explore mode: folds it); held, the node it was pressed on goes, until let go of, to where a node was last taken in hand (clicked, dragged, turned around or held so). A and B do nothing.
Both thumbsticks do the same. Forward and back: with a node held by the right trigger, it further or nearer along the ray; with the graph held by the left grip, it further from the eyes or nearer; flying, on in or back out (a metre a second, fully pushed); while a node is active (a terminal gone into, a node being edited), its text bigger or smaller (maximized: as Ctrl+wheel); else the graph bigger or smaller. Aside: the graph turns about the upright. Zoom and turn are around the node the right ray is on, or the one nearest to it.
With one node selected and none active, the arrow keys (of the window, or typed in the headset's page) select the node next to it on that side, as the headset sees them.
The left controller points too. Trigger click on a node that has
links: follows its link, into a new tab; of several, a menu opens
around the node, and the next click follows the one pointed at.
Grip click: back to where a link was followed from (Ctrl+O). Held,
the grip takes the graph in the hand: it goes and turns as the hand
does, as the eyes see the hand, but in and out (away from the eyes)
4 times as far; turning around with it, the graph is seen as before.
It only ever turns about the upright: its up stays the room's (as the
headset's recentring has it), however the hand tilts.
The trigger held flies one through the graph, in the room and never
turned: across the way the eyes looked when it was pressed the graph
goes as far as the hand; along it, the other way, as fast as the hand
is from where it was pressed (4 metres a second for each metre),
times how hard the trigger is pressed. X goes into
explore mode (from the node selected, or from the roots of the graph)
and out of it; X twice quickly asks the headset to scan the room
anew. Y clicked puts the graph on the walls of the room or off them
into its space (:set walls; the status line says [walls] or
[space]); Y held is o held: the springs move the nodes until it is
let go of. The nodes face whoever looks, from wherever, turned about
the upright only. The window's status line is shown on a
strip before and below the eyes: it stays where it is in the room,
and comes along when one turns or walks away from it. The
headset only places pictures the window makes of the nodes: it holds
nothing, and reloading its page loses nothing. When the editor is
quit, the headset leaves its immersive session and stops trying to
connect: started again, the editor prints a new address. The editor
and the headset tell each other they are there every second; heard
nothing from for 6 seconds, the headset leaves its immersive session
too (and keeps trying to connect), and the editor drops the headset,
letting go of what it held (o).
It is off unless asked for, answers on this machine only and only to who gives the PIN (which sends the browser on to an address with a long token in it); the cable is what lets the headset reach it, and nothing is opened to the network. The PIN is new each time the application starts, and after five wrong ones none is taken until it is started again.
In the page opened in a browser on the desktop the mouse is the controller: a click on a node is the trigger on it, a right click the grip; the left button held on a node and moved takes the node along. Held on nothing it takes the view along, as in the window. The middle button or the right one (or the left one with Shift) turns the eyes around the node last clicked (at first: the middle of the graph, or of the room); the wheel brings them nearer to it or further away. Nodes face whoever looks, on the walls too.
Keys pressed in the headset's page are the window's, as if pressed
there (graph keys, the : line, a node's editor, a terminal node's
shell), but only when the page is served on this machine alone: with
--vr-lan no keys are taken, since they can do all the editor can.
The browser keeps its own (F-keys, Ctrl+R/L/T/W/N, Alt). Only the
page this serves is let in, not another site's.
--no-pin (with --vr) asks for no PIN at the start: for trying
things, when typing it at every start is a nuisance. Whatever runs on
this machine can then open the page, and with --vr-lan anyone on the
local network who finds the port, and sees the graph. There a headset
is still asked for the PIN (printed at the start) before it goes into
a terminal node, which starts a shell: on a keypad before the eyes,
pressed with the right trigger (in a desktop browser, a prompt). The
same five wrong ones in all count.
Without a cable: --vr 8765 --vr-lan answers on the local network
instead, for a headset on the same Wi-Fi. This does open a port to
that network: on the one address this machine has there, over TLS
(https://192.168.…:8765, as it prints), and still only to
who gives the PIN (unless --no-pin: see above). The certificate is one the application makes
for itself with openssl and keeps in its configuration directory
(vr-cert.pem, vr-key.pem), so the headset's browser warns about
it once and has to be told to go on.
Where the headset knows the flat surfaces of its room (on a Quest:
after its space setup, and when its browser is allowed to see them),
the page tells the window of them: :room lists them, with what they
are (wall, floor, desk…), their sizes and where they are. Where
it has the scanned shape of the room too (furniture and all), what of
the graph is behind it is shown faint; the window is not told of it
yet. With :set room (off unless set) the page draws the surfaces'
outlines and the scanned shape as a net of lines; never their names. X twice
quickly on the left controller asks the headset to scan the room
anew, where its browser can (once after entering).
The window draws that room too, in 3D mode, as outlines around the
graph (a unit of the graph is a millimetre; the middle of the graph
is 1.3 m above the floor). :room-mock makes a room up, with a
door, a window and a desk, to try this without a headset.
The set of open file-backed tabs is persisted per workspace to
$XDG_CONFIG_HOME/sane-graph-edit/workspaces/<dir>/session.properties
($XDG_CONFIG_HOME falls back to ~/.config; <dir> is the
workspace path with / written as %2F) whenever
you open, close, or save a file — and one last time on window
close. On the next launch in that directory, those files are reopened
automatically and the tab that was active last time becomes active again.
The init file stays global.
Untitled tabs don't participate (there's no stable identifier to restore them from), and the restore only reopens files from disk — it does not recover unsaved edits. That's what the autosave backup directory is for, and it is never touched automatically by session restore.
Every 5 minutes, any dirty tab is written to
$XDG_CACHE_HOME/sane-graph-edit/backups/ (defaults to
~/.cache/sane-graph-edit/backups/) as a plain DOT file with
the filename
<basename>.<hash>.<id>.dot
where basename is the source file's name without its .dot
extension (or untitled for tabs that have never been saved),
hash is a short SHA-1 prefix of the absolute source path (or
the tab's autosave UUID for untitled tabs) to disambiguate files
that share a basename, and id is a monotonically increasing
integer per (basename, hash) pair. Examples:
foo.3a1b9cd0.1.dot
foo.3a1b9cd0.2.dot
foo.3a1b9cd0.3.dot
untitled.8e7d2f4a.1.dot
Backups accumulate and are never deleted automatically. The
design goal is a safety net against your own saving mistakes: if
you overwrite the wrong file, save after a bad delete, or
clobber a graph with an empty document, the previous snapshots
are still sitting in the backup directory. Recovery is manual —
ls ~/.cache/sane-graph-edit/backups/, find the snapshot you
want, copy it into place. When the directory gets too large,
prune it yourself. The editor never auto-restores from this
directory; it's there when you need it, invisible when you
don't.
| Key | Action |
|---|---|
Ctrl+T / Ctrl+N |
Pick a file and open it in a new tab |
Ctrl+W |
Close current tab (prompt if unsaved) |
Ctrl+Tab |
Next tab |
Ctrl+Shift+Tab |
Previous tab |
Ctrl+PageDown |
Next tab (alias) |
Ctrl+PageUp |
Previous tab (alias) |
Each tab owns its own graph, file path, undo history, and view transform (pan / zoom). Closing the last tab clears it rather than removing it — the window is never empty.
| Key | Action |
|---|---|
Ctrl+C |
Copy selected nodes (plus edges with both endpoints in) |
Ctrl+X |
Cut — copy and delete |
Ctrl+V |
Paste under the cursor |
The clipboard is process-local and works across tabs: copy in one tab, switch tabs, paste. Pasted nodes become the new selection so they can be immediately repositioned or restyled.
Native format is a subset of Graphviz DOT:
digraph g {
n0 [label="Hello"; fillcolor="#fff3b0"; pos="100,200!"];
n1 [label="World"; shape=box; pos="300,200!"];
n0 -> n1;
}Attributes the editor understands and round-trips:
label— node textpos— node position (with the trailing!for fixed placement)fillcolor/color— background / foregroundfontsize— maps to an internal scale factorshape—boxorovalmarks— persistent marksfontname—monospacefor source codelink-<letter>/link— links to other graphs / nodes (g<letter>), or to lines of a source file (file.scala:42,file.scala:40-60)attachTo/attachSide— glue a node to a side of another one; it then moves, hides and lays out with its owner
Graph-level settings, unknown node / edge attributes and comments
are preserved on open/save. The full specification is in
docs/file-format.md.
Most graph editors assume you want to compose a diagram out of prefab pieces on a palette. If you just want to write down "A leads to B, B and C both lead to D, by the way D is the interesting one", you end up fighting the editor. This tool skips the palette and makes the common operations — spawn a node, connect it, label it, move it — one keystroke each.
docs/developer/architecture.mdis the orientation document. Read it first.tasks/holds the design plans for in-flight features.- Top-level types (
Graph,Plotter,GraphView,GraphKeyListener,DotGraphLoader) have KDoc pointing at the relevant sections.
Copyright (C) 2026 Karel Tuček
Licensed under GNU GPLv3.