#+TITLE: Passepartout TUI — Controller #+PROPERTY: header-args:lisp :tangle ../lisp/gateway-tui-main.lisp * Controller Event handlers + daemon I/O + main loop. ** Event Handlers #+begin_src lisp (in-package :passepartout.gateway-tui) (defun on-key (&rest args) (let ((ch (car args))) (cond ;; Enter ((or (eql ch 10) (eql ch 13) (eq ch :enter) (eql ch #\Newline) (eql ch #\Return)) (let ((text (string-trim '(#\Space #\Tab) (input-string)))) (when (> (length text) 0) (push text (st :input-history)) (setf (st :input-hpos) 0) (setf (st :scroll-offset) 0) (cond ;; /eval command ((and (>= (length text) 6) (string-equal (subseq text 0 6) "/eval ")) (handler-case (let* ((*read-eval* t) (*package* (find-package :passepartout.gateway-tui)) (r (eval (read-from-string (subseq text 6))))) (add-msg :system (format nil "=> ~s" r))) (error (c) (add-msg :system (format nil "=> ✗ ~a" c))))) ;; Normal message (t (add-msg :user text) (send-daemon (list :type :event :payload (list :sensor :user-input :text text))))) (setf (st :input-buffer) nil) (setf (st :dirty) (list t t t))))) ;; Backspace ((or (eql ch 127) (eql ch 8) (eq ch :backspace) (eql ch #\Backspace)) (when (st :input-buffer) (pop (st :input-buffer))) (setf (st :dirty) (list nil nil t))) ;; Up arrow ((or (eq ch :up) (eql ch 259)) (let* ((h (st :input-history)) (p (st :input-hpos))) (when (and h (< p (1- (length h)))) (incf (st :input-hpos)) (setf (st :input-buffer) (reverse (coerce (nth (st :input-hpos) h) 'list))) (setf (st :dirty) (list nil nil t))))) ;; Down arrow ((or (eq ch :down) (eql ch 258)) (when (> (st :input-hpos) 0) (decf (st :input-hpos)) (let ((h (st :input-history))) (setf (st :input-buffer) (if (and h (< (st :input-hpos) (length h))) (reverse (coerce (nth (st :input-hpos) h) 'list)) nil)) (setf (st :dirty) (list nil nil t))))) ;; PageUp ((or (eq ch :ppage) (eql ch 339)) (incf (st :scroll-offset) 5) (setf (st :dirty) (list nil t nil))) ;; PageDown ((or (eq ch :npage) (eql ch 338)) (setf (st :scroll-offset) (max 0 (- (st :scroll-offset) 5))) (setf (st :dirty) (list nil t nil))) ;; Printable (t (let ((chr (typecase ch (character ch) (integer (code-char ch)) (t nil)))) (when (and chr (graphic-char-p chr)) (push chr (st :input-buffer)) (setf (st :dirty) (list nil nil t)))))))) (defun on-daemon-msg (msg) (let* ((payload (getf msg :payload)) (text (getf payload :text)) (action (getf payload :action))) (cond (text (add-msg :agent text)) ((eq action :handshake) (add-msg :system (format nil "Connected v~a" (getf payload :version)))) (t (add-msg :agent (format nil "~a" msg)))))) #+end_src ** Daemon Communication #+begin_src lisp (defun send-daemon (msg) (let ((s (st :stream))) (when (and s (open-stream-p s)) (handler-case (progn (format s "~a" (frame-message msg)) (finish-output s)) (error (c) (log-message "TUI-SEND: ~a" c)))))) (defun recv-daemon (s) (handler-case (let* ((hdr (make-string 6)) (n 0)) (loop while (< n 6) do (let ((ch (read-char s nil))) (unless ch (return-from recv-daemon nil)) (setf (char hdr n) ch) (incf n))) (let* ((len (parse-integer hdr :radix 16 :junk-allowed t)) (buf (make-string (or len 0)))) (when (and len (> len 0)) (loop for i from 0 below len do (let ((ch (read-char s nil))) (unless ch (return-from recv-daemon nil)) (setf (char buf i) ch))) (let ((*read-eval* nil)) (read-from-string buf))))) (error (c) (log-message "TUI-RECV: ~a" c) nil))) (defun reader-loop (s) (loop while (and (st :running) (open-stream-p s)) do (let ((msg (recv-daemon s))) (when msg (queue-event (list :type :daemon :payload msg)))))) #+end_src ** Connection #+begin_src lisp (defun connect-daemon (&optional (host "127.0.0.1") (port 9105)) (handler-case (let ((s (usocket:socket-connect host port :element-type 'character))) (setf (st :stream) (usocket:socket-stream s) (st :connected) t) (bt:make-thread (lambda () (reader-loop (st :stream))) :name "tui-reader") (add-msg :system "* Connected *") t) (error (c) (add-msg :system (format nil "* Connection failed: ~a *" c)) nil))) (defun disconnect-daemon () (when (st :stream) (ignore-errors (close (st :stream))) (setf (st :stream) nil (st :connected) nil) (add-msg :system "* Disconnected *"))) #+end_src ** Main Loop #+begin_src lisp (defun tui-main () (init-state) (with-screen (scr :input-blocking nil :input-echoing nil :cursor-visible nil) (let* ((h (or (height scr) 24)) (w (or (width scr) 80)) (sw (make-instance 'window :height 3 :width (- w 2) :y 0 :x 1)) (ch (- h 5)) (cw (make-instance 'window :height ch :width (- w 2) :y 3 :x 1)) (iw (make-instance 'window :height 1 :width (- w 2) :y (- h 1) :x 1)) (swank-port (or (ignore-errors (parse-integer (uiop:getenv "TUI_SWANK_PORT"))) 4006))) (setf (function-keys-enabled-p iw) t (st :dirty) (list t t t)) (connect-daemon) (when (> swank-port 0) (handler-case (progn (ql:quickload :swank :silent t) (funcall (find-symbol "CREATE-SERVER" "SWANK") :port swank-port :dont-close t) (add-msg :system (format nil "* Swank ~d M-x slime-connect *" swank-port))) (error () (add-msg :system "* Swank unavailable *")))) (loop while (st :running) do (dolist (ev (drain-queue)) (when (eq (getf ev :type) :daemon) (on-daemon-msg (getf ev :payload)))) (let ((ch (get-char iw))) (when (and ch (not (equal ch -1))) (on-key ch))) (redraw sw cw ch iw) (refresh scr) (sleep 0.03)) (disconnect-daemon)))) #+end_src * Test Suite #+begin_src lisp (eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t)) (defpackage :passepartout-tui-tests (:use :cl :passepartout) (:export #:tui-suite)) (in-package :passepartout-tui-tests) (fiveam:def-suite tui-suite :description "Verification of the TUI parsing and styling logic") (fiveam:in-suite tui-suite) (fiveam:test test-tui-connection-drop "Tier 2 Chaos: Verify that handle-return degrades gracefully when the daemon connection is lost." (let ((passepartout.gateway-tui::*incoming-msgs* nil) (passepartout.gateway-tui::*input-buffer* (make-array 5 :element-type 'character :initial-contents "hello" :fill-pointer 5 :adjustable t)) (mock-stream (make-string-output-stream))) (close mock-stream) (passepartout.gateway-tui::handle-return mock-stream) (fiveam:is (member "ERROR: Connection to daemon lost." passepartout.gateway-tui::*incoming-msgs* :test #'string=)))) #+end_src