From cb9395982335da2fbc8cf90c5cf2c3b73eee4787 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 2 Dec 2024 09:14:20 -0300 Subject: [PATCH] feat(SIGINT): better handle interrupts (#1255) * feat(SIGINT): better handle interrupts This PR introduces `Interrupt()`, `InterruptMsg{}`, and `ErrInterrupted`. Users can still handle "ctrl+c" as before, but instead of return `tea.Quit`, they can return `tea.Interrupt`. Later on, they can `errors.If(err, tea.ErrInterrupted)` and `os.Exit(130)` if they want to. * chore: fix unrelated typo it was bothering me --- examples/suspend/main.go | 11 ++++++++-- options.go | 2 +- tea.go | 46 ++++++++++++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/examples/suspend/main.go b/examples/suspend/main.go index 09dc1b8c8b..07339bfdba 100644 --- a/examples/suspend/main.go +++ b/examples/suspend/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" @@ -23,9 +24,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: switch msg.String() { - case "q", "ctrl+c", "esc": + case "q", "esc": m.quitting = true return m, tea.Quit + case "ctrl+c": + m.quitting = true + return m, tea.Interrupt case "ctrl+z": m.suspending = true return m, tea.Suspend @@ -39,12 +43,15 @@ func (m model) View() string { return "" } - return "\nPress ctrl-z to suspend, or ctrl+c to exit\n" + return "\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n" } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) + if errors.Is(err, tea.ErrInterrupted) { + os.Exit(130) + } os.Exit(1) } } diff --git a/options.go b/options.go index 12e92e4e8e..c509353b18 100644 --- a/options.go +++ b/options.go @@ -186,7 +186,7 @@ func WithoutRenderer() ProgramOption { // This feature is provisional, and may be changed or removed in a future version // of this package. // -// Deprecated: this incurs a noticable performance hit. A future release will +// Deprecated: this incurs a noticeable performance hit. A future release will // optimize ANSI automatically without the performance penalty. func WithANSICompressor() ProgramOption { return func(p *Program) { diff --git a/tea.go b/tea.go index 87211ba299..743a866e93 100644 --- a/tea.go +++ b/tea.go @@ -26,9 +26,13 @@ import ( "golang.org/x/sync/errgroup" ) -// ErrProgramKilled is returned by [Program.Run] when the program got killed. +// ErrProgramKilled is returned by [Program.Run] when the program gets killed. var ErrProgramKilled = errors.New("program was killed") +// ErrInterrupted is returned by [Program.Run] when the program get a SIGINT +// signal, or when it receives a [InterruptMsg]. +var ErrInterrupted = errors.New("program was interrupted") + // Msg contain data from the result of a IO operation. Msgs trigger the update // function and, henceforth, the UI. type Msg interface{} @@ -186,8 +190,8 @@ func Quit() Msg { return QuitMsg{} } -// QuitMsg signals that the program should quit. You can send a QuitMsg with -// Quit. +// QuitMsg signals that the program should quit. You can send a [QuitMsg] with +// [Quit]. type QuitMsg struct{} // Suspend is a special command that tells the Bubble Tea program to suspend. @@ -199,13 +203,28 @@ func Suspend() Msg { // This usually happens when ctrl+z is pressed on common programs, but since // bubbletea puts the terminal in raw mode, we need to handle it in a // per-program basis. -// You can send this message with Suspend. +// +// You can send this message with [Suspend()]. type SuspendMsg struct{} // ResumeMsg can be listen to to do something once a program is resumed back // from a suspend state. type ResumeMsg struct{} +// InterruptMsg signals the program should suspend. +// This usually happens when ctrl+c is pressed on common programs, but since +// bubbletea puts the terminal in raw mode, we need to handle it in a +// per-program basis. +// +// You can send this message with [Interrupt()]. +type InterruptMsg struct{} + +// Interrupt is a special command that tells the Bubble Tea program to +// interrupt. +func Interrupt() Msg { + return InterruptMsg{} +} + // NewProgram creates a new Program. func NewProgram(model Model, opts ...ProgramOption) *Program { p := &Program{ @@ -263,9 +282,14 @@ func (p *Program) handleSignals() chan struct{} { case <-p.ctx.Done(): return - case <-sig: + case s := <-sig: if atomic.LoadUint32(&p.ignoreSignals) == 0 { - p.msgs <- QuitMsg{} + switch s { + case syscall.SIGINT: + p.msgs <- InterruptMsg{} + default: + p.msgs <- QuitMsg{} + } return } } @@ -362,6 +386,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case QuitMsg: return model, nil + case InterruptMsg: + return model, ErrInterrupted + case SuspendMsg: if suspendSupported { p.suspend() @@ -593,10 +620,11 @@ func (p *Program) Run() (Model, error) { // Run event loop, handle updates and draw. model, err := p.eventLoop(model, cmds) - killed := p.ctx.Err() != nil - if killed { + killed := p.ctx.Err() != nil || err != nil + if killed && err == nil { err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err()) - } else { + } + if err == nil { // Ensure we rendered the final state of the model. p.renderer.write(model.View()) }