Charming CLIM tutorial part 2 – Rethinking The Output
Tagged aslisp, foss
, console
, clim
Written on 2020-06-19 by Daniel 'jackdaniel' Kochmański
This is the second part of a tutorial about building a McCLIM backend
for the terminal starting from zero. After readingthe first issue we
should have a good grasp of how to control and read input from the
terminal. It is time to refine things for efficiency and ease of use.
If you didn't follow the last part, here is the archive with thesource code which will serve as a starter for this post.
Right now our I/O is synchronous with the terminal. When we call out
or ctl
, the characters are sent to it immediately, and we read the
input with read-input
until the stream is empty. The model
introduced in the previous post is certainly simple, but simple models
tend to be hard to use efficiently. We'll settle on easy
instead.
In this post I'll focus on the output.
Layered abstraction
All problems in computer science can be solved by another level of indirection. -- David Wheeler
We'll build a convenient abstraction for writing the console
applications. It would be a shame, though, if we had abandoned means
to manipulate the terminal directly. The library will present
different APIs, so it is possible to cater to the programmer needs. In
principle it is not feasible to use two different abstractions
simultaneously because higher abstractions build upon lower ones and
things may go awry.
... except for the problem of too many layers of indirection. -- Unknown
For now we'll define two packages: eu.turtleware.charming-clim/l0
and eu.turtleware.charming-clim.terminal/l1
with different levels of
abstraction for accessing the terminal. They are meant only as means
to export symbols, all implementation is done in a single package.
This practice greatly improves a quality of life of the person who
works with Common Lisp packages. Now create a file packages.lisp
.
(defpackage #:eu.turtleware.charming-clim/l0
(:export #:init-terminal
#:close-terminal
#:*terminal*
#:put #:esc #:csi #:sgr
#:read-input #:keyp
#:reset-terminal
#:clear-terminal
#:clear-line
#:set-foreground-color
#:set-background-color
#:with-cursor-position
#:set-cursor-position
#:save-cursor-position
#:restore-cursor-position
#:request-cursor-position
#:cursor-up
#:cursor-down
#:cursor-right
#:cursor-left
#:set-cursor-visibility
#:set-mouse-tracking))
(defpackage #:eu.turtleware.charming-clim/l1
(:export #:with-console #:out #:ctl))
(defpackage #:eu.turtleware.charming-clim
(:use #:common-lisp
#:eu.turtleware.charming-clim/l0
#:eu.turtleware.charming-clim/l1))
We'll take this opportunity to make function naming more consistent
and introduce the cursor manipulation utilities. Rename functions
-
(setf cursor-visibility)
-> set-cursor-visibility
-
(setf mouse-tracking)
-> set-mouse-tracking
-
(setf alt-is-meta)
-> set-alt-is-meta
and add escape sequences for manipulating the cursor. Don't forget to
change references to renamed functions in other parts of the code (in
the macro ctl
and in functions initialize-instance
, (setf ptr)
and (setf cvp)
.
(macrolet ((moveit (endch)
`(if (= n 1)
(csi ,endch)
(csi n ,endch))))
(defun cursor-up (&optional (n 1)) (moveit "A"))
(defun cursor-down (&optional (n 1)) (moveit "B"))
(defun cursor-right (&optional (n 1)) (moveit "C"))
(defun cursor-left (&optional (n 1)) (moveit "D")))
(defun set-cursor-visibility (visiblep)
(if visiblep
(csi "?" 2 5 "h")
(csi "?" 2 5 "l")))
;;; (csi ? tracking ; encoding h/l)
;;; tracking: 1000 - normal, 1002 - button, 1003 - all motion
;;; 1004 - focus in/out
;;; encoding: 1006 - sgr encoding scheme
(defun set-mouse-tracking (enabledp)
(if enabledp
(csi "?" 1003 ";" 1006 "h")
(csi "?" 1003 "l")))
(defun set-alt-is-meta (bool)
(if bool
(setf +alt-mod+ +meta-mod+)
(setf +alt-mod+ +alt-mod*+)))
From now on, when we talk about the low level abstraction, we'll call
the destination object a "terminal", while when we talk about the high
level abstraction, we'll call its destination object a "console".
Rename the following symbols
-
*console-io*
-> *terminal*
-
init-console
-> init-terminal
-
close-console
-> close-terminal
-
clear-console
-> clear-terminal
-
reset-console
-> reset-terminal
and replace all references in the source code to use new symbols. Move
the variable *terminal*
and functions init-terminal
and close-terminal
to the top (below the foreign function definitions).
We'll slightly refactor set-*-color
functions. Instead of accepting
each color separately, functions will consume the number representing
a color RGBA value. For instance #ff000000 for a color red. The alpha
channel will be ignored for now, but having this component will save
us another change of a data representation format.
(defun set-foreground-color (color)
(let ((r (ldb '(8 . 24) color))
(g (ldb '(8 . 16) color))
(b (ldb '(8 . 8) color))
(a (ldb '(8 . 0) color)))
(declare (ignore a))
(sgr "38;2;" r ";" g ";" b)))
(defun set-background-color (color)
(let ((r (ldb '(8 . 24) color))
(g (ldb '(8 . 16) color))
(b (ldb '(8 . 8) color))
(a (ldb '(8 . 0) color)))
(declare (ignore a))
(sgr "48;2;" r ";" g ";" b)))
and fix all references in the source code:
(defmacro ctl (&rest operations)
`(#|...|#
(:fgc `(setf (fgc *console*) ,@args))
(:bgc `(setf (bgc *console*) ,@args))))
(defclass console ()
#|...|#
(:default-initargs :fgc #xffa0a000 :bgc #x22222200))
(defmethod initialize-instance :after
((instance console) &key fgc bgc pos cvp ptr)
#|...|#
(set-foreground-color fgc)
(set-background-color bgc))
(defmethod (setf fgc) :after (rgba (instance console))
(set-foreground-color rgba))
(defmethod (setf bgc) :after (rgba (instance console))
(set-background-color rgba))
(defun show-screen ()
#|...|#
(out (:bgc #x00000000 :fgc #xbb000000))
(out (:bgc #x00000000
:fgc (alexandria:random-elt '(#x00444400 #x00444400 #x00664400)))))
We'll now move parts related to the console to a separate file console.lisp
in this order:
-
the variable
*console*
and the macro with-console
-
clipping code (the clip variables and operators
inside
and with-clipping
)
-
macros
letf
, out
and ctl
-
functions
clear-rectangle
, get-cursor-position
and update-console-dimensions
-
the class
console
and its methods
Finally, the example code will be put in a file example.lisp
. Move
functions show-screen
and start-display
there.
The defsystem
form in the file eu.turtleware.charming-clim.asd
now
looks like this:
(defsystem "eu.turtleware.charming-clim"
:defsystem-depends-on (#:cffi)
:depends-on (#:alexandria #:cffi #:swank)
:components ((:cfile "raw-mode")
(:file "packages")
(:file "terminal" :depends-on ("packages"))
(:file "console" :depends-on ("packages" "terminal"))
(:file "example" :depends-on ("packages" "console"))))
Virtual buffers
The console object has many responsibilities, so refactoring it to
inherit from a class which implements only parts related to the output
makes sense. That will also be useful when we decide to add yet
another layer of indirection. When implementing the buffer
class
we'll also fix an unfortunate position representation as a cons
, and
the clip area specification. Create a file output.lisp
and add it to
the asd file.
(defsystem "eu.turtleware.charming-clim"
:defsystem-depends-on (#:cffi)
:depends-on (#:alexandria #:cffi #:swank)
:components ((:cfile "raw-mode")
(:file "packages")
(:file "terminal" :depends-on ("packages"))
(:file "output" :depends-on ("packages"))
(:file "console" :depends-on ("packages" "output" "terminal"))
(:file "example" :depends-on ("packages" "console"))))
Macros out
and ctl
will operate on the current virtual buffer. In
order to do that, we'll define a protocol which must be implemented by
all virtual buffers. with-clipping
now becomes a convenience macro
expanding to a generic function invoke-with-clipping
. A macro with-buffer
is introduced to bind the current buffer, which is bound
to the variable *buffer*
.
(defgeneric put-cell (buffer row col ch fg bg))
(defgeneric fgc (buffer))
(defgeneric (setf fgc) (fgc buffer)
(:argument-precedence-order buffer fgc))
(defgeneric bgc (buffer))
(defgeneric (setf bgc) (bgc buffer)
(:argument-precedence-order buffer bgc))
(defgeneric row (buffer))
(defgeneric (setf row) (row buffer)
(:argument-precedence-order buffer row))
(defgeneric col (buffer))
(defgeneric (setf col) (col buffer)
(:argument-precedence-order buffer col))
(defgeneric rows (buffer))
(defgeneric cols (buffer))
(defgeneric inside-p (buffer row col))
(defgeneric invoke-with-clipping (buffer continuation
&rest opts
&key r1 c1 r2 c2 fn))
(defmacro with-clipping ((buffer &rest opts) &body body)
(let ((fn (gensym)))
`(flet ((,fn () ,@body))
(declare (dynamic-extent (function ,fn)))
(invoke-with-clipping ,buffer (function ,fn) ,@opts))))
(defvar *buffer*)
(defmacro with-buffer ((object) &body body)
`(let ((*buffer* ,object)) ,@body))
Implementing the ctl
and out
macros in these terms follows. We'll
leave out the :cvp
and :ptr
options from the ctl
macro for a
time being. letf
and clear-rectangle
are left unchanged. Remove
old macros from the console.lisp
file.
(defmacro letf (bindings &body body)
(loop for (place value) in bindings
for old-val = (gensym)
collect `(,old-val ,place) into saves
collect `(setf ,place ,value) into store
collect `(setf ,place ,old-val) into restore
finally (return `(let (,@saves)
(unwind-protect (progn ,@store ,@body)
,@restore)))))
(defmacro out ((&key row col fgc bgc) object)
`(let ((buf *buffer*)
(str (princ-to-string ,object)))
(assert (null (find #\newline str)))
(letf (((row buf) (or ,row (row buf)))
((col buf) (or ,col (col buf)))
((fgc buf) (or ,fgc (fgc buf)))
((bgc buf) (or ,bgc (bgc buf))))
(loop with row = (row buf)
for col from (col buf)
for ch across str
with bgc = (bgc buf)
with fgc = (fgc buf)
do (put-cell buf row col ch fgc bgc)))))
(defmacro ctl (&rest operations)
`(let ((buf *buffer*))
,@(loop for op in operations
collect (destructuring-bind (name &rest args) op
(ecase name
(:clr `(clear-rectangle ,@args))
(:fgc `(setf (fgc buf) ,@args))
(:bgc `(setf (bgc buf) ,@args))
(:row `(setf (row buf) ,@args))
(:col `(setf (col buf) ,@args)))))))
(defun clear-rectangle (r1 c1 r2 c2)
(loop with str = (make-string (1+ (- c2 c1)) :initial-element #\space)
for r from r1 upto r2
do (out (:row r :col c1) str)))
What would a protocol be without the implementation? Clipping will be
implemented with the class clip
. This choice is transparent, because
all functions are specialized on the buffer. Each buffer has its own
clipping region. Virtual buffers don't know how to draw on a screen,
so put-cell
prints a warning.
(defclass bbox ()
((r1 :initarg :r1 :accessor r1)
(c1 :initarg :c1 :accessor c1)
(r2 :initarg :r2 :accessor r2)
(c2 :initarg :c2 :accessor c2)))
(defclass clip (bbox)
((fn :initarg :fn :accessor fn))
(:default-initargs :r1 1 :c1 1 :r2 24 :c2 80
:fn (constantly t)))
(defclass buffer ()
((fgc :initarg :fgc :accessor fgc :documentation "Foregorund color")
(bgc :initarg :bgc :accessor bgc :documentation "Background color")
(row :initarg :row :accessor row :documentation "Current row")
(col :initarg :col :accessor col :documentation "Current col")
(clip :initarg :clip :accessor clip :documentation "Clipping object")
(rows :initarg :rows :accessor rows :documentation "Buffer number of rows")
(cols :initarg :cols :accessor cols :documentation "Buffer number of cols"))
(:default-initargs :clip (make-instance 'clip)))
(defmethod put-cell ((buffer buffer) row col ch fg bg)
(warn "put-cell: default method does nothing!"))
(defmethod inside-p ((buffer buffer) row col)
(let ((clip (clip buffer)))
(and (<= (r1 clip) row (r2 clip))
(<= (c1 clip) col (c2 clip))
(funcall (fn clip) row col))))
(defmethod invoke-with-clipping ((buffer buffer) cont &key r1 c1 r2 c2 fn)
(let ((clip (clip buffer)))
(let ((old-r1 (r1 clip))
(old-c1 (c1 clip))
(old-r2 (r2 clip))
(old-c2 (c2 clip))
(old-fn (fn clip)))
(setf (r1 clip) (max (or r1 old-r1) old-r1)
(c1 clip) (max (or c1 old-c1) old-c1)
(r2 clip) (min (or r2 old-r2) old-r2)
(c2 clip) (min (or c2 old-c2) old-c2)
(fn clip) (if (null fn)
old-fn
(lambda (row col)
(and (funcall fn row col)
(funcall old-fn row col)))))
(unwind-protect (funcall cont)
(setf (r1 clip) old-r1
(c1 clip) old-c1
(r2 clip) old-r2
(c2 clip) old-c2
(fn clip) old-fn)))))
Finally, we can modify the console class itself. The macro with-console
binds a buffer separately, so we may access to both the
output buffer and the console at the same time.
(defmacro with-console ((&rest args
&key ios fgc bgc cvp fps &allow-other-keys)
&body body)
(declare (ignore fgc bgc cvp fps))
`(let* ((*terminal* ,ios)
(*console* (make-instance 'console ,@args)))
(unwind-protect (with-buffer (*console*) ,@body)
(close-terminal (hnd *console*)))))
Updating the console dimensions now involves modifying upper bounds of
the clipping region.
(defun update-console-dimensions ()
(with-cursor-position ((expt 2 16) (expt 2 16))
(multiple-value-bind (rows cols)
(get-cursor-position)
(setf (rows *console*) rows)
(setf (cols *console*) cols)
(setf (r2 (clip *console*)) rows)
(setf (c2 (clip *console*)) cols))))
And the class console
itself is remodeled to inherit from the class buffer
. Notice that we get rid of the slots pos
and app
.
(defclass console (buffer)
((ios :initarg :ios :accessor ios :documentation "Console I/O stream.")
(cvp :initarg :cvp :accessor cvp :documentation "Cursor visibility.")
(ptr :initarg :ptr :accessor ptr :documentation "Pointer tracking.")
(fps :initarg :fps :accessor fps :documentation "Desired framerate.")
(hnd :accessor hnd :documentation "Terminal handler."))
(:default-initargs :ios (error "I/O stream must be specified.")
:fgc #xffa0a000 :bgc #x22222200 :row 1 :col 1
:cvp nil :ptr t :fps 10))
(defmethod initialize-instance :after
((instance console) &key fgc bgc row col cvp ptr)
(setf (hnd instance) (init-terminal))
(set-foreground-color fgc)
(set-background-color bgc)
(set-cursor-position row col)
(set-cursor-visibility cvp)
(set-mouse-tracking ptr)
(let ((*console* instance))
(update-console-dimensions)))
(defmethod (setf fgc) :after (rgba (instance console))
(set-foreground-color rgba))
(defmethod (setf bgc) :after (rgba (instance console))
(set-background-color rgba))
(defmethod (setf row) :after (row (instance console))
(set-cursor-position row nil))
(defmethod (setf col) :after (col (instance console))
(set-cursor-position nil col))
(defmethod (setf ptr) :after (ptr (instance console))
(set-mouse-tracking (not (null ptr))))
(defmethod (setf cvp) :after (cvp (instance console))
(set-cursor-visibility (not (null cvp))))
Putting a cell on the screen is a matter of first setting the cursor
position and cell colors, and then calling the function put
. It is
the responsibility of the function put-cell
to verify, that the cell
is inside a clipping region.
(defmethod put-cell ((buffer console) row col ch fg bg)
(when (inside-p buffer row col)
(set-cursor-position row col)
(set-foreground-color fg)
(set-background-color bg)
(put ch)))
Finally we need to account for a change in the with-clipping
macro
to pass a buffer as the first argument and remove references to the app
accessor. Modify the function show-screen
:
(defun show-screen ()
(loop for ch = (read-input)
until (null ch)
do (cond ((keyp ch #\Q :c)
(cl-user::quit))
((keyp ch #\U :c)
(ignore-errors (user-action)))))
(flet ((ll (row col)
(or (and (< (abs (- (+ col row) 26)) 2)
(<= col 20))
(< (abs (- (+ (- 40 col) row) 26)) 2))))
(with-clipping (*buffer* :fn #'ll :r1 2 :r2 11)
(out (:row (1+ (random 12))
:col (1+ (random 40))
:bgc #x00000000
:fgc #xbb000000)
(alexandria:random-elt '("X" "O"))))
(with-clipping (*buffer* :fn (lambda (row col)
(or (= row 1)
(= row 12)
(funcall (complement #'ll) row col))))
(out (:row (1+ (random 12))
:col (1+ (random 40))
:bgc #x00000000
:fgc (alexandria:random-elt '(#x00444400 #x00444400 #x00664400)))
(alexandria:random-elt '("+" "-"))))))
All these changes were pretty invasive, so make sure to restart the
image and try running the application once more to ensure, that
everything still works.