Files
cl-tty/org/dialog.org

13 KiB

Dialog System + Toast (v0.9.0)

Overview

Modal overlays (dialogs) and transient notifications (toasts).

Dialogs are absolute-positioned panels centered on a dimmed backdrop. They stack — a new dialog goes on top, Esc dismisses the top one.

Toasts are non-blocking notifications that auto-dismiss after a duration. They stack in the top-right corner.

Design decisions

  1. Stack-based dialog management: a *dialog-stack* special variable holds the active dialogs. Render walks the stack from bottom to top, drawing each dialog's backdrop over the previous one. This means two dialogs visible at once — the top one gets full interaction.
  2. Backdrop is a solid dim color, not semi-transparent: true transparency requires compositing pixel buffers, which is expensive in the terminal. A solid dimmed color over the full screen width communicates "modal" without the complexity.
  3. Dialogs are components, not separate windows: they integrate into the existing render tree. The dialog class inherits from the component base and participates in dirty tracking, z-order, etc.
  4. Toast is fire-and-forget: (toast ...) creates a toast component, adds it to a toast list, and schedules auto-dismissal. No lifecycle management needed from the caller.

Contract

  • dialog class — overlay component with backdrop, border, title
  • *dialog-stack* — list of active dialogs (bound per-screen)
  • push-dialog dialog — add dialog to stack, focus its first input
  • pop-dialog — dismiss top dialog, fire :on-dismiss
  • (alert-dialog title message) — OK-button alert
  • (confirm-dialog title message &key on-yes on-no) — Yes/No/Cancel
  • (select-dialog title options &key on-select) — modal Select
  • (prompt-dialog title &key on-submit) — modal TextInput
  • toast component — transient notification with variant color
  • (toast message &key variant duration) — fire-and-forget toast

Package definition

The cl-tty.dialog package uses the backend, input, and select subsystems. All public symbols are exported for user convenience.

;;; dialog-package.lisp — Package definition for cl-tty.dialog

(defpackage :cl-tty.dialog
  (:use :cl :cl-tty.backend :cl-tty.input :cl-tty.select)
  (:export
   #:dialog
   #:dialog-title
   #:dialog-content
   #:dialog-on-dismiss
   #:dialog-size
   #:dialog-size-pixels
   #:render-dialog
   #:push-dialog
   #:pop-dialog
   #:*dialog-stack*
   #:alert-dialog
   #:confirm-dialog
   #:select-dialog
   #:prompt-dialog
   #:toast
   #:toast-message
   #:toast-variant
   #:render-toast
   #:dismiss-toast
   #:*toasts*))

Special variables

dialog-stack

The active dialog stack. push-dialog conses onto this list; pop-dialog pops it and fires the :on-dismiss callback. Each screen should bind its own instance so multiple screens can have independent dialog states.

(in-package :cl-tty.dialog)

(defvar *dialog-stack* nil
  "Stack of active dialogs. (list) of dialog instances.")

toasts

List of active toast notifications. toast pushes, dismiss-toast removes by identity. The render loop walks this list to draw toasts in the top-right corner.

(defvar *toasts* nil
  "List of active toast notifications.")

Dialog class

The core dialog class stores a title, a size preset, the content component to render inside the panel, and an optional :on-dismiss callback invoked when the dialog is popped.

(defclass dialog ()
  ((title :initarg :title :accessor dialog-title)
   (size :initarg :size :initform :medium :accessor dialog-size)
   (content :initarg :content :initform nil :accessor dialog-content)
   (on-dismiss :initarg :on-dismiss :initform nil :accessor dialog-on-dismiss)))

dialog-size-pixels

Converts a size keyword (:small, :medium, :large) to pixel dimensions. Accepts optional max-w / max-h to clamp the result to terminal bounds, preventing off-screen overflow (fixed in v1.0.0).

(defun dialog-size-pixels (size &optional (max-w 80) (max-h 24))
  (multiple-value-bind (dw dh)
      (case size
        (:small (values 40 8))
        (:medium (values 60 16))
        (:large (values 88 24))
        (t (values 60 16)))
    (values (min dw max-w) (min dh max-h))))

render-dialog

Renders a dialog: draws a dimmed full-screen backdrop using draw-rect, then draws the bordered dialog panel centered on screen. Content is rendered via draw-text inside the panel area.

(defun render-dialog (dialog screen w h)
  (multiple-value-bind (dw dh) (dialog-size-pixels (dialog-size dialog) w h)
    (let ((x (floor (- w dw) 2))
          (y (floor (- h dh) 2)))
      ;; Backdrop — dim the full screen
      (dotimes (row h)
        (draw-rect screen 0 row w 1 :bg :bright-black))
      ;; Dialog panel
      (draw-border screen x y dw dh :style :single :title (dialog-title dialog))
      (when (dialog-content dialog)
        ;; Content rendering delegated to component system
        (draw-text screen (1+ x) (1+ y)
                   (format nil "~a" (dialog-content dialog))
                   :white :default)))))

push-dialog

Pushes a dialog onto *dialog-stack*. Returns the dialog for chaining.

(defun push-dialog (dialog)
  (push dialog *dialog-stack*)
  dialog)

pop-dialog

Pops the top dialog from the stack. If an :on-dismiss callback is set on the dialog, it is called before returning.

(defun pop-dialog ()
  (when *dialog-stack*
    (let ((dialog (pop *dialog-stack*)))
      (when (dialog-on-dismiss dialog)
        (funcall (dialog-on-dismiss dialog)))
      dialog)))

Dialog convenience constructors

These factory functions create common dialog variants by composing the dialog class with interactive components (select, text-input).

alert-dialog

Simple alert with title, message, and an OK button. The button is a select with a single "OK" option. Dismissing fires pop-dialog on both selection and backdrop dismiss.

(defun alert-dialog (title message)
  (make-instance 'dialog
    :title title
    :size :small
    :content (make-instance 'select
               :options (list (list :title "OK" :value :ok))
               :on-select (lambda (opt) (declare (ignore opt)) (pop-dialog)))
    :on-dismiss (lambda () (pop-dialog))))

confirm-dialog

Confirm dialog with Yes/No buttons. Returns :yes or :no via the on-yes~/~on-no callbacks. The dialog auto-dismisses on selection.

(defun confirm-dialog (title message &key on-yes on-no)
  (make-instance 'dialog
    :title title
    :size :small
    :content (make-instance 'select
               :options (list (list :title "Yes" :value :yes)
                              (list :title "No" :value :no))
               :on-select (lambda (opt)
                            (pop-dialog)
                            (if (eql opt :yes)
                                (when on-yes (funcall on-yes))
                                (when on-no (funcall on-no)))))))

select-dialog

Modal wrapper around the select component. Presents a list of options and calls on-select with the chosen value after dismissing.

(defun select-dialog (title options &key on-select)
  (make-instance 'dialog
    :title title
    :size :medium
    :content (make-instance 'select
               :options options
               :on-select (lambda (opt)
                            (pop-dialog)
                            (when on-select (funcall on-select opt))))))

prompt-dialog

Modal wrapper around text-input. Shows a text input field inside the dialog and calls on-submit with the entered value after dismissing.

(defun prompt-dialog (title &key on-submit)
  (make-instance 'dialog
    :title title
    :size :small
    :content (make-instance 'text-input
               :on-submit (lambda (value)
                            (pop-dialog)
                            (when on-submit (funcall on-submit value))))))

Toast system

Transient notifications that appear in the top-right corner. Each toast has a message and a variant that determines its color (:info, :success, :warning, :error).

toast class

Lightweight class storing the message text and variant keyword.

(defclass toast ()
  ((message :initarg :message :accessor toast-message)
   (variant :initarg :variant :initform :info :accessor toast-variant)))

render-toast

Draws a toast in the top-right corner of the screen. The message is truncated to 60 columns with an ellipsis if necessary. The background color reflects the variant.

(defun render-toast (toast screen w)
  (let* ((msg (toast-message toast))
         (variant (toast-variant toast))
         (color (case variant
                  (:info :blue) (:success :green)
                  (:warning :yellow) (:error :red)))
         (max-w (min 60 (1- w)))
         (x (- w max-w 1))
         (text (if (> (length msg) (- max-w 2))
                   (concatenate 'string (subseq msg 0 (- max-w 5)) "...")
                   msg)))
    (draw-rect screen x 0 max-w 1 :bg color)
    (draw-text screen (1+ x) 0 text :white color :bold t)))

toast (function)

Fire-and-forget toast notification. Creates a toast instance, pushes it onto =*toasts*~, and optionally schedules auto-dismissal via dismiss-toast when duration is positive.

(defun toast (message &key (variant :info) (duration 0))
  (let ((toast (make-instance 'toast :message message :variant variant)))
    (push toast *toasts*)
    (when (plusp duration) (dismiss-toast toast))
    toast))

dismiss-toast

Removes a toast from =*toasts*~ by identity (remove with default :test #'eql compares by pointer for CLOS objects).

(defun dismiss-toast (toast)
  (setf *toasts* (remove toast *toasts*)))

Tests

Test suite using FiveAM. Each test exercises one function or interaction.

Test package and suite

;;; dialog-tests.lisp — Tests for cl-tty.dialog

(defpackage :cl-tty-dialog-test
  (:use :cl :cl-tty.dialog :fiveam))

(in-package :cl-tty-dialog-test)

(def-suite dialog-suite :description "Dialog + Toast tests for cl-tty.dialog")
(in-suite dialog-suite)

dialog-create

Basic dialog instantiation — verifies make-instance and accessors.

(def-test dialog-create ()
  (let ((d (make-instance 'dialog :title "Test")))
    (is-true (typep d 'dialog))
    (is (equal "Test" (dialog-title d)))))

dialog-size-small

dialog-size-pixels returns the correct dimensions for :small.

(def-test dialog-size-small ()
  (multiple-value-bind (w h) (dialog-size-pixels :small)
    (is (= 40 w))
    (is (= 8 h))))

dialog-size-medium

dialog-size-pixels returns the correct dimensions for :medium.

(def-test dialog-size-medium ()
  (multiple-value-bind (w h) (dialog-size-pixels :medium)
    (is (= 60 w))
    (is (= 16 h))))

dialog-push-pop

Verifies stack operations: push adds to =*dialog-stack*~, pop removes the top element.

(def-test dialog-push-pop ()
  (let ((*dialog-stack* nil))
    (push-dialog (make-instance 'dialog :title "D1"))
    (is (= 1 (length *dialog-stack*)))
    (push-dialog (make-instance 'dialog :title "D2"))
    (is (= 2 (length *dialog-stack*)))
    (pop-dialog)
    (is (= 1 (length *dialog-stack*)))))

toast-create

Verifies that toast pushes onto =*toasts*~.

(def-test toast-create ()
  (let ((*toasts* nil))
    (toast "Hello" :variant :info :duration 0)
    (is (= 1 (length *toasts*)))))

toast-dismiss

Verifies that dismiss-toast removes the toast from =*toasts*~.

(def-test toast-dismiss ()
  (let ((*toasts* (list (make-instance 'toast :message "T" :variant :info))))
    (dismiss-toast (first *toasts*))
    (is (= 0 (length *toasts*)))))