
2026/10/05 23:23
Haskell で GTK アプリケーションを作成する、第 1 部
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
このテキストは、GTK 4、Adwaita デザイン言語、および
libadwaita を用いて Haskell でレスポンシブなタスクリストアプリケーションを構築する一連の内容を紹介しています。主な目的は、開発者が強力なクロスプラットフォームツールキットを活用しながら、GNOME ガイドラインに厳密に従いアクセシビリティを保証されたアプリケーションを作成する方法を実証することです。本プロジェクトでは haskell-gi を用いて C API バインディングを効率的にアクセスし、GTK ライブラリと互換のある Haskell コードを生成します。内部では Elm アーキテクチャ(TEA パターン)に従っており、メッセージに基づいて状態変化を管理する update 関数を実装するとともに、効果を通じて副作用処理を行います。インターフェースは標準 GTK コンポーネントである ListBox、ScrolledWindow、Box、ToolbarView、および HeaderBar を使用して一般的なものを提供しています。本シリーズでは実行時のテーマ切り替えと完全なアクセシビリティ準拠を備えており、Haskell コミュニティへの基盤となるリファレンスとなっています。完整プロジェクトは https://github.com/Floreal-Technologies/adwaita-todo で利用可能です。Part 2 が次期リリースされる予定です。本シリーズはこれらの初期段階を進化させ、完全な機能セットへと拡張することを約束しています。最終的に、このオープンソースイニシアチブは、アクセシビリティを重視するツールを求めつつあるユーザーと、Haskell 内での GTK エコシステムを探求する開発者双方に対して、貴重な洞察とコード例を提供します。本文
Haskell & GTK 4 / Adwaita でタスクリストアプリを開発する
本シリーズでは、Haskell、GTK 4、および Adwaita ライブラリを使用して高品質なタスクリストアプリケーションを構築していきます。 Adwaita は、GNOME デフォルトのデザイン言語を提供し、アクセシビリティとスタイルに関する決定(ヒューマン・インターフェースガイドライン)をすべて実装しています。
対象読者
- 前提知識: Haskell の開発経験があること。
- レベル: 中級者向け。
- ツール:
を利用したレスポンシブデザインとテーマ対応(ライト/ダーク)の実装を学べます。libadwaita
GTK 4 と Adwaita
Adwaita は GNOME プロジェクトのデザイン言語として機能する、GTK コンポーネントのライブラリです。 GNOME におけるすべての UI デシジョンは
libadwaita に符号化されています。主な特徴は以下の通りです:
- レスポンシブデザイン: ランタイムでデスクトップのテーマ(ライト/ダーク)に応じた再描画が可能。
Haskell と GTK の連携
本シリーズでは、GTK の C API から Haskell バインディングを自動生成する
haskell-gi ツールキットを使用します。これにより、C API と親和性を保ちつつも Haskell 独自インターフェースで開発できます。
注: この記事内のコードは主要な概念を示すため、完全なプロジェクトではありません。 完全なソースコードは GitHub リポジトリ から入手可能です。
最初のウィンドウ作成
まず、GTK 4 / Adwaita アプリケーションの最小限の構造を構築します。 以下のコードでは、リソース管理やスタイルシートを含んだ
Adw.Application を作成し、空のウィンドウを表示します。
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]` は、OverloadedLabels を使用したプロパティ設定です。 app <- new Adw.Application [#applicationId := "tech.floreal.TodoApp"] 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」、幅 480px、高さ 640px の空のウィンドウが表示されます。
モデル・ビュー・アップデート(Elm アーキテクチャ)
インタラクティブなアプリケーション設計のパターンである Elm アーキテクチャ(または TEA: The Elm Architecture)を採用します。
3 つの核心概念
- Model: アプリケーションの状態を保持するデータ構造。
- View:
をユーザーインターフェース(GTK ウィジェットなど)に変換する関数。Model - Update: メッセージに基づいて
を更新し、新しい状態と副作用(Effects)のリストを返す関数。Model
追加概念
- Message: ユーザーとアプリケーションの相互作用を表す列挙型。
- Effects: 実行すべきアクション(例:ディスク保存)のリスト。
関数は更新されたモデルに加え、同時に実行するアクションのリストも返します。Update
このアプローチは命令型の GTK ツールキットでも十分に制御可能です。
タスクリストアプリの実装
アプリケーションの状態とアクションをデータ構造としてモデル化します。これにより、変更のトリガー内容が完全に可視化できます。
データ定義:Model
-- タスク ID 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
ユーザー操作を表すメッセージです。
data Message = Add Text | SetDoneStatus TodoId Bool deriving stock (Eq, Ord)
メソッド:Update(状態更新)
update 関数は、メッセージを受け取り (新しいモデル,エフェクトリスト) を返します。
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 -- エフェクト確認:保存が必要 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}]] -- 既存の状態への変更(保存不要) ghci> update (SetDoneStatus (TodoId 0) True) m2 (Model{ ... }, []) -- ← エフェクトリストは空!
このロジックでドメイン層が完成しました。次に、UI を構築します。
ビュー(表示層)の設計
使用されるウィジェット
Adwaita と GTK 4 で利用可能な主要なコンポーネントです:
- Box: 子ウィジェットを横並びまたは縦並びに配置するレイアウトマネージャー。
- ListBox: 動的にフィルタリング/ソート可能な行のリスト。
- EntryRow: テキスト入力フィールドを持つ ListBox のサブクラス(編集可能)。
- ActionRow: 編集不可能な行。アクションアイコンを表示可能。
- Clamp: 子ウィジェットを指定サイズに制約し、マージンを維持するコンテナ。
- ScrolledWindow: スクロール可能なウィンドウ。
- ToolbarView: ヘッダーやフッターを含むビュー構造。
レイアウト構成(Widget Tree)
以下のコードは、入力行とタスクリストを組み合わせ、スクロール可能エリア内に配置し、ヘッダー/フッターで包み込む処理です。
-- 表示層の主要関数 view :: (Message -> IO ()) -- メッセージディスパッチャー -> Model -- 現在の状態 -> IO Gtk.Widget -- ウィジェットツリーを返す view dispatch model = do -- [1] 入力エリアの作成とイベントハンドラ設定 inputRow <- new Adw.EntryRow [#title := "New task"] on entry #entryActivated $ do text <- Gtk.editableGetText inputRow dispatch (Add text) entryBox <- newBoxedList Gtk.listBoxAppend entryBox inputRow -- [2] タスクリストの作成(Model からリストを生成) todoList <- newBoxedList forM_ (model.todos) $ \todo -> do row <- new Adw.ActionRow [#title := todo.title, #useMarkup := False] Gtk.listBoxAppend todoList row -- [3] コンテンツエリアのレイアウト設定 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 -- [4] スクロール可能なメインウィンドウ clamp <- new Adw.Clamp [#child := content] scrolled <- new Gtk.ScrolledWindow [ #child := clamp ] -- [5] フッターエリア(タスク数表示など) count <- new Gtk.Label [#label := Text.show (Map.size model.todos)] footer <- new Gtk.Box [ #orientation := Gtk.OrientationHorizontal , #marginTop := 6 , #marginBottom := 6 ] Gtk.boxAppend footer count -- [6] ツールバーとヘッダーの統合 header <- new Adw.HeaderBar [] toolbar <- new Adw.ToolbarView [#content := scrolled] Adw.toolbarViewAddTopBar toolbar header Adw.toolbarViewAddBottomBar toolbar footer pure $ Gtk.toWidget toolbar -- ListBox のヘルパー関数 newBoxedList :: IO Gtk.ListBox newBoxedList = new Gtk.ListBox [ #selectionMode := Gtk.SelectionModeNone , #cssClasses := ["boxed-list"] ]
ランタイム(Runtime)の構築
モデル・ビュー・アップデート(MVU)サイクルを接続するランタイムモジュールです。
: メッセージを受け取り、dispatch
を呼び出して処理を実行します。step
:step- 現在のモデルを読み取る。
で状態変更とエフェクトを計算。update- 新しいモデルを書き込む。
- 新しいモデルを使って
を実行し、新しいウィジェットを生成。view - ウィンドウのコンテンツを更新。
module Runtime where import GI.Adw qualified as Adw import GI.Gio qualified as Gio import GI.GTK qualified as Gtk -- メッセージディスパッチャ(非同期処理用) dispatch :: Message -> IO () dispatch message = void $ GLib.idleAdd GLib.PRIORITY_DEFAULT $ do step message pure GLib.SOURCE_REMOVE -- ステップ処理のメインロジック step :: 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) 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 -} -- 相互再帰型定義(rec)を使用 dispatch :: Message -> IO () dispatch message = void $ GLib.idleAdd GLib.PRIORITY_DEFAULT $ do step message pure GLib.SOURCE_REMOVE step :: 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 import Runtime import GI.Adw qualified as Adw import GI.Gio qualified as Gio main :: IO () main = do app <- new Adw.Application [#applicationId := "tech.floreal.AdwaitaTodo"] on app #activate (Runtime.run app) Gio.applicationRun app Nothing pure ()
これで、本番動作するタスクリストアプリケーションが完成しました。 とても素晴らしい成果です!
このシリーズの Part 2 では、さらに高度な機能を実装していく予定です。楽しみにしていてください。