CyberTrench

A Hackgineer's Blog

Building GUI Apps with Haskell and GTK4 - Understanding the Setup Script

TLDR: Let’s dive deep into the code our installation script creates! We’ll break down each module and understand how it all fits together.

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

Project Structure Overview

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.

Understanding the Cabal File

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 -threaded

Key Points:

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: src

The 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:

The hs-source-dirs: src tells Cabal to look in the src/ directory for these modules. When Cabal sees UI.StatusPage, it follows these steps:

  1. Takes the module path: UI.StatusPage
  2. Converts dots to directory separators: UI/StatusPage
  3. Looks in src/ (from hs-source-dirs): src/UI/StatusPage
  4. Adds the .hs extension: src/UI/StatusPage.hs

This 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.StatusPage

The executable section defines the main program. A few important points:

  1. 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/.

  2. 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)

  1. Why list exposed modules?

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.

Module 1: UI.Shared - Reusable Components

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:

  1. Module Declaration: module UI.Shared (createButton) where
  1. Imports

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)

  1. Function Signature: createButton :: String -> IO Gtk.Button
  1. Function Body: Gtk.buttonNewWithLabel (T.pack label)

Why 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!

Module 2: UI.Layout - Grid Management

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 grid

Breaking it down:

  1. Language Extensions

Required for this module: - OverloadedLabels: Allows using #label syntax (we’ll see this later) - OverloadedStrings: Allows string literals to be Text automatically

  1. Function Signature: setupGrid :: [(Gtk.Widget, (Int, Int))] -> IO Gtk.Grid
  1. Grid Creation: grid <- Gtk.gridNew
  1. The Magic - zipWithM_

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.

Module 3: UI.StatusPage - A Secondary Window

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 True

Breaking it down:

  1. Window Creation: win <- Gtk.windowNew
  1. Window Configuration

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

  1. Text View Setup

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)

  1. Scrolled Window

Adding scrollable container: scrolledWindow <- Gtk.scrolledWindowNew - In GTK4, scrolledWindowNew takes no parameters (simpler than GTK3!)

  1. Widget Hierarchy (GTK4 Style)

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

  1. Display: Gtk.widgetSetVisible win True

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.

Module 4: UI.MainWindow - The Main Application Window

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 win

Breaking it down:

  1. Language Extensions

Required for this module: - OverloadedLabels: Enables #clicked syntax (more on this below) - TupleSections: Allows (, (1, 1)) syntax (partial tuple construction)

  1. Window Setup

Same GTK4 pattern as StatusPage - windowNew with no parameters, setWindowDefaultWidth and setWindowDefaultHeight for dimensions

  1. Button Creation: testButton <- createButton "Just another button"
  1. Event Handling - The Magic Line

Connecting 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!

  1. Widget Positioning

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

  1. Grid Setup: grid <- setupGrid widgetPositions
  1. Add Grid to Window: Gtk.windowSetChild win (Just grid)
  1. Return the Window: return win

The #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!

Module 5: Main.hs - The Entry Point

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:

  1. Create Application: app <- Gtk.applicationNew (Just "com.example.gi-gtk-test") []
  1. Application Activation Handler: _ <- Gtk.on app #activate $ do ...
  1. Create and Configure Window:

    mainWin <- createMainWindow
    Gtk.windowSetApplication mainWin (Just app)
  1. Close Request Handler: _ <- Gtk.on mainWin #closeRequest $ do ...
  1. Show Window: Gtk.widgetSetVisible mainWin True
  1. Run Application: Gio.applicationRun app Nothing

The 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!

How It All Fits Together

Let’s trace the execution flow:

  1. Program starts → Main.main runs
  2. Application created → Gtk.applicationNew
  3. Activation handler connected → #activate signal
  4. Application runs → Gio.applicationRun (handles GTK initialization)
  5. Activation fires → Handler executes:
  1. Event loop running → Application processes events
  2. User clicks button → createStatusPage runs → New window appears!
  3. User closes window → #closeRequest → Gio.applicationQuit → Program exits

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

Key Haskell Concepts Used

1. The IO Monad

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!

2. Do Notation

The do blocks let us sequence IO actions:

main = do
    action1
    result <- action2
    action3 result

3. Function Composition

Notice how functions compose:

testButton <- createButton "Just another button"
_ <- Gtk.on testButton #clicked createStatusPage

We create a button, then immediately attach a handler. Clean and functional!

4. Type Safety

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!

Next Steps

Now that you understand the code structure, try modifying it:

The 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!

Resources

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!