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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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>).
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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
- 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.
- 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
- Parse | delimited rows into cells
- Detect header separator (|---+---|)
- Header row: bold, text color
- Data rows: normal, placeholder color
- Same alternate background as code blocks
- 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
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.
list() returns files AND directories. Section entries are directories only.
Files like 'title', 'keyword', 'body' were being incorrectly treated as
child sections. Using listDirs() filters to directories only.
TreeView (inherits TableView) is designed for file browsers with fixed row
heights, not structured document editing. ListView gives us:
- Full control over row heights (implicitHeight just works)
- Simpler delegate without TreeViewDelegate quirks
- Manual indentation via leftMargin based on level
- Expansion state tracked in model with hasChildren/expanded roles
- Click-to-expand triangles for sections with children
DocumentModel changed from QAbstractItemModel (tree) to QAbstractListModel
(flat list) with sections ordered depth-first. Tree structure preserved via
level, parentId, childIds fields. Expansion state controls visibility.
TreeView (inherits TableView) defaults to fixed row heights. Setting
rowHeightProvider to return -1 tells it to use each delegate's
implicitHeight, enabling variable-height rows.
- In system mode, read palette from QGuiApplication::palette() rather
than parsing kdeglobals (platform plugin already handles this)
- Don't call setPalette() in system mode — let Qt handle it
- Add isOverriding property for QML to check mode
- Use Fusion style by default for consistent palette respect
- Improved fallback logic in systemSchemePath() for dark/light detection
- Added /etc/xdg/kdeglobals as fallback for system-wide defaults
ThemeManager now supports:
- fontFamily, fontSize, fontStyle with full QFontDatabase access
- uiScale (0.5x to 4.0x) saved to theme.conf
- resetFont() to return to system defaults
SettingsDialog provides UI for:
- Color scheme selection (all installed KDE schemes)
- Font family/style/size with live preview
- UI scale slider with percentage display
OutlineView uses pointSize (scalable) instead of pixelSize (fixed).
All font sizes derived from theme.fontSize or baseFontSize.
- Add ork-server and orkmode (GUI) to install target
- Make UI build optional (only if Qt6 Qml available)
- Add ui target for explicit GUI build
- Clean ui/build directory on 'make clean'
- Update help text with requirements
- Replace manual Item+anchoring with ColumnLayout for proper height calc
- Remove redundant bodyMeasure Text and bodyWidth property
- Add proper top/bottom margins using rowPad constant
- Body row now uses RowLayout with Layout.fillWidth
A standalone Qt6/QML app in ui/ that speaks 9P to ork-server; no FFI,
no privileged path. All org logic stays in the server.
- p9client: minimal synchronous 9P2000 client over QTcp/QLocalSocket
- orkclient: QML-facing facade (read/list/listDirs/write/rdwr/docs)
- documentmodel: lazy QAbstractItemModel of the section tree, with a
BodyRole that reads /<doc>/<id>/body (planning line + :PROPERTIES:
drawer stripped, since those are shown as structural objects)
- Main.qml: document sidebar + outline; OutlineView.qml: folding
TreeView rendering keyword/priority/title/tags/timestamps as distinct
objects, with section body shown inline beneath each headline
- main.cpp: --addr/$ORK_ADDR, --selftest (headless outline dump),
--shot (headless window grab for debugging)
- CMake build; binary emitted to bin/ to avoid the QML-module-URI vs
target-name directory clash
Known issue: inline body layout still needs work (row sizing/overlap
when bodies wrap); to be iterated.
Dropping the C FFI layer left orkmode-core unused: nothing in the
workspace depends on it, and the GUI will speak 9P instead. Delete the
crate, drop it from the workspace, and scrub FFI references from the
docs (README structure/usage, AGENTS structure/FFI/next-steps).
The vision predated the 9P pivot and the GUI direction. Correct it:
- Architecture: clients (GUI, CLI, Emacs/vim, agents) speak 9P to
ork-server; drop the C FFI layer entirely.
- Replace "keyboard-first, mouse optional" with "the document is the
control surface": actions live on objects, mouse and keyboard equal,
no menu trees or button walls.
- Add an explicit Editing Model: structured render, per-section
raw-text editing with reparse-on-commit, and direct-manipulation
structural edits that each map to one 9P operation.
- Refresh milestones (v0.1 parser/CLI and v0.2 9P server/client done;
GUI is next) and the rationale (add the "Why 9P" section).