From 973c979663a1e87bdce5d70e5a6696bdaa30c371 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Sat, 17 Oct 2026 21:20:33 +0200 Subject: [PATCH] fix: handle resize on Windows via Terminal.handle (#20) --- CHANGELOG.md | 9 ++++- src/charm/program.clj | 8 ++--- src/charm/terminal.clj | 43 ++++++++++++----------- test/charm/terminal_test.clj | 66 ++++++++++++++++++++++++++---------- 4 files changed, 83 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dcabb..5081d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,12 @@ and an event loop that no longer caps input at about 100 messages a second. ### Fixed +- **Resizing the terminal works on Windows.** Windows has no SIGWINCH, so the + WINCH handler charm installed through JLine's static `Signals` helper never + ran and a program only ever saw its startup size. Signal handlers now go + through `Terminal.handle`, which receives the WINCH JLine's Windows terminal + raises when the console reports a resize + ([#20](https://github.com/TimoKramer/charm.clj/issues/20)). - **Styling no longer rewrites line-drawing characters as ASCII.** JLine's `toAnsi` both emits a style's escapes and rewrites characters it assumes the terminal cannot show, so `(style/render (style/style :border normal :border-fg @@ -233,4 +239,5 @@ and an event loop that no longer caps input at about 100 messages a second. ### Requirements JDK 22+. Ships with JLine 4.4.5, which carries the palette fixes above. -Babashka with JLine 4.4.5 (currently on master) for use with babashka. +Babashka 1.13.225 or later, which ships JLine 4.4.5 and can reify +`Terminal$SignalHandler`. diff --git a/src/charm/program.clj b/src/charm/program.clj index a461dc1..e3a1804 100644 --- a/src/charm/program.clj +++ b/src/charm/program.clj @@ -512,10 +512,10 @@ ;; Handle window resize and interrupt, keeping the handlers they ;; replaced so the finally can put them back (reset! previous-winch - (term/handle-signal :winch + (term/handle-signal terminal :winch #(check-window-size! terminal msg-chan last-size))) (reset! previous-int - (term/handle-signal :int + (term/handle-signal terminal :int #(a/put! msg-chan (msg/key-press "c" :ctrl true)))) ;; Check initial window size @@ -556,8 +556,8 @@ ;; Put back the signal handlers we displaced, before the terminal ;; closes under them - (term/restore-signal! :winch @previous-winch) - (term/restore-signal! :int @previous-int) + (term/restore-signal! terminal :winch @previous-winch) + (term/restore-signal! terminal :int @previous-int) ;; Restore the terminal, then stop guarding it (restore-terminal! renderer terminal original-attrs) diff --git a/src/charm/terminal.clj b/src/charm/terminal.clj index 9e00bc3..e5e6ca6 100644 --- a/src/charm/terminal.clj +++ b/src/charm/terminal.clj @@ -1,7 +1,8 @@ (ns ^:no-doc charm.terminal "JLine terminal wrapper for charm.clj" - (:import [org.jline.terminal Terminal TerminalBuilder Attributes] - [org.jline.utils InfoCmp$Capability Signals])) + (:import [org.jline.terminal Terminal TerminalBuilder Attributes + Terminal$Signal Terminal$SignalHandler] + [org.jline.utils InfoCmp$Capability])) (defn create-terminal "Create a JLine terminal with system I/O and FFM as native interface." @@ -161,16 +162,16 @@ ;; --------------------------------------------------------------------------- (def signals - "Signal names we install handlers for, by keyword." - {:int "INT" - :quit "QUIT" - :tstp "TSTP" - :cont "CONT" - :winch "WINCH"}) + "Signals we install handlers for, by keyword." + {:int Terminal$Signal/INT + :quit Terminal$Signal/QUIT + :tstp Terminal$Signal/TSTP + :cont Terminal$Signal/CONT + :winch Terminal$Signal/WINCH}) (defn handle-signal - "Install `f`, a no-argument function, as the handler for `signal` - (`:int`, `:winch`, ...). + "Install `f`, a no-argument function, as the `terminal`'s handler for + `signal` (`:int`, `:winch`, ...). Returns the handler it replaced, which `restore-signal!` puts back. Leaving a handler installed after the program exits means a later resize or Ctrl+C @@ -178,18 +179,20 @@ the process outlives the program, swallows Ctrl+C for the rest of the session. - JLine 4 also offers `Terminal.handle`, which is the supported route and - likewise returns the previous handler, but it needs the nested - `Terminal$Signal` and `Terminal$SignalHandler` classes, which babashka cannot - resolve. This uses the static `Signals` helper, which works on both." - [signal f] - (Signals/register (get signals signal) - (reify Runnable - (run [_] (f))))) + This goes through `Terminal.handle` rather than the static `Signals` helper, + because Windows has no SIGWINCH: JLine's Windows terminal turns a console + resize event into a WINCH that only handlers on the terminal see. Reifying + `Terminal$SignalHandler` needs babashka 1.13.225 or later." + ^Terminal$SignalHandler [^Terminal terminal signal f] + (.handle terminal + ^Terminal$Signal (get signals signal) + (reify Terminal$SignalHandler + (handle [_ _] (f))))) (defn restore-signal! "Put back a handler that `handle-signal` returned." - [signal handler] + [^Terminal terminal signal handler] (when handler - (Signals/unregister (get signals signal) handler))) + (.handle terminal ^Terminal$Signal (get signals signal) handler) + nil)) diff --git a/test/charm/terminal_test.clj b/test/charm/terminal_test.clj index 523878e..4da5d2e 100644 --- a/test/charm/terminal_test.clj +++ b/test/charm/terminal_test.clj @@ -1,7 +1,8 @@ (ns charm.terminal-test (:require [clojure.test :refer [deftest is testing]] [charm.terminal :as term]) - (:import [org.jline.terminal Terminal])) + (:import [java.io ByteArrayInputStream ByteArrayOutputStream] + [org.jline.terminal Terminal Terminal$Signal TerminalBuilder])) (deftest create-terminal-test (testing "create-terminal returns a JLine Terminal" @@ -72,26 +73,55 @@ (finally (term/close t)))))) +(defn- dumb-terminal + ^Terminal [] + (-> (TerminalBuilder/builder) + (.dumb true) + (.streams (ByteArrayInputStream. (byte-array 0)) (ByteArrayOutputStream.)) + (.build))) + (deftest signal-handling-test + (testing "a handler sees the signal raised on the terminal" + ;; JLine's Windows terminal has no SIGWINCH and raises WINCH on the terminal + ;; itself when the console reports a resize - only a handler installed on + ;; the terminal sees that + (let [t (dumb-terminal) + fired (atom 0)] + (try + (term/handle-signal t :winch #(swap! fired inc)) + (.raise t Terminal$Signal/WINCH) + (is (= 1 @fired)) + (finally + (term/close t))))) + (testing "handle-signal returns the handler it replaced, so it can be put back" - (let [fired (atom 0) - original (term/handle-signal :winch #(swap! fired inc)) - ours (term/handle-signal :winch #(swap! fired inc))] - ;; The second call hands back the handler the first one installed - (is (some? ours)) - (is (not= original ours)) - (term/restore-signal! :winch original) - ;; Restoring put the original back, so it comes round again - (is (= original (term/handle-signal :winch #(swap! fired inc)))) - (term/restore-signal! :winch original))) + (let [t (dumb-terminal) + fired (atom 0)] + (try + (let [original (term/handle-signal t :winch #(swap! fired inc)) + ours (term/handle-signal t :winch #(swap! fired inc))] + ;; The second call hands back the handler the first one installed + (is (some? ours)) + (is (not= original ours)) + (term/restore-signal! t :winch original) + ;; Restoring put the original back, so it comes round again + (is (= original (term/handle-signal t :winch #(swap! fired inc))))) + (finally + (term/close t))))) (testing "restore-signal! tolerates a nil handler" - (is (nil? (term/restore-signal! :int nil)))) + (let [t (dumb-terminal)] + (try + (is (nil? (term/restore-signal! t :int nil))) + (finally + (term/close t))))) - (testing "every signal name is one this platform accepts" + (testing "every signal can be handled and restored on the system terminal" (is (= #{:int :quit :tstp :cont :winch} (set (keys term/signals)))) - ;; JLine throws for a signal the platform does not have - INFO, for one, - ;; is BSD-only - so registering each in turn is the check that matters - (doseq [signal (keys term/signals)] - (let [previous (term/handle-signal signal (fn []))] - (is (nil? (term/restore-signal! signal previous))))))) + (let [t (term/create-terminal)] + (try + (doseq [signal (keys term/signals)] + (let [previous (term/handle-signal t signal (fn []))] + (is (nil? (term/restore-signal! t signal previous))))) + (finally + (term/close t))))))