#+TITLE: Plugin / Slot System (v0.11.0) #+DATE: 2026-05-11 #+AUTHOR: Amr Gharbeia / Hermes #+STARTUP: content * 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. #+BEGIN_SRC lisp :tangle ../src/components/slot-package.lisp (defpackage :cl-tty.slot (:use :cl) (:export #:defslot #:slot-render #:slot-p #:clear-slot #:list-slots #:*slots*)) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (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 :entries <(order . render-fn) list>).") #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (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) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (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))))))))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (defun slot-p (slot-name) (nth-value 1 (gethash (string slot-name) *slots*))) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (defun clear-slot (slot-name) (remhash (string slot-name) *slots*)) #+END_SRC ** 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. #+BEGIN_SRC lisp :tangle ../src/components/slot.lisp (defun list-slots () (loop for key being the hash-keys of *slots* collect key)) #+END_SRC ** Tests The test suite uses FiveAM and exercises each public function, including mode-specific behavior. *** Test Package and Suite #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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) #+END_SRC *** defslot-register: Registering a slot makes it visible #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (def-test defslot-register () (clear-slot :test-slot) (defslot :test-slot :order 1 :render-fn (lambda () "hello")) (is-true (slot-p :test-slot))) #+END_SRC *** slot-render-calls: Stack mode calls all functions in order Verifies that ~:stack~ mode preserves multiple registrations and calls them in ascending order sequence. #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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)))) #+END_SRC *** slot-render-empty: Unregistered slot returns nil #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (def-test slot-render-empty () (clear-slot :ghost) (is-false (slot-render :ghost))) #+END_SRC *** clear-slot-removes: Clearing a slot makes it absent #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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))) #+END_SRC *** stack-mode-multiple-entries: Stack keeps all registrations Verifies that ~:stack~ mode (default) accumulates entries across multiple ~defslot~ calls. #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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)))) #+END_SRC *** replace-mode-last-wins: Replace keeps only the last registration Verifies that ~:replace~ mode discards previous entries on each new ~defslot~ call. #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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)))) #+END_SRC *** single-winner-mode-first-wins: Single-winner keeps only the first Verifies that ~:single-winner~ mode ignores subsequent registrations. #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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)))) #+END_SRC *** 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. #+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp (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)))) #+END_SRC