Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
8 changes: 4 additions & 4 deletions src/charm/program.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 23 additions & 20 deletions src/charm/terminal.clj
Original file line number Diff line number Diff line change
@@ -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."
Expand Down Expand Up @@ -161,35 +162,37 @@
;; ---------------------------------------------------------------------------

(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
still reaches a closed terminal and a closed channel - which in a REPL, where
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))

66 changes: 48 additions & 18 deletions test/charm/terminal_test.clj
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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))))))
Loading