Skip to content

Commit

Permalink
feat(SIGINT): better handle interrupts (#1255)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
caarlos0 authored Dec 2, 2024
1 parent 2ea49dd commit cb93959
Show file tree
Hide file tree
Showing 3 changed files with 47 additions and 12 deletions.
11 changes: 9 additions & 2 deletions examples/suspend/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"fmt"
"os"

Expand All @@ -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
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 37 additions & 9 deletions tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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.
Expand All @@ -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{
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
}
Expand Down

0 comments on commit cb93959

Please sign in to comment.