Controlling the terminal from Common Lisp (part 2)

栏目: IT技术 · 发布时间: 3年前

内容简介:Tagged aslisp,Written on 2020-06-19 by Daniel 'jackdaniel' KochmańskiThis 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 r

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.


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

ME2.0

ME2.0

丹·斯柯伯尔 / 2011-11 / 36.00元

《Me2.0个人品牌崛起E时代》,本书介绍在信息技术飞速发展的今天,如何使用网络来营建个人形象,建立关系网,谋求理想的工作,完成商务交易。成功学与今天的网络通讯相结合,smart 原则与SWOT分析,视频网站、博客、社交网站、搜索引擎如何使用才能让你以直线方式走向成功等内容。一起来看看 《ME2.0》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具