diff --git a/go.mod b/go.mod index 34bcfa4f9a..e84a49b908 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/charmbracelet/bubbletea go 1.18 require ( - github.com/charmbracelet/x/ansi v0.1.4 + github.com/charmbracelet/x/ansi v0.1.5-0.20240814155920-d72bfb0444d7 github.com/charmbracelet/x/term v0.1.1 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 diff --git a/go.sum b/go.sum index afe8f921d0..29768a7219 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ 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/ansi v0.1.5-0.20240813204730-a29dab672bf2 h1:/BRVAHphENSsvuYG+iVP/qgC6Bjjc9zdOmY3pQzg7mE= +github.com/charmbracelet/x/ansi v0.1.5-0.20240813204730-a29dab672bf2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.1.5-0.20240814155920-d72bfb0444d7 h1:wcBKMnW8Fm0Oiw6b7pTXe+5eCntEuKI6s82J41nZcEY= +github.com/charmbracelet/x/ansi v0.1.5-0.20240814155920-d72bfb0444d7/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= diff --git a/key_test.go b/key_test.go index d46da05379..bea16ed316 100644 --- a/key_test.go +++ b/key_test.go @@ -113,6 +113,84 @@ func buildBaseSeqTests() []seqTest { func TestParseSequence(t *testing.T) { td := buildBaseSeqTests() td = append(td, + // Kitty keyboard / CSI u (fixterms) + seqTest{ + []byte("\x1b[1B"), + []Msg{KeyPressMsg{Type: KeyDown}}, + }, + seqTest{ + []byte("\x1b[1;B"), + []Msg{KeyPressMsg{Type: KeyDown}}, + }, + seqTest{ + []byte("\x1b[1;4B"), + []Msg{KeyPressMsg{Mod: ModShift | ModAlt, Type: KeyDown}}, + }, + seqTest{ + []byte("\x1b[8~"), + []Msg{KeyPressMsg{Type: KeyEnd}}, + }, + seqTest{ + []byte("\x1b[8;~"), + []Msg{KeyPressMsg{Type: KeyEnd}}, + }, + seqTest{ + []byte("\x1b[8;10~"), + []Msg{KeyPressMsg{Mod: ModShift | ModMeta, Type: KeyEnd}}, + }, + seqTest{ + []byte("\x1b[27;4u"), + []Msg{KeyPressMsg{Mod: ModShift | ModAlt, Type: KeyEscape}}, + }, + seqTest{ + []byte("\x1b[127;4u"), + []Msg{KeyPressMsg{Mod: ModShift | ModAlt, Type: KeyBackspace}}, + }, + seqTest{ + []byte("\x1b[57358;4u"), + []Msg{KeyPressMsg{Mod: ModShift | ModAlt, Type: KeyCapsLock}}, + }, + seqTest{ + []byte("\x1b[9;2u"), + []Msg{KeyPressMsg{Mod: ModShift, Type: KeyTab}}, + }, + seqTest{ + []byte("\x1b[195;u"), + []Msg{KeyPressMsg{Runes: []rune{'Ã'}, Type: KeyRunes}}, + }, + seqTest{ + []byte("\x1b[20320;2u"), + []Msg{KeyPressMsg{Runes: []rune{'你'}, Mod: ModShift, Type: KeyRunes}}, + }, + seqTest{ + []byte("\x1b[195;:1u"), + []Msg{KeyPressMsg{Runes: []rune{'Ã'}, Type: KeyRunes}}, + }, + seqTest{ + []byte("\x1b[195;2:3u"), + []Msg{KeyReleaseMsg{Runes: []rune{'Ã'}, Mod: ModShift}}, + }, + seqTest{ + []byte("\x1b[195;2:2u"), + []Msg{KeyPressMsg{Runes: []rune{'Ã'}, IsRepeat: true, Mod: ModShift}}, + }, + seqTest{ + []byte("\x1b[195;2:1u"), + []Msg{KeyPressMsg{Runes: []rune{'Ã'}, Mod: ModShift}}, + }, + seqTest{ + []byte("\x1b[195;2:3u"), + []Msg{KeyReleaseMsg{Runes: []rune{'Ã'}, Mod: ModShift}}, + }, + seqTest{ + []byte("\x1b[97;2;65u"), + []Msg{KeyPressMsg{Runes: []rune{'A'}, Mod: ModShift, altRune: 'a'}}, + }, + seqTest{ + []byte("\x1b[97;;229u"), + []Msg{KeyPressMsg{Runes: []rune{'å'}, altRune: 'a'}}, + }, + // focus/blur seqTest{ []byte{'\x1b', '[', 'I'}, diff --git a/kitty.go b/kitty.go index ff6279d78d..826c0352be 100644 --- a/kitty.go +++ b/kitty.go @@ -7,32 +7,71 @@ import ( "github.com/charmbracelet/x/ansi" ) -// KittyKeyboardMsg represents Kitty keyboard progressive enhancement flags message. -type KittyKeyboardMsg int +// setKittyKeyboardFlagsMsg is a message to set Kitty keyboard progressive +// enhancement protocol flags. +type setKittyKeyboardFlagsMsg int -// IsDisambiguateEscapeCodes returns true if the DisambiguateEscapeCodes flag is set. -func (e KittyKeyboardMsg) IsDisambiguateEscapeCodes() bool { - return e&ansi.KittyDisambiguateEscapeCodes != 0 +// EnableKittyKeyboard is a command to enable Kitty keyboard progressive +// enhancements. +// +// The flags parameter is a bitmask of the following +// +// 1: Disambiguate escape codes +// 2: Report event types +// 4: Report alternate keys +// 8: Report all keys as escape codes +// 16: Report associated text +// +// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/ for more information. +func EnableKittyKeyboard(flags int) Cmd { + return func() Msg { + return setKittyKeyboardFlagsMsg(flags) + } +} + +// DisableKittyKeyboard is a command to disable Kitty keyboard progressive +// enhancements. +func DisableKittyKeyboard() Msg { + return setKittyKeyboardFlagsMsg(0) } -// IsReportEventTypes returns true if the ReportEventTypes flag is set. -func (e KittyKeyboardMsg) IsReportEventTypes() bool { - return e&ansi.KittyReportEventTypes != 0 +// kittyKeyboardMsg is a message that queries the current Kitty keyboard +// progressive enhancement flags. +type kittyKeyboardMsg struct{} + +// KittyKeyboard is a command that queries the current Kitty keyboard +// progressive enhancement flags from the terminal. +func KittyKeyboard() Msg { + return kittyKeyboardMsg{} } -// IsReportAlternateKeys returns true if the ReportAlternateKeys flag is set. -func (e KittyKeyboardMsg) IsReportAlternateKeys() bool { - return e&ansi.KittyReportAlternateKeys != 0 +// EnableEnhancedKeyboard is a command to enable enhanced keyboard features. +// This unambiguously reports more key combinations than traditional terminal +// keyboard sequences. This will also enable reporting of release key events. +func EnableEnhancedKeyboard() Msg { + return setKittyKeyboardFlagsMsg(3) } -// IsReportAllKeys returns true if the ReportAllKeys flag is set. -func (e KittyKeyboardMsg) IsReportAllKeys() bool { - return e&ansi.KittyReportAllKeys != 0 +// DisableEnhancedKeyboard is a command to disable enhanced keyboard features. +func DisableEnhancedKeyboard() Msg { + return setKittyKeyboardFlagsMsg(0) } -// IsReportAssociatedKeys returns true if the ReportAssociatedKeys flag is set. -func (e KittyKeyboardMsg) IsReportAssociatedKeys() bool { - return e&ansi.KittyReportAssociatedKeys != 0 +// KittyKeyboardMsg represents Kitty keyboard progressive enhancement flags message. +type KittyKeyboardMsg int + +// Kitty Keyboard Protocol flags. +const ( + KittyDisambiguateEscapeCodes KittyKeyboardMsg = 1 << iota + KittyReportEventTypes + KittyReportAlternateKeys + KittyReportAllKeys + KittyReportAssociatedKeys +) + +// Contains reports whether m contains the given flags. +func (m KittyKeyboardMsg) Contains(flags KittyKeyboardMsg) bool { + return m&flags == flags } // Kitty Clipboard Control Sequences @@ -268,13 +307,13 @@ func parseKittyKeyboard(csi *ansi.CsiSequence) Msg { } } } - // TODO: Associated keys are not support yet. - // if params := csi.Subparams(2); len(params) > 0 { - // r := rune(params[0]) - // if unicode.IsPrint(r) { - // key.AltRune = r - // } - // } + if params := csi.Subparams(2); len(params) > 0 { + r := rune(params[0]) + if unicode.IsPrint(r) { + key.altRune = key.Rune() + key.Runes = []rune{r} + } + } if isRelease { return KeyReleaseMsg(key) } diff --git a/options.go b/options.go index a810fe1c7b..cb2ee41424 100644 --- a/options.go +++ b/options.go @@ -234,3 +234,33 @@ func WithFPS(fps int) ProgramOption { p.fps = fps } } + +// WithEnhancedKeyboard enables support for enhanced keyboard features. This +// unambiguously reports more key combinations than traditional terminal +// keyboard sequences. This will also enable reporting of release key events. +// +// This is a syntactic sugar for WithKittyKeyboard(3). +func WithEnhancedKeyboard() ProgramOption { + return WithKittyKeyboard(3) +} + +// WithKittyKeyboard enables support for the Kitty keyboard protocol. This +// protocol enables more key combinations and events than the traditional +// ambiguous terminal keyboard sequences. +// +// Use flags to specify which features you want to enable. +// +// 0: Disable all features +// 1: Disambiguate escape codes +// 2: Report event types +// 4: Report alternate keys +// 8: Report all keys as escape codes +// 16: Report associated text +// +// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/ for more information. +func WithKittyKeyboard(flags int) ProgramOption { + return func(p *Program) { + p.kittyFlags = flags + p.startupOptions |= withKittyKeyboard + } +} diff --git a/screen_test.go b/screen_test.go index 728cd9779f..a62a10044f 100644 --- a/screen_test.go +++ b/screen_test.go @@ -56,6 +56,11 @@ func TestClearMsg(t *testing.T) { cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste}, expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, + { + name: "kitty_start", + cmds: []Cmd{DisableKittyKeyboard, EnableKittyKeyboard(3)}, + expected: "\x1b[?25l\x1b[?2004h\x1b[>u\x1b[>3u\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[>0u", + }, } for _, test := range tests { diff --git a/tea.go b/tea.go index d9b95a6d2f..29e1befdd4 100644 --- a/tea.go +++ b/tea.go @@ -97,6 +97,7 @@ const ( // feature is on by default. withoutCatchPanics withoutBracketedPaste + withKittyKeyboard ) // channelHandlers manages the series of channels returned by various processes. @@ -173,6 +174,9 @@ type Program struct { // fps is the frames per second we should set on the renderer, if // applicable, fps int + + // kittyFlags stores kitty keyboard protocol progressive enhancement flags. + kittyFlags int } // Quit is a special command that tells the Bubble Tea program to exit. @@ -392,6 +396,17 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.renderer.execute(ansi.DisableBracketedPaste) p.bpActive = false + case KittyKeyboardMsg: + // Store the kitty flags whenever they are queried. + p.kittyFlags = int(msg) + + case setKittyKeyboardFlagsMsg: + p.kittyFlags = int(msg) + p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + + case kittyKeyboardMsg: + p.renderer.execute(ansi.RequestKittyKeyboard) + case execMsg: // NB: this blocks. p.exec(msg.cmd, msg.fn) @@ -556,6 +571,9 @@ func (p *Program) Run() (Model, error) { p.renderer.execute(ansi.EnableMouseAllMotion) p.renderer.execute(ansi.EnableMouseSgrExt) } + if p.startupOptions&withKittyKeyboard != 0 { + p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + } // Start the renderer. p.renderer.start() @@ -727,6 +745,9 @@ func (p *Program) RestoreTerminal() error { p.renderer.hideCursor() p.renderer.execute(ansi.EnableBracketedPaste) p.bpActive = true + if p.kittyFlags != 0 { + p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + } } // If the output is a terminal, it may have been resized while another diff --git a/tty.go b/tty.go index 9d5612cfbb..52c1e1f4d9 100644 --- a/tty.go +++ b/tty.go @@ -37,6 +37,9 @@ func (p *Program) restoreTerminalState() error { p.bpActive = false p.renderer.showCursor() p.disableMouse() + if p.kittyFlags != 0 { + p.renderer.execute(ansi.DisableKittyKeyboard) + } if p.renderer.altScreen() { p.renderer.exitAltScreen()