diff --git a/examples/focus-blur/main.go b/examples/focus-blur/main.go new file mode 100644 index 0000000000..155ebae1f1 --- /dev/null +++ b/examples/focus-blur/main.go @@ -0,0 +1,66 @@ +package main + +// A simple program that handled losing and acquiring focus. + +import ( + "log" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + p := tea.NewProgram(model{ + // assume we start focused... + focused: true, + reporting: true, + }, tea.WithReportFocus()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} + +type model struct { + focused bool + reporting bool +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.FocusMsg: + m.focused = true + case tea.BlurMsg: + m.focused = false + case tea.KeyMsg: + switch msg.String() { + case "t": + m.reporting = !m.reporting + case "ctrl+c", "q": + return m, tea.Quit + } + } + + return m, nil +} + +func (m model) View() string { + s := "Hi. Focus report is currently " + if m.reporting { + s += "enabled" + } else { + s += "disabled" + } + s += ".\n\n" + + if m.reporting { + if m.focused { + s += "This program is currently focused!" + } else { + s += "This program is currently blurred!" + } + } + return s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n" +} diff --git a/focus.go b/focus.go new file mode 100644 index 0000000000..4d34bea6f8 --- /dev/null +++ b/focus.go @@ -0,0 +1,9 @@ +package tea + +// FocusMsg represents a terminal focus message. +// This occurs when the terminal gains focus. +type FocusMsg struct{} + +// BlurMsg represents a terminal blur message. +// This occurs when the terminal loses focus. +type BlurMsg struct{} diff --git a/go.mod b/go.mod index b03c969c12..cfe3556678 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.18 require ( github.com/charmbracelet/lipgloss v0.13.0 - github.com/charmbracelet/x/ansi v0.1.4 - github.com/charmbracelet/x/term v0.1.1 + github.com/charmbracelet/x/ansi v0.2.3 + github.com/charmbracelet/x/term v0.2.0 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f github.com/mattn/go-localereader v0.0.1 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 @@ -16,13 +16,10 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/input v0.1.0 // indirect - github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index e027dcf56c..890268a9b3 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= -github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= -github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= -github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= -github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= -github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= -github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -29,9 +25,6 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/key.go b/key.go index 89a588aec0..ab4792ac63 100644 --- a/key.go +++ b/key.go @@ -628,6 +628,13 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { } } + // Detect focus events. + var foundRF bool + foundRF, w, msg = detectReportFocus(b) + if foundRF { + return w, msg + } + // Detect bracketed paste. var foundbp bool foundbp, w, msg = detectBracketedPaste(b) diff --git a/key_sequences.go b/key_sequences.go index 4ba0f79e34..0732ffdf1b 100644 --- a/key_sequences.go +++ b/key_sequences.go @@ -117,3 +117,14 @@ func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) { return true, inputLen, KeyMsg(k) } + +// detectReportFocus detects a focus report sequence. +func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) { + switch { + case bytes.Equal(input, []byte("\x1b[I")): + return true, 3, FocusMsg{} + case bytes.Equal(input, []byte("\x1b[O")): + return true, 3, BlurMsg{} + } + return false, 0, nil +} diff --git a/key_test.go b/key_test.go index 67b0c50ed5..0970b986f2 100644 --- a/key_test.go +++ b/key_test.go @@ -134,6 +134,15 @@ func TestDetectOneMsg(t *testing.T) { // Add tests for the inputs that detectOneMsg() can parse, but // detectSequence() cannot. td = append(td, + // focus/blur + seqTest{ + []byte{'\x1b', '[', 'I'}, + FocusMsg{}, + }, + seqTest{ + []byte{'\x1b', '[', 'O'}, + BlurMsg{}, + }, // Mouse event. seqTest{ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, @@ -242,7 +251,8 @@ func TestReadInput(t *testing.T) { out []Msg } testData := []test{ - {"a", + { + "a", []byte{'a'}, []Msg{ KeyMsg{ @@ -251,7 +261,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {" ", + { + " ", []byte{' '}, []Msg{ KeyMsg{ @@ -260,14 +271,16 @@ func TestReadInput(t *testing.T) { }, }, }, - {"a alt+a", + { + "a alt+a", []byte{'a', '\x1b', 'a'}, []Msg{ KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}, }, }, - {"a alt+a a", + { + "a alt+a a", []byte{'a', '\x1b', 'a', 'a'}, []Msg{ KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, @@ -275,7 +288,8 @@ func TestReadInput(t *testing.T) { KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, }, }, - {"ctrl+a", + { + "ctrl+a", []byte{byte(keySOH)}, []Msg{ KeyMsg{ @@ -283,14 +297,16 @@ func TestReadInput(t *testing.T) { }, }, }, - {"ctrl+a ctrl+b", + { + "ctrl+a ctrl+b", []byte{byte(keySOH), byte(keySTX)}, []Msg{ KeyMsg{Type: KeyCtrlA}, KeyMsg{Type: KeyCtrlB}, }, }, - {"alt+a", + { + "alt+a", []byte{byte(0x1b), 'a'}, []Msg{ KeyMsg{ @@ -300,7 +316,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"abcd", + { + "abcd", []byte{'a', 'b', 'c', 'd'}, []Msg{ KeyMsg{ @@ -309,7 +326,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"up", + { + "up", []byte("\x1b[A"), []Msg{ KeyMsg{ @@ -317,7 +335,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"wheel up", + { + "wheel up", []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ MouseMsg{ @@ -329,7 +348,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"left motion release", + { + "left motion release", []byte{ '\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33), '\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33), @@ -351,7 +371,8 @@ func TestReadInput(t *testing.T) { }), }, }, - {"shift+tab", + { + "shift+tab", []byte{'\x1b', '[', 'Z'}, []Msg{ KeyMsg{ @@ -359,11 +380,13 @@ func TestReadInput(t *testing.T) { }, }, }, - {"enter", + { + "enter", []byte{'\r'}, []Msg{KeyMsg{Type: KeyEnter}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\r'}, []Msg{ KeyMsg{ @@ -372,7 +395,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"insert", + { + "insert", []byte{'\x1b', '[', '2', '~'}, []Msg{ KeyMsg{ @@ -380,7 +404,8 @@ func TestReadInput(t *testing.T) { }, }, }, - {"alt+ctrl+a", + { + "alt+ctrl+a", []byte{'\x1b', byte(keySOH)}, []Msg{ KeyMsg{ @@ -389,52 +414,64 @@ func TestReadInput(t *testing.T) { }, }, }, - {"?CSI[45 45 45 45 88]?", + { + "?CSI[45 45 45 45 88]?", []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, []Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})}, }, // Powershell sequences. - {"up", + { + "up", []byte{'\x1b', 'O', 'A'}, []Msg{KeyMsg{Type: KeyUp}}, }, - {"down", + { + "down", []byte{'\x1b', 'O', 'B'}, []Msg{KeyMsg{Type: KeyDown}}, }, - {"right", + { + "right", []byte{'\x1b', 'O', 'C'}, []Msg{KeyMsg{Type: KeyRight}}, }, - {"left", + { + "left", []byte{'\x1b', 'O', 'D'}, []Msg{KeyMsg{Type: KeyLeft}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\x0d'}, []Msg{KeyMsg{Type: KeyEnter, Alt: true}}, }, - {"alt+backspace", + { + "alt+backspace", []byte{'\x1b', '\x7f'}, []Msg{KeyMsg{Type: KeyBackspace, Alt: true}}, }, - {"ctrl+@", + { + "ctrl+@", []byte{'\x00'}, []Msg{KeyMsg{Type: KeyCtrlAt}}, }, - {"alt+ctrl+@", + { + "alt+ctrl+@", []byte{'\x1b', '\x00'}, []Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}}, }, - {"esc", + { + "esc", []byte{'\x1b'}, []Msg{KeyMsg{Type: KeyEsc}}, }, - {"alt+esc", + { + "alt+esc", []byte{'\x1b', '\x1b'}, []Msg{KeyMsg{Type: KeyEsc, Alt: true}}, }, - {"[a b] o", + { + "[a b] o", []byte{ '\x1b', '[', '2', '0', '0', '~', 'a', ' ', 'b', @@ -446,11 +483,13 @@ func TestReadInput(t *testing.T) { KeyMsg{Type: KeyRunes, Runes: []rune("o")}, }, }, - {"[a\x03\nb]", + { + "[a\x03\nb]", []byte{ '\x1b', '[', '2', '0', '0', '~', 'a', '\x03', '\n', 'b', - '\x1b', '[', '2', '0', '1', '~'}, + '\x1b', '[', '2', '0', '1', '~', + }, []Msg{ KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true}, }, @@ -460,11 +499,13 @@ func TestReadInput(t *testing.T) { // Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows. // This is incorrect, but it makes our test fail if we try it out. testData = append(testData, - test{"?0xfe?", + test{ + "?0xfe?", []byte{'\xfe'}, []Msg{unknownInputByteMsg(0xfe)}, }, - test{"a ?0xfe? b", + test{ + "a ?0xfe? b", []byte{'a', '\xfe', ' ', 'b'}, []Msg{ KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, diff --git a/nil_renderer.go b/nil_renderer.go index f4a83b6bc4..0bc4a17206 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -23,3 +23,6 @@ func (n nilRenderer) enableMouseSGRMode() {} func (n nilRenderer) disableMouseSGRMode() {} func (n nilRenderer) bracketedPasteActive() bool { return false } func (n nilRenderer) setWindowTitle(_ string) {} +func (n nilRenderer) reportFocus() bool { return false } +func (n nilRenderer) enableReportFocus() {} +func (n nilRenderer) disableReportFocus() {} diff --git a/options.go b/options.go index a810fe1c7b..4a56fc8114 100644 --- a/options.go +++ b/options.go @@ -234,3 +234,12 @@ func WithFPS(fps int) ProgramOption { p.fps = fps } } + +// WithReportFocus enables reporting when the terminal gains and lost focus. +// +// You can then check for FocusMsg and BlurMsg in your model's Update method. +func WithReportFocus() ProgramOption { + return func(p *Program) { + p.startupOptions |= withReportFocus + } +} diff --git a/renderer.go b/renderer.go index de3936e73b..9eb7943bcf 100644 --- a/renderer.go +++ b/renderer.go @@ -70,6 +70,15 @@ type renderer interface { // setWindowTitle sets the terminal window title. setWindowTitle(string) + + // reportFocus returns whether reporting focus events is enabled. + reportFocus() bool + + // enableReportFocus reports focus events to the program. + enableReportFocus() + + // disableReportFocus stops reporting focus events to the program. + disableReportFocus() } // repaintMsg forces a full repaint. diff --git a/screen.go b/screen.go index 6256c15920..dfec48f0b4 100644 --- a/screen.go +++ b/screen.go @@ -144,6 +144,26 @@ func DisableBracketedPaste() Msg { // disableBracketedPasteMsg with DisableBracketedPaste. type disableBracketedPasteMsg struct{} +// enableReportFocusMsg is an internal message that signals to enable focus +// reporting. You can send an enableReportFocusMsg with EnableReportFocus. +type enableReportFocusMsg struct{} + +// EnableReportFocus is a special command that tells the Bubble Tea program to +// report focus events to the program. +func EnableReportFocus() Msg { + return enableReportFocusMsg{} +} + +// disableReportFocusMsg is an internal message that signals to disable focus +// reporting. You can send an disableReportFocusMsg with DisableReportFocus. +type disableReportFocusMsg struct{} + +// DisableReportFocus is a special command that tells the Bubble Tea program to +// stop reporting focus events to the program. +func DisableReportFocus() Msg { + return disableReportFocusMsg{} +} + // EnterAltScreen enters the alternate screen buffer, which consumes the entire // terminal window. ExitAltScreen will return the terminal to its former state. // diff --git a/standard_renderer.go b/standard_renderer.go index f81920de01..59448e1d41 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -47,6 +47,9 @@ type standardRenderer struct { // whether or not we're currently using bracketed paste bpActive bool + // reportingFocus whether reporting focus events is enabled + reportingFocus bool + // renderer dimensions; usually the size of the window width int height int @@ -458,6 +461,29 @@ func (r *standardRenderer) bracketedPasteActive() bool { return r.bpActive } +func (r *standardRenderer) enableReportFocus() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.execute(ansi.EnableReportFocus) + r.reportingFocus = true +} + +func (r *standardRenderer) disableReportFocus() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.execute(ansi.DisableReportFocus) + r.reportingFocus = false +} + +func (r *standardRenderer) reportFocus() bool { + r.mtx.Lock() + defer r.mtx.Unlock() + + return r.reportingFocus +} + // setWindowTitle sets the terminal window title. func (r *standardRenderer) setWindowTitle(title string) { r.execute(ansi.SetWindowTitle(title)) diff --git a/tea.go b/tea.go index 62cd6415bb..5ed9707622 100644 --- a/tea.go +++ b/tea.go @@ -97,6 +97,7 @@ const ( // feature is on by default. withoutCatchPanics withoutBracketedPaste + withReportFocus ) // channelHandlers manages the series of channels returned by various processes. @@ -167,6 +168,7 @@ type Program struct { ignoreSignals uint32 bpWasActive bool // was the bracketed paste mode active before releasing the terminal? + reportFocus bool // was focus reporting active before releasing the terminal? filter func(Model, Msg) Msg @@ -390,6 +392,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case disableBracketedPasteMsg: p.renderer.disableBracketedPaste() + case enableReportFocusMsg: + p.renderer.enableReportFocus() + + case disableReportFocusMsg: + p.renderer.disableReportFocus() + case execMsg: // NB: this blocks. p.exec(msg.cmd, msg.fn) @@ -542,6 +550,9 @@ func (p *Program) Run() (Model, error) { p.renderer.enableMouseAllMotion() p.renderer.enableMouseSGRMode() } + if p.startupOptions&withReportFocus != 0 { + p.renderer.enableReportFocus() + } // Start the renderer. p.renderer.start() @@ -694,6 +705,7 @@ func (p *Program) ReleaseTerminal() error { p.renderer.stop() p.altScreenWasActive = p.renderer.altScreen() p.bpWasActive = p.renderer.bracketedPasteActive() + p.reportFocus = p.renderer.reportFocus() } return p.restoreTerminalState() @@ -723,6 +735,9 @@ func (p *Program) RestoreTerminal() error { if p.bpWasActive { p.renderer.enableBracketedPaste() } + if p.reportFocus { + p.renderer.enableReportFocus() + } // If the output is a terminal, it may have been resized while another // process was at the foreground, in which case we may not have received diff --git a/tty.go b/tty.go index 88aacabd51..02507782cc 100644 --- a/tty.go +++ b/tty.go @@ -44,6 +44,10 @@ func (p *Program) restoreTerminalState() error { p.renderer.showCursor() p.disableMouse() + if p.renderer.reportFocus() { + p.renderer.disableReportFocus() + } + if p.renderer.altScreen() { p.renderer.exitAltScreen()