← Markwork · Learning notes · October 3–8, 2026

Learning GTK from Markwork

Markwork began as a single GtkSourceView in a GTK 3 window that hid Markdown syntax. Five days later it is a GNOME app on GTK 4 + libadwaita with tabs, panels that fold on narrow windows, dialogs that return a Promise, GSettings, list models, .ui templates, translations, and a Flatpak manifest. This page follows that path commit by commit. For each Markwork feature it names the GTK pieces it uses and explains why. It also covers the third of the code that never touches GTK, which is why most of the app can be tested without a window.

Because this page is about building interfaces, every chapter draws the widgets it talks about: Adwaita-style mock-ups you can click, resize, and inspect, with the GTK type of every part named next to it. Start with the window anatomy, which maps each region of Markwork's window to the widget that draws it.

Lines in files that import GTK/Adw
11,521
from 1,235 · 6a0f03d → 1bac3b8
Lines without any GTK import
5,055
pure TypeScript in markdown/, agent/, …; plus 3,397 lines that use only Gio/GLib
Adw.* references in src/
125
from 0 before c85aa0d (October 6)
Layouts written as .ui templates
6
header bar, status bar, find bar, chat, palette, preferences
The big picture

The journey: three eras of the same app

Each row is a commit that changed how Markwork uses GTK. The bar splits the src/ lines into three layers: files that import GTK/Adw/GtkSourceView/WebKit, files that import only Gio/GLib (files, processes, settings), and files with no gi:// import at all. Hover over a row to see the numbers. The counts come from git show <commit>:<file> for each file, so they are real measurements, not estimates.

Imports GTK / AdwOnly Gio / GLibNo GI import (pure)
  1. 6a0f03dGTK 3Chapters 1, 2 · The first editor: Gtk.Application, GtkSource.View 4, text tags that hide Markdown syntax, images placed with add_child_in_window().
  2. 4667616GTK 3Chapter 1 · TypeScript bundled by Vite; GJS modules stay external.
  3. a86dfb4GTK 3Chapter 9 · Open a folder: sidebar with Files/Outline tabs (Gtk.TreeView + TreeStore).
  4. 8e3ce86GTK 3Chapter 4 · Tables drawn as a Gtk.Grid above hidden text; the hidden-tag size changes from 1 to 256 because emoji failed to draw.
  5. fc7592aGTK 3Chapter 5 · Image viewer: zoom at the pointer, drag to pan, drawn with cairo.
  6. c0fb9d4GTK 3Chapter 5 · Kanban board with its own pointer drag (ghost card, drop marker, auto scroll).
  7. 7f32cc8GTK 3Chapter 4 · Mermaid diagrams rendered by a hidden WebKitGTK and pasted as a picture.
  8. 50dd86bGTK 3Chapter 2 · Tags applied by difference (setTagRanges) so typing in long documents stays fast.
  9. c989744GTK 3Chapter 13 · History tab: git run through Gio.Subprocess, never blocking.
  10. 5999e22GTK 3Chapter 5 · Create, move (drag and drop), rename, and trash files in the tree.
  11. f6d4147GTK 3Chapter 7 · Document tabs, one MarkdownView per tab.
  12. 472c298GTK 3Chapter 3 · replaceAllText() and background autosave: a 650 KB manuscript opens without freezing.
  13. 295d7b9GTK 4Chapter 6 · Migration to GTK 4, GtkSourceView 5, and WebKitGTK 6.0 (63 files, +1,813/−1,275).
  14. 9079384GTK 4Chapter 4 · Overlays scroll with the text again (OverlaySlots reallocates on scroll).
  15. 36ae571GTK 4Chapter 12 · Gtk.Calendar date picker for kanban due dates.
  16. e87b422GTK 4Chapter 12 · [[note]] links: Ctrl+click and a suggestion Gtk.Popover that keeps focus in the text.
  17. c85aa0dAdwChapter 7 · libadwaita: Adw.Application, ApplicationWindow, HeaderBar, StyleManager, AboutDialog.
  18. 3fe7fddAdwChapter 7 · Dialogs move to Adw.Dialog and Adw.AlertDialog.
  19. 614f171AdwChapter 8 · Settings move to GSettings with a compiled schema.
  20. 9e7ffdaAdwChapter 8 · Adw.PreferencesDialog and a command palette built on Gio.Action.
  21. ad0f095AdwChapter 7 · Adw.TabView/TabBar for tabs and Adw.OverlaySplitView for the side panels.
  22. 1f364e4AdwChapter 10 · Header bar, status bar, find bar, and chat panel move to Gtk.Template.
  23. b51c43bAdwChapter 9 · Manual lists replaced by Gio.ListStore + Gtk.ListView; the file tree uses TreeListModel.
  24. 1a1a716AdwChapter 11 · App ID com.ekaput.Markwork, desktop file, metainfo, icons, Meson, Flatpak.
  25. 9750175AdwChapters 7, 11 · GNOME HIG pass: Promise dialogs, Adw.Breakpoint, shortcuts dialog, Adwaita colors, gettext.
  26. b31556dAdwChapter 7 · Agent plan checklist with Adw.Spinner and a Gtk.Spinner fallback.
  27. efecb82AdwChapter 2 · Hanging indent for list items: negative indent tags made on demand.
  28. 222dc97AdwChapter 12 · Inbox view (Adw.Clamp, Adw.StatusPage, Gtk.ListBox as a boxed list).
  29. 79ad33dAdwChapter 12 · Home tab (Gtk.FlowBox of folder cards, Adw.ActionRows).
  30. d21cfb0AdwChapter 4 · Code blocks become a horizontally scrolling Gtk.ScrolledWindow overlay.
  31. 5877392AdwChapter 12 · Daily journal with quick capture and an activity log (JSONL, no GTK).
  32. c237d7cAdwChapter 12 · Image files show in the file tree and open in the image viewer.
  33. f74d5b5AdwChapter 13 · Home reads boards and inboxes past the first 300 files.
  34. 1c44423AdwChapter 5 · Files dropped from a file manager: the tree's Gtk.DropTarget also accepts Gdk.FileList and copies them in with Gdk.DragAction.COPY.
  35. de2924fAdwLayers, Chapter 13 · Journal, harness, Home, agent writes, and autosave leave window.ts for window/ controllers that import only GLib/Gio; shared types move down (colors.ts, gitlog.ts) so no lower layer imports an upper one.
  36. 1bac3b8AdwChapter 12 · The assistant writes the commit message: a shared MessageWriter button whose Gtk.Stack swaps a bundled symbolic icon for a spinner with a stop mark, a capture-phase key controller for Ctrl+Z, and a status Gtk.Label with the error class and a markup link.
Before reading the chapters

Three layers, one direction

The most important GTK decision in Markwork is not a widget. It is where GTK is allowed to appear. The rule has held since the first commit: Markdown rules live in src/markdown/ and never import GTK; the editor engine reports through callbacks and knows nothing about files or menus; window.ts is the only place that connects the components.

GTK

editor/, ui/, window.ts, actions.ts, window/doc.ts (types only). Widgets, text tags, event controllers, dialogs. Tested with a real window on Xvfb (tests/gui/).

Gio / GLib

files.ts, fileops.ts, git.ts, settings.ts, orchestrator.ts, activity.ts, the window/ controllers, the network part of agent/. No widgets, but the GLib main loop and its async I/O.

Pure

markdown/, most of agent/, gitlog.ts, colors.ts, editor/offsets.ts. Strings in, values out. Tested in tests/unit/ with --no-gui.

window.ts→ window/* controllers→ ui/* · editor/*→ files · git · settings→ markdown/* · agent/* (pure)

A lower layer never imports an upper one. When you learn GTK from this code, start in a pure module (for example markdown/kanban.ts) to see the data, then read the widget that draws it (ui/kanban.ts). The widget is short because the decisions were made before GTK was involved.

When the connecting layer grows: de2924f

By October 8 window.ts had reached 1,642 lines and about 45 imports: tabs and files, but also the journal, harness runs, Home, applying agent changes, and autosave. Three imports also pointed the wrong way: editor/ took the Palette type from ui/theme.ts (a file that loads Adw), git.ts took its request types from agent/, and actions.ts and window.ts imported each other. The fix moved no widget code. Shared types went down to the lowest layer that uses them (colors.ts, gitlog.ts); actions.ts now sees the window as a small ActionHost interface; and each feature became a controller in window/ that receives a narrow host object built by MainWindow.makeHost() from closures, the same pattern as ChatPanel.host. The controllers import GLib and Gio but no GTK module, so 414 lines left the GTK layer (11,359 → 10,945) and window.ts dropped to about 1,190 lines. They still call widgets the window owns (the board, the chat panel, dialogs), so they are tested through the window in tests/gui/, not with --no-gui.

LessonThe one place allowed to connect everything is the place most likely to swallow everything. Keep it as wiring: when a feature needs more than a few methods there, give it its own module and hand it only the part of the window it uses, never the window itself.
Before reading the chapters · window.ts (the “Layout” block of the constructor), ui/sidebar.ts, ui/headerbar.ui

Anatomy of the window: every region is a widget

A GTK window is a tree of widgets. Each widget owns a rectangle, and each container decides where its children go. The mock-up below is Markwork's main window drawn as HTML. The tree on the right is copied from the code that builds it. Hover over (or tap) a region or a line in the tree to see which widget draws which part. The buttons change the window the same way the real actions do.

Gtk.Stack page:
FilesOutlineHistory
Meeting Notes
  Decisions
  Schedule
  Open questions
Meeting Notes.mdRoadmap.md
meeting2 of 5
Meeting Notes
Weekly meeting, Friday morning. Things that need follow-up are below.
Decisions
- The 1.0 release moves one week
- [ ] Write the kanban docs
Saved92 words · Line 3, Column 6
Summarize the decisions
The 1.0 release moves one week; the export bug is fixed.
Ask…
Committed successfully
  • Adw.ApplicationWindow
    • Adw.ToolbarView
      • top-barMarkworkHeaderBarAdw.Bin → Adw.HeaderBar
        • startGtk.ToggleButton, Gtk.Button ×3
        • titleAdw.WindowTitle
        • endGtk.ToggleButton, Gtk.Button, Gtk.MenuButton
      • contentAdw.ToastOverlay
        • Adw.OverlaySplitViewsidebar 240 px
          • sidebarGtk.Box.sidebar
            • Gtk.StackSwitcher
            • Gtk.Stackfiles · outline · history
          • contentAdw.OverlaySplitViewEND, 360 px
            • contentGtk.Boxvertical
              • Adw.TabBarautohide
              • MarkworkFindBartemplate
              • Gtk.Stackeditor · board · inbox · home
              • MarkworkStatusBartemplate
            • sidebarChatPaneltemplate
const column = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, hexpand: true });
column.append(this.tabBar.widget);        // Adw.TabBar
column.append(this.findBar);              // template, hidden until Ctrl+F
column.append(this.content);              // Gtk.Stack: editor | board | inbox | home
column.append(this.statusBar);
this.chatSplit = new Adw.OverlaySplitView({ sidebar_position: Gtk.PackType.END,
    sidebar: this.chat.widget, content: column, min_sidebar_width: 360, max_sidebar_width: 360 });
this.sidebar.setContent(this.chatSplit);  // the left OverlaySplitView wraps the right one
this.toasts.set_child(this.sidebar.widget);
const toolbar = new Adw.ToolbarView({ content: this.toasts });
toolbar.add_top_bar(this.header);
this.win.set_content(toolbar);

Notice three GTK habits in the tree. Nesting replaces layout code: two split views inside each other give three columns. A Gtk.Stack swaps whole pages (the editor, the board, the inbox, Home) without rebuilding them. Overlays sit on top: the toast floats above everything because Adw.ToastOverlay wraps the whole content. To see the real tree of any GTK app, run it with GTK_DEBUG=interactive to open GTK Inspector.

Chapter 1 · commits 6a0f03d, 4667616 · src/main.ts, src/app.ts, vite.config.ts

A GTK app in GJS: not Node, not a browser

What GJS is. GJS is SpiderMonkey (Firefox's JavaScript engine) plus GObject Introspection. Any library that ships a typelib can be imported with gi://: GTK, GLib, Gio, Pango, GtkSourceView, WebKit, libsecret, libsoup. There is no fs, no fetch, no setTimeout from Node. Files go through Gio, timers through GLib.timeout_add, HTTP through libsoup.

First commit GTK 3

import Gtk from 'gi://Gtk?version=3.0';
const app = new Gtk.Application({
  application_id: APP_ID,
  flags: Gio.ApplicationFlags.NON_UNIQUE });
app.connect('activate', () => {
  const settings = loadSettings();   // JSON file
  new MainWindow(app, settings, file);
  saveSettings(settings);
});

Now GTK 4 + libadwaita

import './i18n.js';   // gettext first
import Adw from 'gi://Adw?version=1';
const app = new Adw.Application({
  application_id: APP_ID,
  flags: Gio.ApplicationFlags.NON_UNIQUE });
app.connect('activate', () => {
  new MainWindow(app, new AppSettings(), path);
});   // GSettings saves itself

The pieces you meet first

LessonTreat GJS as its own platform. Everything a browser or Node gives you for free has a GLib/Gio equivalent, and that equivalent is integrated with the GTK main loop, which is what you want anyway.
Chapter 2 · commits 6a0f03d, 8e3ce86, 50dd86b, efecb82 · editor/tags.ts, editor/tagsync.ts, editor/offsets.ts

The editor is one TextBuffer and many TextTags

The idea. Markwork shows formatted Markdown while the file stays plain text. There is no separate preview widget: the GtkSource.Buffer holds the exact file contents, and Gtk.TextTags change how ranges look. A heading gets a tag with scale: 2.0; the ## marker gets a tag that makes it almost invisible, except on the line with the cursor.

Tags as data

const TAG_DEFS = {
  h1: { scale: 2.0, weight: 800,
        pixels_above_lines: 22 },
  bold: { weight: Pango.Weight.BOLD },
  code: { family: FONT_MONO, scale: 0.9 },
  // last = highest priority
  marker: { weight: NORMAL, ... },
  hidden: { size: TINY },   // 256, not 1
};

Tag priority is creation order, so marker, dim, and hidden are created last.

What GTK features this uses

GtkSource.BufferGtkSource.ViewGtk.TextTagGtk.TextIterGtk.TextMarkPango.WeightGtkSource.SearchContextGtkSource.LanguageManagerGtkSource.StyleSchemeManager

GtkSourceView adds undo grouping, search with highlighting (find bar), and per-language highlighting that Markwork reuses for code block contents (editor/codehighlight.ts).

Three things the TextBuffer teaches you

1. Positions are code points. Gtk.TextIter counts Unicode characters; JavaScript strings count UTF-16 units. An emoji is 1 in GTK and 2 in JS. The Markdown parser works on JS strings, so every offset is converted right before it touches the buffer with makeCpMap(). Try it below.

2. Hiding text is a layout question. The invisible tag property crashed GTK 3 (“Byte index is off the end of the line”). Markwork hides markers with a tiny font instead. That size was 1 at first; commit 8e3ce86 raised it to 256 Pango units (TINY) because a 1-unit emoji font fails to draw.

3. Tags that change size cost a relayout. Removing a tag from the whole buffer and applying it again makes GTK lay out every line again. In a 30 KB document that once cost about 300 ms per keystroke. Commit 50dd86b introduced setTagRanges(): read where the tag is now, compute the difference with where it should be, and touch only that. The second simulation runs this exact code. Learn performance has the measurements.

Illustration: one buffer, many tags what the user sees vs. what GTK holds

The text below is the exact file content. Nothing is a separate widget: each style is a Gtk.TextTag applied to a range. Turn on Show tag ranges to outline every tag, and move the cursor to another line to see the markers fold away.

Cursor on line:
## Decisions
Weekly meeting, **Friday morning**, see `v1.0`.
*Things that need follow-up* are below.

The orange tags are the hidden tag (size TINY): the characters are still in the buffer, so copy, undo, search, and Git see plain Markdown. On the cursor line the highlighter swaps hidden for marker, which shows them dimmed.

Later, the same tool for layout. Commit efecb82 added a hanging indent for list items with a negative indent plus left_margin on a tag. Because the prefix width (- , 10. , - [ ] ) varies, editor/listindent.ts creates one tag per pixel width on demand and updates its margin when the editor column changes.

Simulation: UTF-16 offsets vs GTK positions real logic · copy of makeCpMap()

Orange cells are surrogate pairs: two JavaScript units, one GTK position. Passing the JavaScript index straight to get_iter_at_offset() would put the bold tag one character too far right for every emoji before it.

Simulation: apply the difference, not everything real logic · copy of normalize()/subtract()

A 200-character line. Grey = where the tag is now, blue = where the highlighter wants it. Red is removed and green is applied by setTagRanges().

have
want
operations
LessonA TextBuffer with tags is a small styled document model. Keep the text as the only truth, keep every tag inside one line where you can, and change tags by difference.
Chapter 3 · commits 6a0f03d, 472c298, d7afe2f · editor/view.ts, files.ts

The main loop: idle priorities, not threads

The problem. Every keystroke emits changed on the buffer, and moving the cursor emits mark-set. Highlighting in those handlers directly means highlighting several times per frame during paste, undo, or auto-repeat.

The design. Handlers only queue work. queueHighlight() schedules one GLib.idle_add(GLib.PRIORITY_HIGH_IDLE, …) and ignores further calls until it runs. PRIORITY_HIGH_IDLE (100) runs before GTK redraws (120) and before its background relayout (125), so the frame that is drawn already has the new tags and nothing flickers.

queueHighlight(): void {
    if (this.destroyed || this.highlightQueued) return;
    this.highlightQueued = GLib.idle_add(GLib.PRIORITY_HIGH_IDLE, () => {
        this.highlightQueued = 0;
        this.highlight();
        return GLib.SOURCE_REMOVE;
    });
}

Simulation: many signals, one pass model of the queueing rule

When idle is not enough

Cleanup is manual. GTK 4 through GJS does not emit destroy for a widget that JavaScript still holds. Every component with idles or timers keeps their IDs, has a destroy() method and a destroyed flag, and is cleaned up from the window's unrealize signal. The GTK 4 audit (docs/audit-gtk4.md) found three places where this was missing.
LessonIn GTK you rarely need threads. Use idle priorities to merge work and order it around drawing, and use the _async variants of Gio for I/O.
Chapter 4 · commits 6a0f03d, 8e3ce86, 7f32cc8, 9079384, d21cfb0 · editor/overlays.ts, images.ts, tablelayer.ts, codelayer.ts, mermaid.ts

Widgets above the text without touching the text

The need. Images, table grids, diagrams, and scrollable code blocks are widgets, but the document must stay plain Markdown. A GtkTextChildAnchor would put an object character into the buffer and into the undo history, so Markwork never uses one.

The technique. The lines a block covers are shrunk with a hidden tag, empty space is reserved below them with pixels_below_lines, and the widget is placed in that space at buffer coordinates. When the cursor enters the block, the widget hides and the raw text returns for editing.

GTK 3

const box = new Gtk.EventBox({ visible_window: false });
view.add_child_in_window(box,
    Gtk.TextWindowType.TEXT, 0, 0);
view.move_child(box, x, y);
box.destroy();   // when the block goes away

GTK 4 OverlaySlots

const slot = slots.borrow();   // a Box already
                               // added with add_overlay()
slots.place(slot, x, y);       // move_overlay()
slots.release(slot);           // empty + hide,
                               // reused by the next block

Illustration: placing a table grid above its own text step by step

Step through what tablelayer.ts does with a Markdown table. The striped band is space that only exists because of pixels_below_lines; the grid is a widget in a slot borrowed from OverlaySlots.

The budget for the trip:
| Item | Cost | Who | |--------|-----:|------| | Train | 120 | Ana | | Hotel | 340 | Budi |
pixels_below_lines = 92
Item
Cost
Who
Train
120
Ana
Hotel
340
Budi
The next paragraph flows below the reserved space.

Two GTK 4.14 quirks shaped OverlaySlots:

BlockWidget inside the slotNotes
ImageGtk.Picture from a Gdk.TextureRemote images load through Gio; double click opens the viewer (Chapter 5).
TableGtk.Grid of Gtk.Labels with Pango markupCell Markdown → markup by the pure markdown/pango.ts; built only when visible (8d976da).
Code blockGtk.ScrolledWindow with its own horizontal scrollA TextView wraps all or nothing, so long code lines would widen the document (d21cfb0).
Mermaid / DBMLGtk.Picture of a WebKit snapshotA WebKit.WebView that is never shown runs mermaid.js; the snapshot becomes a texture. DBML is translated to Mermaid by pure code.

Two later fixes are about first frames: a new overlay stays transparent until it is placed (007c4e9) and a new editor stays hidden until its column margins are set (07ba09a). Without them, images blink at the top-left corner and text briefly sticks to the left edge.

LessonWhen a widget does not support what you need (here: removing an overlay), pool and reuse instead of fighting it. Write the reason in a comment, because the next reader will try the obvious API first.
Chapter 5 · commits fc7592a, c0fb9d4, 5999e22, 295d7b9 · gtkutil.ts, ui/kanban.ts, ui/filetree.ts, ui/imageviewer.ts

Input: from event signals to controllers

GTK 3 widgets emitted button-press-event and key-press-event. GTK 4 removed those signals; input is handled by event controllers attached to a widget. Markwork wraps the common cases in gtkutil.ts so every layer writes them the same way:

export function onClick(widget, handler, button = 1): Gtk.GestureClick {
    const gesture = new Gtk.GestureClick({ button });
    gesture.connect('pressed', (g, count, x, y) => {
        if (handler(count, x, y, g.get_current_event_state()) === true)
            g.set_state(Gtk.EventSequenceState.CLAIMED);   // stop here
    });
    widget.add_controller(gesture);
    return gesture;
}

Illustration: where a click travels model of GTK 4 event propagation

GTK delivers an event in three phases: capture from the window down to the widget under the pointer, then the target, then bubble back up. Controllers listen in the bubble phase by default, so a child reacts before its parent. This is a real case from ui/kanban.ts: a kanban card has a Gtk.GestureDrag (press = start a drag, or open the editor on release), and inside it a Gtk.CheckButton. Click the checkbox and then the card text, then turn off claim and click the checkbox again.

Adw.ApplicationWindow
Gtk.ScrolledWindow .kanban-board
Gtk.Box .kanban-column
Gtk.Box .kanban-card · GestureDrag
Click the checkbox or the card text on the left…

The comment in buildCard() says it in one line: “The checkbox handles its own click first (children come first), so checking does not open the edit dialog.”

FeatureGTK piecesWhy this way
Editor keysGtk.EventControllerKey (onKeyPress)Enter continues lists, Tab moves table cells, arrows drive the [[ suggestion popover.
Kanban dragGtk.GestureDrag, Gtk.Fixed ghost layer, Gtk.WidgetPaintable, Gtk.OverlayIts own pointer handling instead of DnD: a ghost image of the card follows the pointer, a drop marker, auto scroll at the edges; testable with synthetic events.
File tree moveGtk.DragSource, Gtk.DropTarget, Gdk.DragAction.MOVEReal drag and drop between rows; folders open when hovered for 600 ms. The drag icon is a themed icon paintable passed to set_icon(), not a widget (GtkDragIcon gives a Gtk-CRITICAL). The same DropTarget accepts Gdk.FileList from a file manager and copies those files in (1c44423).
Image viewerEventControllerScroll, EventControllerMotion, GestureDrag, a custom widget with vfunc_snapshotZoom at the pointer, pan by dragging; the texture is drawn with append_scaled_texture() and a NEAREST filter above 300% instead of cairo + pixbuf (deprecated in 4.20).
Context menusGtk.PopoverMenu from a Gio.Menu, a Gio.SimpleActionGroupMenus are written as plain MenuEntry[] (ui/menu.ts); tests call the entry's run().
LessonControllers compose: a widget can have a click gesture, a drag gesture, and a key controller, each with its own propagation phase. Claim the event sequence when you handle it, so the widget below does not act as well.
Chapter 6 · commits 295d7b9, 9079384, eda4a32 · docs/audit-gtk4.md

GTK 3 → GTK 4: what changed in practice

The migration was one commit touching 63 files, followed by an audit that listed the GTK 3 patterns that still worked but were no longer the GTK 4 way. These are the replacements Markwork made, in the order you are likely to meet them:

GTK 3GTK 4 in MarkworkWhere
container.add(), pack_start()box.append(), set_child(), pack() helpergtkutil.ts
get_children(), destroy() on a childchildrenOf(), removeWidget(), removeChildren()gtkutil.ts; a ListBox is emptied row by row because remove_all() also removes the placeholder
button-press-event, key-press-eventGtk.GestureClick, Gtk.EventControllerKeyChapter 5
size-allocate signalnotify::width/adjustment signals, get_width()editor margins, overlays
Gtk.EventBoxAny widget + a controllerimages, tables
add_child_in_window()add_overlay() + OverlaySlotsChapter 4
get_iter_at_line() → iterreturns [ok, iter]; iterAtLine()gtkutil.ts
dialog.run(), FileChooserNativeGtk.FileDialog, Adw.Dialog, modal() → PromiseChapter 7
TreeView + TreeStoreTreeListModel + ListViewChapter 9
ComboBoxTextGtk.DropDown + Gtk.StringListmodel picker in the chat panel
Picture.new_for_pixbuftextureFromPixbuf() → Gdk.MemoryTexturegtkutil.ts
show() / hide() / show_all()set_visible(); widgets are visible by defaulteverywhere
get_style_context().add_class()add_css_class(); CssProvider.load_from_string() (4.12+)ui/theme.ts
Gtk.ShortcutsWindowAdw.ShortcutsDialog (1.8+) or an Adw.Dialog fallbackui/shortcuts.ts

A GTK 4 surprise: hexpand/vexpand now propagate upward. One expanding label inside the History tab made the whole sidebar expand. The side panels are given an explicit hexpand: false, and a test guards it.

Illustration: hexpand climbs the tree model of GTK 4 expand propagation

In GTK 4, a parent “computes” its hexpand from its children unless it was set explicitly. One Gtk.Label in the History tab with hexpand: true marks every ancestor as expanding, and the split view gives the sidebar extra width. An explicit hexpand: false on the panel stops the climb.

FilesOutlineHistory
Fix export bug · 2h
Add inbox view
Decisions
The editor column gets what is left.

Another one: on X11, if a format command makes the selection empty for a moment, GTK 4 drops the new selection (PRIMARY clipboard). wrapSelection() inserts and deletes at the edges of the selection instead of replacing it.

LessonPort first, then audit. A big-bang port that keeps behavior is easier to verify; modernizing deprecated-but-working APIs is a separate list of small, testable changes.
Chapter 7 · commits c85aa0d, 3fe7fdd, ad0f095, 90699c3, 9750175, b31556d · window.ts, ui/dialogs.ts, ui/tabbar.ts

libadwaita: the GNOME layer on top of GTK

GTK gives widgets; libadwaita gives the GNOME patterns: header bars, dialogs that are bottom sheets on phones, toasts, tab views, split views, breakpoints, and the Adwaita style with named colors. Markwork moved to it in one day, one pattern per commit.

Patternlibadwaita APIIn Markwork
WindowAdw.ApplicationWindow, Adw.ToolbarView, Adw.HeaderBar, Adw.WindowTitleMain window; secondary windows (history, image viewer, proposals, agent log) use Adw.Window + ToolbarView, not set_titlebar().
ThemeAdw.StyleManager, Adw.ColorSchemeFollow system / light / dark; interface CSS uses @accent_color, @card_bg_color, … Only document surfaces (tags, tables, diagrams) keep hex colors.
DialogsAdw.Dialog, Adw.AlertDialog, Adw.AboutDialog, Adw.PreferencesDialogCard editor, confirmations (destructive response appearance), About, Preferences.
NotificationsAdw.ToastOverlay, Adw.Toast“Committed successfully” and other messages that need no answer.
TabsAdw.TabView, Adw.TabBar, Adw.TabPageOne page per document or Home; a page is only a marker, the window's own Gtk.Stack shows the editor or the board. The bar autohides with one tab.
Side panelsAdw.OverlaySplitViewSidebar (Files/Outline/History) and the Assistant panel, bound to GSettings.
AdaptiveAdw.Breakpoint, Adw.BreakpointConditionAssistant folds below 900 sp, sidebar below 600 sp. Try the simulation.
PagesAdw.Clamp, Adw.StatusPage, Adw.ActionRow, Adw.ButtonContentHome and Inbox (Chapter 12).
ProgressAdw.Spinner (1.6+)Agent plan checklist; Gtk.Spinner when the system libadwaita is older.

Gallery: the libadwaita widgets Markwork uses, drawn

Each card draws one widget as it looks in Markwork, with the API under it. The small buttons change the widget's state the way its properties do.

Adw.HeaderBar + Adw.WindowTitleButtons go in start/end slots; the title widget shows the document name and its folder as subtitle (windowTitle.subtitle).
Adw.TabBar + Adw.TabViewWith autohide: true the bar disappears when only one page is left. Remove tabs until one remains.
Document…
Committed successfully
Adw.ToastOverlay + Adw.ToastFor news that needs no answer. timeout: 3 seconds; the overlay stacks and queues toasts.
Save changes to “Meeting Notes”?
Changes will be lost if they are not saved.
Don't SaveCancelSave
Adw.AlertDialogResponses with DESTRUCTIVE and SUGGESTED appearance. On a narrow window the buttons stack. askSaveChanges() awaits alert() and gets 'save' | 'discard' | 'cancel'.
Draft chapter 3Due: Friday
Adw.DialogThe kanban card editor. A floating dialog on a wide window, a bottom sheet on a narrow one, with no code in Markwork for either.
The inbox is empty
Write something above to capture it.
Adw.StatusPageBig icon, title, description: the empty state of the Inbox and the first-run page of Home (with buttons as its child). The compact class makes it smaller.
Today
Today's Journal2026-10-08.md
Inbox3 unprocessed
Gtk.ListBox .boxed-list + Adw.ActionRowRows with add_prefix() (icon), title/subtitle, and add_suffix() (a button, a chevron). The Home tab is built from these.
View
Color schemeFollow system
Focus modeDim everything except the paragraph
Typewriter mode
Adw.PreferencesGroup, Adw.ComboRow, Adw.SwitchRowFrom preferences.ui. Each switch is bound to a GSettings key with bind(), so the dialog has no save code (Chapter 8).
Read the outline
Rewrite chapter 3
Run the checks
Adw.Spinner (libadwaita 1.6+) on the step the agent is working on, Gtk.Spinner on older systems: AdwSpinner ? new AdwSpinner() : new Gtk.Spinner({ spinning: true }) in ui/worklist.ts.
child
Adw.Clamp maximum_size keeps the Home and Inbox column readable on a wide window; below tightening_threshold the child simply fills the width.
Decisions
Interface CSS uses @accent_color, @card_bg_color…
A card
Adw.StyleManager color_scheme = Adw.ColorScheme.FORCE_DARK (or DEFAULT to follow GNOME). Named colors switch with it, so the interface CSS needs no dark copy.

Dialogs without a nested main loop

GTK 4 removed gtk_dialog_run() on purpose: a main loop inside a handler lets other code run in the middle of that handler. Markwork's GTK 3 dialogs returned their answer synchronously; commit 9750175 changed them to return a Promise through modal(). To keep tests and question-free paths synchronous, callers continue with after(), which accepts either a value or a Promise:

export type Awaitable<T> = T | Promise<T>;
export function after<T, R>(value: Awaitable<T>, next: (v: T) => Awaitable<R>): Awaitable<R> {
    return value instanceof Promise ? value.then(next) : next(value);
}
// a dialog stand-in in tests returns a plain value → the whole flow stays synchronous

A continuation after a dialog must check the state again: the tab or card may be gone by the time the user answers.

Simulation: breakpoints and remembered panels model of addBreakpoints() + bindPanel()

Markwork

Narrow the window with both panels open, then widen it again: the panels come back because bindPanel() stored the setting from before the fold. Close a panel while wide and it stays closed after a fold and unfold. Without the pin, OverlaySplitView would always reopen it.

LessonPick in this order: an Adw.* widget, then a GTK widget, then an Adwaita style class (card, dim-label, boxed-list, navigation-sidebar), and only then your own CSS. Newer APIs go behind feature detection ('ShortcutsDialog' in Adw) with a tested fallback.
Chapter 8 · commits 614f171, 9e7ffda, 90699c3 · actions.ts, settings.ts, ui/palette.ts

One command, many entry points: Gio.Action and GSettings

Actions. Every command is a Gio.SimpleAction on the application, with accelerators from set_accels_for_action(). The menu item, the header button, the keyboard shortcut, and the command palette all activate the same action. Disabling an action (set_enabled(false)) dims the menu item and silences the shortcut at once; that is how text-editing actions turn off while the kanban board is shown.

const action = (name, accels, run) => {
    const a = new Gio.SimpleAction({ name });
    a.connect('activate', run);
    app.add_action(a);
    if (accels) app.set_accels_for_action(`app.${name}`, accels);
};
action('journal-capture', ['<Control><Shift>j'], () => w.journal.capture());

Illustration: four entry points, one stateful action model of app.chat = gsettings.create_action('chat')

Use any of the four entry points on the left. They do not call each other: each one only activates app.chat, and each one shows the action's state by itself. Then turn the action off with set_enabled(false).

Header bar Gtk.ToggleButton
action-name: app.chat
Menu item in a Gio.Menu
(a check mark for a boolean state)
Accelerator
set_accels_for_action('app.chat', …)
assis
Toggle AssistantCtrl+Shift+A
Command palette row
(lists enabled actions)
→
app.chat
true
stateful Gio.Action
state = GSettings key chat
↓ gsettings.bind('chat', split, 'show-sidebar')
editor
Hi

Settings. A JSON file in ~/.config became a GSettings schema (data/com.ekaput.Markwork.gschema.xml), compiled at build time and loaded from next to the bundle when running from dist/. Three GSettings features remove whole classes of code:

The palette (Ctrl+Shift+P) lists the enabled actions that have a label in commands.ts, filtered as you type. Its rows are Command GObjects in a Gio.ListStore (Chapter 9).

LessonIf something can be triggered from two places, make it an action. If something must be remembered, make it a GSettings key and bind it; do not write save/load code.
Chapter 9 · commits a86dfb4, b51c43b · ui/filetree.ts, ui/outline.ts, ui/history.ts, ui/palette.ts

Lists as models: ListStore, ListView, TreeListModel

GTK 3 lists were either a TreeView with cell renderers or a ListBox filled by hand. GTK 4's answer is a model → selection → view pipeline with a factory that makes and recycles row widgets. Only visible rows have widgets.

Gio.ListStore<Command>→ Gtk.FilterListModel→ Gtk.SingleSelection→ Gtk.ListView + SignalListItemFactory

Items are GObjects

class FileNode extends GObject.Object {
  static { GObject.registerClass(
    { GTypeName: 'MarkworkFileNode' }, this); }
  name = ''; path = ''; isDir = false;
}

A list model holds GObjects, so plain JS data is wrapped once in a registered class.

The file tree

Gtk.TreeListModel calls a create_func for each folder to get its children model. GTK also calls it just to ask whether a row can expand, so folder contents are cached in dirStores; a Gio.FileMonitor per read folder inserts/removes only the changed rows. Gtk.TreeExpander draws the arrow and indentation.

Illustration: 1,000 items, a handful of row widgets real virtual list · model of SignalListItemFactory

The list on the left is the outline of a 1,000-heading manuscript. Scroll it. Each colored badge is one row widget; when a row scrolls out of view, the factory does not destroy it but binds it to the item that scrolls in. setup runs once per widget, bind runs on every reuse.

Row widget pool (created by setup)
const factory = new Gtk.SignalListItemFactory();
factory.connect('setup', (_f, item) =>      // once per widget
    item.set_child(new Gtk.Label({ xalign: 0 })));
factory.connect('bind', (_f, item) =>       // on every reuse
    item.get_child().label = item.get_item().title);
new Gtk.ListView({ model: selection, factory });

Where a list is short and every row is different (Home sections, Inbox items, the kanban columns), Markwork still uses Gtk.ListBox with the boxed-list style or plain boxes. ListView pays off for long, uniform lists: the outline of a book, Git history, the file tree, the palette.

LessonFiltering or sorting a model is a property change, not a rebuild. The palette's search only calls filter.changed(); no row widgets are created or discarded by hand.
Chapter 10 · commits 9e7ffda, 1f364e4 · ui/*.ui, ui/headerbar.ts, ui/preferences.ts

Gtk.Template: layout in XML, behavior in TypeScript

A chain of new Gtk.Box() + append() hides the shape of a layout. Static layouts moved into .ui files that Vite bundles as text (import xml from './headerbar.ui?raw'). The class registers the template and names the children it needs:

export class HeaderBar extends Adw.Bin {          // Adw.HeaderBar is final: wrap it
    static {
        GObject.registerClass({
            GTypeName: 'MarkworkHeaderBar',
            Template: uiTemplate(template),       // Uint8Array of the XML
            InternalChildren: ['windowTitle'],    // → this._windowTitle
        }, this);
    }
    declare _windowTitle: Adw.WindowTitle;
}

Illustration: the XML and what it draws shortened from ui/headerbar.ui

Hover over a line of XML or a part of the header bar. Every <child type="start"> lands left of the title in the order written, every type="end" lands right of it, starting from the edge. The buttons need no click handlers: action-name connects them to Gio.Actions.

<template class="MarkworkHeaderBar" parent="AdwBin">  <object class="AdwHeaderBar">    <property name="title-widget">
      <object class="AdwWindowTitle" id="windowTitle"/>    <child type="start"> GtkToggleButton
      icon-name: format-justify-left-symbolic
      action-name: app.sidebar    <child type="start"> GtkButton  app.open    <child type="start"> GtkButton  app.open-folder    <child type="start"> GtkButton  app.new    <child type="end"> GtkMenuButton
      menu-model: mainMenu    <child type="end"> GtkButton  app.save    <child type="end"> GtkToggleButton  app.chat

 

LessonFinal classes such as Adw.HeaderBar cannot be subclassed; wrap them in Adw.Bin. A GTypeName must be unique per process, so prefix it (Markwork…).
Chapter 11 · commits 1a1a716, 9750175 · i18n.ts, data/, meson.build, build-aux/flatpak/

Becoming a GNOME citizen: ID, translations, accessibility, packaging

The development machine has libadwaita 1.5 / GTK 4.14, while the Flatpak runtime is newer. That is why every newer API is feature-detected: Adw.ShortcutsDialog (1.8), the system accent (1.6), Adw.Spinner (1.6). CssProvider.load_from_string() sets the real minimum at GTK 4.12.
Chapter 12 · commits 36ae571, e87b422, 222dc97, 79ad33d, 5877392, c237d7c, 1bac3b8

Workbench features: each one is a pure model plus a thin view

The newest features follow one recipe. A pure module in markdown/ parses and serializes the file; a view in ui/ draws it and reports changes through a callback; the window writes the change back into the buffer as one undo step. The GTK part is the smallest part.

FeaturePure model (no GTK)GTK / Adw pieces
Kanban boardmarkdown/kanban.ts: frontmatter layout: board, ## lists, - [ ] cards, tags, due datesGtk.Stack (text ↔ board), GestureDrag, Gtk.Fixed, WidgetPaintable; card editor in an Adw.Dialog with a Gtk.Calendar due-date picker in a popover
[[note]] linksmarkdown/wikilink.ts: parse, resolve by file name (case-insensitive, folder/Note#Section)editor/wikicomplete.ts: a Gtk.Popover pointed at the cursor rectangle (buffer_to_window_coords), focus stays in the TextView; table cells use markwork-note: URIs on activate-link
Inboxmarkdown/inbox.ts: frontmatter layout: list, one bullet per item, ➕ date timeAdw.Clamp, Adw.StatusPage for the empty state, Gtk.Entry quick capture, Gtk.ListBox rows, Adw.ButtonContent
Homemarkdown/home.ts: due cards, unprocessed inbox items, recent files, greetingGtk.FlowBox of folder cards (card style), Adw.ActionRows, Gtk.CheckButton to finish a card in place, its own Adw.TabPage
Daily journalmarkdown/journal.ts + activity.ts (JSONL log in .markwork/activity, merged into the Activity section)Quick capture through an action (Ctrl+Shift+J) and a small Adw.Dialog; the journal itself is a normal editor tab
Commit message writeragent/commitmessage.ts: the diff budget, the prompt, and cleanMessage(); git and the key come in as CommitSourcesui/messagewriter.ts: a Gtk.Button holding a Gtk.Stack (a bundled com.ekaput.Markwork-sparkle-symbolic icon ↔ a Gtk.Overlay of Adw.Spinner and a stop icon), a capture-phase Gtk.EventControllerKey on the entry, a status Gtk.Label with error and an activate-link markup link; used by the History tab and the single-file window
Images in the file treeisImageFile()Same ListView rows; activation opens the ImageViewer (Adw.Window) instead of a tab

Illustration: the views with GTK Inspector on widget names from ui/inbox.ts, ui/kanban.ts

Turn on Inspector to label every widget with its type and CSS class, the way GTK Inspector does. Empty the inbox to see the Adw.StatusPage take the list's place.

Inbox
New Note
Write an idea, link, or quick note…
Read the GTK 4 list docs➕ Oct 8 09:12#gtk
Idea: tag colors➕ Oct 8 10:40
The inbox is empty
Write something above to capture it.
To do 2
Draft chapter 3
#book
Fix export bug
Add card
Done 1
Release notes

The board's Gtk.Overlay holds a Gtk.Fixed on top, where the ghost image of a dragged card (a Gtk.WidgetPaintable of the card) follows the pointer (Chapter 5).

Illustration: the commit form with the assistant button widget names from ui/history.ts, ui/messagebox.ts, ui/messagewriter.ts

The first form is before writing, the second while the model writes. Only the Gtk.Stack page changes; the box, the checkboxes, and Commit are made insensitive by the owner through onBusy. The Inspector button above also labels these.

Commit message
Commit 2 files
Written by the assistant from 2 files. Check it before committing.
Move the public release to 22 November
Commit 2 files
Writing from the changes in 2 files…

The message is a Gtk.TextView that wraps, not a Gtk.Entry: a 72-character subject did not fit a one-line entry in the narrow sidebar. MessageBox keeps it one line (Enter commits from a capture-phase key handler; a pasted line break becomes a space) and draws the placeholder as a dim Gtk.Label in a Gtk.Overlay, since a TextView has none. The commit row is a horizontal Gtk.Box in which Commit has hexpand. GTK 4 passes hexpand up to the parents, which would widen the sidebar; an explicit set_hexpand(false) on the row stops it there (see Pitfalls).

The Home tab with folder cards, due dates, and inbox items
Home: Adw.Clamp keeps the column readable, Gtk.FlowBox wraps the folder cards.
Lesson“The Markdown text is the only source of truth” is a GTK design rule too: the board and inbox never hold state that the buffer does not have, so undo, autosave, Git, and the agent all keep working without knowing those views exist.
Chapter 13 · src/markdown/, src/agent/, files.ts, git.ts, orchestrator.ts, window/

The code that never touches GTK

About 5,000 lines import nothing from GObject Introspection, and about 3,400 more use only Gio/GLib. This is deliberate. Code without widgets runs in gjs -m dist/run-tests.js --no-gui in a fraction of a second, has no main-loop timing, and can be read without knowing GTK.

Pure modules (no gi://)

ModuleWhat it doesWho draws it
markdown/syntax.ts, inline.tsBlock and inline parsing into markers with offsetseditor/highlighter.ts → tags
markdown/table.ts, pango.tsTable model, alignment, edit commands; cell Markdown → Pango markup stringtablelayer.ts, tableedit.ts
markdown/kanban.ts, inbox.ts, home.ts, journal.ts, wikilink.tsParse/serialize the workbench formats; every operation returns a new valueui/kanban.ts, ui/inbox.ts, ui/home.ts, editor
markdown/dbml.ts, html.ts, lint.ts, chatmarkup.tsDBML → Mermaid ER, HTML export, structure checks, chat Markdown → markupMermaid layer, export action, agent, chat panel
editor/offsets.tsUTF-16 ↔ code point maps, allocation-free word countEvery tag operation
colors.tsThe light and dark document palettes (plain values)ui/theme.ts CSS, editor tags, tables, board
gitlog.tsParse git log/diff output into commits and hunks; the agent's Git request typesHistory tab, diff viewer
agent/context.ts, tools.ts, changes.ts, batch.ts, harness.ts, commitmessage.ts, …Context building, BM25, tools, change proposals, preflight, pi prompt/queue, the commit message prompt and budgetui/chat.ts, proposal viewer (see Learn agents)

Gio/GLib without widgets

files.ts · GLib.file_get_contents, replace_contents_bytes_async fileops.ts · Gio.File.move, trash git.ts · Gio.SubprocessLauncher + communicate_utf8_async orchestrator.ts · GLib.spawn_async_with_pipes + IOChannel settings.ts · Gio.Settings + SettingsSchemaSource activity.ts · append-only JSONL agent/deepseek.ts · libsoup streaming agent/apikey.ts · libsecret workspace.ts · Gio.FileEnumerator + FileMonitor window/*.ts · feature controllers on a narrow host; GLib timers and paths

One GJS detail from orchestrator.ts: after GTK is loaded, Gio.Subprocess.get_stdout_pipe() triggers a Gjs-WARNING about Gio.UnixInputStream, so the harness reads its pipes with GLib.IOChannel and writes bytes with an explicit length.

Why the split pays off

LessonPush decisions down into pure functions and keep widgets as thin adapters. When the toolkit changes (GTK 3 → 4, GTK → libadwaita), only the adapters move.
Reference

GTK pitfalls Markwork hit, and the rule it kept

SymptomCauseRule
Crash “Byte index is off the end of the line”invisible tag in GTK 3Hide with the hidden tag at size TINY = 256
Emoji not drawnFont size 1 Pango unitNever size 1; 256 is small enough
Tag in the wrong place after an emojiUTF-16 vs code pointsmakeCpMap() right before touching the buffer
300 ms per keystrokeRemove + reapply tags across the buffersetTagRanges() / LineTagger
“GtkBox is not a child of GtkSourceView”Removing a TextView overlay in 4.14Borrow/return slots from OverlaySlots
Images stay put while text scrollsOverlay offset updated only on allocationqueue_allocate() on every scroll
Window cannot shrinkhscrollbar_policy: NEVER forwards the minimum widthUse EXTERNAL
Sidebar suddenly expandshexpand propagates upward in GTK 4Explicit hexpand: false on side panels
“already disposed” criticals after closingIdle/timeout outliving the widget; no destroy signal for held widgetsdestroy() + destroyed flag, called from unrealize
Answer cut off in the chat panelset_value() inside an adjustment's changed during allocationScroll from an idle (stickIdle)
Gtk-CRITICAL when dragging a fileA widget as GtkDragIconDragSource.set_icon() with a paintable
GLib-CRITICAL on exitGtk.AlertDialog / destroy_with_parentAdw.AlertDialog through alert()
Ctrl+Z does not bring back the user's draft after the program filled the boxGtk.TextBuffer.set_text() (like Gtk.Entry.set_text()) is an irreversible action for the undo history; only typing is recordedKeep the one step yourself: a capture-phase EventControllerKey restores the saved text while the entry still holds what was set, and forgets it on the first edit (MessageWriter)
A bundled SVG icon shows as “missing” in testsNo SVG loader for gdk-pixbuf (librsvg) on the test machine, although IconTheme.has_icon() is trueCheck the icon's shape separately and report it as not visually verified; never conclude the icon is wrong from that screenshot
Gdk-CRITICAL gdk_monitor_get_geometry in testsA popover from a button outside the Xvfb monitorKeep test windows within 1280×800
Summary

Lessons that carry over to other GTK apps

  1. Decide where GTK may appear before writing widgets. A pure model plus a thin view survived two toolkit migrations.
  2. The text buffer is a document model. Style it with tags, change tags by difference, and convert offsets at the boundary.
  3. Use the main loop. Idle priorities merge work and order it around drawing; Gio's async calls keep I/O off the main thread.
  4. Clean up explicitly in GJS. Store source IDs, add destroy(), and check a flag in every deferred callback.
  5. Port, then audit, then modernize in small commits with tests.
  6. Let libadwaita carry the GNOME patterns: dialogs, toasts, tabs, split views, breakpoints, named colors.
  7. Actions and GSettings remove code: one command for every entry point, bindings instead of save/load.
  8. Models for long lists, templates for static layouts, code for layouts built from data.

Related pages: Learn performance (measurements behind Chapters 2–4) and Learn agents (the pure agent/ layer from Chapter 13).