用 Haskell 构建 GTK 应用,第一部分
摘要
教程系列第一篇,介绍如何用 Haskell、GTK 4 和 Adwaita 构建一个待办事项应用,涵盖 haskell-gi 自动绑定工具和 Model-View-Update 架构。
<p><a href="https://lobste.rs/s/xmir2s/making_gtk_application_haskell_part_1">评论</a></p>
查看缓存全文
缓存时间: 2026/10/05 15:13
# 用 Haskell 编写 GTK 应用,第一部分 — Floréal Technologies
来源:https://floreal.tech/blog/2026/making-a-gtk-app-in-haskell-part-1/
在本系列中,我们将使用 Haskell、GTK 4 和 Adwaita 库来构建一个待办事项列表应用。Adwaita 会为我们提供许多实用的控件和样式。让我们开始吧!
本系列面向具有一定 Haskell 开发经验的中级读者。
## GTK 4 与 Adwaita
Adwaita 是一套 GTK 组件库,它构成了 GNOME 项目的设计语言。简而言之:GNOME 项目在无障碍性和样式方面做出的每项决策(即人机界面指南,HIG),都已编码在 libadwaita 中。
Libadwaita 为你提供了许多功能:例如,它允许你构建具有响应式设计的应用,在桌面切换明暗主题时,应用会在运行时自动重新着色。
## Haskell 与 GTK
在整个系列中,我们将使用 haskell-gi(https://github.com/haskell-gi/haskell-gi)工具包,它能从 GTK 库自动生成 Haskell 绑定,让我们获得一个仍可与 C API 对应起来的 Haskell 版 GTK 接口。
---
*为了保持本文的可读性,你将看到的代码并不完整,以便我着重强调其中的主要概念。你可以在 https://github.com/Floreal-Technologies/adwaita-todo 找到完整的项目。*
---
## 你的第一个窗口
首先,这是一个展示 GTK 4 / Adwaita 应用结构的完整自包含示例。
让我们创建 Adwaita 的 "Application"(https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.10/class.Application.html)实例,它将为我们处理资源管理(包括 Adwaita 的样式表,这一点相当酷):
````
module Main (main) where
import GI.Adw qualified as Adw
import GI.Gio qualified as Gio
import GI.GTK qualified as Gtk
main :: IO ()
main = do
-- `new X [#attribute := value]` 语法会创建一个带有
-- 属性的 Gtk 对象。这种哈希语法称为 OverloadedLabels。
app <- new Adw.Application [#applicationId := "tech.floreal.TodoApp"]
-- 我们把 "activate" 信号连接到 "activate" 处理函数上。
on app #activate (activate app)
-- 运行主应用循环。
Gio.applicationRun app Nothing
pure ()
activate :: Adw.Application -> IO ()
activate app = do
header <- new Adw.HeaderBar []
toolbar <- new Adw.ToolbarView []
Adw.toolbarViewAddTopBar toolbar header
window <-
new Adw.ApplicationWindow
[ #application := app
, #title := "Todos"
, #defaultWidth := 480
, #defaultHeight := 640
, #content := toolbar
]
Gtk.windowPresent window
````
看,一个空窗口出现了!标题显示为 "Todos",尺寸为 480×640。
一个空窗口
## Model-View-Update:Elm 架构
Elm 架构(https://guide.elm-lang.org/architecture/)(简称 TEA)是一种为交互式程序构建架构的模式。它的核心包含三个概念:
**Model(模型)**——应用的状态**View(视图)**——将**Model**转化为用户界面的方式(HTML、GTK 等)**Update(更新)**——根据**Message**更新**Model**的方式在这三个概念之外,我们还有
**Message(消息)**——用户与应用之间所有可能交互的枚举**Effects(副作用)**——**Update**函数不仅返回更新后的**Model**,还会返回一份需要在侧面执行的操作列表,称为副作用。这一模式因 Elm 而广为人知,非常适合用来驯服 GTK 工具包的命令式特性。
## 待办事项应用
现在,该用数据表示我们的应用状态及其行为了。秉承 Elm 架构的精神,一切都将被建模为数据结构,这样我们就能对触发了哪些操作及其影响拥有绝对的可见性。
### Model(模型)
````
newtype TodoId = TodoId Word
deriving stock (Show)
deriving newtype (Eq, Ord)
data Todo = Todo
{ id :: TodoId
, title :: Text
, done :: Bool
}
deriving stock (Eq, Show)
data Model = Model
{ todos :: Map TodoId Todo
, nextId :: TodoId
}
deriving stock (Eq, Show)
-- 在我们的场景中,这个副作用代表
-- 将待办事项列表保存到磁盘。
data Effect = Save [Todo]
deriving stock (Eq, Show)
init :: Model
init = Model
{ todos = Map.empty
, nextId = TodoId 0
}
````
### Message(消息)
用户与应用的交互被建模为 Message:一组已知的操作,每种操作都有明确的作用来修改 model。
让我们先从几个用户可能触发的、用于更新 model 的消息开始:
````
data Message
= Add Text
| SetDoneStatus TodoId Bool
deriving stock (Eq, Ord)
````
目前还比较少,但我们会随着进度逐步添加。
### 更新 Model
````
update :: Message -> Model -> (Model, [Effect])
update message model = case message of
Add raw ->
let text = Text.strip raw
todoId@(TodoId n) = model.nextId
todo = Todo{ id = todoId, title = text, done = False }
in if Text.null text
then (model, [])
else
withTodos
(Map.insert todo.id todo)
model{ nextId = TodoId (n + 1)}
SetDoneStatus todoId value ->
withTodos (Map.adjust (\todo -> todo {done = value}) todoId) model
where
-- 在这里我们判断待办事项是否发生了变化,
-- 以便决定是否需要保存它们。
withTodos f changed =
let result = changed {todos = f changed.todos}
in if result.todos == model.todos
then (result, [])
else (result, [Save (Map.elems result.todos)])
````
现在打开 GHCi,试一试:
````
$ cabal repl
-- 添加一个"买韭葱"的任务
ghci> let (m1, e1) = update (Add "Buy leeks") init
-- 这会触发一个针对未完成任务的 "Save" 副作用
ghci> e1
[Save [Todo {id = TodoId 0, title = "Buy leeks", done = False}]]
-- 将这个任务标记为已完成
ghci> let (m2, e2) = update (SetDoneStatus (TodoId 0) True) m1
-- 由于状态发生了变化,我们必须保存它
ghci> e2
[Save [Todo {id = TodoId 0, title = "Buy leeks", done = True}]]
-- 再次把任务设置为 True 不会触发 Save 副作用,
-- 所以副作用列表是空的。
ghci> update (SetDoneStatus (TodoId 0) True) m2
(Model{ nextId = TodoId 1
, todos = fromList [
(TodoId 0, Todo{ id = TodoId 0
, title = "Buy leeks"
, done = True})]
}, []) -- ← 空列表!
````
这就是我们的领域逻辑,它被编码为消息的和类型以及一个用于改变状态的 update 函数。
现在让我们来设计界面。
## View(视图)
在开始编写用户界面之前,先把自己的期望写下来总是好的。
根据经验,设计并不会直接从数据中涌现,所以我越来越少根据数据的形状来指导我的设计。
用铅笔在纸上画的 UI 草图在我们这里它相当简单,但我喜欢画图。### 控件
我们将使用若干控件(点击它们的名称可以查看其外观的截图):
Box(https://docs.gtk.org/gtk4/class.Box.html)一个将子控件按行或列排列的盒子。ListBox(https://docs.gtk.org/gtk4/class.ListBox.html)一个可以动态过滤和排序的行列表。之后用来按状态过滤、按创建时间排序会很有用。EntryRow(https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.10/class.EntryRow.html)一行中的输入框,带有标题、占位文本,以及一个表示可编辑的图标。它是 ListBox 的子类,因此会放在 ListBox 中。ActionRow(https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.10/class.ActionRow.html)EntryRow 的一个受限版本,不能就地编辑。它仍然可以带有操作图标,并且也放在 ListBox 中。Clamp(https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.10/class.Clamp.html)一个将子控件限制在给定尺寸内的控件。用来确保留出边距,使边缘处能看到背景。ScrolledWindow(https://docs.gtk.org/gtk4/class.ScrolledWindow.html)这个控件让它的子控件可以滚动。名副其实。ToolbarView(https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.10/class.ToolbarView.html)一个包含页面以及顶部和底部栏的视图控件。### 开始搭积木
有了这些积木,让我们写下各个控件之间是如何连接的:
````
module Todo.View (view) where
-- 导入
-- [...]
-- `dispatch` 稍后将在 Runtime 模块中定义,
-- 它是将消息转化为状态 / model 变化的函数。
view
:: (Message -> IO ())
-> Model
-> IO Gtk.Widget
view dispatch model = do
-- 在这里,我们定义输入行,包含它的标题和一个信号处理函数,
-- 当该行被激活时会取回我们输入的内容。
inputRow <- new Adw.EntryRow [#title := "New task"]
on entry #entryActivated $ do
text <- Gtk.editableGetText inputRow
dispatch (Add text) -- 就在这里发送我们的 "Add" 消息
-- EntryRow 是 ListBox 的子类,
-- 所以我们必须把 inputRow 追加到 entryBox 中
entryBox <- newBoxedList
Gtk.listBoxAppend entryBox inputRow
-- 将包含我们任务的 ListBox
todoList <- newBoxedList
forM_ (model.todos) $ \todo -> do
row <- new Adw.ActionRow [#title := todo.title, #useMarkup := False]
Gtk.listBoxAppend todoList row
-- 主内容控件,带有各种选项,
-- 用于定义它在父容器中的边距以及方向。
content <-
new
Gtk.Box
[ #orientation := Gtk.OrientationVertical
, #spacing := 12
, #marginTop := 12
, #marginBottom := 12
, #marginStart := 12
, #marginEnd := 12
]
-- 别忘了把所有东西都追加进去……
Gtk.boxAppend content entryBox
Gtk.boxAppend content todoList
-- 内容控件的父容器。
clamp <- new Adw.Clamp [#child := content]
scrolled <-
new
Gtk.ScrolledWindow
[ #child := clamp
]
-- 一个可爱的小页脚,用来展示附加信息,
-- 比如任务数量。
count <- new Gtk.Label [#label := Text.show (Map.size model.todos)]
footer <-
new
Gtk.Box
[ #orientation := Gtk.OrientationHorizontal
, #marginTop := 6
, #marginBottom := 6
, #marginStart := 12
, #marginEnd := 12
]
Gtk.boxAppend footer count
-- 现在把 header、content 和 footer 组装起来!
header <- new Adw.HeaderBar []
toolbar <- new Adw.ToolbarView [#content := scrolled]
Adw.toolbarViewAddTopBar toolbar header
Adw.toolbarViewAddBottomBar toolbar footer
Gtk.toWidget toolbar
-- 一个用于创建带正确设置的 ListBox 的小辅助函数。
newBoxedList :: IO Gtk.ListBox
newBoxedList =
new
Gtk.ListBox
[ #selectionMode := Gtk.SelectionModeNone
, #cssClasses := ["boxed-list"]
]
````
目前我们还看不到多少视觉效果,所以你得相信它和之前那张铅笔草图长得差不多。
## Runtime(运行时)
与文章开头的演示内容类似,在这里我们将把 Model-View-Update 三件套接插起来。
我们再定义两个函数:`dispatch` 和 `step`,它们彼此相爱得不得了。
`dispatch` 负责接收一条消息,处理 GTK 执行循环的优先级,然后调用 `step` 来执行会导致视图更新的 model 更新。
`step` 读取 model,对其调用 `update`(来自 `Model.hs`)以获得新的 model,把新的 model 写入,然后将新的 model 和 dispatch 函数传给 View。View 以 Gtk 控件的形式返回新的内容,我们再把这个新内容设置到窗口中。
代码如下:
````
run :: Adw.Application -> IO ()
run app = do
window <-
new
Adw.ApplicationWindow
[ #application := app
, #title := "Todos"
, #defaultWidth := 480
, #defaultHeight := 640
]
ref <- newIORef Model.init
let {- rec -}
dispatch :: Model.Message -> IO ()
dispatch message =
void $ GLib.idleAdd GLib.PRIORITY_DEFAULT $ do
step message
pure GLib.SOURCE_REMOVE
step :: Model.Message -> IO ()
step message = do
oldModel <- readIORef ref
let (newModel, _effects) = Model.update message oldModel
writeIORef ref newModel
content <- View.view dispatch newModel
Adw.applicationWindowSetContent window (Just content)
content <- View.view dispatch Model.init
Adw.applicationWindowSetContent window (Just content)
Gtk.windowPresent window
````
现在,我们的 Main 模块是这样的:
````
module Main where
main :: IO ()
main = do
app <- new Adw.Application
[#applicationId := "tech.floreal.AdwaitaTodo"]
on app #activate (Runtime.run app)
Gio.applicationRun app Nothing
pure ()
````
接下来就是见证奇迹的时刻:
一个应用窗口,包含一个输入框,以及 'Touch grass' 和 'Buy leeks' 两项的列表真不错啊。
---
本文到此结束。请期待 Part 2(https://floreal.tech/blog/2026/making-a-gtk-app-in-haskell-part-2/),我们将为待办事项应用添加更多功能!
相似文章
我们重写了 Ghostty GTK 应用程序
Mitchell Hashimoto 详细介绍了 Ghostty 的 GTK 应用程序重写,以完全拥抱来自 Zig 的 GObject 类型系统,从而提高了稳定性、功能和内存安全性,并通过 Valgrind 验证。
借助Jolt库,用800行Clojure代码封装GTK4
本文介绍使用Jolt库在Clojure中封装GTK4的响应式UI模型,旨在与原生工具包和Electron相比提升开发者体验。
ghcup-gtk V0.1.0.0 已发布
ghcup-gtk 版本 0.1.0.0 已发布,提供了一个图形界面用于管理 Haskell 开发环境。
约600行的GTK+Adwaita Markdown编辑器
一个约600行的紧凑型GTK+Adwaita分屏Markdown编辑器实现,使用GTK WebView和CDN加载的marked.js与KaTeX。它作为一个受Apostrophe启发的原型,展示了构建此类应用如今已变得多么容易。
展示HN:面向Ada的现代GUI库——CSS样式、XML界面与SDL3
Adi2是一个面向Ada的现代GUI库,提供CSS样式、基于XML的UI布局,并在SDL3上实现丰富内容渲染,同时支持WebAssembly和工具特性。