Skip to content

Commit

Permalink
feat: add generic event filter (#536)
Browse files Browse the repository at this point in the history
`WithFilter` lets you supply an event filter that will be invoked
before Bubble Tea processes a `tea.Msg`. The event filter can return
any `tea.Msg` which will then get handled by Bubble Tea instead of
the original event. If the event filter returns nil, the event
will be ignored and Bubble Tea will not process it.

As an example, this could be used to prevent a program from
shutting down if there are unsaved changes.

Based on the fantastic work by @aschey and supersedes #521.

Resolves #472.
  • Loading branch information
muesli authored Apr 17, 2023
1 parent 8514d90 commit c56884c
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 6 deletions.
154 changes: 154 additions & 0 deletions examples/prevent-quit/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package main

// A program demonstrating how to use the WithFilter option to intercept events.

import (
"fmt"
"log"

"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

var (
choiceStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("241"))
saveTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170"))
quitViewStyle = lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("170"))
)

func main() {
p := tea.NewProgram(initialModel(), tea.WithFilter(filter))

if _, err := p.Run(); err != nil {
log.Fatal(err)
}
}

func filter(teaModel tea.Model, msg tea.Msg) tea.Msg {
if _, ok := msg.(tea.QuitMsg); !ok {
return msg
}

m := teaModel.(model)
if m.hasChanges {
return nil
}

return msg
}

type model struct {
textarea textarea.Model
help help.Model
keymap keymap
saveText string
hasChanges bool
quitting bool
}

type keymap struct {
save key.Binding
quit key.Binding
}

func initialModel() model {
ti := textarea.New()
ti.Placeholder = "Only the best words"
ti.Focus()

return model{
textarea: ti,
help: help.NewModel(),
keymap: keymap{
save: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "save"),
),
quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
},
}
}

func (m model) Init() tea.Cmd {
return textarea.Blink
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.quitting {
return m.updatePromptView(msg)
}

return m.updateTextView(msg)
}

func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd

switch msg := msg.(type) {
case tea.KeyMsg:
m.saveText = ""
switch {
case key.Matches(msg, m.keymap.save):
m.saveText = "Changes saved!"
m.hasChanges = false
case key.Matches(msg, m.keymap.quit):
m.quitting = true
return m, tea.Quit
case msg.Type == tea.KeyRunes:
m.saveText = ""
m.hasChanges = true
fallthrough
default:
if !m.textarea.Focused() {
cmd = m.textarea.Focus()
cmds = append(cmds, cmd)
}
}
}
m.textarea, cmd = m.textarea.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}

func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// For simplicity's sake, we'll treat any key besides "y" as "no"
if key.Matches(msg, m.keymap.quit) || msg.String() == "y" {
m.hasChanges = false
return m, tea.Quit
}
m.quitting = false
}

return m, nil
}

func (m model) View() string {
if m.quitting {
if m.hasChanges {
text := lipgloss.JoinHorizontal(lipgloss.Top, "You have unsaved changes. Quit without saving?", choiceStyle.Render("[yn]"))
return quitViewStyle.Render(text)
}
return "Very important, thank you\n"
}

helpView := m.help.ShortHelpView([]key.Binding{
m.keymap.save,
m.keymap.quit,
})

return fmt.Sprintf(
"\nType some important things.\n\n%s\n\n %s\n %s",
m.textarea.View(),
saveTextStyle.Render(m.saveText),
helpView,
) + "\n\n"
}
35 changes: 35 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,38 @@ func WithANSICompressor() ProgramOption {
p.startupOptions |= withANSICompressor
}
}

// WithFilter supplies an event filter that will be invoked before Bubble Tea
// processes a tea.Msg. The event filter can return any tea.Msg which will then
// get handled by Bubble Tea instead of the original event. If the event filter
// returns nil, the event will be ignored and Bubble Tea will not process it.
//
// As an example, this could be used to prevent a program from shutting down if
// there are unsaved changes.
//
// Example:
//
// func filter(m tea.Model, msg tea.Msg) tea.Msg {
// if _, ok := msg.(tea.QuitMsg); !ok {
// return msg
// }
//
// model := m.(myModel)
// if model.hasChanges {
// return nil
// }
//
// return msg
// }
//
// p := tea.NewProgram(Model{}, tea.WithFilter(filter));
//
// if _,err := p.Run(); err != nil {
// fmt.Println("Error running program:", err)
// os.Exit(1)
// }
func WithFilter(filter func(Model, Msg) Msg) ProgramOption {
return func(p *Program) {
p.filter = filter
}
}
7 changes: 7 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func TestOptions(t *testing.T) {
}
})

t.Run("filter", func(t *testing.T) {
p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg }))
if p.filter == nil {
t.Errorf("expected filter to be set")
}
})

t.Run("startup options", func(t *testing.T) {
exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) {
p := NewProgram(nil, opt)
Expand Down
22 changes: 16 additions & 6 deletions tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,18 @@ type Program struct {
// as this value only comes into play on Windows, hence the ignore comment
// below.
windowsStdin *os.File //nolint:golint,structcheck,unused

filter func(Model, Msg) Msg
}

// Quit is a special command that tells the Bubble Tea program to exit.
func Quit() Msg {
return quitMsg{}
return QuitMsg{}
}

// quitMsg in an internal message signals that the program should quit. You can
// send a quitMsg with Quit.
type quitMsg struct{}
// QuitMsg signals that the program should quit. You can send a QuitMsg with
// Quit.
type QuitMsg struct{}

// NewProgram creates a new Program.
func NewProgram(model Model, opts ...ProgramOption) *Program {
Expand Down Expand Up @@ -194,7 +196,7 @@ func (p *Program) handleSignals() chan struct{} {

case <-sig:
if !p.ignoreSignals {
p.msgs <- quitMsg{}
p.msgs <- QuitMsg{}
return
}
}
Expand Down Expand Up @@ -267,9 +269,17 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
return model, err

case msg := <-p.msgs:
// Filter messages.
if p.filter != nil {
msg = p.filter(model, msg)
}
if msg == nil {
continue
}

// Handle special internal messages.
switch msg := msg.(type) {
case quitMsg:
case QuitMsg:
return model, nil

case clearScreenMsg:
Expand Down
41 changes: 41 additions & 0 deletions tea_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ func TestTeaQuit(t *testing.T) {
}
}

func TestTeaWithFilter(t *testing.T) {
testTeaWithFilter(t, 0)
testTeaWithFilter(t, 1)
testTeaWithFilter(t, 2)
}

func testTeaWithFilter(t *testing.T, preventCount uint32) {
var buf bytes.Buffer
var in bytes.Buffer

m := &testModel{}
shutdowns := uint32(0)
p := NewProgram(m,
WithInput(&in),
WithOutput(&buf),
WithFilter(func(_ Model, msg Msg) Msg {
if _, ok := msg.(QuitMsg); !ok {
return msg
}
if shutdowns < preventCount {
atomic.AddUint32(&shutdowns, 1)
return nil
}
return msg
}))

go func() {
for atomic.LoadUint32(&shutdowns) <= preventCount {
time.Sleep(time.Millisecond)
p.Quit()
}
}()

if err := p.Start(); err != nil {
t.Fatal(err)
}
if shutdowns != preventCount {
t.Errorf("Expected %d prevented shutdowns, got %d", preventCount, shutdowns)
}
}

func TestTeaKill(t *testing.T) {
var buf bytes.Buffer
var in bytes.Buffer
Expand Down

0 comments on commit c56884c

Please sign in to comment.