Posted on • Edited on • Originally published atblog.matthewdmiller.net
Learn LambdaNative by Example: Desktop GUI
This tutorial was updated on January 10, 2021 to generate a tone using LambdaNative instead of merely wrapping a Linux-only command-line program called beep that controls the PC speaker. This should now make it possible to follow this tutorial on any OS supported by LambdaNative. This tutorial can also be found in theGitHub repo with the example code. Pull requests for improving the example and tutorial are welcome.
LambdaNative is a cross-platform framework for developing desktop and mobile apps using Scheme (built atop Gambit). Resources on LambdaNative seem to be extremely scarce. I couldn't find a single step-by-step tutorial for using LambdaNative, so hopefully my small contribution will benefit others trying to get started with LambdaNative.
LambdaNative already includes a calculator demo, and frankly, I find making a millionth calculator example to be a little dull. Instead I'll be building a GUI for generating a tone. We're only going to be building a desktop GUI, so we aren't going to be tapping into the full power of LambdaNative. Where it seems LambdaNative would really be useful is enabling you to write cross-platform mobile apps with Scheme! Unfortunately, that is outside the scope of this tutorial. Maybe in the future I'll revisit LambdaNative and use it to create a mobile app.
Installing LambdaNative
The LambdaNative wiki gives alist of dependencies that need to be installed with your distro's package manager before installing LambdaNative. They give anapt
command you can copy and paste to your terminal to install all the needed dependencies on Ubuntu.
Download the most recentrelease.
Unzip the release to a system-wide location such as
/opt
or/usr/local
.
sudounzip lambdanative-*.zip-d /opt
- Rename unzipped directory.
cd /optsudo mvlambdanative* lambdanative
- Create the files
SETUP
andPROFILE
. If you were developing a mobile app, you would need to configure these files for the respective SDKs. Since that is outside the scope of this tutorial, that is left as an exercise for the reader.
cdlambdanativesudo cpSETUP.template SETUPsudo cpPROFILE.template PROFILE
- Edit
scripts/lambdanative
and populate theLAMBDANATIVE
variable with/opt/lambdanative
.
LAMBDANATIVE=/opt/lambdanative
- Place the LambdaNative initialization script in the system path.
sudo ln-s /opt/lambdanative/scripts/lambdanative /usr/bin/lambdanative
- Create and initialize a LambdaNative build directory.
mkdir ~/lambdanativecd ~/lambdanativelambdanative init
Creating a New GUI App
Your freshly initiated build directory will look like this:
apps/modules/configureMakefile
Your app will go in its own subdirectory inapps
. To create a new app:
lambdanative create <appname> <apptype>
The options for<apptype>
areconsole
,gui
, andeventloop
. I created a new GUI app called bleep:
lambdanative create bleep gui
This will create a directory inapps
calledbleep
with several files in it.
Compiling the App
LambdaNative utilizes the GNU Build System.
./configure bleepmakemakeinstall
By default, the build will target the local host. The first time you do this will take awhile, because it is downloading and compiling prerequisites. These prerequisites are cached to speed up subsequent compiles. If you get any errors during the initial build, you probably missed installing a dependency. Install it with your distro's package manager and then try compiling again.
You can also configure in debug mode. You will want to clean the cache withmake scrub
so everything is rebuilt. It will take awhile since everything is being rebuilt.
./configure bleep debugmake scrubmakemakeinstall
In my experience, debug mode didn't help much. Runtime errors are logged to~/Desktop/log/*.txt
.
[SYSTEM] 2019-03-16 00:59:21: Application bleep built 2019-03-16 00:59:14[SYSTEM] 2019-03-16 00:59:21: Git hash[ERROR] 2019-03-16 00:59:22: primordial: (assoc 49 #f): (Argument 2) LIST expected[ERROR] 2019-03-16 00:59:22: HALT
Not very helpful, is it? So I enabled debug mode.
[SYSTEM] 2019-03-16 01:16:44: Application bleep built 2019-03-16 01:16:34[SYSTEM] 2019-03-16 01:16:44: Git hash[ERROR] 2019-03-16 01:16:44: primordial: (assoc 49 #f): (Argument 2) LIST expected[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/primitives.scm line=230 col=21[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/primitives.scm line=273 col=21[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/slider.scm line=80 col=8[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/glgui.scm line=151 col=36[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/glgui.scm line=145 col=11[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/ln_glgui/glgui.scm line=183 col=3[ERROR] 2019-03-16 01:16:44: trace: /opt/lambdanative/modules/eventloop/eventloop.scm line=151 col=9[ERROR] 2019-03-16 01:16:44: HALT
It's definitely longer but still not very helpful. It actually includes line numbers, which appears promising, but they are all line numbers in LambdaNative modules. It doesn't actually trace the error all the way to the line in my app causing the error. I spent a lot more time reading the source of the LambdaNative modules than I would have liked.
And that's when the log file even contained an error. Sometimes the app failed with a segmentation fault and there was no error in the log at all. I often peppered my source with(log-status "reached here")
andtail -f
the log file to debug and isolate errors.
Errors caught at compile time, on the other hand, were much nicer. If an error occurred while compiling, the error message included the line number from my app.
The development workflow is more akin to traditional compiled languages like C. I missed the quick feedback I'm used to while developing Scheme on a REPL. After the initial compile, subsequent compiles are much quicker.
$timemakereal 0m3.877suser 0m2.652ssys 0m0.605s
Four seconds can seem like a long time when you are making a series of small changes or chasing down a bug. There is amodule for REPL editing with Emacs. Since I don't use Emacs, I didn't give it a spin, but if I was developing a larger app, I might give it a try.
Theinstall
step will move the executable to~/Desktop/bleep/bleep
and launch it. This will launch a rectangular window.
This window looks awful lonely. Let's add some widgets!
Coding the App
If your interface will use text (such as labels on buttons), you must include aFONTS
file in your application subdirectory. I just copied theFONTS
file from one of the demos included with LambdaNative.
cdapps/bleepcp /opt/lambdanative/apps/LineDrop/FONTS.
This is what the file looks like:
DejaVuSans.ttf 8 18,25 ascii
For a description of the file format, see the documentation of the file on theLambdaNative wiki.
Your application subdirectory will already contain amain.scm
. This file contains the basic skeleton for a GUI app (the black rectangle above):
;; LambdaNative gui template(definegui#f)(main;; initialization(lambda(wh)(make-window320480)(glgui-orientation-set!GUI_PORTRAIT)(set!gui(make-glgui));; initialize gui here);; events(lambda(txy)(if(=tEVENT_KEYPRESS)(begin(if(=xEVENT_KEYESCAPE)(terminate))))(glgui-eventguitxy));; termination(lambda()#t);; suspend(lambda()(glgui-suspend)(terminate));; resume(lambda()(glgui-resume)));; eof
I started by changing the comment at the top to
;; bleep - GUI for generating a tone made with LambdaNative
The bulk of the skeleton consists of theevent loop. The(main p1 p2 p3 p4 p5)
loop takes five functions as arguments:
Parameter | Description |
---|---|
p1 | Function to be run before the main loop is initialized. This is where you setup the GUI. |
p2 | Main loop function, which is called constantly throughout the application's life. This is where you listen for events like key presses. Since most widgets take a callback, you shouldn't need to do much in this area. |
p3 | Function to be run when the application is to be terminated. |
p4 | Function, which is called when the application is suspended. |
p5 | Function, which is called when the application is resumed. |
The functions supplied in the skeleton for p3, p4, and p5 should be sufficient for most applications. We won't need to touch them.
(make-window540360)(glgui-orientation-set!GUI_LANDSCAPE)
I started by changing the dimensions and orientation of the window. Now let's add some widgets to that initializationlambda
. I copy and pasted the example from the bottom of theslider documentation page.
(set!sl(glgui-slidergui20202806015#fWhiteWhiteOrangeBlacknum_25.fntnum_20.fnt#fWhite))(glgui-widget-set!guisl'showvalue#t)
If you are familiar with Scheme, thatset!
probably made you pause. Too many exclamation marks in my Scheme code always make me nervous. I immediately start wondering if there is a better way to write the code. As it should. Side effects should be avoided when possible. I tried changing theset!
todefine
and got the following error when recompiling:
*** ERROR IN "/home/matthew/lambdanative/apps/bleep/main.scm"@97.5 -- Ill-placed 'define'
Gambit (the underlying Scheme implementation used by LambdaNative) only allowsdefine
s at the beginning of alambda
body. This actually conforms with the R[5-7]RS specs, but I'm used to Scheme implementations (such as Racket, Chicken, and MIT/GNU Scheme) that allowdefine
anywhere in alambda
body. All the LambdaNative examples and demos useset!
, so I used it as well.
We must specify the color for several elements of the slider. LambdaNative doesn't use native widgets but draws its own widgets with OpenGL. I googled "color schemes" for inspiration and specified a few colors at the top of the script above the(main)
loop that I could reference throughout the program.
;; UI color palette(define*background-color*(color-rgb262629))(define*foreground-color*(color-rgb195763))(define*accent-color*(color-rgb1113450))(define*text-color*(color-rgb255255255));; Scale used by slider(define*min-position*0)(define*max-position*2000);; Range of frequencies(define*min-frequency*20)(define*max-frequency*20000)
The variable name*background-color*
is just a Lisp naming convention for global parameters. LambdaNative provides(color-rgb r g b)
for creating colors. I also defined variables for the scale used by the slider and the range of frequencies accepted by beep.
We also need to specify the position and size of the slider. Both are specified in pixels. There aren't percentages or other scalable units you may be familiar with from CSS. There are functions to get the width and height of the window, so you could code the math to make a widget 80% the width of the window. Since we're dealing with a simple example with hard-coded window dimensions, I just hard-coded the position and size as well. Note that you specify the position along the y-axis as pixels from the bottom of the window. This seemed counter intuitive to me, and I continually caught myself trying to specify pixels from the top of the window.
;; Background color(let((w(glgui-width-get))(h(glgui-height-get)))(glgui-boxgui00wh*background-color*));; Frequency slider(set!slider(glgui-slidergui2028050060*min-position**max-position*#fWhite*foreground-color**accent-color*#fascii_18.fntascii_18.fnt#fWhite))(glgui-widget-set!guislider'showlabels#f)
I set a background color for the entire window. The only way I could find to do this was to create aglgui-box
the size of the entire window and set the color of the box. I also renamed the variable fromsl
toslider
. LambdaNative has the tendency to use short, non-descriptive variable names throughout its examples and documentation. I prefer to use more descriptive variable names. Replace the fonts in the example slider code with the fonts we specified in theFONTS
file. I also disabled the slider labels.
The range of frequencies audible by humans is typically between 20 Hz and 20 KHz (we lose the ability to hear some of those higher frequencies as we age). Themusical note A above middle C is 440 Hz. Since A4 serves as a general tuning standard, it seems like a sensible default.
The scale of 20 to 20,000 is so large that 440 wouldn't appear to move the slider at all. Ideally, 440 would fall about the middle of the slider. To achieve this, let's use a logarithmic scale.
I found aStack Overflow answer on how to map a slider to a logarithmic scale. The code given in the answer is JavaScript, but it was easy enough to port to Scheme.
;; Logarithmic scale for frequency (so middle A [440] falls about in the middle);; Adapted from https://stackoverflow.com/questions/846221/logarithmic-slider(definemin-freq(log*min-frequency*))(definemax-freq(log*max-frequency*))(definefrequency-scale(/(-max-freqmin-freq)(-*max-position**min-position*)));; Convert slider position to frequency(define(position->frequencyposition)(inexact->exact(round(exp(+min-freq(*frequency-scale(-position*min-position*)))))));; Convert frequency to slider position(define(frequency->positionfreq)(/(-(logfreq)min-freq)(+frequency-scale*min-position*)))
I created two functions: one that takes the position on the slider and returns the frequency (position->frequency
) and another that takes a frequency and returns the position on the slider (frequency-position
). Now let's set the initial position of our slider with thefrequency->position
function.
(glgui-widget-set!guislider'value(frequency->position440))
Underneath the slider is a text field showing the current frequency, buttons to increase/decrease the frequency by one octave, and a play button.
;; Frequency display(set!frequency-field(glgui-inputlabelgui2102308030"440"ascii_18.fnt*text-color**foreground-color*))(glgui-widget-set!guifrequency-field'alignGUI_ALIGNCENTER)(set!frequency-label(glgui-labelgui2902304030"Hz"ascii_18.fnt*foreground-color**accent-color*))(glgui-widget-set!guifrequency-label'alignGUI_ALIGNCENTER);; Octave buttons(set!lower-button(glgui-button-stringgui1402305030"<"ascii_18.fnt(lambda(gwtxy)#t)))(set!higher-button(glgui-button-stringgui3502305030">"ascii_18.fnt(lambda(gwtxy)#t)));; Play button(set!play-button(glgui-button-stringgui2301258050"Play"ascii_25.fnt(lambda(gwtxy)#t)))
That last argument toglgui-button-string
is a callback function. This is a function that is called when the button is pressed. I'm just trying to get the widgets layed out right now. I don't yet care about the function of the button, so I used anonymous functions (lambdas) that don't do anything for now.
The buttons do come with some default styling, but you'll probably want to tweak the look to fit your color scheme and UI design. We can useglgui-widget-set!
to set parameters of a widget. Buttons have various parameters that can be set such as'button-normal-color
and'button-selected-color
.
(glgui-widget-set!guiplay-button'button-normal-color*foreground-color*)(glgui-widget-set!guiplay-button'button-selected-color*accent-color*)(glgui-widget-set!guiplay-button'solid-color#t)(glgui-widget-set!guiplay-button'rounded#f)
That seems like a lot to type (or copy and paste) for each button. With CSS I'm able to define a style for all buttons or apply a class to buttons. I used afor-each
loop to loop through all the buttons and apply the above styling:
;; Style buttons(for-each(lambda(button)(glgui-widget-set!guibutton'button-normal-color*foreground-color*)(glgui-widget-set!guibutton'button-selected-color*accent-color*)(glgui-widget-set!guibutton'solid-color#t)(glgui-widget-set!guibutton'rounded#f))(listlower-buttonhigher-buttonplay-button))
At this point, we are starting to have a nice looking interface, but it doesn't do anything. If you click the buttons or slide the slider, nothing happens. While the buttons take a callback function parameter, I couldn't find a way to wire up the slider to a function. I read theglgui-slider
documentation page several times searching for clues.
Finally, I resorted to looking at the source code forglgui-slider
. Each of the widget documentation pages link directly to their implementation in the LambdaNative GitHub repo. I already mentioned that I ended up reading the LambdaNative source more than I would have liked for debugging. Documentation is one area where LambdaNative really could stand to improve. I scannedslider.scm
and discovered it had a'callback
parameter. I created a function that would set the frequency displayed in theglgui-inputlabel
to the one that corresponded to the position of theglgui-slider
.
;; Link slider to text field display of frequency(define(adjust-frequency)(glgui-widget-set!guifrequency-field'label(number->string(position->frequency(glgui-widget-getguislider'value)))))
and wired it up to the slider:
(glgui-widget-set!guislider'callback(lambda(parentwidgeteventxy)(adjust-frequency)))
A callback function takes five arguments. In the code examples in the LambdaNative documentation, these always appeared as(lambda (g w t x y))
. These one-letter variables aren't very descriptive, and the arguments of the callback functions don't appear to be documented. Through experimentation and reading the source code and examples, I worked out the following:
Parameter | Description |
---|---|
g | The [G]UI the widget belongs to. I used the nameparent for this variable in my callback functions. |
w | The [w]idget that triggered the callback function. I used the namewidget for this variable in my callback functions. |
t | The [t]ype of event. I used the nameevent for this variable in my callback functions. |
x | First argument of event (x coordinate in pixels, keyboard character, etc.) |
y | Second argument of event (y coordinate in pixels, modifier flags, etc.) |
The callback function is only called once the user releases the slider handle. I want the user to get feedback as they drag the slider. You can write your own event handling code in thelambda
that forms the second parameter of(main)
. The generated skeleton already includes code to terminate the application when theEsc
key is pressed. I added some code to calladjust-frequency
when the slider handle is being dragged:
;; events(lambda(txy)(if(=tEVENT_KEYPRESS)(begin(if(=xEVENT_KEYESCAPE)(terminate))));; Also update frequency when dragging slider (callback is only on release)(if(and(glgui-widget-getguislider'downval)(=tEVENT_MOTION))(adjust-frequency))(glgui-eventguitxy))
By looking at the implementation ofglgui-slider
inslider.scm
, I noticed that LambdaNative was setting a'downval
parameter whenever the user was holding down the mouse button on the slider handle. Whenever that parameter is true, I listen for anEVENT_MOTION
event to calladjust-frequency
.
I replaced the anonymous lambdas in the octave button declarations with callback functions calleddecrease-octave
andincrease-octave
. Anoctave is "the interval between one musical pitch and another with double its frequency."
;; Set frequency slider and display(define(set-frequencyfreq)(glgui-widget-set!guislider'value(frequency->positionfreq))(glgui-widget-set!guifrequency-field'label(number->stringfreq)));; Buttons increase and decrease frequency by one octave(define(adjust-octavemodifier)(let((new-freq(*(string->number(glgui-widget-getguifrequency-field'label))modifier)))(if(and(>=new-freq*min-frequency*)(<=new-freq*max-frequency*))(set-frequencynew-freq))))(define(decrease-octaveparentwidgeteventxy)(adjust-octave0.5))(define(increase-octaveparentwidgeteventxy)(adjust-octave2))
The'aftercharcb
callback ofglgui-inputlabel
is called after each character is typed or deleted. We can use this to update the slider as a user enters a frequency. What if a user (and you know they will) enters a number higher than 20,000 or a letter? We need a function that will only allow numbers within a given range.
;; Only allow numbers within range of min-value and max-value(define(num-onlymin-valuemax-valueold-value)(lambda(parentwidget)(let*((current-value(glgui-widget-getparentwidget'label))(current-numified(string->numbercurrent-value)))(if(or(=(string-lengthcurrent-value)0); Allow field to be empty(andcurrent-numified(>=current-numifiedmin-value)(<=current-numifiedmax-value)))(set!old-valuecurrent-value)(glgui-widget-set!parentwidget'labelold-value)))))
If the user types a character that makes the value invalid, we want to revert to the last known good value. To accomplish this, I used a closure to remember the last known value. Many programming languages today have closures, but Scheme practically invented them. A closure enables variables to be associated with a function that persist through all the calls of the function.
Now we can wire theglgui-inputlabel
callback up to these functions.
(set!frequency-range(num-only*min-frequency**max-frequency*(glgui-widget-getguifrequency-field'label)))(glgui-widget-set!guifrequency-field'aftercharcb(lambda(parentwidgeteventxy)(frequency-rangeparentwidget)(let((freq(string->number(glgui-widget-getparentwidget'label))))(iffreq(glgui-widget-set!parentslider'value(frequency->positionfreq))))))
We call thenum-only
closure specifying the allowed range and initial value which returns a new function that can be used in the callback. After we make sure there are no high jinks going on with the value using the function created by the closure (frequency-range
), we update the position of the slider using the current value of the text field.
We can use thenum-only
closure again to create a field to specify the duration of the beep in milliseconds:
;; General Controls(glgui-labelgui20408030"Duration"ascii_18.fnt*foreground-color*)(set!duration-field(glgui-inputlabelgui110408030"200"ascii_18.fnt*text-color**foreground-color*))(glgui-widget-set!guiduration-field'alignGUI_ALIGNCENTER)(set!duration-range(num-only1600000(glgui-widget-getguiduration-field'label)))(glgui-widget-set!guiduration-field'aftercharcb(lambda(parentwidgeteventxy)(duration-rangeparentwidget)))(glgui-labelgui195404030"ms"ascii_18.fnt*foreground-color*)
Frequency is rather abstract. Let's also give the user the ability to select a musical note. We can store the corresponding frequencies for A4-G4 in a table.
;; Notes -> frequency (middle A-G [A4-G4]);; http://pages.mtu.edu/~suits/notefreqs.html(definenotes(list->table'((0.440.00); A(1.493.88); B(2.261.63); C(3.293.66); D(4.329.63); E(5.349.23); F(6.292.00)))); G
We'll give the user a drop-down menu. Whenever a note is selected from the drop-down menu, we'll look up the frequency in the table and set it using theset-frequency
helper function we created for the octave buttons.
(glgui-labelgui410406030"Note"ascii_18.fnt*foreground-color*)(set!note(glgui-dropdownboxgui470405030(map(lambda(str)(lambda(lglwxywhs)(ifs(glgui:draw-boxxywh*foreground-color*))(glgui:draw-text-left(+x5)y(-w10)hstrascii_18.fnt*text-color*)))(list"A""B""C""D""E""F""G"))*accent-color**foreground-color**accent-color*))(glgui-widget-set!guinote'scrollcolor*accent-color*)(glgui-widget-set!guinote'callback(lambda(parentwidgeteventxy)(set-frequency(table-refnotes(glgui-widget-getparentwidget'current)))))
Now, let's make some noise. LambdaNative has a rtaudio module. We'll use that to generate a tone with a sine wave. Edit theMODULES
file in your applications subdirectory and add rtaudio to the list. The Scheme API of the rtaudio module consists of essentially just two functions:rtaudio-start
andrtaudio-stop
. You must first register four real-time hooks (an initialization hook, input hook, output hook, and close hook) in a chunk of C code embedded within your Scheme code. I wish the rtaudio module had an API that allowed implementing these hooks in pure Scheme. Thankfully theDemoRTAudio app included with LambdaNative implements a sine wave, and I was able to copy and paste most of what I needed from there without spending a lot of time trying to figure out how to write a sine wave in C myself.
;; Register C-side real-time audio hooks(c-declare#<<end-of-c-declare#include<math.h>voidrtaudio_register(void(*)(int),void(*)(float),void(*)(float*,float*),void(*)(void));doublef;doublesrate=0;floatbuffer;voidmy_realtime_init(intsamplerate){srate=(double)samplerate; buffer=0; }voidmy_realtime_input(floatv){}voidmy_realtime_output(float*v1,float*v2){staticdoublet=0;buffer=0.95*sin(2*M_PI*f*t);*v1=*v2=(float)buffer;t+=1/srate;}voidmy_realtime_close(){buffer=0; }end-of-c-declare)(c-initialize"rtaudio_register(my_realtime_init,my_realtime_input,my_realtime_output,my_realtime_close);")
Thebasic formula for a sine wave is A sin(2πft) whereA is amplitude,f is frequency, andt is time. We need a way to pass the frequency from our slider in the Scheme to the output hook in the C. Gambit scheme has ac-lambda
special form that makes it possible to create a Scheme function that is a representative of a C function or code sequence.
(definertaudio-frequency(c-lambda(double)void"f=___arg1;"))
This creates a Scheme function that sets the f variable in our C chunk. Now let's create a Schem function that will set the frequency and start and stop the real-time audio subsystem.
;; Generate a tone using the rtaudio module(define(generate-toneparentwidgeteventxy); Make sure neither frequency or duration were left blank(if(=(string-length(glgui-widget-getparentfrequency-field'label))0)(set-frequency1))(if(=(string-length(glgui-widget-getparentduration-field'label))0)(glgui-widget-set!parentduration-field'label"1"))(rtaudio-frequency(exact->inexact(string->number(glgui-widget-getparentfrequency-field'label))))(rtaudio-start441000.5)(thread-sleep!(/(string->number(glgui-widget-getparentduration-field'label))1000))(rtaudio-stop))
When playing a note such as B4 (493.88 Hz) that has a decimal point, the type passed from Scheme to C lines up with the C typefloat
, but when passing an integer (such as 440), it will cause an error. Theexact->inexact
conversion forces Scheme to pass the value along as afloat
. Wire this up to the play button, and you're ready to make some noise.
(set!play-button(glgui-button-stringgui2301258050"Play"ascii_25.fntgenerate-tone))
LambdaNative has a lot of rough edges, not least of which is the documentation (or lack thereof). Looking at the source code for a widget seems to be the only way to determine all the parameters available for that widget. If you're like me, being able to write mobile apps in Lisp is a dream come true! LambdaNative may not be the smoothest development experience right now, but I hope to revisit it again in the future. It is being actively developed (and has the backing of a university research team), so my hopes are high for the future of LambdaNative.
You can check out the entire example onGitHub. This started as a personal learning project to explore the state of GUI programming in Lisp and has become a series of tutorials on building GUIs with various dialects of Lisp.
Top comments(1)
For further actions, you may consider blocking this person and/orreporting abuse