Clean shutdown on Ctrl-C and on resize
Two events that should always restore the terminal cleanly:
Ctrl-C— user wants to quit, fast.- Window resize — not a shutdown event itself, but a frequent trigger for "my app crashed and now my terminal is broken".
The runtime handles most of this for you. The remaining work is
making sure your app dispatches Cmd.Exit from the right places.
What the runtime already does
TuiRuntime.runregisters a JVM shutdown hook that restores the terminal (alt-buffer off, cursor visible, raw mode off, mouse tracking off) even on uncaught exceptions orkill.- All
Subs registered viaRuntimeCtx(Sub.Every,Sub.InputKey,Sub.TerminalResize) auto-cancel onCmd.Exitand on shutdown. - The renderer watches for SIGWINCH (size change) and re-renders without you doing anything.
What you have to wire
- A
QuitMsgand aCmd.Exitfollow-up for it. - A keystroke that produces
QuitforCtrl-C(and ideallyq). - An optional re-read of
ctx.terminal.{width, height}on everyupdateso the model knows the current size.
Code: the canonical wiring
import termflow.tui.{Cmd, KeyDecoder, RuntimeCtx, Sub, Tui, TuiApp, TuiRuntime, RootNode}
import termflow.tui.KeyDecoder.InputKey.*
import termflow.tui.Tui.*
import termflow.tui.TuiPrelude.*
object MyApp:
final case class Model(
width: Int,
height: Int,
input: Sub[Msg]
)
enum Msg:
case KeyPressed(k: KeyDecoder.InputKey)
case Quit
object App extends TuiApp[Model, Msg]:
private def syncSize(m: Model, ctx: RuntimeCtx[Msg]): Model =
val w = ctx.terminal.width
val h = ctx.terminal.height
if w == m.width && h == m.height then m else m.copy(width = w, height = h)
def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
val keys = Sub.InputKey[Msg](
msg = Msg.KeyPressed.apply,
onError = _ => Msg.Quit, // hard-quit on input pump errors
ctx = ctx
)
Model(ctx.terminal.width, ctx.terminal.height, keys).tui
def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
val sized = syncSize(m, ctx)
msg match
case Msg.KeyPressed(Ctrl('C')) => Tui(sized, Cmd.Exit)
case Msg.KeyPressed(CharKey('q')) => Tui(sized, Cmd.Exit)
case Msg.KeyPressed(_) => sized.tui
case Msg.Quit => Tui(sized, Cmd.Exit)
def view(m: Model): RootNode =
RootNode(m.width, m.height, children = /* … */, input = None)
def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)
def main(args: Array[String]): Unit =
val _ = args
TuiRuntime.run(App)
Notes
syncSizeon every update is the cheapest way to stay correct on resize. The cost is a single==comparison per message — effectively free.Ctrl-Cshould always quit. Don't be tempted to swallow it: if your app hangs, the user will reach forCtrl-Cand expect a graceful exit.- Quit during a modal dialog. If you have an open
Dialogsoverlay,Ctrl-Ccan either close the dialog or quit the whole app. Pick one and be consistent. Most apps treatCtrl-Cas unconditional quit andEscas "close the dialog". - Quit during text input. Inside a
PromptorTextField,qis a literal letter — only quit onqoutside text fields. The wizard sample shows the quit-when-not-in-text-field idiom. - Don't
System.exit. It bypasses the runtime's cleanup. Always returnCmd.Exitfromupdate; the runtime takes it from there. - Crash recovery. If your app crashes despite all of this,
resetin your shell will get the terminal back. The shutdown hook makes this rare.
For a working example, every demo app under
modules/termflow-sample/.../apps
follows this pattern.