Impressive title right?
The following image is a "sneak peek" at the full implementation and the resultant system:
To see it come to fruition, we would first need to choose an appropriate tool/language to implement the task in.
If you've read any of my other blog entries, you may be guessing that I would lean towards a platform/language which packs a lot of punch in a small size (see Clojure is Cool in which I compare Java verbosity to Clojure and show how many Java tasks can be done in about 1/10th the lines of Clojure).
If you guessed I would choose Clojure, you were perhaps correct - I did choose and use it for a few different GUI application efforts in the past (a native Jira client https://github.com/ahungry/insectarium/ , a web browser https://github.com/ahungry/ahubu and a mapping application for the EverQuest game https://github.com/ahungry/p99/).
However, Clojure has a huge glaring fault (which is very obvious in hindsight) and that is the JVM. Either a boon or a bane depending on who you ask, community opinions on the like or disdain for JVM tooling is very polarized.
Well, simply put - it is likely not a good choice for a user distributed application. Sure, on the development side of things (where user facing concerns can be neglected and ignored) it seems to offer a lot of appealing benefits:
However, the elephant in the room (which I also like to ignore as a Clojure enthusiast) is that the start up time of a Java based GUI app is too slow (this is exacerbated even moreso when Clojure is involved).
I put up with slow REPL loading, and slow app start times, because I truly enjoy writing code in Clojure, and I find developing GUIs enjoyable to some degree, but when I start timing these boot up times, its egregrious to pretend its fine.
Running one of the previously mentioned applications (insectarium or p99) on Arch Linux on a mid-end machine takes over 10 seconds from launching the java -jar command (yes, it is AOT compiled and packed in an uberjar - not just 'lein run' development start) - which is a magnitude slower than any GUI app (sans-Emacs) that I would electively choose to use in my day to day (I always tend to lean towards minimalist software like i3 window manager and terminal based tools). Heck, even the games I play, I enjoy small and fast vs slow and bloated (why I would rather play Baldur's Gate Definitive Edition (snappy) vs Pillars of Eternity (slow)).
"Well, I'm writing these tools as open source out of the goodness of my heart - users can deal with the start up times", is what I'd often think to myself…at least, until I began benchmarking setup + startup times on Windows (which over 85% of the desktop user space still uses - GNU/Linux clocking in at a paltry 1 to 2%).
Why did I decide this was a problem? It took over 30 minutes - wait, no, 30 seconds (but it sure seemed like 30 minutes) to start one of these apps. That was after the giant headache I had to endure to even get to the point of being able to run Java on a fresh Windows 7 VM (thanks Microsoft for providing IE testing images - I can at least try to support a broader community with FOSS as its somewhat accessible, unlike MAC/OSX which makes it impossible to develop/test on unless you shell out for their proprietary systems). First, I have to hunt down a JRE - Oracle's site for this is terrible - if you even attempt to pull in the JDK by mistake, they want you to sign in before you get to the download. Eventually I stumbled upon https://adoptopenjdk.net/ - this was a great discovery, as the newer Windows "package managers" (Scoop and Chocolatey) are a bear to make work on Windows 7 image (Powershell 2 to 5 upgrade is an entire ordeal I'll avoid griping about for now) - be careful you don't hit the .com variant of the Adopt site though, its virus-laden/spam.
Well, if you don't use something easy to code in and that runs in many places with minimal build steps, what do you use?
"Electron!" pipes the masses. Hmm, no, that still won't do - it is also very bloated and heavy (here, have an entire web browser that we'll pretend is a GUI framework). Honestly, this one I've never really understood - I have projects from a decade ago that were "native cross platform GUIs" of a similar concept, much like CUPS (a locally run webserver, with a GUI hosted on localhost that talks to said server). How Electron caught on with "bundling" a chrome per app will never make sense to me, but I suppose people are like lightning, and will travel along the path of least resistance to achieve their goals (see: "Easy is not Simple" speeches by Rich Hickey for some good insight into this).
"Maybe look into options further back or more low level", I think to myself. I know I've come across the latest and greatest cross platform GUI links buzzing about on HackerNews/Reddit in the last few years (libyue, Nuklear) and old tried and true standards (wxWidgets).
Lets do a very quick rundown on a couple:
Oh yea, I actually wrote an app in this (a slack client, predating the libyue author's slack client): https://github.com/ahungry/lack
So, what didn't I like about this? Building steps SUCK! Its complicated to build cross platform, especially when the mind-share in each platform is SO different and segmented. There really isn't much out there for tutorials/guides on "develop a GUI app in GNU/Linux and cross compile it for Windows" - most Windows development focuses on setting up Visual Studio (which also errored out for me on Windows 7) and compiling with Windows tools. I just want to use (ming)gcc and not deal with all that (if I have to compile at all).
Oh, did I mention - waiting for compilation also sucks (as does writing in C++ - I shouldn't have to deal with such a complex language for such simple application writing).
This one was a big "WTF" for me. At first glance (and looking at star count) I thought this was a panacea. Hey, a simple C file! No C++ needed! And its just one file, no complex build process! Lets dive into some of the samples on the home page!
Well, as I very quickly found out, it is not a complete sample on the home page, its essentially a snippet of a tiny portion of what is required to get a "simple" GUI going.
I gave up before figuring out their font dictionary system (it doesn't seem to have a default/builtin, it wants me to load my own and requires about 1000 lines (no exaggeration) of boilerplate before I can render a "Hello World").
Well, I'm too averse to (and impatient) to deal with all the C++ cross platform build complexities, and as a fan of small powerful languages like Clojure, abhor overly verbose languages to implement code in.
Thats when I recalled an old friend I had spent some time tinkering with roughly a year ago. Her name is Janet and she's a programming language (https://janet-lang.org). The syntax is VERY Clojure like, and the language inventor/author is IMHO quite genius. The documentation is very good and accessible (I like Clojure, but reading the Clojure docs is hard sometimes, it can be like an alien language at times).
When I first toyed with Janet, I was awed by how powerful, yet simple the language and implementation were. Able to build a webserver with Clojure Ring like routing that could run in an Alpine docker image with a 5MB RAM footprint and a 1MB binary footprint was pretty neat.
Anyways - I dusted off my Janet-chops and went to work on what I wanted (hint: an all-in-one GUI kit that was fast to boot, simple to install, and could be coded in a Lisp-like language).
Thus, Puny GUI was born: https://github.com/ahungry/puny-gui
Ok, so, how does Puny GUI compare to other options? Well, if you read through my rambling, I'll repeat some of the highlights here:
Well, if you've made it this far, I suppose we should start creating an app.
First, go here https://github.com/ahungry/puny-gui and clone the repo, or jump to the Releases section and download the one for your host OS.
In the future, if you invent something new, you'd likely repeat this same process in a fresh directory.
There are two main executable files to take note of, they are:
Essentially, they are pre-compiled Janet runtimes, packed full of useful C libraries that provide the features outlined for Puny GUI above.
"app" is the one you'd tend to distribute to users, and simply loads app.janet and calls it's (main) to start the application.
"super-repl" is the Janet runtime + Janet shell - if you've installed "janet" locally, it will seem very familiar (its just the "janet" binary with those C libs as builtins).
You can test out the dynamic nature of the REPL by making a new file such as "hello.janet"
Add this to it:
(defn main [x] (print "Hello from Janet"))
Now run (in a terminal/CMD prompt):
See the pretty output? Neat right?
Next up, we'll try invoking the GUI. app.janet is already chock-full of GUI goodness, so just try running it:
Thats the kitchen-sink. It demos a lot of stuff at once (peek at app.janet if you're curious - you can see it imports from some other files).
Ok, back to this app. First off, if we're going to find cute puppies, we need a way to pull in their pics.
Lets implement that first.
Create yourself a file called dog-sdk.janet, and lets add some code.
First, we will implement a convenience wrapper written in Janet that makes working with C curl bindings more convenient:
(import lib/net/curl :as client)
Janet's module system is similar to the nodejs commonjs system. Had I not aliased it at the end, it would be under the "curl/" prefix.
Now, lets define some stuff:
# The remote endpoint we will request from - it returns a dog link (def random-host "https://dog.ceo/api/breeds/image/random") (defn get-random-dog [host] (-> (client/json-get host) (get "message")))
If you've used Clojure, that thread macro will not look very odd - if you haven't, it may be jarring. You can see the macro expansion in Janet via the macex call (similar to macro-expand-1 in Common Lisp).
Anyways, this small function will invoke json-get on the host argument, then use the Janet accessor to pull out the key in the json titled "message" (thats where the random dog image URL is stored).
Go ahead, try executing the code to make sure it works (if you use Emacs, you could set up inf-janet mode + janet-mode for a good REPL based workflow by now - if not, no big deal, you can do this all in Notepad even).
Oops! Nothing happened - that's because there is no 'main' in this file.
Ok, so you have two options - add a main, or call super-repl.bin without any arguments to have the REPL active.
If you chose to use the REPL, try this out next:
janet:1:> (use dog-sdk) nil janet:2:> (get-random-dog random-host) "https://images.dog.ceo/breeds/saluki/n02091831_3760.jpg"
Neat, it worked! If you wanted to add a main, you may have done so like this:
(defn main [_ ] (-> (get-random-dog random-host) pp))
Now when you run it with './super-repl.bin ./dog-sdk.janet', you see it prints out a URL.
Now lets get to the point of saving this as an image.
Fortunately, this is a trivial addition to what we already have.
Modify your code to look like this:
# Small SDK to get a random dog picture and save it to disk (import lib/net/curl :as client) (def random-host "https://dog.ceo/api/breeds/image/random") (defn get-random-dog [host] (-> (client/json-get host) (get "message"))) (defn get-image-content "Fetches remote dog picture and saves to disk." [url] (def img (client/http-get url)) (spit "dog.jpg" img)) (defn main [_ ] (-> (get-random-dog random-host) get-image-content))
If you messed up or got confused, just peek at the "examples/dog-sdk.janet" file in the release you downloaded.
So, get-image-content is a new function - its job is to invoke the random url endpoint, and make a basic http request to the resultant image URL, then "spit" the code into a new file named dog.jpg.
Run this file and observe your new dog picture in dog.jpg
Open up another new file, and call it dog-gui.janet.
First off, we're going to do the most trivial of GUIs, and just pop up an alert message.
Add this to the file:
# Entrypoint - arg0 is the script, arg1 is the first arg you call it with (defn main [this-file msg] # This pointer stuff is ugly, but here to work with the C bindings for now (IupOpen (int-ptr) (char-ptr)) # Make a new dialog element (def dialog (IupDialog (int-ptr))) # Make a button and append it to the dialog (def button (IupButton msg "NULL")) (IupAppend dialog button) # Add a callback binding to run when clicked - first arg is the element, next is # an IUP signal code - not really used here (iup-set-thunk-callback button "ACTION" (fn [ih c] (pp "Close the IUP...") (IupClose) (const-IUP-CLOSE))) # This prepares to show it (IupShowXY dialog (const-IUP-CENTER) (const-IUP-CENTER)) # Like most/all gui apps, the "main" gui thread must start (IupMainLoop))
Now, try running it with:
./super-repl.bin dog-gui.janet 'Hello World'
Tada! You made a GUI app. At this point, if you don't mind discarding the sample in app.janet, you can open that up and change it as such:
(import dog-gui :as gui) (defn main [& _] (gui/main "app.janet" "Puppy Finder"))
Now, if you run app.bin (no arguments required) it will show your GUI app.
From here on out, I'll assume you've done this step.
Go back to dog-gui.janet and lets get to doing some real work.
First off, ignore what we had previously, lets start from the SDK.
(import dog-sdk :as sdk) (defn get-new-dog [ih c] (thread/new (fn [parent] (sdk/main nil))))
Ok - what is all that about? Well, you'll find out shortly, but if you've ever done GUI programming before, you may realize that you should avoid "blocking" events (network, IO, etc.) in the main GUI thread, or else your GUI will appear to lock up.
Janet has an actor-model threading/concurrency system (message passing), so this is defining a function we'll use later (on the button click) that will pull down and re-save new dog.jpg images in the background in response to the user clicking the button.
If you recall the previous GUI example, the ih = IupHandle and the 'c' is just an int we don't care about for this.
Now, beneat that, lets set up our "show" function:
(defn show  (def label (IupLabel "Click the button to see a new dog.\r Click the dog to close the app")) (def button (IupButton "Find a cute dog" "NULL")) (def button2 (IupButton "" "NULL")) (var dog-image (IupLoadImage "dog.jpg")) (IupSetAttribute button2 "SIZE" "300x300") (IupSetAttributeHandle button2 "IMAGE" dog-image) (def vbox (IupVbox button (int-ptr))) (IupAppend vbox label) (IupAppend vbox button2) (IupSetAttribute vbox "ALIGNMENT" "ACENTER") (IupSetAttribute vbox "GAP" "10") (IupSetAttribute vbox "MARGIN" "10x10") (def dialog (IupDialog vbox)) (IupSetAttribute dialog "TITLE" "Puppy finder") # Handle refreshing the image in a timer so the GUI doesn't lag while image loads (def timer (IupTimer)) (IupSetAttribute timer "TIME" "1000") (iup-set-thunk-callback timer "ACTION_CB" (fn [ih c] (def img (IupLoadImage "dog.jpg")) (IupSetAttributeHandle button2 "IMAGE" img) (IupRedraw button2 0))) (IupSetAttribute timer "RUN" "yes") (iup-set-thunk-callback button "ACTION" get-new-dog) (iup-set-thunk-callback button2 "ACTION" (fn [_ _] (const-IUP-CLOSE))) (IupSetAttribute dialog "SIZE" "330x300") (IupShowXY dialog (const-IUP-CENTER) (const-IUP-CENTER)))
Ok - maybe that was a bit of a doozy all at once, however if you've seen "flexbox" like systems before, you may realize some of this looks familiar.
Also, note the easy string based API - that's due to the lovely cross-platform GUI IUP (http://webserver2.tecgraf.puc-rio.br/iup/en/).
For the most part, these Janet bindings match 1 to 1 with the C bindings in that lib (as do all these Janet bindings I've made - wonder why? It's because of SWIG - I wrote an update to SWIG on my branch here: https://github.com/ahungry/swig to parse C libraries and spit out Janet bindings).
One piece of the above snippet that may stick out is the timer - the reason that exists is so that on a time interval, the image handler for button will be periodically refreshed (every 1 second).
Now, we need to revisit our 'main' for this file:
(defn main [_ _] (IupOpen (int-ptr) (char-ptr)) (show) (IupMainLoop))
And there you go! A complete GUI app - you can now send this to your friends, and all they need to do is run 'app'.
If you developed in a GNU/Linux release and want to test it on Windows, just grab the Windows release and copy your .janet files over (or merge the exe/dll files into this repo). I find WINE makes a great test candidate.
Leave a comment below (or on reddit/hn).