If you’ve run our installation script,
you now have a working Haskell GTK project called gi-gtk-test. But what does all that code actually do?
In this post, we’ll break down every module the script creates, explaining the Haskell concepts, GTK patterns,
and how everything connects together. This is your deep dive into understanding the code structure!
Prerequisites:
- Have run the installation script (or have the gi-gtk-test project)
- Basic understanding of Haskell syntax
- Curiosity about how GUI applications work in Haskell
The installation script creates a modular project structure. Let’s see what we’re working with:
gi-gtk-test/
├── gi-gtk-test.cabal # Build configuration
├── src/
│ └── UI/
│ ├── Shared.hs # Reusable UI components
│ ├── Layout.hs # Layout management
│ ├── StatusPage.hs # Status page window
│ └── MainWindow.hs # Main application window
└── app/
└── Main.hs # Application entry point
This structure follows a solid Haskell practice: separation of concerns. Each module has a specific responsibility, making the code easier to understand, test, and maintain.
Before we dive into the Haskell code, let’s understand the project configuration. The .cabal file tells Cabal how to build our project:
Cabal vs Stack - Which Should You Use?
Our installation script uses Cabal, but Stack is another popular option. Here are the subtle differences:
Cabal:
• Official build tool, comes bundled with GHC
• Uses the global package database (Hackage)
• Faster for simple projects and quick iterations
• More direct control over dependency resolution
• Uses .cabal files directly
• Simpler mental model - what you see is what you get
Stack:
• Uses isolated snapshots (curated, tested package sets)
• More reproducible builds across different machines
• Uses stack.yaml + package.yaml (or .cabal)
• Better for teams needing identical environments
• Can be slower due to snapshot management overhead
• Uses a custom repository for modules called Stackage
For GTK Development: Both work great! Cabal is simpler for getting started and learning, while Stack offers more reproducibility for production teams. Our script uses Cabal because it’s more straightforward for Windows setup and requires less configuration, but you can easily convert the project to Stack if needed. The choice often comes down to: simplicity (Cabal) vs reproducibility (Stack).
cabal-version: 3.12
name: gi-gtk-test
version: 0.1.0.0
build-type: Simple
common shared-props
default-language: Haskell2010
build-depends:
base >=4 && <5
, gi-gtk4 >=4.0
, haskell-gi-base
, text
ghc-options: -Wall -Wmissing-home-modules -threadedKey Points:
common shared-props: Defines shared properties used by both library and executablebuild-depends: Lists all required packagesghc-options: Compiler flags
-Wall = all warnings-threaded = multi-threaded runtime (We will delve way more into this)Compiler options are powerful tools for controlling how GHC builds your code. The flags we’re using here (-Wall, -Wmissing-home-modules, -threaded) are just the tip of the iceberg. In a future post, we’ll dive deep into compiler options - exploring optimization flags, warning levels, language extensions, and how to tune performance for GUI applications. For now, these basic flags will serve you well!
library
import: shared-props
exposed-modules:
UI.StatusPage
UI.Shared
UI.MainWindow
hs-source-dirs: srcThe library section exposes modules that can be reused by other projects or within the executable.
How the library matches the directory layout:
The module names in exposed-modules directly correspond to the file structure:
UI.StatusPage → src/UI/StatusPage.hsUI.Shared → src/UI/Shared.hsUI.MainWindow → src/UI/MainWindow.hsThe hs-source-dirs: src tells Cabal to look in the src/ directory for these modules. When Cabal sees UI.StatusPage, it follows these steps:
UI.StatusPageUI/StatusPagesrc/ (from hs-source-dirs): src/UI/StatusPage.hs extension: src/UI/StatusPage.hsThis is why the module declaration in each file (module UI.StatusPage where) must match both the file path and the name in the .cabal file!
executable gi-gtk-test
import: shared-props
main-is: Main.hs
hs-source-dirs: src, app
other-modules:
UI.Layout
UI.MainWindow
UI.Shared
UI.StatusPageThe executable section defines the main program. A few important points:
hs-source-dirs: src, app: The executable can look for modules in both directories. This is why Main.hs can be in app/ while the UI modules are in src/UI/.
other-modules vs exposed modules
Understanding the difference:
- UI.Layout must be listed here because it’s not exposed in the library
- UI.MainWindow, UI.Shared, and UI.StatusPage are already exposed in the library, so they could be imported without being listed here
- However, listing them in other-modules is also valid (and makes it explicit which modules the executable uses)
While technically redundant: - Explicitly listing all modules the executable uses makes the dependencies clear and can help with build optimization - It’s a matter of preference - some prefer explicit lists, others rely on the library’s exposed modules
The separation between exposed-modules (library) and other-modules (executable) is a design choice.
What is a “public API”? Exposed modules define what other code can import and use from your library. If someone installs your package as a dependency, they can only import the exposed modules - these are the functions, types, and utilities you’re making available to the outside world. Think of it like a restaurant menu: exposed modules are the dishes customers can order, while other modules are the kitchen recipes and prep work that stay behind the scenes.
Why does this matter? By controlling what’s exposed, you create a stable interface that won’t break when you refactor internal code. You can change how UI.Layout works internally without breaking code that depends on your library, as long as the exposed modules (UI.MainWindow, UI.Shared, UI.StatusPage) maintain their public interface.
Let’s start with the simplest module - UI.Shared.hs. This module provides reusable UI components:
module UI.Shared (createButton) where
import GI.Gtk as Gtk
import qualified Data.Text as T
createButton :: String -> IO Gtk.Button
createButton label = Gtk.buttonNewWithLabel (T.pack label)Breaking it down:
module UI.Shared (createButton) wherecreateButton (explicit export list = good practice!)Required dependencies:
- GI.Gtk as Gtk: GTK4 bindings (the GI stands for “GObject Introspection”)
- qualified Data.Text as T: Text handling (GTK uses Text, not String)
createButton :: String -> IO Gtk.ButtonString (Haskell’s basic string type)IO Gtk.Button (an action that creates a button)IO monad is necessary because creating GTK widgets has side effectsGtk.buttonNewWithLabel (T.pack label)T.pack: Converts String to Text (GTK’s preferred type)Gtk.buttonNewWithLabel: Creates a new button with the given labelWhy this matters: This abstraction lets us create buttons consistently throughout the app. If we need to change button creation (add styling, set properties, etc.), we only change it in one place!
Next up is UI.Layout.hs - this module handles widget positioning using GTK’s Grid layout:
{-# LANGUAGE OverloadedLabels, OverloadedStrings #-}
module UI.Layout where
import qualified GI.Gtk as Gtk
import Control.Monad (zipWithM_)
setupGrid :: [(Gtk.Widget, (Int, Int))] -> IO Gtk.Grid
setupGrid widgetPositions = do
grid <- Gtk.gridNew
zipWithM_ (\(widget, (col, row)) _ ->
Gtk.gridAttach grid widget (fromIntegral col) (fromIntegral row) 1 1
) widgetPositions ([1..] :: [Int])
return gridBreaking it down:
Required for this module:
- OverloadedLabels: Allows using #label syntax (we’ll see this later)
- OverloadedStrings: Allows string literals to be Text automatically
setupGrid :: [(Gtk.Widget, (Int, Int))] -> IO Gtk.Grid(Widget, (Column, Row))grid <- Gtk.gridNew<- binds the result from the IO actionzipWithM_This function attaches widgets to the grid:
zipWithM_ (\(widget, (col, row)) _ -> Gtk.gridAttach grid widget (fromIntegral col) (fromIntegral row) 1 1 ) widgetPositions [1..]
- zipWithM_: Combines two lists, applying a function to each pair
- widgetPositions: Our list of (Widget, (col, row)) tuples
- [1..]: Infinite list [1, 2, 3, ...] (we ignore it with _)
- Gtk.gridAttach: Attaches widget to grid at position (col, row) with size 1x1
- fromIntegral: Converts Int to the type GTK expects
Why this pattern? This function lets us declaratively position widgets: “put this widget at column 1, row 1; that widget at column 2, row 1”, etc. Much cleaner than manually calling gridAttach for each widget!
The zipWithM_ pattern is a common Haskell idiom for iterating over a list while performing monadic actions.
The _ at the end means “discard the result” - we’re doing this for side effects (attaching widgets), not for the return value.
From a cybersecurity perspective: In this specific case, it’s safe because zipWithM_ returns IO () (unit type) - there’s literally nothing meaningful to capture. However, be cautious when discarding results in general - ignoring return values can hide error conditions or important status information. If gridAttach could fail and return an error code, we’d want to check it. Haskell’s type system helps here: functions that can fail typically return Either ErrorType SuccessType or Maybe SuccessType, forcing you to handle errors explicitly. The _ pattern is safe when you’re certain the function can’t fail in a way that matters, or when failures would be caught by exceptions/higher-level error handling.
UI.StatusPage.hs creates a secondary window with a text view. This demonstrates opening new windows from button clicks:
{-# LANGUAGE OverloadedStrings #-}
module UI.StatusPage where
import qualified GI.Gtk as Gtk
createStatusPage :: IO ()
createStatusPage = do
win <- Gtk.windowNew
Gtk.setWindowTitle win "Testing GUI Dev on Windows"
Gtk.setWindowDefaultWidth win 300
Gtk.setWindowDefaultHeight win 100
-- Set a text buffer (Testing, no real use for this yet)
textView <- Gtk.textViewNew
buffer <- Gtk.textViewGetBuffer textView
Gtk.textViewSetBuffer textView ( Just buffer )
scrolledWindow <- Gtk.scrolledWindowNew
Gtk.scrolledWindowSetChild scrolledWindow (Just textView)
Gtk.windowSetChild win (Just scrolledWindow)
Gtk.widgetSetVisible win TrueBreaking it down:
win <- Gtk.windowNewwindowNew creates a top-level window by default (no type parameter needed)Setting window properties:
Gtk.setWindowTitle win "Testing GUI Dev on Windows" Gtk.setWindowDefaultWidth win 300 Gtk.setWindowDefaultHeight win 100
- setWindowTitle: Sets the window title
- setWindowDefaultWidth and setWindowDefaultHeight: Set window dimensions separately
- Note: setContainerBorderWidth and setWindowWindowPosition were removed in GTK4
Creating the text editing widget:
textView <- Gtk.textViewNew buffer <- Gtk.textViewGetBuffer textView
- textViewNew: Creates an editable text area
- textViewGetBuffer: Gets the underlying text buffer (where text is stored)
Adding scrollable container:
scrolledWindow <- Gtk.scrolledWindowNew
- In GTK4, scrolledWindowNew takes no parameters (simpler than GTK3!)
The parent-child structure:
Window └── ScrolledWindow └── TextView
- GTK4 uses a different API: setChild instead of containerAdd
- scrolledWindowSetChild: Sets the child of the scrolled window
- windowSetChild: Sets the child of the window
- This is more explicit and type-safe than GTK3’s containerAdd
Gtk.widgetSetVisible win TruewidgetShow and widgetShowAll were deprecatedwidgetSetVisible widget True to show widgets (GTK4 shows children automatically when parent is shown)Notice this function returns IO () - it creates and shows the window but doesn’t return it. This is intentional
for this example, but in a real app, you might want to return the window to attach event handlers or manage its lifecycle. As we continue to build more in-depth apps you will have a deeper understanding.
Now for the star of the show - UI.MainWindow.hs. This creates the main window and wires everything together:
{-# LANGUAGE OverloadedStrings, OverloadedLabels, TupleSections #-}
module UI.MainWindow where
import qualified GI.Gtk as Gtk
import UI.Shared (createButton)
import UI.StatusPage (createStatusPage)
import UI.Layout (setupGrid)
createMainWindow :: IO Gtk.Window
createMainWindow = do
win <- Gtk.windowNew
Gtk.setWindowTitle win "Testing GUI Dev on Windows"
Gtk.setWindowDefaultWidth win 300
Gtk.setWindowDefaultHeight win 100
testButton <- createButton "Just another button"
_ <- Gtk.on testButton #clicked createStatusPage
widgetPositions <- sequence
[ (, (1, 1)) <$> Gtk.toWidget testButton ]
grid <- setupGrid widgetPositions
Gtk.windowSetChild win (Just grid)
return winBreaking it down:
Required for this module:
- OverloadedLabels: Enables #clicked syntax (more on this below)
- TupleSections: Allows (, (1, 1)) syntax (partial tuple construction)
Same GTK4 pattern as StatusPage - windowNew with no parameters, setWindowDefaultWidth and setWindowDefaultHeight for dimensions
testButton <- createButton "Just another button"createButton function from UI.SharedConnecting button clicks to actions:
_ <- Gtk.on testButton #clicked createStatusPage
- Gtk.on: Connects a signal (event) to a handler function
- #clicked: The “clicked” signal using overloaded labels syntax
- createStatusPage: The function to call when clicked
- This is functional event handling - no callbacks, just function composition!
Arranging widgets in the grid:
widgetPositions <- sequence [ (, (1, 1)) <$> Gtk.toWidget testButton ]
Let’s break this down step by step:
- Gtk.toWidget testButton: Converts button to generic Widget type
- (, (1, 1)) <$> ...: Uses <$> (fmap) to create (Widget, (1, 1)) tuple
- sequence: Converts [IO (Widget, (Int, Int))] to IO [(Widget, (Int, Int))]
- Result: A list with one tuple: [(buttonWidget, (1, 1))]
grid <- setupGrid widgetPositionssetupGrid function to create a positioned gridGtk.windowSetChild win (Just grid)windowSetChild instead of containerAddJust wrapper is required (GTK4’s API is more explicit about optional values)return winMain.hs can use itThe #clicked syntax is Haskell’s way of using GTK’s signal system. Under the hood, GTK uses signals
(like “clicked”, “destroy”, “changed”) that widgets emit. Haskell’s gi-gtk bindings make this
type-safe and functional!
Finally, Main.hs - where it all starts:
{-# LANGUAGE OverloadedStrings, OverloadedLabels #-}
module Main (Main.main) where
import qualified GI.Gtk as Gtk
import qualified GI.Gio as Gio
import UI.MainWindow (createMainWindow)
main :: IO ()
main = do
-- Create application
app <- Gtk.applicationNew (Just "com.example.gi-gtk-test") []
-- Handle application activation
_ <- Gtk.on app #activate $ do
mainWin <- createMainWindow
Gtk.windowSetApplication mainWin (Just app)
_ <- Gtk.on mainWin #closeRequest $ do
Gio.applicationQuit app
return False
Gtk.widgetSetVisible mainWin True
-- Run the application (this handles GTK initialization)
_ <- Gio.applicationRun app Nothing
return ()Breaking it down:
app <- Gtk.applicationNew (Just "com.example.gi-gtk-test") []Application to manage the app lifecycleGtk.init! The application handles initialization_ <- Gtk.on app #activate $ do ...#activate signal fires when the application startsCreate and Configure Window:
mainWin <- createMainWindow
Gtk.windowSetApplication mainWin (Just app)_ <- Gtk.on mainWin #closeRequest $ do ...#closeRequest instead of #destroyGio.applicationQuit app: Quits the applicationreturn False: Tells GTK “I handled the close, don’t do default behavior”Gtk.widgetSetVisible mainWin TrueGio.applicationRun app NothingNothing parameter means “no command-line arguments to process”applicationQuit is calledThe order matters! With GTK4’s Application pattern:
1. Create the Application (applicationNew)
2. Connect the #activate signal handler
3. Inside the handler: Create widgets, connect events, show widgets
4. Run the application (applicationRun)
The Application pattern is the modern GTK4 way - it handles initialization, lifecycle, and cleanup automatically!
Let’s trace the execution flow:
Main.main runsGtk.applicationNew#activate signalGio.applicationRun (handles GTK initialization)createMainWindowGio.applicationQuitcreateStatusPage runs → New window appears!#closeRequest → Gio.applicationQuit → Program exitsThe Flow:
Main.main
└── Gtk.applicationNew
└── Gtk.on app #activate
└── createMainWindow
├── UI.Shared.createButton
├── UI.Layout.setupGrid
└── Event: clicked → UI.StatusPage.createStatusPage
└── Event: closeRequest → Gio.applicationQuit
└── Gio.applicationRun (event loop)
Almost everything returns IO something because GUI operations have side effects:
- Creating widgets
- Showing windows
- Handling events
Haskell’s type system forces us to be explicit about side effects!
The do blocks let us sequence IO actions:
main = do
action1
result <- action2
action3 resultNotice how functions compose:
testButton <- createButton "Just another button"
_ <- Gtk.on testButton #clicked createStatusPageWe create a button, then immediately attach a handler. Clean and functional!
GTK’s Haskell bindings are type-safe. The compiler catches errors: - Wrong widget type? Compile error! - Missing required parameter? Compile error! - Wrong signal name? Compile error!
Now that you understand the code structure, try modifying it:
Gtk.entryNewThe best way to learn is to break things and fix them! Try changing function signatures, removing event handlers, or modifying widget properties. The compiler will guide you!
You now understand the code your installation script creates! Each module has a purpose, and together they form a working GUI application. The beauty of Haskell is in this modularity and type safety - the compiler is your friend, catching errors before runtime.
In our next post, we’ll dive deeper into advanced patterns, state management, and building more complex applications. Until then, experiment with the code and see what you can create!