Wrapping GTK4 in 800 lines of Clojure with Jolt
Wrapping GTK4 in 800 lines of Clojure with Jolt
使用 Jolt 和 800 行 Clojure 代码封装 GTK4
August 29, 2026 2026 年 8 月 29 日
Native toolkits are not terribly ergonomic, and are a lot more painful to use compared with web dev in many ways. Building a UI with them is an imperative exercise where you construct widgets one call at a time, pack them into containers, and wire each event to its handler by hand. What’s worse is that the structure of the interface often ends up living outside your language altogether forcing you to use tools like GtkBuilder XML or Xcode storyboards, where none of your usual tools for composing and refactoring code can reach. Layout is governed by box packing rules and constraint systems that are easy to describe but hard to predict. And a common task such as turning a list of items into a list of widgets that stay in sync with the data results in a ton of boilerplate that you have to repeat over and over. 原生工具包(Native toolkits)的人体工程学并不理想,在许多方面比 Web 开发痛苦得多。使用它们构建 UI 是一种命令式过程:你需要一次一个调用地构建组件,将它们打包进容器,并手动将每个事件连接到其处理程序。更糟糕的是,界面的结构往往完全脱离了你的编程语言,迫使你使用 GtkBuilder XML 或 Xcode Storyboards 等工具,而你平时用于代码组合和重构的工具在这些地方根本无法发挥作用。布局受限于盒子打包规则和约束系统,这些规则描述起来容易,但难以预测。此外,像将列表项转换为与数据保持同步的组件列表这种常见任务,往往会导致大量需要反复编写的样板代码。
The pain of working with native toolkits gave rise to things like Electron which simply package a browser engine as a frontend for the application. While that technically works, it’s a clunky and inefficient hack around the problem. Every app ends up having to ship its own copy of the browser along with a JavaScript runtime, and the result never quite feels native. 使用原生工具包的痛苦催生了像 Electron 这样的技术,它们只是简单地将浏览器引擎打包作为应用程序的前端。虽然从技术上讲这行得通,但这是一种笨重且低效的权宜之计。每个应用程序最终都必须携带一份浏览器副本以及 JavaScript 运行时,导致最终的体验总感觉不够“原生”。
However, even doing that still doesn’t address the biggest frustration about having a compile cycle that breaks your development flow. And that’s especially problematic when building a UI where you can’t easily automate testing. You have to load up the app, click through its menus, and get it to a particular state so that you can visually inspect whether a change works as you intended and has decent UX. 然而,即使这样做也无法解决最大的痛点:破坏开发流程的编译周期。在构建无法轻松实现自动化测试的 UI 时,这个问题尤为突出。你必须启动应用程序,点击菜单,将其调整到特定状态,以便直观地检查更改是否按预期工作,以及用户体验(UX)是否良好。
Working with Reagent and other Clojure UI toolkits in ClojureScript is an enjoyable experience precisely because you can build up the UI gradually, and keep a running state as you add more components to it. You make a change, look at the app, see it instantly, and then iterate on it. 在 ClojureScript 中使用 Reagent 和其他 Clojure UI 工具包是一种愉快的体验,正是因为你可以逐步构建 UI,并在添加更多组件时保持运行状态。你进行更改,查看应用程序,立即看到效果,然后进行迭代。
I’ve always been a big fan of the Reagent reactive model which I find to be intuitive. You treat your UI state as a data structure, and have UI components subscribe to paths within it. Whenever an element at a particular path changes, the UI component associated with it gets updated. And that’s really all there is to it. While Reagent is built on top of React, the latter isn’t actually needed in this model. React uses a VDOM that gets diffed and then rendered to the actual DOM, and the reason it needs a VDOM is due to the fact that React is agnostic regarding what triggers a component change. That’s why you need the whole React lifecycle with checks for componentDidMount, componentWillUnmount, and so on. In Reagent a component collapses to a single render function, and the reactive tracking decides which components need to be re-run, so the lifecycle is derived from the state of the data. And because the reactive model already knows exactly what changed, it makes it possible to use it to drive the DOM directly without needing React as the mr-clean library illustrates.
我一直非常推崇 Reagent 的响应式模型,我认为它非常直观。你将 UI 状态视为一种数据结构,并让 UI 组件订阅其中的路径。每当特定路径下的元素发生变化时,关联的 UI 组件就会更新。其实就这么简单。虽然 Reagent 是构建在 React 之上的,但在这个模型中实际上并不需要 React。React 使用 VDOM 进行差异比对(diffing)并渲染到实际 DOM,它需要 VDOM 的原因是 React 本身并不关心是什么触发了组件更改。这就是为什么你需要完整的 React 生命周期,包括对 componentDidMount、componentWillUnmount 等的检查。在 Reagent 中,组件简化为一个单一的渲染函数,响应式追踪机制决定了哪些组件需要重新运行,因此生命周期是从数据状态中派生出来的。由于响应式模型已经确切地知道发生了什么变化,正如 mr-clean 库所展示的那样,它完全可以直接驱动 DOM,而无需 React。
Since this model works well with a browser DOM, then why not apply it to a native toolkit? All that’s needed is to create wrappers to render native widgets by passing them values from the reactive atom, and then provide a callback for the widgets to trigger on user input. We don’t need an equivalent of a VDOM because all the changes are driven by the state of the reactive atoms, and can be rendered directly to the UI. This can even be done with a batching layer to control the rate of UI updates if needed. And this is how glimmer works, providing a reactive core that can be hooked up to a particular UI toolkit. Then, there are glimmer-gtk, glimmer-uikit, and glimmer-tui to provide concrete bindings for different types of UI widgets. 既然这个模型在浏览器 DOM 中运行良好,为什么不把它应用到原生工具包上呢?所需要的仅仅是创建包装器,通过从响应式 atom 中传递值来渲染原生组件,并为组件提供回调以触发用户输入。我们不需要 VDOM 的等价物,因为所有的更改都是由响应式 atom 的状态驱动的,并且可以直接渲染到 UI 上。如果需要,甚至可以通过批处理层来控制 UI 更新的频率。这就是 Glimmer 的工作方式:提供一个可以连接到特定 UI 工具包的响应式核心。然后,通过 glimmer-gtk、glimmer-uikit 和 glimmer-tui 为不同类型的 UI 组件提供具体的绑定。
The canonical counter with glimmer-gtk looks pretty much exactly like its Reagent counterpart: 使用 glimmer-gtk 实现的经典计数器看起来与 Reagent 的对应版本几乎完全一样:
(ns counter
(:require [glimmer.ratom :as r :refer [atom]]
[glimmer.core :as ui]
[glimmer-gtk.core])) ; installs the GTK4 backend
(defn counter []
(let [count (atom 0)]
(fn []
[:vbox {:spacing 12}
[:label {:label (str "Count: " @count)}]
[:hbox {:spacing 8}
[:button {:label "- 1" :on-click #(swap! count dec)}]
[:button {:label "+ 1" :on-click #(swap! count inc)}]
[:button {:label "reset" :on-click #(reset! count 0)}]]])))
(defn -main [& _]
(ui/run counter :title "counter" :width 320 :height 160))
The outer function runs once to create the local state, and the inner one re-runs whenever @count changes, patching the live widgets in place instead of rebuilding the tree. If you’ve used Reagent before, this should all look very familiar with the only difference being that the elements are GTK4 widgets instead of DOM nodes.
外部函数运行一次以创建本地状态,内部函数在 @count 发生变化时重新运行,就地修补实时组件,而不是重建整个树。如果你以前使用过 Reagent,这一切看起来应该非常熟悉,唯一的区别在于元素是 GTK4 组件而不是 DOM 节点。
What it takes to wrap a widget
封装组件需要什么
It turns out that there is surprisingly little code involved in hooking a C toolkit up to a reactive Clojure core. The entire glimmer-gtk backend sits under 800 lines of Clojure split across four namespaces, and none of it involves anything exotic. 事实证明,将 C 工具包连接到响应式 Clojure 核心所需的代码量出奇地少。整个 glimmer-gtk 后端代码不到 800 行 Clojure 代码,分布在四个命名空间中,且不涉及任何复杂奇特的内容。
Jolt’s FFI lets you promote a C function to the Clojure layer by naming the symbol it points at along with its argument and return types. We can see a few examples of what bindings from the GTK backend look like below. Jolt 的 FFI 允许你通过指定 C 函数指向的符号及其参数和返回类型,将其提升到 Clojure 层。我们可以从下面看到 GTK 后端绑定的一些示例。
(ns glimmer-gtk.ffi
(:require [jolt.ffi :as ffi]))
(ffi/defcfn gtk-button-new-with-label "gtk_button_new_with_label" [:string] :pointer)
(ffi/defcfn gtk-button-set-label "gtk_button_set_label" [:pointer :string] :void)
(ffi/defcfn gtk-box-new "gtk_box_new" [:int :int] :pointer)
(ffi/defcfn gtk-box-append "gtk_box_append" [:pointer :pointer] :void)
Pointers are plain machine addresses represented as numbers, strings are marshalled to and from C strings automatically, and GTK booleans are ints that are handled by a one line ->bool helper. There is no C shim to compile or bindings generator to run, and no interface DSL to learn. The shared libraries are declared in deps.edn under :jolt/native, and Jolt loads them before the namespaces are required.
指针是表示为数字的普通机器地址,字符串会自动在 C 字符串之间进行编组(marshalling),而 GTK 布尔值则是通过一行 ->bool 辅助函数处理的整数。无需编译 C 垫片(shim),无需运行绑定生成器,也无需学习接口 DSL。共享库在 deps.edn 的 :jolt/native 下声明,Jolt 会在命名空间被引用之前加载它们。
One thing to note here is that the main loop binding needs a bi… 这里需要注意的一点是,主循环绑定需要一个双向……(原文截断)