Before getting started with the non-UI code of my application, I couldn't help but dip my toes into the GUI side first. In doing so, I learned that there is something that web developers take for granted that you quickly learn is your job when making a native application: an open window.
Previously, in part 02
In part 2, I explained that I will build a native application to encode religious rules. The rules of the Hebrew calendar are surprisingly complex and Bible readings add to the complexity. I explained that the goal is to codify those rules to make it easier for future generations to follow them.
Return to part 02Section 01 / 07
When first setting out to make this native application for my father, I thought it would good to first learn how to open a window in MacOS from Common Lisp since I'd never done it before. But straight from the beginning, I was faced with answering the question: What kind of native application?
In the beginning, I am fine with creating something that is only usable on MacOS--LLMs these days seem quite capable of translating from one environment to another anyhow. But ultimately, I want to make a cross-platform application usable on Windows, MacOS, and even Linux. To achieve that, what will my final architecture(s) look like?
You see, there isn't just one way to build a native GUI application. There are actually roughly three architectures to choose from:
The choice is especially important when you want to build a cross-platform application because of the strengths and weaknesses of each approach.
Section 1.1
Strengths:
However, there are some important weaknesses especially when you want to build something cross-platform:
The "true" path is a complicated and time/token intensive venture.
And in reality, some of the pros might be overstated. MS Office applications look nothing like Notepad or the Start Menu; MacOS infamously has multiple border radiuses and the "style" is routinely "updated" ala liquid glass which you don't just get automatically in your application and may not even like; building directly in Objective-C or C++ is much more well-supported than building in Common Lisp; Look and feel in Linux isn't standardized--you application will only look and feel familiar on a per GUI framework basis.
But again, the weaknesses are also a bit overblown. While you may initially need to write for several platforms, you can create a framework wrapping the platform APIs so that you can still code once and run anywhere. That is, in fact what LispWorks does with their CAPI GUI toolkit.
So although intuitively the ported path would seem like the path to a "pure" native application, the reality is far dirtier.
Section 1.2
The cross-platform approach means using something like KDE, GTK, etc. to make the GUI. Since those toolkits work on multiple platforms, you get cross-platform support for free. The main strength of this approad is that you build once, then your application works on multiple platforms; no need for OS-specific code.
The weaknesses are that it will feel alien, and potentially cheap/retro. Typically you would also be limited by what Common Lisp libraries exist for those toolkits. If the GTK ones are more mature, but you like KDE more, then you have a conundrum.
Section 1.3
In this approach, you need to actually do the work of drawing the pixels on the screen. Instead of using an OS API or cross-platform toolkit, you make your own toolkit, creating the API for building interfaces the way you like. This approach has some significant strengths and weaknesses.
Strengths:
The strengths mean maximum ownership and control. Of course, you can imagine the weaknesses:
This is the approach used in some rather complex software like Photoshop, JetBrains IDEs, etc.
Notably, the Zen code editor uses this approach with their own custom toolkit called GPUI. This toolkit has an API that will look familiar to front-end developers. From their homepage:
impl Render for HelloWorld { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { div() .flex() .flex_col() .gap_3() .bg(rgb(0x505050)) .size(px(500.0)) .justify_center() .items_center() .shadow_lg() .border_1() .border_color(rgb(0x0000ff)) .text_xl() .text_color(rgb(0xffffff)) .child(format!("Hello, {}!", &self.text)) .child( div() .flex() .gap_2() .child(div().size_8().bg(gpui::red())) .child(div().size_8().bg(gpui::green())) .child(div().size_8().bg(gpui::blue())) .child(div().size_8().bg(gpui::yellow())) .child(div().size_8().bg(gpui::black())) .child(div().size_8().bg(gpui::white())), ) }}This has a distinctly Tailwind flavor to it.
Given that we have a large surplus population of front-end and fullstack developers who I believe will be redirected towards building native UIs, there is a strong possibility that this approach will be the ascendant one for the next decade. The largest variable is whether said developers will even be looking at the code, in which case web-like API will mean less to them than other factors (speed and efficiency).
Section 02 / 07
So the question is: for my first application, where do I begin?
The correct approach I believe is to start with the easiest thing possible, learn the native paradigm, and then adapt as I learn and gain experience. That means for now I will be making a native MacOS application.
For native MacOS development, there are two options:
Because LLMs are so good at converting code from one framework/language to another, and because I don't plan on making this the final architecture choice for this application, the choice isn't very important. For fun, I am going to use Clozure.
However, given the capabilities of the latest LLMs, in the future I will try making a custom GUI framework. Whether I go with a web-like API or something else is something to decide later.
Section 03 / 07
After doing the research to come to this conclusion, it's time to actually open a window. To get started, we need to gain access to Clozure's Cocoa/AppKit API.
At the top of a Lisp file, add:
(eval-when (:compile-toplevel :load-toplevel :execute) (require "OBJC-SUPPORT"))This loads the CCL API for working with Cocoa. eval-when with those options ensures that the OBJC-SUPPORT code is loaded before any of the rest of the code in the file is compiled. In a real, modern system you'd use asdf to load your dependencies before compiling your own.
(defsystem "opening-a-window" :depends-on ((:require "OBJC-SUPPORT")) :components ((:file "opening-a-window")))OBJC-SUPPORT isn't an asdf system nor is it a package name. It's an old-style module provide'd by the objc-support.lisp file in the CCL code base, and thus you need to write ((:require "OBJC-SUPPORT")) instead of ("OBJC-SUPPORT"). And since OBJC-SUPPORT is a module only provided by ClozureCL, you might hide it behind a feature flag:
(defsystem "opening-a-window" :depends-on ((:feature :ccl (:require "OBJC-SUPPORT"))) :components ((:file "opening-a-window.lisp")))However you load the Objective-C bridge, first you have to get it loaded into the Lisp image before compiling the rest of your code.
Section 04 / 07
Once you have the bridge loaded, you are ready for a minimal-but-dysfunctional window.
(defun open-window () (#/sharedApplication ns:ns-application) (#/setActivationPolicy: #&NSApp #$NSApplicationActivationPolicyRegular) (let ((w (make-instance 'ns:ns-window :with-content-rect (ns:make-ns-rect 0 0 400 300) :style-mask (logior #$NSWindowStyleMaskTitled #$NSWindowStyleMaskClosable) :backing #$NSBackingStoreBuffered :defer nil))) (#/makeKeyAndOrderFront: w nil) (#/activateIgnoringOtherApps: #&NSApp t) (#/run #&NSApp))) (ccl:process-interrupt ccl::*initial-process* #'open-window)It's a lot to take in if you're a Lisper unfamiliar with Objective-C. The equivalent Objective-C looks like this:
#import <Cocoa/Cocoa.h> void openWindow(void) { [NSApplication sharedApplication]; [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; NSWindow *w = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300) styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable backing:NSBackingStoreBuffered defer:NO]; [w makeKeyAndOrderFront:nil]; [NSApp activateIgnoringOtherApps:YES]; [NSApp run];} int main(void) { openWindow(); return 0;}Notice it's very similar because the ClozureCL code is just writing the Objective-C code in Lisp--similar to how Parenscript writes JavaScript.
Let's take this code line by line.
Section 4.1
(#/sharedApplication ns:ns-application)The sharedApplication message in Objective-C tells the ns-application class to set the value of a copy of AppKit's NSApp global variable to a new NSApplication. Each process needs an NSApplication object.
Section 4.2
(#/setActivationPolicy: #&NSApp #$NSApplicationActivationPolicyRegular)Now that the NSApp variable is set to the application object, we begin configuring the application.
Applications in MacOS can either be a background process, in which case you don't want a Dock icon or menu bar, or they can be menu bar icon applications, or they can be a full-blown application that can come into the foreground. We want the last one--a regular application.
Section 4.3
(let ((w (make-instance 'ns:ns-window :with-content-rect (ns:make-ns-rect 0 0 400 300) :style-mask (logior #$NSWindowStyleMaskTitled #$NSWindowStyleMaskClosable) :backing #$NSBackingStoreBuffered :defer nil)))Here we make the window object. Windows in AppKit have what is called a content rect, the rectangle or box that holds everything in it. A rect has four values: x coordinant, y coordinant, width, and height.
A style mask is a configuration setting for how the window should look and act--should it have borders, should it have a title bar, should it be resizable, etc.
The logior setting here might not be familiar to you if you're a simple web dev like me. Bits have an on and off state--1 or 0. There are different ways to modify a certain set of bits--called bitwise operations--and logior is one of them.
Each style mask is set to a numeric value. logior combines those values at the bit level. The above style masks (Titled and Closable) have values 1 and 2. At the bit level, they are represented as follows:
1 = 00012 = 0010logior forms the union of the two, 0011.
In any case, the exact settings for the style mask are stored as a combined set of bits.
The backing is related to how the window should use pixels written elsewhere in memory. For modern applications, it should always be set to NSBackingStoreBuffered.
defer refers to whether a window's resources should be allocated immediately or if the resources should be allocated after it is opened. For windows you are about to display, :defer nil is what you want.
Section 4.4
(#/makeKeyAndOrderFront: w nil)(#/activateIgnoringOtherApps: #&NSApp t)One application can have multiple windows displayed at once (think Photoshop). makeKeyAndOrderFront sets whether a window should be the one "on top of" or in front of the others.
And of course, you can have multiple applications open. activateIgnoringOtherApps is used to either have the application open in front of or behind the others.
Section 4.5
(#/run #&NSApp)))run does what you imagine, but what you might not know is that applications have what is called an event loop which waits for events. Events queue up and are processed in order by the loop.
In MacOS, the event loop for an application needs to be started on the main thread. This will become relevant shortly.
Section 4.6
(ccl:process-interrupt ccl::*initial-process* #'open-window)Simply called (open-window) won't work--it will immediately blow up your REPL without signaling an error. When that happens--no debugger, just a dead Lisp process--that means there was an error on the Objective-C side.
The reason for the error is that MacOS apps need to open on the main thread. In the Objective-C code, we don't see any reference to the main thread because it will naturally do it; there's no need to explicitly reference the main thread.
That is not the case with Common Lisp. We need to purposefully set the open-window function as the process on the main thread. process-interrupt will tell *initial-process*--the main thread--to stop what it's doing and run the open-window function.
Section 05 / 07
When you close the window, you'll notice that the icon in the Dock sticks around. That's because the window is closed, but the process is still running.
To get it to close the process properly as we expect, we need to assign a delegate object to the application object.
(defclass app-delegate (ns:ns-object) ((quit-on-close :initarg :quit-on-close :initform nil :reader quit-on-close)) (:metaclass ns:+ns-object)) (objc:defmethod (#/applicationShouldTerminateAfterLastWindowClosed: :<BOOL>) ((self app-delegate) app) (declare (ignore app)) (quit-on-close self)) (defvar *delegate* nil)Why do we need this "delegate" thing? When you close the window, AppKit gets the close event and asks, "Should I terminate the process?" But AppKit asks the delegate. If the delegate doesn't exist, then AppKit defaults to not terminating the process.
app-delegate looks like a regular Lisp class definition, but the :metaclass ns:+ns-object part of the definition makes this an Objective-C class. The same is true of the objc:defmethod--it's an Objective-C method for the appDelegate class (in Objective-C).
The protocol for Objective-C methods for AppKit objects includes a method called applicationShouldTerminateAfterLastWindowClosed: (mind the :) that returns a boolean value. If the application object has a delegate, when the AppKit goes to ask the delegate, "Should I terminate the process?", the answer the delegate returns is the return value of applicationShouldTerminateAfterLastWindowClosed:.
Now we just need to update the open-window function to use the delegate.
(defun open-window () (#/sharedApplication ns:ns-application) (#/setActivationPolicy: #&NSApp #$NSApplicationActivationPolicyRegular) ;; Add the next two lines. (setf *delegate* (make-instance 'app-delegate :quit-on-close t)) (#/setDelegate: #&NSApp *delegate*) (let ((w (make-instance 'ns:ns-window :with-content-rect (ns:make-ns-rect 0 0 400 300) :style-mask (logior #$NSWindowStyleMaskTitled #$NSWindowStyleMaskClosable) :backing #$NSBackingStoreBuffered :defer nil))) (#/makeKeyAndOrderFront: w nil) (#/activateIgnoringOtherApps: #&NSApp t) (#/run #&NSApp)))Now if we close the window, the process is closed, too. The application process and the Lisp process are the same, so you Lisp REPL will be killed, too. You probably don't want that during development. Feel free to add a (dev-p) function or something to configure it to stay open during development and to close in production.
Section 06 / 07
Now we have a problem: If we set :quit-on-close to nil, we can't reopen the window. Click on the icon in the Dock... nothing. Rerun (ccl:process-interrupt ccl::*initial-process* #'open-window)... nothing. process-interrupt won't interrupt the first open-window event loop. Now we have to restart our Lisp process to open another window.
One of the problems with open-window is that it does too much: it makes the window instance and it starts the application. What we want to do is open a window on the main thread. To do that, we need to split up open-window a bit.
(defun make-window () (let ((window (make-instance 'ns:ns-window :with-content-rect (ns:make-ns-rect 0 0 400 300) :style-mask (logior #$NSWindowStyleMaskTitled #$NSWindowStyleMaskClosable) :backing #$NSBackingStoreBuffered :defer nil))) (#/makeKeyAndOrderFront: window nil) ;; Bring the window to the front and focus on it. (#/activateIgnoringOtherApps: #&NSApp t) window)) (defun open-window () (#/sharedApplication ns:ns-application) (#/setActivationPolicy: #&NSApp #$NSApplicationActivationPolicyRegular) (setf *delegate* (make-instance 'app-delegate :quit-on-close t)) (#/setDelegate: #&NSApp *delegate*) (make-window) (#/run #&NSApp))To get a message to the already running application, we need to send a message to the delegate.
We want the delegate to reopen the window for the application, so first we need to add that method to the delegate.
(objc:defmethod (#/reopenWindow: :void) ((self app-delegate) sender) (declare (ignore sender)) (make-window))Now we need to send a message to the delegate to run that method. To do that, we need to use an Objective-C method called performSelectorOnMainThread:.
(#/performSelectorOnMainThread: *delegate* (#/objc:@selector #/reopenWindow:))A selector is roughly the equivalent of a function symbol name like 'my-function. The first argument to the selector performSelectorOnMainThread is the object--*delegate*--you want to ask to call some function, and the selector--openWindow--is the function you want it to call.
Now, in Objective-C, the above code would look something like this:
[delegate performSelectorOnMainThread:@selector(reopenWindow:)]Everything between the brackets is called a message, delegate is the receiver of the message, performSelectorOnMainThread: is a selector, the argument to the selector is @selector(reopenWindow:).
Well, this isn't entirely accurate. The full selector is:
performSelectorOnMainThread:withObject:waitUntilDone:The equivalent name in Lisp would be perform-selector-on-main-thread-with-object-wait-until-done. And you thought Lisp function names were already long!
But instead of having one function/selector name followed by its arguments, a selector in Objective-C breaks the selector apart into pieces for each argument the selector takes. Confusing, I know, but that's why the ClozureCL bridge code actually looks like this:
(#/performSelectorOnMainThread:withObject:waitUntilDone ,*delegate* (objc:@selector #/reopenWindow:) nil t)The performSelectorOnMainThread: and withObject: together are similar to a funcall:
;; Send this function these arguments(funcall #'my-function args)waitUntilDone's argument is t, meaning we want the main thread to block the calling thread (the main thread) until the method is finished running.
And now we just need to make it callable from a Lisp function:
(defun reopen-window () (#/performSelectorOnMainThread:withObject:waitUntilDone *delegate* (objc:@selector #/reopenWindow:) nil t))Section 07 / 07
The final code looks like this:
(eval-when (:compile-toplevel :load-toplevel :execute) (require "OBJC-SUPPORT")) ;; * Delegate(defclass app-delegate (ns:ns-object) ((quit-on-close :initarg :quit-on-close :initform nil :reader quit-on-close)) (:metaclass ns:+ns-object)) (objc:defmethod (#/applicationShouldTerminateAfterLastWindowClosed: :<BOOL>) ((self app-delegate) app) (declare (ignore app)) (quit-on-close self)) (objc:defmethod (#/reopenWindow: :void) ((self app-delegate) sender) (declare (ignore sender)) (make-window)) (defvar *delegate* nil) ;; * Window(defun make-window () (let ((window (make-instance 'ns:ns-window :with-content-rect (ns:make-ns-rect 0 0 400 300) :style-mask (logior #$NSWindowStyleMaskTitled #$NSWindowStyleMaskClosable) :backing #$NSBackingStoreBuffered :defer nil))) (#/makeKeyAndOrderFront: window nil) (#/activateIgnoringOtherApps: #&NSApp t) window)) (defun open-window () (#/sharedApplication ns:ns-application) (#/setActivationPolicy: #&NSApp #$NSApplicationActivationPolicyRegular) (setf *delegate* (make-instance 'app-delegate :quit-on-close nil)) (#/setDelegate: #&NSApp *delegate*) (make-window) (#/run #&NSApp)) (defun reopen-window () (#/performSelectorOnMainThread:withObject:waitUntilDone: ,*delegate* (objc:@selector #/reopenWindow:) nil t)) ;; * Open the first time with this(ccl:process-interrupt ccl::*initial-process* #'open-window) ;; * Reopen with this;; (reopen-window)If you get warnings in CCL's REPL and the file compilation fails the first time, but you try it a second time and it works, then you can replace the first eval-when form with this:
(eval-when (:compile-toplevel :load-toplevel :execute) (handler-bind ((warning #'muffle-warning)) (require "OBJC-SUPPORT")))After that, it should compile the first time.
End transmission
Native application is back. My body is ready. I am putting down the hypermedia and embracing deranged native GUI APIs--beginning with MacOS and Objective-C development (in Almighty Common Lisp, of course).
4 of 6 published
Type to search every article: titles, subtitles and body text.