#+TITLE: Dialog System + Toast (v0.9.0) #+DATE: 2026-05-11 #+AUTHOR: Amr Gharbeia / Hermes * 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog-package.lisp ;;; 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*)) #+END_SRC * 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (in-package :cl-tty.dialog) (defvar *dialog-stack* nil "Stack of active dialogs. (list) of dialog instances.") #+END_SRC ** *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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (defvar *toasts* nil "List of active toast notifications.") #+END_SRC * 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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))) #+END_SRC ** 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). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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)))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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))))) #+END_SRC ** push-dialog Pushes a dialog onto =*dialog-stack*=. Returns the dialog for chaining. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (defun push-dialog (dialog) (push dialog *dialog-stack*) dialog) #+END_SRC ** pop-dialog Pops the top dialog from the stack. If an ~:on-dismiss~ callback is set on the dialog, it is called before returning. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (defun pop-dialog () (when *dialog-stack* (let ((dialog (pop *dialog-stack*))) (when (dialog-on-dismiss dialog) (funcall (dialog-on-dismiss dialog))) dialog))) #+END_SRC * 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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)))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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))))))) #+END_SRC ** select-dialog Modal wrapper around the ~select~ component. Presents a list of options and calls ~on-select~ with the chosen value after dismissing. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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)))))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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)))))) #+END_SRC * 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (defclass toast () ((message :initarg :message :accessor toast-message) (variant :initarg :variant :initform :info :accessor toast-variant))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (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)) #+END_SRC ** dismiss-toast Removes a toast from =*toasts*~ by identity (~remove~ with default ~:test #'eql~ compares by pointer for CLOS objects). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/dialog.lisp (defun dismiss-toast (toast) (setf *toasts* (remove toast *toasts*))) #+END_SRC * Tests Test suite using FiveAM. Each test exercises one function or interaction. ** Test package and suite #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp ;;; 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) #+END_SRC ** dialog-create Basic dialog instantiation — verifies ~make-instance~ and accessors. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (def-test dialog-create () (let ((d (make-instance 'dialog :title "Test"))) (is-true (typep d 'dialog)) (is (equal "Test" (dialog-title d))))) #+END_SRC ** dialog-size-small ~dialog-size-pixels~ returns the correct dimensions for ~:small~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (def-test dialog-size-small () (multiple-value-bind (w h) (dialog-size-pixels :small) (is (= 40 w)) (is (= 8 h)))) #+END_SRC ** dialog-size-medium ~dialog-size-pixels~ returns the correct dimensions for ~:medium~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (def-test dialog-size-medium () (multiple-value-bind (w h) (dialog-size-pixels :medium) (is (= 60 w)) (is (= 16 h)))) #+END_SRC ** dialog-push-pop Verifies stack operations: push adds to =*dialog-stack*~, pop removes the top element. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (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*))))) #+END_SRC ** toast-create Verifies that ~toast~ pushes onto =*toasts*~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (def-test toast-create () (let ((*toasts* nil)) (toast "Hello" :variant :info :duration 0) (is (= 1 (length *toasts*))))) #+END_SRC ** toast-dismiss Verifies that ~dismiss-toast~ removes the toast from =*toasts*~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/dialog-tests.lisp (def-test toast-dismiss () (let ((*toasts* (list (make-instance 'toast :message "T" :variant :info)))) (dismiss-toast (first *toasts*)) (is (= 0 (length *toasts*))))) #+END_SRC