用 Haskell 构建 GTK 应用,第一部分

Lobsters Hottest 工具

摘要

教程系列第一篇,介绍如何用 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

Mitchell Hashimoto 详细介绍了 Ghostty 的 GTK 应用程序重写,以完全拥抱来自 Zig 的 GObject 类型系统,从而提高了稳定性、功能和内存安全性,并通过 Valgrind 验证。

ghcup-gtk V0.1.0.0 已发布

Lobsters Hottest

ghcup-gtk 版本 0.1.0.0 已发布,提供了一个图形界面用于管理 Haskell 开发环境。

约600行的GTK+Adwaita Markdown编辑器

Lobsters Hottest

一个约600行的紧凑型GTK+Adwaita分屏Markdown编辑器实现,使用GTK WebView和CDN加载的marked.js与KaTeX。它作为一个受Apostrophe启发的原型,展示了构建此类应用如今已变得多么容易。