Add :mode parameter to defslot with three behaviors: - :stack (default) — accumulate all registrations, render in order - :replace — each registration replaces previous entries - :single-winner — first registration wins, rest ignored Mode is set on first defslot call and frozen for subsequent calls to prevent conflicting mode specifications from different plugins. Store slot data as plist (:mode <keyword> :entries <list>) instead of bare entries list. Add 5 new tests covering mode-specific behavior. All 9 slot tests pass. All 13 suites pass at 100%.
10 KiB
Plugin / Slot System (v0.11.0)
- Overview
- Implementation
- Package
- Slot Storage: slots
- defslot: Register a Render Function
- slot-render: Invoke Render Functions Per Mode
- slot-p: Check Slot Existence
- clear-slot: Remove All Registrations
- list-slots: Enumerate Registered Slots
- Tests
- Test Package and Suite
- defslot-register: Registering a slot makes it visible
- slot-render-calls: Stack mode calls all functions in order
- slot-render-empty: Unregistered slot returns nil
- clear-slot-removes: Clearing a slot makes it absent
- stack-mode-multiple-entries: Stack keeps all registrations
- replace-mode-last-wins: Replace keeps only the last registration
- single-winner-mode-first-wins: Single-winner keeps only the first
- clear-slot-removes-mode: Clearing resets mode, allowing new mode
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 slotslot-render slot-name &rest args— call all registered render-fns, return combined outputslot-p slot-name— check if a slot has registrationsclear-slot slot-name— remove all registrations for a slotlist-slots— return all slot names with registrations
Slot modes
:stack(default) — render all registered functions in:ordersequence. Eachdefslotadds to the list.slot-rendercalls 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. Eachdefslotreplaces the slot's entire entry list with the new registration.slot-rendercalls 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, furtherdefslotcalls for the same slot are no-ops.slot-rendercalls 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 accepted but only respected on the first registration for a slot. 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 — set mode and create entry
(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))))