Files
cl-tty/org/slot.org

11 KiB

Plugin / Slot System (v0.11.0)

Overview

Extensible named slots. Applications and plugins register content into named slots. The component tree renders whatever is registered.

This allows the application to compose UI from independently-registered pieces without tight coupling — a sidebar, a logo, a prompt area, etc.

Contract

  • defslot name &key order render-fn mode — register a render function for a slot
  • slot-render slot-name &rest args — call all registered render-fns, return combined output
  • slot-p slot-name — check if a slot has registrations
  • clear-slot slot-name — remove all registrations for a slot
  • list-slots — return all slot names with registrations

Slot modes

  • :stack (default) — render all registered functions in :order sequence. Each defslot adds to the list. slot-render calls every function and returns a list of results. Use this for composable slots where multiple plugins contribute content (e.g., toolbar buttons, status bar segments).
  • :replace — last registration wins, previous ones are discarded. Each defslot replaces the slot's entire entry list with the new registration. slot-render calls only the most recently registered function. Use this for exclusive slots where only one renderer should be active at a time (e.g., main content area, active panel).
  • :single-winner — first registration wins, subsequent ones are ignored. Once a slot has an entry, further defslot calls for the same slot are no-ops. slot-render calls only the first (lowest-order) registered function. Use this for slots where the first plugin to register should own the spot (e.g., logo area, command palette).

The mode is set on the first defslot call for a slot. Subsequent calls for the same slot ignore the :mode argument and use the established mode — this prevents confusion when multiple plugins register into the same slot with conflicting mode specifications.

Implementation

Package

The package provides the public API and exports all slot system symbols. Clients :use this package or refer to symbols qualified.

(defpackage :cl-tty.slot
  (:use :cl)
  (:export
   #:defslot
   #:slot-render
   #:slot-p
   #:clear-slot
   #:list-slots
   #:*slots*))

Slot Storage: slots

The central registry is a hash table keyed by slot name (strings, for case-insensitive lookup via equal). Each value is a plist:

  • :mode — the slot's mode keyword (:stack, :replace, :single-winner)
  • :entries — list of (order . render-fn) cons cells, sorted by order

The :test #'equal ensures that :sidebar and "sidebar" map to the same key.

(in-package :cl-tty.slot)

(defvar *slots* (make-hash-table :test 'equal)
  "Hash table mapping slot name (string) -> plist of slot data.
Each entry: (:mode <mode> :entries <(order . render-fn) list>).")

defslot: Register a Render Function

defslot inserts a new (order . render-fn) entry into the slot's entry list. The behavior depends on the slot's mode, which is set on the first call and frozen for subsequent calls:

  • :stack — merge into existing entries, sorted by order
  • :replace — clear all previous entries, keep only the new one
  • :single-winner — no-op if the slot already has entries

The render-fn itself is returned so callers can use it inline.

The mode parameter is validated on first call via assert and then frozen for subsequent calls. This prevents a later registration from changing the slot's semantics out from under earlier registrations.

(defun defslot (name &key (order 0) render-fn (mode :stack))
  (let* ((key (string name))
         (slot (gethash key *slots*)))
    (if (null slot)
        ;; First registration — validate and set mode, create entry
        (progn
          (assert (member mode '(:stack :replace :single-winner)) ()
                  "Invalid slot mode: ~S (use :stack, :replace, or :single-winner)"
                  mode)
          (setf (gethash key *slots*)
                (list :mode mode
                      :entries (list (cons order render-fn)))))
        ;; Existing slot — respect frozen mode
        (let ((entries (getf slot :entries)))
          (ecase (getf slot :mode)
            (:stack
             (setf (getf slot :entries)
                   (sort (cons (cons order render-fn) entries)
                         #'< :key #'car)))
            (:replace
             (setf (getf slot :entries)
                   (list (cons order render-fn))))
            (:single-winner
             ;; First registration already present — no-op
             (values))))))
  render-fn)

slot-render: Invoke Render Functions Per Mode

slot-render dispatches on the slot's mode:

  • :stack — call every non-nil render function in order, return a list of results. This is the most flexible mode, supporting multiple contributors per slot.
  • :replace — call only the single registered function (the last one registered, since :replace clears earlier entries). Returns a single value, not a list.
  • :single-winner — call only the first registered function (lowest order). Subsequent registrations were silently dropped during defslot.

Returns nil if the slot has no registrations or if the handler is nil.

(defun slot-render (slot-name &rest args)
  (let ((slot (gethash (string slot-name) *slots*)))
    (when slot
      (let ((mode (getf slot :mode))
            (entries (getf slot :entries)))
        (ecase mode
          (:stack
           (mapcar (lambda (entry)
                     (let ((fn (cdr entry)))
                       (when fn (apply fn args))))
                   entries))
          (:replace
           (let ((fn (cdar (last entries))))
             (when fn (apply fn args))))
          (:single-winner
           (let ((fn (cdar entries)))
             (when fn (apply fn args)))))))))

slot-p: Check Slot Existence

Uses nth-value 1 of gethash which returns t if the key is present (even if the value is nil) or nil if absent. This is the canonical Common Lisp idiom for testing hash-table membership.

(defun slot-p (slot-name)
  (nth-value 1 (gethash (string slot-name) *slots*)))

clear-slot: Remove All Registrations

Calls remhash to delete the slot's entry from the hash table entirely. After this call slot-p returns false and slot-render returns nil for the given slot name.

(defun clear-slot (slot-name)
  (remhash (string slot-name) *slots*))

list-slots: Enumerate Registered Slots

Iterates over all hash keys in *slots* and returns them as a list. Only slots that have been registered (i.e. have at least one entry) appear in the result.

(defun list-slots ()
  (loop for key being the hash-keys of *slots* collect key))

Tests

The test suite uses FiveAM and exercises each public function, including mode-specific behavior.

Test Package and Suite

(defpackage :cl-tty-slot-test (:use :cl :cl-tty.slot :fiveam))
(in-package :cl-tty-slot-test)

(def-suite slot-suite :description "Slot system tests")
(in-suite slot-suite)

defslot-register: Registering a slot makes it visible

(def-test defslot-register ()
  (clear-slot :test-slot)
  (defslot :test-slot :order 1 :render-fn (lambda () "hello"))
  (is-true (slot-p :test-slot)))

slot-render-calls: Stack mode calls all functions in order

Verifies that :stack mode preserves multiple registrations and calls them in ascending order sequence.

(def-test slot-render-calls ()
  (clear-slot :test-slot)
  (defslot :test-slot :order 1 :render-fn (lambda () "a"))
  (defslot :test-slot :order 2 :render-fn (lambda () "b"))
  (is (equal '("a" "b") (slot-render :test-slot))))

slot-render-empty: Unregistered slot returns nil

(def-test slot-render-empty ()
  (clear-slot :ghost)
  (is-false (slot-render :ghost)))

clear-slot-removes: Clearing a slot makes it absent

(def-test clear-slot-removes ()
  (clear-slot :test-slot)
  (defslot :test-slot :order 1 :render-fn (lambda () "x"))
  (clear-slot :test-slot)
  (is-false (slot-p :test-slot)))

stack-mode-multiple-entries: Stack keeps all registrations

Verifies that :stack mode (default) accumulates entries across multiple defslot calls.

(def-test stack-mode-multiple-entries ()
  (clear-slot :stack-test)
  (defslot :stack-test :order 1 :render-fn (lambda () "first"))
  (defslot :stack-test :order 2 :render-fn (lambda () "second"))
  (defslot :stack-test :order 3 :render-fn (lambda () "third"))
  (is (equal '("first" "second" "third") (slot-render :stack-test))))

replace-mode-last-wins: Replace keeps only the last registration

Verifies that :replace mode discards previous entries on each new defslot call.

(def-test replace-mode-last-wins ()
  (clear-slot :replace-test)
  (defslot :replace-test :mode :replace :order 1 :render-fn (lambda () "old"))
  (defslot :replace-test :mode :replace :order 2 :render-fn (lambda () "new"))
  (is (equal "new" (slot-render :replace-test))))

single-winner-mode-first-wins: Single-winner keeps only the first

Verifies that :single-winner mode ignores subsequent registrations.

(def-test single-winner-mode-first-wins ()
  (clear-slot :winner-test)
  (defslot :winner-test :mode :single-winner :order 1
           :render-fn (lambda () "alpha"))
  (defslot :winner-test :mode :single-winner :order 2
           :render-fn (lambda () "beta"))
  (is (equal "alpha" (slot-render :winner-test))))

clear-slot-removes-mode: Clearing resets mode, allowing new mode

Verifies that clearing a slot removes the mode lock, so a subsequent defslot can set a new mode.

(def-test clear-slot-removes-mode ()
  (clear-slot :mode-test)
  (defslot :mode-test :mode :replace :render-fn (lambda () "only"))
  (clear-slot :mode-test)
  (defslot :mode-test :mode :stack :render-fn (lambda () "fresh"))
  (is-true (slot-p :mode-test))
  (is (equal '("fresh") (slot-render :mode-test))))