Files
clojure-tui/src/tui/terminal.clj
2026-01-21 10:51:45 -05:00

150 lines
3.7 KiB
Clojure

(ns tui.terminal
"Terminal management: raw mode, size, input/output."
(:require [tui.ansi :as ansi]
[clojure.java.io :as io])
(:import [java.io BufferedReader InputStreamReader]))
;; === Terminal State ===
(def ^:private original-stty (atom nil))
(defn- stty [& args]
(let [cmd (concat ["sh" "-c" (str "stty " (clojure.string/join " " args) " </dev/tty")]
(when (empty? args) ["sh" "-c" "stty </dev/tty"]))
pb (ProcessBuilder. ^java.util.List (vec cmd))
_ (.inheritIO pb)
proc (.start pb)
exit (.waitFor proc)]
(when (zero? exit)
"")))
(defn- stty-get [& args]
(let [cmd ["sh" "-c" (str "stty " (clojure.string/join " " args) " </dev/tty")]
pb (ProcessBuilder. ^java.util.List (vec cmd))
_ (.redirectInput pb java.lang.ProcessBuilder$Redirect/INHERIT)
proc (.start pb)
output (slurp (.getInputStream proc))
exit (.waitFor proc)]
(when (zero? exit)
(clojure.string/trim output))))
(defn get-terminal-size
"Get terminal dimensions as [width height]."
[]
(try
(let [result (stty-get "size")]
(when result
(let [[rows cols] (map parse-long (clojure.string/split result #"\s+"))]
{:width cols :height rows})))
(catch Exception _
{:width 80 :height 24})))
(defn raw-mode!
"Enter raw terminal mode (no echo, no line buffering)."
[]
(reset! original-stty (stty-get "-g"))
(stty "raw" "-echo" "-icanon" "min" "1")
(print ansi/hide-cursor)
(flush))
(defn restore!
"Restore terminal to original state."
[]
(when @original-stty
(stty @original-stty)
(reset! original-stty nil))
(print ansi/show-cursor)
(print ansi/reset)
(flush))
(defn alt-screen!
"Enter alternate screen buffer."
[]
(print ansi/enter-alt-screen)
(flush))
(defn exit-alt-screen!
"Exit alternate screen buffer."
[]
(print ansi/exit-alt-screen)
(flush))
(defn clear!
"Clear screen and move cursor home."
[]
(print ansi/clear-screen)
(print ansi/cursor-home)
(flush))
(defn render!
"Render string to terminal."
[s]
(print ansi/cursor-home)
(print ansi/clear-to-end)
;; In raw mode, \n only moves down without returning to column 0
;; Replace \n with \r\n to get proper line breaks
(print (clojure.string/replace s "\n" "\r\n"))
(flush))
;; === Input Handling ===
(def ^:private tty-reader (atom nil))
(defn init-input!
"Initialize input reader from /dev/tty."
[]
(reset! tty-reader
(BufferedReader.
(InputStreamReader.
(java.io.FileInputStream. "/dev/tty")))))
(defn close-input!
"Close input reader."
[]
(when-let [r @tty-reader]
(.close r)
(reset! tty-reader nil)))
(defn input-ready?
"Check if input is available without blocking."
[]
(when-let [r @tty-reader]
(.ready r)))
(defn read-char
"Read a single character. Blocking."
[]
(when-let [r @tty-reader]
(let [c (.read r)]
(when (>= c 0)
(char c)))))
(defn read-available
"Read all available characters without blocking."
[]
(when-let [r @tty-reader]
(loop [chars []]
(if (.ready r)
(let [c (.read r)]
(if (>= c 0)
(recur (conj chars (char c)))
chars))
chars))))
(defn read-char-timeout
"Read char with timeout in ms. Returns nil on timeout."
[timeout-ms]
(when-let [r @tty-reader]
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
(loop []
(cond
(.ready r)
(let [c (.read r)]
(when (>= c 0) (char c)))
(> (System/currentTimeMillis) deadline)
nil
:else
(do
(Thread/sleep 1)
(recur)))))))