Commit Graph

82 Commits

Author SHA1 Message Date
Levi Neely 3d46936613 Hide outline until a document is selected
When no document was selected, the OutlineView's own "No sections"
empty-state label (count === 0 with an empty model) rendered centered
in the same pane as the "Select a document" placeholder. Both used
anchors.centerIn: parent, so the two messages overlapped and read as
crunched text. Hide the OutlineView until currentDoc is set so only the
"Select a document" placeholder shows.
2026-10-06 12:54:26 +02:00
Levi Neely 42f8aaa62c Wrap and space the empty-state placeholder in main pane
The centered placeholder ("Select a document" / "Not connected: ...")
had no width constraint, so long text rendered as a single over-wide,
cramped line. Constrain its width, center-align, enable word wrap, and
set lineHeight 1.3 so the message breathes.
2026-10-06 12:52:59 +02:00
Levi Neely e418177bad Give sidebar document entries more breathing room 2026-10-06 11:31:37 +02:00
Levi Neely 4c2250ba3c Fix drag start properly; lazy-load body editor; stop make reconfigure
Three fixes:

Drag: the previous attempt removed Drag.active=true before startDrag(),
but startDrag() *requires* an active drag — so it errored "startDrag()
drag must be active" and no drag began (the no-drop cursor). Use the
idiomatic automatic-drag pattern instead: bind the proxy's
Drag.active to the grip MouseArea's drag.active and set drag.target to
the proxy; with dragType Automatic, Qt starts the OS drag itself when the
threshold is crossed — no startDrag() call. Lifecycle is driven off
Drag.onActiveChanged / onReleased.

Body editor: wrap it in a Loader (active: editingBody) so the TextArea is
only instantiated when the user opens the editor, not once per row. This
avoids the per-row Breeze "Unable to assign TextArea to QQuickTextInput"
instantiation error and is lighter.

Makefile: the ui target ran `cmake -S ui -B ui/build` on every invocation,
reconfiguring the tree each time (which could leave the QML cache stale
relative to edited .qml). Configure only when ui/build/Makefile is absent;
otherwise just `cmake --build`.

GUI builds clean.
2026-10-06 11:06:07 +02:00
Levi Neely b2efe6ea27 Fix body editor under the Breeze style
The body editor was a TextArea with a custom background inside a
ScrollView. Under the Breeze QML style that raised "Unable to assign
TextArea to QQuickTextInput" (Breeze's TextArea.qml rejects a custom
background in that nesting).

Switch to the Qt-recommended idiom: a Flickable with
TextArea.flickable: TextArea { background: null }, wrapped in a plain
Rectangle that provides the border/background. Keeps monospace wrapping,
seed-on-open, Ctrl+Enter save and Esc cancel; adds a vertical ScrollBar.

GUI builds clean.

(The separate "startDrag() drag must be active" log was the previous
binary; the a3d2c49 fix removes it — relaunch to clear.)
2026-10-05 09:49:05 +02:00
Levi Neely a3d2c4947e Fix drag start: let startDrag() activate the automatic drag
The grip set dragProxy.Drag.active = true and then called startDrag().
For a Drag.Automatic drag that is wrong — startDrag() is what activates
the drag — so it raised "startDrag() drag must be active", the OS drag
never began, and every target showed the no-drop cursor.

Remove the manual active assignment; call startDrag() alone (it runs the
platform drag and blocks until it completes). Add Drag.proposedAction =
MoveAction. Do the drag cleanup in onReleased via commitDrop(), which
commits when the drop landed on a valid outline target and otherwise just
clears the drag state.

GUI builds clean.
2026-10-05 09:44:30 +02:00
Levi Neely b329bcebd0 Support cross-file refile via spring-loaded drag
Dragging a section's grip can now drop it into another file: hover the
drag over a document in the sidebar for ~450ms and the view springs to
that file, after which the normal in-file drop chooses the exact
position. The subtree is removed from the source file and inserted in the
target (reparented, re-leveled, and placed before the chosen sibling).

The key change is making the drag scene-level so it survives the view
switching files mid-drag:

Model (decouple source from view):
- beginDrag(row) captures (sourceDoc, sourceId) by value; dragSourceDoc()/
  dragSourceId() expose them for the drag payload.
- resolveDrop(gapAboveRow, desiredLevel) resolves against the current view
  and excludes the dragged subtree only when it lives in the current file
  (so cross-file drags, whose source isn't present, exclude nothing).
- commitDrag(parentId, beforeId) issues refile <sourceDoc> <id> <curDoc>
  … so it targets whatever file is now shown; endDrag() clears state.
- Drops the old row-indexed refileSection/resolveDrop.

GUI:
- OutlineView: the grip starts a Qt Automatic (scene) drag on an invisible
  proxy carrying the section payload (keys ["ork-section"]); a DropArea
  over the outline resolves the live position and commits on drop. Because
  the drag is scene-level, springing the view no longer cancels it.
- Main.qml: each sidebar document is a DropArea with a 450ms spring Timer
  that selects that document when a section is held over it; it highlights
  while a drag hovers.

Verified: cross-file refile over 9P (refile src mover tgt two child) moves
the subtree between files, re-levels it, inserts before the sibling, and
carries its body. Server tests still green. GUI builds clean.

The scene-drag gesture and spring timing can't be verified headlessly;
the data path (begin/resolve/commit + cross-doc refile) is confirmed.
2026-10-05 09:35:34 +02:00
Levi Neely 614378ffa4 Make the drag grip own its gesture; fix scene mapping
Two drag fixes:

- The gutter grip still fought the ListView (a Flickable) for the drag.
  Make it a dedicated control: pressing the grip sets outline.interactive
  = false for the duration of the press (via a TapHandler), so the
  Flickable cannot scroll and the grip's DragHandler takes the grab
  cleanly. Flicking is restored on release. Drops the earlier
  press-and-hold arming (and the now-unused dragArmed property); a plain
  press on the grip starts the drag.

- Fix a runtime TypeError on every drag move: Item has no mapFromScene().
  Use contentItem.mapFromItem(null, x, y), where a null source item means
  scene coordinates, to convert the DragHandler centroid to ListView
  content coordinates.

GUI builds clean.
2026-10-05 09:23:34 +02:00
Levi Neely ed65235940 Fix field clearing and gate drag behind press-and-hold
Two issues with the editing UI:

Clearing a keyword/priority did nothing. The "None" menu items write an
empty value to the field, which the client sends as an OTRUNC open plus a
zero-length Twrite. But the server flushed the virtfs write handler on
clunk only when the write buffer was non-empty, so the empty write — the
whole point of a clear — was dropped and the field kept its old value.
Flush on clunk whenever the fid was opened for writing (non-rdwr), not
just when bytes were buffered, so an empty write clears the field.
Verified live: an empty stdin write to /<doc>/<id>/keyword and /priority
now removes the keyword and priority from the headline.

Drag-to-refile fought the ListView. The grip's DragHandler and the
enclosing Flickable both wanted the press, so dragging usually scrolled
instead of moving the section. Gate the drag behind a press-and-hold: a
left-gutter grip (⠿ in a rounded cell) arms on a 0.35s long press
(TapHandler.onLongPressed), and the DragHandler is enabled only while
armed. A quick press-drag now scrolls the list; a hold then drag performs
the structural move. The grip highlights while armed/dragging and shows
an open-hand/size-all cursor.

GUI builds clean; server builds clean.
2026-10-05 09:19:24 +02:00
Levi Neely 0a06ca60bf Fix drag-to-refile: ListView stole the grip's drag
The grip used a bare MouseArea for the drag. Inside the ListView (a
Flickable), once the pointer moved past the drag threshold the ListView
stole the gesture for flicking, so the grip's positionChanged stopped
firing: the drop target was never resolved and release did nothing.

Replace the MouseArea with a DragHandler (target: null — it tracks, it
doesn't move the item) whose grabPermissions take the pointer grab and
refuse to yield it to the Flickable, so the drag is no longer stolen. It
reports centroid.scenePosition continuously, mapped to ListView content
coordinates via contentItem.mapFromScene, driving the same updateDrop/
commitDrop path. A HoverHandler keeps the hover affordance, and the grip
gets a slightly larger hit target.

QML compiles (AOT cache build of OutlineView). The drop resolution and
refile commit paths were already verified; this restores the gesture
that feeds them.
2026-10-02 16:57:18 +02:00
Levi Neely 1033820229 Add drag-and-drop structural moves with a live drop indicator
Drag a section by its ⠿ grip to restructure the outline. Vertical pointer
position chooses the insertion gap; horizontal position (indent) chooses
the depth. A live indicator shows the resolved target — an insertion bar
indented to the resolved level plus a ghost label ("L3 under \"B\"") —
computed by the same function that commits, so the preview is truthful.

Resolving the ambiguity (is this a new L2, an L3 sibling, or an L4
child?): at a gap between row A (level La, above) and row B (level Lb,
below), the legal depth range is [max(1, Lb), La + 1]. The pointer's
indent is clamped into that range; the parent is then computed by walking
A's ancestor chain to the row at level-1 (never guessed), and the
before-sibling is the first row after the gap at that level under that
parent. The dragged subtree is excluded, so it can't drop into itself.

Server (refile insert-before):
- refile_section gains a `before` parameter: insert the subtree
  immediately before that sibling instead of appending at the end of the
  parent. The ctl verb becomes
  `refile <doc> <id> <to-doc> [parent-id] [before-id]`, and a literal "-"
  in the parent slot means top level (so a before-id can be given while
  refiling to the top).

Model:
- moveSection/promoteSection/demoteSection/refileSection drive the move,
  promote, demote, and refile ctl verbs and reload on success.
- resolveDrop(draggedRow, gapAboveRow, desiredLevel) implements the
  clamp + ancestor-walk and returns {valid, level, parentId, parentTitle,
  beforeId}. sectionIdAt(row) exposes the row's id.

GUI:
- A drag grip per headline with a manual-drag MouseArea mapping the
  pointer to ListView content coords and calling resolveDrop live; the
  drop indicator overlay renders from the result; release commits via
  refileSection.

Verified: server tests (75 passed, +3: refile before-sibling same-doc,
before top-level, and the ctl "-" top-level placeholder). The drop
resolution was checked with a standalone harness over the ambiguous
C|E-gap cases (indent 1/2/3/4 → top-before-E / L2-under-A / L3-under-B /
L4-under-C, clamping, and self-drop exclusion). Live 9P confirmed
`refile d f d b c` (reparent + relevel + insert-before) and
`refile d f d - a` (promote to top, insert-before). GUI builds clean.
2026-10-02 16:00:54 +02:00
Levi Neely 9ed3b730e6 Add in-place body editing with timestamp insertion
Click "⤤ edit" on a section body to replace the rendered segments with a
multi-line editor seeded from the raw body text. Save writes the body
back; the server reparses on commit. Cancel/Escape discards; Ctrl+Enter
saves.

Model:
- DocumentModel::bodyText(row) returns the raw /body for seeding the
  editor; setBody(row, text) writes /<doc>/<id>/body and reload()s, since
  a body edit can change the section's substructure.

Body editor (OutlineView):
- The body block now shows while editing even when empty, so an empty
  body can be opened. The segment Repeater is gated off (empty model)
  while editing and a TextArea in a ScrollView takes its place, with a
  toolbar (Insert timestamp…, Cancel, Save).

Timestamp-at-cursor insertion:
- DatePickerDialog gains an "insert" mode: openInsert() shows an
  Active <>/Inactive [] selector and, on Insert, emits timestampAccepted
  with the built timestamp instead of writing a planning field. The
  shared picker inserts it at the body editor's cursor (via the view's
  insertTarget).

The server's set_section_body already writes only the region after the
headline/planning/properties, so planning lines and property drawers are
preserved across a body edit.

Verified over 9P: body round-trips (seed read, multi-line write with an
inserted <...> timestamp and a checkbox item, read back), the section and
its siblings stay intact, and a section with SCHEDULED + a property
drawer keeps both after a body edit. GUI builds clean. VISION v0.4
updated: in-place editing and keyword/priority/tag/timestamp manipulation
done.
2026-10-02 15:18:08 +02:00
Levi Neely 741ca6d963 Keep keyword/priority editable when empty; add chooser menus
Once a keyword cycled to "none" its pill disappeared, leaving no target
to cycle or set it again; the priority cookie had the same dead end.

- The keyword pill and priority cookie now stay present when empty,
  rendered as a faint placeholder (+ / [#]) that brightens on hover, so
  a value can always be re-added. Left-click / wheel still cycle.
- Right-click opens a chooser menu. The keyword menu is populated from the
  document's keyword set via a new DocumentModel::keywordChoices() (reads
  /<doc>/keywords, drops the | separator) plus a "None" entry; the
  priority menu offers A/B/C and None. Selecting an item is one 9P write
  via setKeyword/setPriority.

Verified: keywordChoices returns TODO/DONE for a plain document and the
full declared sequence for a #+TODO: document; re-adding a keyword after
"none" and menu-selecting a specific state both work with the section id
stable. GUI builds clean.
2026-10-02 15:10:24 +02:00
Levi Neely 9de0c99f25 Edit SCHEDULED/DEADLINE via a calendar date picker
Add timestamp editing to the headline: a 📅 button and clickable
scheduled/deadline labels open a date picker that writes the planning
field. One 9P write per change; the id is unaffected.

GUI:
- New DatePickerDialog.qml: a MonthGrid calendar with month/year
  navigation, a Scheduled/Deadline selector, optional time (HH:MM) and
  repeater (+1w / ++1m / .+2d) fields, and Set/Clear/Cancel. It parses an
  existing <...>/[...] value to prefill, and builds the inner timestamp
  "YYYY-MM-DD Day [HH:MM] [repeater]" for the server to wrap.
- OutlineView: a shared DatePickerDialog instance; the calendar button
  (prefills from scheduled, else deadline, else today) and clicking a
  timestamp label open it on the right field.
- DocumentModel::setScheduled/setDeadline write /<doc>/<id>/{scheduled,
  deadline} and refresh the row.

Server fix (set_planning_field):
- The old logic rebuilt the planning line by splitting on whitespace,
  which breaks timestamps that contain spaces (day name, time, repeater):
  replacing a date-only SCHEDULED with a time-bearing one left a stray
  trailing token ("... 14:30> Sun>") and could duplicate the line, so the
  field then failed to parse. Rewrite it to parse the planning line into
  (keyword, bracketed-timestamp) pairs via new parse_planning_line, update
  or remove the target field, and re-emit in canonical order
  (SCHEDULED, DEADLINE, CLOSED) via emit_planning_line. CLOSED now wraps
  as inactive [...]; SCHEDULED/DEADLINE as active <...>.

Verified: server tests (72 passed, +4: time/repeater round-trip, two
coexisting fields without duplication, planning-line parse, and an exact
repro of the live scenario). Live 9P run: date-only -> time -> time+
repeater all round-trip, and SCHEDULED+DEADLINE share one clean planning
line. GUI builds clean (DatePickerDialog compiles).
2026-10-02 14:50:51 +02:00
Levi Neely d83324e1e4 Edit priority and tags by direct manipulation
Extend v0.4 direct manipulation to the priority cookie and tags, each a
single 9P write. Priority and tags are not part of the slug, so these
edits never change the section id.

Priority:
- DocumentModel::setPriority writes /<doc>/<id>/priority; cyclePriority
  steps (none) -> A -> B -> C -> (none). The priority cookie gains a
  MouseArea: left-click / wheel-up advance, right-click / wheel-down back.

Tags:
- DocumentModel::setTags writes /<doc>/<id>/tags from a free-form string
  (space/comma/colon separated; the server normalizes and colon-joins).
- The tag row becomes editable: clicking a pill or the "+" affordance
  opens an inline TextField prefilled with the current tags; Enter or
  focus-loss commits, Escape cancels.

Server fix:
- set_section_priority passed None to rebuild_headline for an empty
  priority, but rebuild_headline treats None as "keep the existing
  value", so clearing a priority silently did nothing. Pass Some("")
  explicitly, matching set_section_keyword. Found while testing the clear
  path.

Verified: server tests (68 passed, +2 for priority cycle/clear and tag
set/clear keeping the id, plus the priority-clear fix). Live 9P run
cycled priority (none/A/B/C) and set/replaced tags on a headline with the
id stable throughout. GUI builds clean.
2026-10-02 12:58:06 +02:00
Levi Neely dc34f6467f Cycle a TODO keyword by clicking its pill
First direct-manipulation edit (v0.4): clicking a headline's keyword pill
cycles its TODO state, each change a single 9P write. Left-click / wheel-up
advance (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click / wheel-down
step back.

Per-document keyword set (server):
- keywords() previously advertised a hardcoded "TODO NEXT WAITING | DONE
  CANCELLED", but the parser only recognizes NEXT/WAITING/CANCELLED when the
  document declares them via #+TODO:. Writing an unrecognized keyword merged it
  into the title and changed the section's slug id.
- keywords() now returns the parser default "TODO | DONE"; new keywords_for(doc)
  returns a document's declared #+TODO: sequence or that default. Exposed as a
  per-document /<doc>/keywords file.

GUI:
- DocumentModel::setKeyword writes /<doc>/<id>/keyword and refreshes the row;
  cycleKeyword reads the per-document /<doc>/keywords so it only cycles to
  keywords the parser recognizes (keeping the section id stable), with a
  trailing empty state to clear the keyword.
- The keyword pill gains a hover highlight and a MouseArea for click/wheel
  cycling.

Verified: server tests (66 passed, +3: default keyword set, declared sequence,
id stable across set+clear). Live 9P run cycling both a plain document
(TODO|DONE) and one with a #+TODO: sequence confirms the section id stays
stable through a full cycle and every transition writes correctly; empty-write
clearing is covered by a unit test (the ork CLI can't pass an empty arg, but
the GUI's p9 client sends a real empty write). GUI builds clean.

Also refresh VISION.md milestones: v0.3 done; checkbox toggling, code
execution, and keyword cycling ticked under v0.4.
2026-10-02 12:37:28 +02:00
Levi Neely bfc0c80d09 Scroll to the body segment that contains a link offset
Link reveal previously scrolled only to the section top (offset > 0 just
chose Beginning vs Contain). Thread the body offset through to the
rendered segment so the view scrolls to the matched segment, keeping the
existing segmented body rendering (text/code/table/list).

- The bodyParts builder now records each segment's startLine (the body
  line index where it begins) for text, list, code, and table parts,
  tracked through the line-categorizing loop and pushText.
- A new delegate function offsetToY(bodyOffset) converts the offset to a
  body line, finds the last segment whose startLine <= that line, and
  returns that segment's y relative to the delegate via mapToItem.
- On sectionRevealed with offset > 0, position the row at the viewport
  top (which realizes the delegate), then nudge contentY by offsetToY so
  the matched segment sits at the top, clamped to the content bounds.

The offset->line->segment selection was verified with a standalone
harness replicating the builder and selection logic: for a body mixing
intro text, a code block, a paragraph, a table, trailing text, and a
list, every line maps to the segment that actually contains it. The
final pixel scroll uses standard Qt Quick coordinate mapping; it needs a
rendered scene to verify visually, which is not possible headlessly.
Granularity is now segment-precise; intra-segment line precision is not
attempted (text segments are Labels without glyph positioning).
2026-10-02 12:26:09 +02:00
Levi Neely de43a3721d Fix empty section body when content follows a property drawer
section_body_text() checked for a drawer START (:NAME:) before checking
for a drawer END (:END:). But :END: itself matches the generic :NAME:
pattern, so the end line re-opened a drawer instead of closing it,
leaving in_drawer set and swallowing the body that followed. A section
whose only content came after a property drawer therefore rendered an
empty /body (and, by extension, empty fuzzy-resolve bodies and GUI
sections).

Reorder the two checks: when already inside a drawer, handle :END:
first and consume the line, before the drawer-start pattern can match
it. This also leaves planning-line and leading-blank handling intact.

Add regression tests: body after a drawer directly under the headline,
and body after a planning line plus drawer. Server tests: 63 passed.
2026-10-02 12:21:32 +02:00
Levi Neely 2f040bf810 Make the Settings dialog content scrollable
Adding the Org Path group pushed the settings content past the dialog's
fixed height, so the lower groups and the Close button overflowed the
dialog bounds. Wrap the content ColumnLayout in a ScrollView (vertical
only; the inner layout matches the viewport width so Layout.fillWidth
children still size correctly) and raise the default height from 400 to
520 so the common case fits without scrolling. Content that still exceeds
the dialog now scrolls instead of overflowing.
2026-10-02 12:14:12 +02:00
Levi Neely c4dc18cbfc Add an org path setting with a directory picker to Settings
Let the user change the server's scanned org directory from the GUI,
backed by the server's /cfg file. The server expands a leading ~ and
rescans, so the client only sends a path.

OrkClient:
- orgPath() reads /cfg and extracts the org_path value (basic TOML
  string, with escapes).
- setOrgPath() writes org_path to /cfg with proper TOML escaping, and
  emits orgPathChanged() on success. On failure the server's message is
  left in error().

SettingsDialog:
- New "Org Path" group: a text field prefilled from orgPath() when the
  dialog opens, a Browse… button opening a FolderDialog (QtQuick.Dialogs),
  and an Apply button. The folder picker's file:// URL is converted to a
  local path. A status line shows success or the server error. The field
  accepts a raw path too, so ~ and typed paths work.

Main.qml:
- On orgPathChanged, clear the selection and refresh the document list so
  the sidebar reflects the newly scanned directory.

Verified over 9P against a live server using the exact TOML setApply
writes: empty initial config, ~/org expanded to $HOME/org, and an
absolute Browse-style path each switch the served documents. GUI builds
clean (QML cache compiles SettingsDialog).
2026-10-02 12:11:19 +02:00
Levi Neely 7c9779b2fc Expose /cfg to read and write an on-disk config file
Add a root /cfg file to the 9P namespace backed by a TOML config file on
physical storage, so clients can read and change server settings without
restarting. The first setting is org_path: the directory scanned for org
files, with a leading ~ expanded to $HOME.

New config module (config.rs):
- Resolve the config file at $XDG_CONFIG_HOME/orkmode/config.toml, else
  ~/.config/orkmode/config.toml.
- expand_tilde(), read_raw(), write_raw(), parse(), and load().
- Config holds an optional org_path. Unit tests cover tilde expansion
  (bare ~, ~/sub, non-tilde, ~user left intact) and TOML parsing.

Mutable org directory (state.rs):
- org_dir becomes an RwLock<PathBuf> with an org_dir() getter and a
  set_org_dir() that validates the directory and rescans.
- config_raw() backs the /cfg read; apply_config() validates the TOML and
  the target directory, applies org_path, then persists. A rejected write
  (bad TOML or missing directory) never reaches disk and never mutates
  state.

Namespace (namespace.rs):
- /cfg is a read+write file: read returns the raw config, write calls
  apply_config.

Startup (ork-server.rs):
- With no -d/positional directory, fall back to the config file's
  org_path (tilde-expanded), else the current directory. An explicit CLI
  directory still takes precedence. Help text documents the config file.

Verified over 9P with an isolated HOME: /cfg reads empty on a fresh
install; writing org_path="~/org" expands the tilde and live-switches the
served documents; absolute paths work; nonexistent directories and
invalid TOML are rejected without changing state or disk; the setting
persists; and a no-flag restart loads org_path from the config file.
Server tests: 61 passed.
2026-10-02 12:07:52 +02:00
Levi Neely b385429d1b Add sample links exercising every resolver branch
Extend the sample org data with links that cover each resolve_target
dispatch path, so the link-navigation feature can be exercised by hand
and in the GUI.

test.org:
- Add a <<intro-anchor>> dedicated target and a #+NAME: languages-table
  named element as link destinations.
- Add "Same-file links" and "Cross-file links" subsections listing one
  link per kind: external URL, #custom-id, *headline, dedicated target,
  named element, plain text, and file:notes.org[::target] forms.

notes.org (new):
- Second document providing cross-file targets: *Meeting Notes headline,
  #design-decision custom id, <<release-anchor>> dedicated target, and a
  #+NAME: roadmap-table named element.
- Includes a back-link to test.org and its own same-file links.

All 14 links verified to resolve over a live server (dedicated/fuzzy
kinds, same-file, cross-file, and back-link directions). Property
drawers are placed directly under their headlines so CUSTOM_ID targets
parse; the plain-text target phrase is chosen so sorted-order section
scanning lands on the intended prose.
2026-10-02 11:43:24 +02:00
Levi Neely 66a1ca11b5 Make resolve offsets index the rendered /body text
Link resolution reported byte offsets relative to a section's raw
content region (headline end), but the GUI renders /body, which strips
the planning line, property drawer, and surrounding blank lines. An
offset into raw content therefore did not line up with the body the
GUI shows, so scrolling to a link target landed in the wrong place.

Server:
- Extract section_body_text() as a shared helper so section_body() and
  resolve_target() derive offsets from the exact same rendered string.
- Rewrite the dedicated-target, named-element, and plain-text branches
  of resolve_target() to search section_body_text() and report a
  /body-relative offset.
- Replace the affiliated-keyword lookup with find_name_line(), a text
  scan over /body: the parser only attaches #+NAME to some element
  kinds (not tables), so scanning the rendered body is more reliable.
- Drop the now-unused section_content() helper.
- Update tests with concrete /body-relative offset assertions
  (<<target>> at 22, blank-stripped at 5, #+NAME at 44).

GUI:
- sectionRevealed gains a bodyOffset argument, threaded from the
  resolve response through revealSection into the QML Connections.
- OutlineView positions the row at Beginning when the match lies inside
  the body (offset > 0) so the body is visible, else just Contain.

Scroll granularity is section-top: the row is scrolled into view, not
the exact character. True char-precise scrolling needs a single
addressable body text widget; /body is currently segmented into
text/code/table/list parts. Deferred.
2026-10-02 11:04:05 +02:00
Levi Neely cc2b341754 Make org links in headline titles clickable
Headline titles were rendered as plain text, so an inline org link in a
heading showed its raw [[target][desc]] syntax and could not be followed.
Body text and list items already route links through renderInline and
onLinkActivated; apply the same treatment to the title Label.

The title now renders as StyledText via renderInline, exposes a pointer
cursor over links through a HoverHandler, and dispatches clicks to
openLink (which resolves and navigates via the model). Non-link text
keeps its done-aware color and strike-through.
2026-10-02 09:37:15 +02:00
Levi Neely c6bb21800a Resolve fuzzy org link targets across the full search ladder
Fuzzy link targets previously resolved only to an exact headline title or
a slug id, always with offset 0. Extend resolve_in_doc to follow the
Emacs org-link-search precedence:

  1. a <<target>> dedicated target (or <<<radio>>> radio target)
  2. an element named with #+NAME: target
  3. a headline whose title matches
  4. (compat) a section whose slug id matches
  5. the target text appearing literally in a section's content
     (case-insensitive)

Each branch returns the owning section plus a byte offset relative to the
start of that section's raw content, so a caller can later scroll within
/body. Dedicated-target and plain-text matches use lightweight scans over
the raw section slice (find_org_target, find_plain_text); #+NAME uses the
parsed affiliated keywords. The new section_content helper yields a
section's raw content region and its starting offset.

Fuzzy matching inherits the cross-document fallback already wrapping
resolve_in_doc, so a bare target can reach a <<target>>, named element,
or body text in another file.
2026-10-02 09:34:33 +02:00
Levi Neely 27e63f7a6f Follow org links in the GUI, resolving across the org path
Clicking an internal org link now navigates the outline to its target.
The GUI resolves the link via /<doc>/resolve and acts on the result:
external targets open in the OS handler; internal targets expand the
target's ancestors, scroll to it, and flash it.

Resolution spans the whole org path rather than a single document:

  - file:DOC::TARGET and file:DOC resolve TARGET within the named
    document (a file: link to a document not in the path stays external).
  - id:uuid links are globally unique and resolve in whichever document
    owns the ID.
  - #custom-id, *Headline, and bare fuzzy targets resolve in the current
    document first, then fall back across every other document.

The resolve result gains a `doc:` field naming the owning document. When
it differs from the current document, DocumentModel::followLink switches
the model to that document before revealing the section.

DocumentModel gains revealSection() and followLink(); OutlineView wires
link clicks to followLink and animates the revealed row via the new
sectionRevealed signal.
2026-10-02 09:26:03 +02:00
Levi Neely b21b675980 Add /<doc>/resolve for link target resolution
A read-write 9P file per document: write a link target, read back its
structural resolution. resolve_target dispatches by target syntax,
mirroring Emacs org-link-search precedence but terminating in a section
id rather than a buffer position:

  #custom-id / id:uuid / *Headline -> dedicated (exact section)
  http(s)/mailto/ftp/file          -> external (OS-handled)
  bare target naming a heading     -> fuzzy (section)
  otherwise                        -> notfound

Response schema carries kind/section/offset; the offset field is wired
now (0 for current cases) so Phase 2 fuzzy text/<<target>>/#+NAME search
with an intra-section byte offset slots in without changing the contract.
Resolved ids are directly valid 9P paths (/<doc>/<id>).
2026-10-01 16:31:41 +02:00
Levi Neely b79970b62f Render org links in the GUI body
Body text and list items now render org links ([[target]] and
[[target][description]]) as clickable hyperlinks via StyledText. Other
text is HTML-escaped and newlines become <br> so formatting is preserved.

Activating a link opens external URL schemes (http(s), mailto, file) in
the system handler; internal links (id:, #custom, *headline) are left for
a future navigation pass. The pointer cursor shows over hovered links.
2026-10-01 15:56:56 +02:00
Levi Neely 39ab7108ff Cycle checkboxes with the mouse wheel
Scrolling over a checkbox glyph cycles its state
unchecked -> partial -> checked (and back), respecting scroll direction:
wheel up steps forward, wheel down steps in reverse. Left-click still
toggles checked/unchecked.

The shared rewriteCheckbox helper applies a state-mapping function to the
Nth cookie; toggleCheckbox and the new cycleCheckbox are thin callers.
2026-10-01 15:53:06 +02:00
Levi Neely 3d3ccd1e11 Align checkbox detection between QML and model
The QML list parser required whitespace after the checkbox bracket
(\]\s+), so a bare cookie with no trailing text (e.g. "- [X]") was not
counted as a checkbox. The model's toggle regex had no such requirement,
so the two disagreed on checkbox ordinals and a click could toggle the
wrong item. Relax the QML regex to \]\s* so both layers count the same
set of checkboxes in the same order.
2026-10-01 15:48:20 +02:00
Levi Neely 55856707c7 Make GUI checkboxes clickable
Clicking a checkbox glyph toggles its state and writes the change back.
DocumentModel::toggleCheckbox(row, ordinal) finds the Nth checkbox cookie
in the section body (document order), flips [ ]/[-] -> [X] and [X] -> [ ],
writes the body via /body, and refreshes the row.

The body parser assigns each checkbox item a stable ordinal across the
whole section, and the list delegate wires a pointer-cursor MouseArea on
the glyph (checkbox items only) to the model toggle.
2026-10-01 15:45:26 +02:00
Levi Neely 754051b3a4 Render list items and checkboxes in the GUI body
The body parser now recognizes org list items (- / + / ordered) and
splits them out of text runs into dedicated "list" parts. Each item
captures its indent, bullet, optional checkbox state, and text.

A new list component renders checkbox items with ☐ / ▣ / ☑ glyphs
(unchecked / partial / checked), colored by state, with checked items
dimmed and struck through. Plain list items render with their bullet.
2026-10-01 15:40:50 +02:00
Levi Neely 36a9607576 Add code block execution to the GUI outline view
Each executable source block now shows a "Run" button in the outline.
Clicking it runs the block server-side and renders the inserted
#+RESULTS: in place, with an exit-status indicator.

DocumentModel gains executeBlock(row, blockIndex), which runs
/ctl execute <doc> <id> <n>, refreshes the section body, and emits
dataChanged(BodyRole). isLanguageSupported(lang) backs button visibility
via /ctl languages (loaded once, cached).

OutlineView tags each parsed code part with its src-block ordinal so the
GUI index matches the server's block indexing, and the code block header
carries the Run control plus running/ok/error status.
2026-10-01 15:26:05 +02:00
Levi Neely d073c8fa5e Honor :results header args on code execution
Parse the :results switch from a src block's header arguments and apply
Emacs org-babel semantics when writing results:

- silent/none: execute and report, but do not write a #+RESULTS: block
- raw: insert output verbatim, no prefix or wrapping
- drawer: wrap output in a :results: ... 🔚 drawer
- default: fixed-width for <10 lines, #+begin_example for 10+

The insert_results detection loop now recognizes and replaces all four
wrapping styles in place, so re-execution and cross-mode transitions
(e.g. drawer to fixed-width) update cleanly without duplication. Raw
results, which have no delimiters, terminate at a blank line or heading.
2026-10-01 13:48:13 +02:00
Levi Neely a13f76cd40 Add languages and reload-languages ctl verbs
Expose the language registry via ctl: `languages` lists supported src
block languages (one per line), `reload-languages` reloads plugin configs
from /etc/orkmode/languages and ~/.config/orkmode/languages and reports
how many were loaded.
2026-10-01 13:39:16 +02:00
Levi Neely 84101145d7 Add language plugin registry and Emacs-style #+RESULTS: formatting
Code execution now uses a LanguageRegistry with built-in defaults
(bash, python, ruby, perl, javascript) that can be overridden by TOML
config files in /etc/orkmode/languages or ~/.config/orkmode/languages.

Match Emacs org-babel result formatting: fixed-width (": " prefix) for
output under 10 lines, #+begin_example blocks for 10+ lines
(org-babel-min-lines-for-block-output).

Fix insert_results duplication: the source block span end already points
to the start of the line after #+END_SRC, so searching for the next
newline skipped the existing #+RESULTS: line and prepended duplicates on
re-execution. Also handle replacing existing #+begin_example result blocks.

Fix parser: #+begin_example, comment, and export blocks were misparsed as
SourceBlock with a bogus language, causing "unsupported language" errors
on re-execution. Map them to their correct AST element kinds.
2026-10-01 13:36:38 +02:00
Levi Neely 4a865d7546 Write #+RESULTS: blocks after code execution
- Execute returns results and writes them to the org file
- Replaces existing #+RESULTS: block if present
- Results formatted with : prefix per org convention
- Process blocks in reverse order to preserve byte offsets
- Blank line added after results for proper separation
2026-10-01 11:46:16 +02:00
Levi Neely 17f18b044c Add Landlock sandbox for code execution
- sandbox.rs: Landlock filesystem restrictions applied via pre_exec
- Library paths (/lib, /usr/lib) need execute permission for dynamic linker
- 30s timeout with SIGKILL on expiry
- Network access allowed (Landlock only restricts filesystem)
- Minimal env: PATH, HOME=/tmp, LANG, TERM
2026-10-01 11:38:43 +02:00
Levi Neely 445177bbfa Add execute ctl verb for org-babel style code execution
- execute <doc> — all blocks in document
- execute <doc> <section> — all blocks in subtree
- execute <doc> <section> <idx> — single block by index
- execute <doc> <section> <name> — block by #+NAME in section
- execute <doc> :<name> — named block anywhere in doc

Parser now extracts #+NAME: from block's affiliated keywords.
Supports bash, python, ruby, perl, and node interpreters.
Basic sandboxing via env_clear() and restricted PATH.
2026-10-01 10:51:39 +02:00
Levi Neely b2953a7caf Fix font and scaling in Main.qml
Replace all pixelSize with pointSize using baseFontSize:
- Sidebar width: 16em (was 220px)
- Header margins: 1em (was 12px)
- Document title: baseFontSize (was 13px)
- Document name: baseFontSize-2 (was 10px)
- Section header: baseFontSize-1 (was 11px)
- Delegate padding: 0.8em / 0.4em
- All spacing font-relative
2026-10-01 10:25:03 +02:00
Levi Neely 9e48a89c92 Use font-relative units instead of pixels
All spacing/margins now scale with baseFontSize:
- Table cell padding: 0.5em horizontal, 0.25em vertical
- Table margins: 0.75em
- Column spacing: 1.5em
- Code block margins: 0.5em
- Border radius: fontSize/3
- Left rule width: fontSize/6

Ensures proper display at any DPI/font size.
2026-10-01 10:23:20 +02:00
Levi Neely 09962cc800 Fix table rendering: left align, padding, nested Repeater scope
- Store tableData at Rectangle level to avoid scope issues
- Use ColumnLayout + RowLayout instead of GridLayout (cleaner nesting)
- Anchor left instead of center
- 8px margins, 16px column spacing
- Add ?? false fallback for hasHeader to prevent undefined errors
2026-10-01 10:21:09 +02:00
Levi Neely 30fa1bf35e Render org tables as styled grids
- Parse | delimited rows into cells
- Detect header separator (|---+---|)
- Header row: bold, text color
- Data rows: normal, placeholder color
- Same alternate background as code blocks
2026-10-01 10:19:15 +02:00
Levi Neely 17906c06f9 Render org src blocks as styled code blocks
- Parse body text into segments (text vs code)
- Src blocks get: monospace font, alternate background, rounded corners
- Language label shown above code (python, bash, etc.)
- Regular text keeps left rule indicator

Handles #+BEGIN_SRC lang ... #+END_SRC blocks.
2026-10-01 10:16:27 +02:00
Levi Neely 9e6dc589bd Fix Makefile install target
install depends on 'all' (which includes ui if Qt6 present)
instead of having broken split dependency lines
2026-10-01 10:12:53 +02:00
Levi Neely 43565ad1b6 Fix planning line stripping in section_body
- Use 'contains' instead of 'starts_with' to handle combined lines
  like 'SCHEDULED: <...> DEADLINE: <...>'
- Track 'seen_content' to strip planning/drawers only at the start
- Skip empty lines before content
- Continue stripping planning lines until real content appears
2026-10-01 10:10:45 +02:00
Levi Neely f5f2bffce4 Strip planning line and drawers from section body
section_body() now filters out:
- Leading planning line (SCHEDULED/DEADLINE/CLOSED)
- Property drawers (:PROPERTIES: ... :END:)
- Any other drawers (:LOGBOOK:, etc.)
- Leading/trailing blank lines

These are displayed separately in the UI.
2026-10-01 10:09:39 +02:00
Levi Neely ff48f9c7ee Remove Qt jargon from comments 2026-10-01 10:01:22 +02:00
Levi Neely 429c896f5a Add collapsible properties drawer
- PropertiesRole returns QVariantMap of key->value
- loadProperties() parses KEY=value lines from /properties file
- QML drawer shows ':PROPERTIES:' header with ▶/▼ toggle
- Properties shown as monospace :KEY: value pairs when expanded
- Drawer starts collapsed by default
2026-10-01 10:00:20 +02:00
Levi Neely eb3b174b12 Increase body text size and line height
Body text: baseFontSize + 1 (was baseFontSize)
Line height: 1.3 (was 1.15)
2026-10-01 09:47:56 +02:00