You don't need a backend to use CLIM

Tagged as lisp, clim

Written on 2021-05-14 by Daniel 'jackdaniel' KochmaƄski

Table of Contents

  1. Introduction
    1. The boilerplate
    2. Platform agnostic features
  2. Step 0: you don't need a backend to use CLIM
    1. The command table
    2. The application frame
    3. The frame manager
  3. Summary

Introduction

The purpose of this document is to illustrate various strategies for writing McCLIM backends. I will try to cover various CLIM use scenarios as well as some gotchas waiting for the beginner implementer.

The CLIM specification has VIII parts. Conceptually we could group it into a few major groups:

  • platform agnostic: geometry and designs
  • low-level primitives: windowing and drawing
  • extended i/o: streams, recording and presentations
  • user interface: panes and gadgets
  • applications: commands, frames and frame managers

This separation is not a definitive. It is possible to use command tables without frames and to create output records without using streams. It is also not reflecting the separation presented in the specification.

The default implementation for the standard CLIM classes use abstractions provided by "lower" groups - i.e panes are built on streams (extended i/o) and sheets (low-level primitives appropriately). It is possible to provide alternative implementation which are self-sufficient at the expense of missing some CLIM features defined "below" - like drawing and typed output.

The boilerplate

I will assume that the extension package-local-nicknames is available in the implementation of your choosing. The package nicknames I'm going to use are:

  • m: clim
  • e: clim-extensions
  • b: clim-backend
  • a: alexandria
  • t: local-time
  • h: hunchenoot

All snippets are assumed to be run in the package #:eu.turtleware.dimwit unless stated otherwise.

CL-USER> (ql:quickload '("mcclim" "alexandria" "hunchentoot" "local-time"))
("mcclim" "alexandria" "hunchentoot" "local-time")
CL-USER> (defpackage #:eu.turtleware.dimwit
           (:use #:clim-lisp)
           (:local-nicknames (#:m #:clim)
                             (#:e #:clim-extensions)
                             (#:b #:clim-backend)
                             (#:a #:alexandria)
                             (#:t #:local-time)
                             (#:h #:hunchentoot)))
#<PACKAGE "EU.TURTLEWARE.DIMWIT">
CL-USER> (in-package #:eu.turtleware.dimwit)
#<PACKAGE "EU.TURTLEWARE.DIMWIT">
DIMWIT>

Platform agnostic features

Some features of McCLIM may be used without any backend. Geometry protocols are a good example.

DIMWIT> (m:region-intersection (m:make-rectangle* 0 0 100 100)
                               (m:make-rectangle* 50 50 200 200)) 
#<M:STANDARD-BOUNDING-RECTANGLE X 50:100 Y 50:100>

Other features provide reusable abstractions that are used by CLIM itself. For example presentation types and presentation generic functions may be used independently:

DIMWIT> (m:presentation-typep '(1 2 3 4) '(sequence integer)) ; -> T
DIMWIT> (m:presentation-typep '(1 2 3 t) '(sequence integer)) ; -> NIL

Output recording is used to implement the extended input and the extended output streams, but it can be used as a base of different abstractions that associate information with 2-dimensional objects.

(defclass darts-circle (m:basic-output-record)
  ((points
    :initarg :points
    :reader points)
   (radius
    :initarg :radius
    :reader radius)))

(defmethod m:output-record-refined-position-test ((record darts-circle) x y)
  (let ((r (radius record)))
    (m:region-contains-position-p (m:make-ellipse* 0 0 r 0 0 r) x y)))

(defun make-dart-circle (points radius)
  (let ((-radius (- radius)))
    (make-instance 'darts-circle :points points :radius radius
                                 :x1 -radius :y1 -radius
                                 :x2  radius :y2  radius)))

(defclass dart (m:basic-output-record) ())

(defun make-dart (x y)
  (make-instance 'dart :x-position x :y-position y))

(defun make-board (number-of-circles)
  (check-type number-of-circles (integer 1))
  (loop with board = (make-instance 'm:standard-tree-output-record)
        for circle from number-of-circles above 0
        for points from 10 by 10
        for radius = (* circle 10)
        for record = (make-dart-circle points radius)
        do (m:add-output-record record board)
        finally (return board)))

(defun throw-dart (board x y &aux (robin nil))
  (m:map-over-output-records-containing-position
   (lambda (record)
     (etypecase record
       (darts-circle
        (let ((points (points record)))
          (format t "~d points!~%" points)
          (when (null robin)
            (m:add-output-record (make-dart x y) board))
          (return-from throw-dart points)))
       (dart
        (format t "Uh oh, we have a Robin Hood! ~a.~%" record)
        (setf robin t))))
   board x y)
  (m:add-output-record (make-dart x y) board)
  (format t "Phew.~%")
  0)

(defun remove-darts (board)
  (let ((to-remove '()))
    (m:map-over-output-records
     (lambda (record)
       (when (typep record 'dart)
         (push record to-remove)))
     board)
    (map () (a:rcurry #'m:delete-output-record board) to-remove)))

Presentation methods that are applicable to the view textual-view should in principle use only the standard functions of the character streams like read-char and write-char so they should be safe to use "outside" of McCLIM. That said, this mechanism is currently buggy on streams that are not aware of the CLIM protocols.

Step 0: you don't need a backend to use CLIM

Even on platforms that do not have a backend, it is possible to use properly structured CLIM applications. The application requires:

  1. The command table. It contains all commands specific to the application. A command is a function that implements the application logic operation.

  2. The application frame. It is a dynamic state bound to the variable m:*application-frame* which provides the processing context. The application frame usually implies the command table.

  3. The utility to switch between different applications (change the program context). It is the responsibility of the frame manager. For example on desktop it may be achieved by pressing Alt+Tab.

In degenerate cases, a command table may contain commands that don't require any context - in that case there is no need for the application frame. Similarly, it is possible that the program doesn't allow switching the context, so there is no need for a frame manager.

The command table

I'll start with the first degenerate case. A default command table doesn't depend on any specific context. It allows you to only print the time, echo a string and evaluate the expression.

(m:define-command-table default-command-table)

(m:define-command (com-get-time :command-table default-command-table :name t)
    ()
  (t:format-rfc1123-timestring t (t:now)))

(m:define-command (com-echo :command-table default-command-table :name t)
    ((string 'string))
  (format t "~a" string))

(m:define-command (com-eval :command-table default-command-table :name t)
    ((expression 'm:expression))
  (eval expression))

The command table, among other things, contains commands, parsers and the presentation translators. The command parsers are responsible for reading arguments, and the presentation translators provide means to convert argument of one type to another. I will ignore for now parsers and translators and use commands directly because they are implemented as functions. In other words they may be invoked from the REPL.

DIMWIT> (com-get-time)
Fri, 14 May 2021 14:48:54 +0200
"Fri, 14 May 2021 14:48:54 +0200"
DIMWIT> (com-eval '(print "Yo ho and a bottle of rum."))
"Yo ho and a bottle of rum." 
"Yo ho and a bottle of rum."
DIMWIT> (com-echo "Yo ho and two bottles of rum.")
Yo ho and two bottles of rum.
NIL   

It may seem not very useful to have functions with extra steps. The primary reason to have command tables is to make available only a certain set of operations to the human operator. Writing the command loop is simple1:

(defun simple-repl (ct &optional (prompt (m:command-table-name ct))
                    &aux (* nil))
  (labels ((list-commands ()
             (format t "Commands in ~a:~%" ct)
             (m:map-over-command-table-commands #'print ct))
           (assert-command (command)
             (when (eq command 'help)
               (list-commands)
               (return-from assert-command `(climi::com-null-command)))
             (let ((name (car command)))
               (unless (symbolp name)
                 (error "~a does not designate a command." name))
               (unless (m:command-accessible-in-command-table-p name ct)
                 (error "Command ~a not found." name))
               command)))
    (loop
      (format t "~&~a> " prompt)
      (handler-case
          (let* ((cmd (assert-command (read)))
                 (results (multiple-value-list (eval cmd))))
            (format t "~&---~%~{~a~%~}" results)
            (setf * (first results)))
        (m:frame-exit ()
          (format t "~&---~%Good Bye!~%")
          (return))
        (serious-condition (c)
          (format t "~&---~%ERROR:~%~a" c))))))

If we type "help" in the prompt the command loop will print available commands. Note that the default-command-table inherits from the m:global-command-table, so there are some commands inherited from there - most notably climi::com-quit.

DIMWIT> (simple-repl (m:find-command-table 'default-command-table))

DEFAULT-COMMAND-TABLE> help
Commands in #<STANDARD-COMMAND-TABLE DEFAULT-COMMAND-TABLE {102029B323}>:

COM-GET-TIME 
COM-ECHO 
COM-EVAL 
CLIM-INTERNALS::COM-NULL-COMMAND 
CLIM-INTERNALS::COM-HELP 
CLIM-INTERNALS::COM-QUIT 
CLIM-INTERNALS::COM-DESCRIBE 
CLIM-INTERNALS::COM-DESCRIBE-PRESENTATION 
---
NIL
DEFAULT-COMMAND-TABLE> (com-get-time)
Fri, 14 May 2021 15:33:31 +0200
---
Fri, 14 May 2021 15:33:31 +0200
DEFAULT-COMMAND-TABLE> (com-eval `(list 1 2 3))

---
(1 2 3)
DEFAULT-COMMAND-TABLE> (climi::com-describe *)
1,2,3 is of type CONS
---
DEFAULT-COMMAND-TABLE> (list 1 2 3)

---
ERROR:
Command LIST not found.
DEFAULT-COMMAND-TABLE> (climi::com-quit)

---
Good Bye!
NIL

The cool thing about command tables is that they may be reused. For instance you can use it with a new application frame using the existing backend. Using the proper machinery to run the application frame gives you command names, autocompletion, pointer-sensitive output etc. Note that after executing the command you may not see the values that it returns - from the CLIM perspective commands are called for side effects that affect a display function.

(m:define-application-frame default-frame () ()
  (:command-table (default-command-table)))
(m:find-application-frame 'default-frame)

The application frame

An application frame instance is the context for its commands. When the frame is defined, then by default a command table with the same name is created and a new macro define-{frame-name}-command is introduced to define commands in that command table. It is possible for a frame to use another command table.

The example application is a todo list. It has panes and it allows changing the state of each task. Note how I subclass the m:textual-view so that I can introduce the color without sabotaging a textual interface. Another new thing is a presentation to command translator making a presentation "activate" the command when clicked. All user interactions are specified as commands2.

(defclass task ()
  ((state
    :type (member :todo :done)
    :initarg :state
    :accessor state)
   (title
    :type string
    :initarg :title
    :accessor title)))

(defclass textual-view-with-colors (m:textual-view) ())
(defconstant +textual-view-with-colors+
  (make-instance 'textual-view-with-colors))

(m:define-presentation-method m:present
    (object (type task) stream (view m:textual-view) &key)
  (format stream "[~s | ~a]" (state object) (title object)))

(m:define-presentation-method m:present
    (object (type task) stream (view textual-view-with-colors) &key)
  (m:with-drawing-options (stream :ink (ecase (state object)
                                         (:done m:+dark-green+)
                                         (:todo m:+dark-red+)))
    (format stream "~a " (state object)))
  (format stream "| ~a" (title object)))

(m:define-application-frame todo-list ()
  ((tasks :initarg :tasks :accessor tasks))
  (:panes (int :interactor
               :default-view +textual-view-with-colors+
               :text-margins '(:left 5 :top 3))
          (app :application
               :default-view +textual-view-with-colors+
               :text-margins '(:left 10 :top 5)
               :display-function (lambda (frame stream)
                                   (declare (ignore frame stream))
                                   (show-tasks))))
  (:layouts (default (m:vertically () app int)))
  (:command-table (todo-list :inherit-from (default-command-table)))
  (:default-initargs :tasks nil))

(define-todo-list-command (add-task :name t)
    ((title 'string))
  (push (make-instance 'task :title title :state :todo)
        (tasks m:*application-frame*)))

(define-todo-list-command (delete-task :name t)
    ((task 'task))
  (let ((frame m:*application-frame*))
    (setf (tasks frame) (remove task (tasks frame)))))

(define-todo-list-command (change-state :name t)
    ((task 'task))
  (setf (state task)
        (ecase (state task)
          (:done :todo)
          (:todo :done))))

(define-todo-list-command (show-tasks :name t)
    ()
  (m:format-textual-list
   (tasks m:*application-frame*)
   (lambda (object stream)
     (m:present object 'task :stream stream))
   :separator #\newline))

(m:define-presentation-to-command-translator click-change-state
    (task change-state todo-list)
    (object)
  (list object))

Time to use the application. When defined commands are called, they use the implicit context provided by the m:*application-frame*. A frame is a standard object that is stored in the variable *todo* so it may be used like any other standard class instance. All we need is to bind the frame context and call the simple-repl defined earlier.

(defun command-loop (frame)
  (let ((m:*application-frame* frame)
        (ct (m:frame-command-table frame)))
    (simple-repl ct)))

(defparameter *todo-list* (m:find-application-frame 'todo-list))

(command-loop *todo-list*)

The frame manager

I will now define a application dedicated to managing frames for the command loop. The best way to do that is to define an application which is a frame manager at the same time3.

By "entering frames" you change the computing context, so it is possible to manage multiple todo lists. An interesting aspect of this is that when the frame manager is also a frame then you may also manage frame managers and create hierarchies of contexts.

(m:define-application-frame proxy-frame-manager
    (e:headless-frame-manager m:standard-application-frame)
  ((frames
    :initform nil
    :accessor frames
    :reader m:frame-manager-frames)
   (destination-frame-manager
    :initarg :frame-manager
    :reader frame-manager))
  (:command-table (frame-manager :inherit-from (default-command-table)))
  (:default-initargs :port '#:port
                     :frame-manager (m:find-frame-manager)))

(defmethod m:adopt-frame ((fm proxy-frame-manager) frame)
  (pushnew frame (frames fm))
  (m:adopt-frame (frame-manager fm) frame))

(defmethod m:disown-frame ((fm proxy-frame-manager) frame)
  (a:removef (frames fm) frame)
  (m:disown-frame (frame-manager fm) frame))

(define-proxy-frame-manager-command (make-frame :name t)
    ((frame 'symbol))
  (adopt-frame (m:make-application-frame frame)))

(define-proxy-frame-manager-command (adopt-frame :name t)
    ((frame 'clim:application-frame))
  (m:adopt-frame m:*application-frame* frame))

(define-proxy-frame-manager-command (disown-frame :name t)
    ((frame 'clim:application-frame))
  (m:disown-frame m:*application-frame* frame))

(define-proxy-frame-manager-command (enter-frame :name t)
    ((frame 'clim:application-frame))
  (command-loop frame))

(define-proxy-frame-manager-command (start-frame :name t)
    ((frame 'clim:application-frame))
  (clim-sys:make-process (lambda () (m:run-frame-top-level frame))))

(define-proxy-frame-manager-command (show-frames :name t) ()
  (let ((frames (m:frame-manager-frames m:*application-frame*)))
    (dolist (frame frames)
      (m:present frame)
      (terpri))
    frames))

(command-loop (m:make-application-frame 'frame-manager))

Summary

In this tutorial I've examplified a minimalistic "no-backend" that allows running CLIM applications oriented around commands and contexts. The simplicity of it shows that writing applications in a way that defines user interactions in terms of user commands, and after that adding more flashy elements on top of them has a strong portability advantage.

This is a first part of the document I'm working on, meant to provide a guide for writing McCLIM backends. The next part will explore the idea of a fully fledged non-standard frame managers arranged in a tree hierarchy that are capable of adopting standard application frames.

If you want to discuss CLIM-related topics you should join the IRC channel #clim @ libera.chat (or you may contact me directly via other channels).

Footnotes

1 This example is by no means safe - arguments are still evaluated. Moreover we just use read and *read-suppress is not changed.

2 A shorter version of this application wouldn't have a custom view, a translator and the explicit options :panes and :layouts.

3 It is a frame manager only by name - it doesn't implement the necessary protocols and doesn't run the frame top-level loop itself.