Xây dựng ứng dụng GTK bằng Haskell, phần 1: Từ cửa sổ trống đến ứng dụng Todo

Công nghệ05 tháng 10, 2026·8 phút đọc

Bài viết hướng dẫn từng bước cách xây dựng một ứng dụng danh sách công việc (todo-list) sử dụng Haskell, GTK 4 và thư viện Adwaita. Tác giả giới thiệu kiến thức nền tảng về GTK, Adwaita, cách tổ chức mã nguồn theo mô hình kiến trúc rõ ràng và các widget cơ bản để tạo giao diện người dùng.

Xây dựng ứng dụng GTK bằng Haskell, phần 1: Từ cửa sổ trống đến ứng dụng Todo

Xây dựng ứng dụng GTK bằng Haskell, phần 1: Từ cửa sổ trống đến ứng dụng Todo

Trong loạt bài này, chúng ta sẽ cùng nhau xây dựng một ứng dụng danh sách công việc (todo-list) bằng Haskell, GTK 4 và thư viện Adwaita. Adwaita sẽ cung cấp cho chúng ta nhiều widget và kiểu dáng hữu ích. Hãy cùng bắt đầu!

Cửa sổ trống ban đầu của ứng dụngCửa sổ trống ban đầu của ứng dụng

Loạt bài này hướng đến đối tượng là những lập trình viên Haskell ở mức trung cấp, đã có kinh nghiệm phát triển với ngôn ngữ này.

GTK 4 và Adwaita

Adwaita là một thư viện các thành phần GTK đóng vai trò là ngôn ngữ thiết kế của dự án GNOME. Nói ngắn gọn: mọi quyết định mà dự án GNOME đưa ra về khả năng tiếp cận và phong cách (Hướng dẫn Giao diện Người dùng, hay HIG) đều được mã hóa trong libadwaita.

Libadwaita mang đến nhiều tính năng hữu ích: ví dụ, nó cho phép bạn tạo các ứng dụng có thiết kế đáp ứng (responsive), tự động đổi màu khi môi trường桌面 chuyển đổi giữa chế độ sáng và tối.

Haskell và GTK

Xuyên suốt loạt bài này, chúng ta sẽ sử dụng bộ công cụ haskell-gi, công cụ này tự động sinh ra các binding Haskell từ các thư viện GTK, cho phép chúng ta có một giao diện Haskell cho GTK mà vẫn có thể liên hệ được với API C.

Để bài viết dễ đọc, mã nguồn mà bạn thấy sẽ không đầy đủ, nhằm giúp tôi nhấn mạnh các khái niệm chính. Bạn có thể tìm thấy toàn bộ dự án tại địa chỉ GitHub của Floreal-Technologies.

Cửa sổ đầu tiên của bạn

Để bắt đầu, đây là một ví dụ khép kín về cấu trúc của một ứng dụng GTK 4 / Adwaita.

Hãy tạo một đối tượng Application của Adwaita, đối tượng này sẽ xử lý việc quản lý tài nguyên cho chúng ta (bao gồm cả các stylesheet của Adwaita, vốn rất thú vị):

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
  -- Cú pháp `new X [#attribute := value]` tạo một đối tượng Gtk
  -- cùng với các thuộc tính của nó. Cú pháp dấu thăng này được gọi là OverloadedLabels.
  app <- new Adw.Application
    [ #applicationId := "tech.floreal.AdwaitaTodo" ]
  on app #activate (activate app)
  Gio.applicationRun app Nothing

activate :: Adw.Application -> IO ()
activate app = do
  header <- Adw.headerBarNew
  #setTitleWidget app header
  #present app

Có thể bạn sẽ nhận thấy ở đây chưa có nhiều thứ để xem. Chúng ta sẽ dần dần bổ sung thêm thành phần khi xây dựng ứng dụng.

Kiến trúc: Mô hình, Thông điệp và Hiệu ứng

Trước khi đi vào giao diện, chúng ta cần định nghĩa logic nghiệp vụ. Một cách tiếp cận phổ biến trong Haskell là mô hình hóa trạng thái (Model), các thông điệp (Message) và các hiệu ứng (Effect) một cách riêng biệt.

data Model = Model
  { todos :: Map TodoId Todo
  , nextId :: TodoId
  }

data Todo = Todo
  { id :: TodoId
  , title :: Text
  , done :: Bool
  }

data Message
  = Add Text
  | SetDoneStatus TodoId Bool
  | Delete TodoId
  | Save [Todo]

Hàm update sẽ nhận một thông điệp và mô hình hiện tại, trả về một mô hình mới cùng danh sách các hiệu ứng cần thực thi:

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
    -- Đây là nơi chúng ta xác định xem các công việc đã thay đổi chưa,
    -- để quyết định có lưu chúng lại hay không.
    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)])

Hãy mở GHCi và thử nghiệm:

$ cabal repl
-- Thêm một công việc mua tỏi tây
ghci> let (m1, e1) = update (Add "Mua tỏi tây") init

-- Điều này tạo ra một hiệu ứng "Save" cho công việc chưa hoàn thành
ghci> e1
[Save [Todo {id = TodoId 0, title = "Mua tỏi tây", done = False}]]

-- Đánh dấu công việc đã hoàn thành
ghci> let (m2, e2) = update (SetDoneStatus (TodoId 0) True) m1

-- Vì trạng thái đã thay đổi, chúng ta cần lưu lại
ghci> e2
[Save [Todo {id = TodoId 0, title = "Mua tỏi tây", done = True}]]

-- Đặt lại trạng thái True lần nữa sẽ không tạo hiệu ứng Save,
-- nên danh sách hiệu ứng rỗng.
ghci> update (SetDoneStatus (TodoId 0) True) m2
(Model{ nextId = TodoId 1
      , todos = fromList [
          (TodoId 0, Todo{ id = TodoId 0
                         , title = "Mua tỏi tây"
                         , done = True})]
      }, []) -- ← danh sách rỗng!

Đây chính là logic nghiệp vụ của chúng ta, được mã hóa dưới dạng một kiểu tổng (sum type) của các thông điệp và một hàm update để thay đổi trạng thái.

Thiết kế giao diện

Trước khi bắt đầu viết giao diện, việc phác thảo những gì bạn mong đợi luôn là một thói quen tốt. Theo kinh nghiệm, thiết kế không tự động suy ra từ dữ liệu, và vì vậy tôi ngày càng ít nhìn vào hình dạng dữ liệu của mình để định hình thiết kế.

Bản phác thảo thiết kế trên giấyBản phác thảo thiết kế trên giấy

Trong trường hợp của chúng ta thì khá đơn giản, nhưng tôi vẫn thích vẽ ra.

Các widget chính

Chúng ta sẽ sử dụng một số widget sau (nhấp vào tên của chúng để xem ảnh chụp màn hình):

  • Box: Sắp xếp các widget con theo hàng hoặc cột.
  • ListBox: Một danh sách các hàng, có thể được lọc và sắp xếp động. Hữu ích về sau để lọc theo trạng thái và sắp xếp theo thời gian.
  • EntryRow: Ô nhập liệu của một hàng, với tiêu đề, văn bản gợi ý và biểu tượng cho biết có thể chỉnh sửa.
  • ActionRow: Phiên bản hạn chế hơn của EntryRow, không thể chỉnh sửa tại chỗ. Nó vẫn có thể có các biểu tượng hành động và nằm trong một ListBox.
  • Clamp: Widget giới hạn kích thước của widget con trong một kích thước cho trước. Hữu ích để đảm bảo lề để nền vẫn hiển thị ở các cạnh.
  • ScrolledWindow: Widget này làm cho widget con của nó có thể cuộn được.
  • ToolbarView: Một widget chứa một trang cùng với các thanh trên và dưới.

Lắp ghép các mảnh ghép

Với những khối xây dựng này, hãy viết ra cách các widget kết nối với nhau:

module Todo.View (view) where

-- `dispatch` sẽ được định nghĩa sau trong module Runtime,
-- và là hàm biến một thông điệp thành một thay đổi
-- trạng thái / mô hình.
view
  :: (Message -> IO ())
  -> Model
  -> IO Gtk.Widget
view dispatch model = do
  -- Ở đây, chúng ta định nghĩa hàng nhập liệu, với tiêu đề và một signal handler
  -- để kích hoạt việc lấy dữ liệu đầu vào khi nó được kích hoạt.
  inputRow  do
    row  IO ()
run app = do
  window  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

Bây giờ, module Main của chúng ta trông như thế này:

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 ()

Và đây là thành quả:

Kết quả ứng dụng Todo hoàn chỉnhKết quả ứng dụng Todo hoàn chỉnh

Khá là ấn tượng phải không!

Kết luận

Bài viết này khép lại phần đầu tiên. Hẹn gặp lại bạn ở phần 2 với nhiều tính năng hơn cho ứng dụng danh sách công việc của chúng ta!

Đối với độc giả Việt Nam quan tâm đến phát triển ứng dụng desktop bằng ngôn ngữ hàm, đây là một ví dụ thực tế cho thấy Haskell không chỉ dành cho các bài toán học thuật mà còn có thể xây dựng giao diện người dùng hiện đại nhờ hệ sinh thái binding GTK phong phú. Việc kết hợp mô hình kiến trúc rõ ràng với các widget của Adwaita giúp mã nguồn dễ bảo trì và mở rộng, một kỹ năng ngày càng được các công ty phần mềm tại Việt Nam chú trọng.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗