Building Interactive Desktop Apps with Clojure and Humble UI

This chapter explores building functional, interactive desktop applications using Clojure and the —-Humble UI—- library. We will transition from a simple “Hello World” greeting application to more complex tools, including an AI-powered chat client and a PDF viewer.

Core Concepts of Humble UI

Humble UI provides a declarative way to build user interfaces in Clojure. It uses a concept called signals for state management and components for building the UI structure.

State Management with Signals

Signals are reactive pieces of state that notify components when their value changes. In the examples below, you will see how we use ui/signal to define state and reset! or swap! to update it.

1 (def *name (ui/signal {:text ""})) ;; A signal containing a map
2 (def *messages (ui/signal []))      ;; A signal containing a vector

Declarative UI Components

Components are defined using the ui/defcomp macro. They describe what the UI should look like based on the current state, rather than how to change it.

1. The Basic Greeting App

We start with a simple example in src/apps/hello.clj. This application demonstrates how to capture user input and reactively display a greeting.

Implementation Details

The application consists of:

  • State: A signal for the user’s name and another for the greeting message.
  • Logic: A greet function that reads the current name and updates the greeting.
  • UI Structure: A vertical column containing a label, a text field tied to the name signal, a button to trigger the greeting, and the resulting greeting display.

Listing of humble-ui-app-dev/src/apps/hello.clj:

 1 (ns apps.hello
 2   (:require [io.github.humbleui.ui :as ui]))
 3 
 4 (def *name (ui/signal {:text ""}))
 5 
 6 (def *greeting (ui/signal ""))
 7 
 8 (defn greet []
 9   (let [n (:text @*name)]
10     (reset! *greeting
11       (if (clojure.string/blank? n)
12         "Please enter your name first!"
13         (str "Hello, " n "!")))))
14 
15 (ui/defcomp ui []
16   [ui/center
17    [ui/padding {:padding 30}
18     [ui/column {:gap 15}
19      [ui/align {:x :center}
20       [ui/label {:font-size 28 :font-weight :bold} "Hello Humble UI"]]
21      [ui/gap {:height 10}]
22      [ui/label {:font-size 16} "Enter your name:"]
23      [ui/size {:width 280}
24       [ui/text-field {:*state *name}]]
25      [ui/gap {:height 5}]
26      [ui/align {:x :center}
27       [ui/button {:on-click (fn [_] (greet))}
28        [ui/label {:font-size 16} "Greet"]]]
29      [ui/gap {:height 5}]
30      [ui/align {:x :center}
31       [ui/label {:font-size 20 :font-weight 500} *greeting]]]]])
32 
33 (defn -main [& args]
34   (ui/start-app!
35     (ui/window
36       {:title "Hello Humble UI"
37        :width 500
38        :height 400}
39       #'ui)))

2. AI Chat Client: Integrating External APIs

Moving up in complexity, src/apps/chat.clj implements a chat interface that communicates with Large Language Models like Gemini, OpenAI, or local models via Ollama.

Key Features

  • Multi-Provider Support: The application allows users to toggle between different AI backends at runtime.
  • Asynchronous Communication: API calls are wrapped in future to prevent the UI from freezing while waiting for a response.
  • Rich History Management: It maintains a conversation history, passing it to the APIs to allow for context-aware dialogue.

Listing of humble-ui-app-dev/src/apps/chat.clj:

  1 (ns apps.chat
  2   (:require [clojure.data.json :as json]
  3             [clojure.string :as str]
  4             [io.github.humbleui.ui :as ui])
  5   (:import [java.net.http HttpClient HttpRequest HttpRequest$BodyPublishers HttpResponse$BodyHandlers]
  6            [java.net URI]))
  7 
  8 ;; ── State ───────────────────────────────────────────────────────
  9 
 10 (def *messages  (ui/signal []))
 11 (def *input     (ui/signal {:text ""}))
 12 (def *api       (ui/signal :gemini))
 13 (def *loading?  (ui/signal false))
 14 
 15 ;; ── API Helpers ──────────────────────────────────────────────────
 16 
 17 (defn gemini-api-key [] (System/getenv "GOOGLE_API_KEY"))
 18 (defn openai-api-key [] (System/getenv "OPENAI_API_KEY"))
 19 (def ^:private http-client (HttpClient/newHttpClient))
 20 
 21 (defn- http-post [url headers body]
 22   (let [builder (HttpRequest/newBuilder (URI. url))]
 23     (.POST builder (HttpRequest$BodyPublishers/ofString body))
 24     (doseq [[k v] headers]
 25       (.header builder k v))
 26     (let [req     (.build builder)
 27           resp    (.send http-client req (HttpResponse$BodyHandlers/ofString))
 28           status  (.statusCode resp)]
 29       (if (<= 200 status 299)
 30         (.body resp)
 31         (throw (ex-info (str "HTTP " status ": " (.body resp))
 32                         {:status status}))))))
 33 
 34 (defn- call-gemini [prompt history]
 35   (let [api-key (gemini-api-key)]
 36     (when-not api-key
 37       (throw (ex-info "GOOGLE_API_KEY environment variable not set" {})))
 38     (let [contents (->> (conj history {:role :user :content prompt})
 39                         (mapv (fn [m]
 40                                 {:role  (if (= :assistant (:role m)) "model" "user")
 41                                  :parts [{:text (:content m)}]})))
 42           body     (json/write-str {:contents contents})
 43           url      (str "https://generativelanguage.googleapis.com/v1beta/models/"
 44                         "gemini-2.0-flash:generateContent?key=" api-key)
 45           resp     (http-post url {"Content-Type" "application/json"} body)
 46           data     (json/read-str resp :key-fn keyword)]
 47       (-> data :candidates first :content :parts first :text))))
 48 
 49 (defn- call-openai [prompt history]
 50   (let [api-key (openai-api-key)]
 51     (when-not api-key
 52       (throw (ex-info "OPENAI_API_KEY environment variable not set" {})))
 53     (let [messages (->> (conj history {:role :user :content prompt})
 54                         (mapv #(select-keys % [:role :content])))
 55           body     (json/write-str {:model    "gpt-4o-mini"
 56                                     :messages messages})
 57           resp     (http-post "https://api.openai.com/v1/chat/completions"
 58                               {"Content-Type"  "application/json"
 59                                "Authorization" (str "Bearer " api-key)}
 60                               body)
 61           data     (json/read-str resp :key-fn keyword)]
 62       (-> data :choices first :message :content))))
 63 
 64 (defn- call-ollama [prompt history]
 65   (let [messages (->> (conj history {:role :user :content prompt})
 66                       (mapv #(select-keys % [:role :content])))
 67         body     (json/write-str {:model    "phi3:latest"
 68                                   :messages messages
 69                                   :stream   false})
 70         resp     (http-post "http://localhost:11434/api/chat"
 71                             {"Content-Type" "application/json"}
 72                             body)
 73         data     (json/read-str resp :key-fn keyword)]
 74     (:message data)))
 75 
 76 (defn- send-message []
 77   (let [text (str/trim (:text @*input))]
 78     (when (and (not (str/blank? text)) (not @*loading?))
 79       (swap! *messages conj {:role :user :content text})
 80       (swap! *input assoc :text "")
 81       (reset! *loading? true)
 82       (let [history @*messages
 83             api     @*api]
 84         (future
 85           (try
 86             (let [response (case api
 87                              :gemini (call-gemini text history)
 88                              :openai (call-openai text history)
 89                              :ollama (call-ollama text history))]
 90               (swap! *messages conj {:role :assistant :content response}))
 91             (catch Exception e
 92               (swap! *messages conj
 93                 {:role :assistant
 94                  :content (str "Error: " (.getMessage e))}))
 95             (finally
 96               (reset! *loading? false))))))))
 97 
 98 ;; ── UI Components ────────────────────────────────────────────────
 99 
100 (def api-options
101   [{:key :gemini :label "Gemini"}
102    {:key :openai :label "OpenAI"}
103    {:key :ollama :label "Ollama"}])
104 
105 (ui/defcomp api-toggle-btn [api-option]
106   (let [selected? (= (:key api-option) @*api)]
107     [ui/clickable
108      {:on-click (fn [_] (reset! *api (:key api-option)))}
109      (fn [state]
110        [ui/rect {:radius 4
111                  :paint  {:fill (cond
112                                   (:pressed state) 0xFFD0D0D0
113                                   selected?         0xFFB2D7FE
114                                   (:hovered state)  0xFFE1EFFA
115                                   :else             0xFFF0F0F0)}}
116         [ui/padding {:horizontal 16 :vertical 6}
117          [ui/label {:font-weight (if selected? :bold 400)}
118           (:label api-option)]]])]))
119 
120 (ui/defcomp api-toggle []
121   [ui/row {:gap 4}
122    (for [opt api-options]
123      [api-toggle-btn opt])])
124 
125 (ui/defcomp chat-message [msg]
126   (let [role (:role msg)]
127     [ui/padding {:horizontal 12 :vertical 4}
128      [ui/row {:gap 8}
129       [ui/size {:width 60}
130        [ui/label {:font-weight :bold
131                   :paint       {:fill (if (= :user role) 0xFF2196F3 0xFF4CAF50)}}
132         (if (= :user role) "You:" "AI:")]]
133       [ui/label (:content msg)]]]))
134 
135 (ui/defcomp chat-history []
136   [ui/vscroll
137    [ui/padding {:bottom 8}
138     [ui/column
139      (if (empty? @*messages)
140        [ui/padding {:padding 20}
141         [ui/label {:paint {:fill 0xFF999999}}
142          "Start a conversation by typing a message below."]]
143        (for [[i msg] (map-indexed vector @*messages)]
144          ^{:key i} [chat-message msg]))]]])
145 
146 (ui/defcomp input-area []
147   [ui/rect {:paint {:fill 0xFFF5F5F5}}
148    [ui/padding {:horizontal 8 :vertical 8}
149     [ui/row {:gap 8}
150      ^{:stretch 1}
151      [ui/text-field {:*state *input}]
152      [ui/button {:on-click (fn [_] (send-message))}
153       (if @*loading?
154         [ui/label {:paint {:fill 0xFF999999}} "Sending..."]
155         [ui/label "Send"])]]]])
156 
157 (ui/defcomp ui []
158   [ui/column
159    [ui/padding {:horizontal 12 :vertical 8}
160     [ui/rect {:paint {:fill 0xFFFAFAFA}}
161      [ui/row {:gap 12}
162       [ui/label {:font-weight :bold :font-size 14} "AI Chat Client"]
163       [ui/gap {:width 20}]
164       [api-toggle]]]]
165    [ui/rect {:paint {:stroke 0xFFE0E0E0}}
166     [ui/gap {:height 1}]]
167    ^{:stretch 1}
168    [chat-history]
169    [input-area]])
170 
171 (defn -main [& args]
172   (ui/start-app!
173     (ui/window
174       {:title "AI Chat Client"
175        :width 650
176        :height 550}
177       #'ui)))

The Chat Loop

When a user sends a message:

  1. The message is added to the local state (*messages).
  2. A background thread (future) is spawned to perform the HTTP request.
  3. Once the API responds, the assistant’s reply is appended to *messages.
  4. The UI, being reactive, automatically re-renders to show the new messages.

3. PDF Viewer: Handling Files and Graphics

Finally, we look at src/apps/pdf_viewer.clj, which demonstrates how to integrate Java libraries like Apache PDFBox with Humble UI to create a functional utility.

Rendering PDFs as Images

Since graphical UIs often prefer working with bitmapped data, this application converts PDF pages into images on the fly:

  • PDFBox: Used to load and parse the PDF file.
  • Java AWT/ImageIO: Processes the rendered page from PDFBox and converts it into a byte array representing a PNG image.
  • Humble UI ui/image: Displays the resulting byte array within the application window.

The app provides a toolbar with:

  • File Loading: Uses JFileChooser to allow users to select a file from their system.
  • Pagination: Buttons to move between pages, leveraging state to keep track of the current page number and total count.

Listing of humble-ui-app-dev/src/apps/pdf_viewer.clj:

  1 (ns apps.pdf-viewer
  2   (:require [clojure.string :as str]
  3             [io.github.humbleui.ui :as ui])
  4   (:import [java.awt.image BufferedImage]
  5            [java.io ByteArrayOutputStream File]
  6            [javax.imageio ImageIO]
  7            [javax.swing JFileChooser]
  8            [javax.swing.filechooser FileNameExtensionFilter]
  9            org.apache.pdfbox.Loader
 10            org.apache.pdfbox.rendering.PDFRenderer))
 11 
 12 ;; ── State ───────────────────────────────────────────────────────
 13 
 14 (def *page-bytes  (ui/signal nil))
 15 (def *page-count  (ui/signal 0))
 16 (def *page-num    (ui/signal 1))
 17 (def *file-path   (ui/signal nil))
 18 (def *error-msg   (ui/signal nil))
 19 (def ^:private *pdf-doc (ui/signal nil))
 20 
 21 ;; ── PDF Logic ────────────────────────────────────────────────────
 22 
 23 (defn- render-page [doc page-num]
 24   (let [renderer (PDFRenderer. doc)
 25         scale    1.5
 26         dpi      (* 72 scale)
 27         img      (.renderImageWithDPI renderer (dec page-num) dpi)
 28         baos     (ByteArrayOutputStream.)]
 29     (ImageIO/write img "png" baos)
 30     (.toByteArray baos)))
 31 
 32 (defn- load-pdf [file]
 33   (try
 34     (let [doc   (Loader/loadPDF file)
 35           pages (.getNumberOfPages doc)]
 36       (reset! *error-msg nil)
 37       (reset! *pdf-doc doc)
 38       (reset! *page-count pages)
 39       (reset! *page-num 1)
 40       (reset! *file-path (.getName file))
 41       (reset! *page-bytes (render-page doc 1)))
 42     (catch Exception e
 43       (reset! *error-msg (str "Failed to load PDF: " (.getMessage e)))
 44       (reset! *pdf-doc nil)
 45       (reset! *page-count 0))))
 46 
 47 (defn- go-to-page [n]
 48   (when-let [doc @*pdf-doc]
 49     (let [n (max 1 (min @*page-count n))]
 50       (reset! *page-num n)
 51       (reset! *page-bytes (render-page doc n)))))
 52 
 53 (defn- choose-and-load-pdf []
 54   (let [chooser (JFileChooser.)]
 55     (.setDialogTitle chooser "Open PDF File")
 56     (.setFileFilter chooser (FileNameExtensionFilter. "PDF Files" (into-array String ["pdf"])))
 57     (when (= JFileChooser/APPROVE_OPTION (.showOpenDialog chooser nil))
 58       (let [file (.getSelectedFile chooser)]
 59         (load-pdf file)))))
 60 
 61 ;; ── UI Components ────────────────────────────────────────────────
 62 
 63 (ui/defcomp toolbar []
 64   [ui/rect {:paint {:fill 0xFFF0F0F0}}
 65    [ui/padding {:horizontal 12 :vertical 8}
 66     [ui/row {:gap 12}
 67      [ui/button {:on-click (fn [_] (choose-and-load-pdf))}
 68       [ui/label "Open PDF"]]
 69      (when (pos? @*page-count)
 70        [ui/row {:gap 8}
 71         [ui/button {:on-click (fn [_] (go-to-page (dec @*page-num)))}
 72          [ui/label "Prev"]]
 73         [ui/label {:font-size 14}
 74          (str "Page " @*page-num " / " @*page-count)]
 75         [ui/button {:on-click (fn [_] (go-to-page (inc @*page-num)))}
 76          [ui/label "Next"]]])
 77      (when @*file-path
 78        [ui/label {:paint {:fill 0xFF666666} :font-size 12}
 79         @*file-path])]]])
 80 
 81 (ui/defcomp pdf-view []
 82   [ui/rect {:paint {:fill 0xFFE0E0E0}}
 83    (if @*page-bytes
 84      [ui/image {:src @*page-bytes :scale :fit}]
 85      [ui/center
 86       [ui/column {:gap 10}
 87        (if @*error-msg
 88          [ui/label {:paint {:fill 0xFFCC0000}} @*error-msg]
 89          [ui/label {:paint {:fill 0xFF999999} :font-size 18}
 90           "No PDF loaded. Click 'Open PDF' to select a file."])]])])
 91 
 92 (ui/defcomp ui []
 93   [ui/column
 94    [toolbar]
 95    [ui/rect {:paint {:stroke 0xFFD0D0D0}}
 96     [ui/gap {:height 1}]]
 97    ^{:stretch 1}
 98    [pdf-view]])
 99 
100 (defn -main [& args]
101   (ui/start-app!
102     (ui/window
103       {:title "PDF Viewer"
104        :width 800
105        :height 700}
106       #'ui)))

Summary

Through these three examples, we have covered:

  1. Basic reactive input and display.
  2. Interacting with web services via HTTP in a non-blocking way.
  3. Bridging standard Java desktop libraries (AWT/Swing) with a modern declarative UI framework.

With Humble UI, the boundary between simple scripts and full-featured desktop applications becomes incredibly thin, allowing you to focus on your application logic rather than the intricacies of GUI event loops.