Repository: charmbracelet/bubbletea
Pull request #1083 (2024-08-13 20:58:48)

feat: add kitty keyboard options and settings
This adds the necessary options to enable/disable the kitty keyboard protocol.

Needs: https://github.com/charmbracelet/bubbletea/pull/1080
Fixes: https://github.com/charmbracelet/bubbletea/issues/869


Patch:
diff --git a/go.mod b/go.mod
index 34bcfa4..e84a49b 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 afe8f92..29768a7 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.go b/key.go
index 3367941..9997321 100644
--- a/key.go
+++ b/key.go
@@ -226,14 +226,14 @@ const (
 // one character, though certain input method editors (most notably Chinese
 // IMEs) can input multiple runes at once.
 type Key struct {
+	// Type is a special key, like enter, tab, backspace, and so on.
+	Type KeyType
+
 	// Runes contains the actual characters received. This usually has a length
 	// of 1. Use [Rune()] to get the first key rune received. If the user
 	// presses shift+a, the Runes will be `[]rune{'A'}`.
 	Runes []rune
 
-	// Type is a special key, like enter, tab, backspace, and so on.
-	Type KeyType
-
 	// altRune is the actual, unshifted key pressed by the user. For example,
 	// if the user presses shift+a, or caps lock is on, the altRune will be
 	// 'a'.
@@ -323,22 +323,22 @@ func (k Key) Rune() rune {
 // "shift+ctrl+alt+a".
 func (k Key) String() string {
 	var s string
-	if k.Mod.Contains(ModCtrl) && k.Type != KeyLeftCtrl && k.Type != KeyRightCtrl {
+	if k.Mod.HasCtrl() && k.Type != KeyLeftCtrl && k.Type != KeyRightCtrl {
 		s += "ctrl+"
 	}
-	if k.Mod.Contains(ModAlt) && k.Type != KeyLeftAlt && k.Type != KeyRightAlt {
+	if k.Mod.HasAlt() && k.Type != KeyLeftAlt && k.Type != KeyRightAlt {
 		s += "alt+"
 	}
-	if k.Mod.Contains(ModShift) && k.Type != KeyLeftShift && k.Type != KeyRightShift {
+	if k.Mod.HasShift() && k.Type != KeyLeftShift && k.Type != KeyRightShift {
 		s += "shift+"
 	}
-	if k.Mod.Contains(ModMeta) && k.Type != KeyLeftMeta && k.Type != KeyRightMeta {
+	if k.Mod.HasMeta() && k.Type != KeyLeftMeta && k.Type != KeyRightMeta {
 		s += "meta+"
 	}
-	if k.Mod.Contains(ModHyper) && k.Type != KeyLeftHyper && k.Type != KeyRightHyper {
+	if k.Mod.HasHyper() && k.Type != KeyLeftHyper && k.Type != KeyRightHyper {
 		s += "hyper+"
 	}
-	if k.Mod.Contains(ModSuper) && k.Type != KeyLeftSuper && k.Type != KeyRightSuper {
+	if k.Mod.HasSuper() && k.Type != KeyLeftSuper && k.Type != KeyRightSuper {
 		s += "super+"
 	}
 
diff --git a/kitty.go b/kitty.go
index ff6279d..826c035 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/mod.go b/mod.go
index 9bb43a0..a93ec72 100644
--- a/mod.go
+++ b/mod.go
@@ -24,14 +24,47 @@ const (
 	ModScrollLock // Defined in Windows API only
 )
 
-// Contains reports whether m contains the given modifiers.
-//
-// Example:
-//
-//	m := ModAlt | ModCtrl
-//	m.Contains(ModCtrl) // true
-//	m.Contains(ModAlt | ModCtrl) // true
-//	m.Contains(ModAlt | ModCtrl | ModShift) // false
-func (m KeyMod) Contains(mods KeyMod) bool {
-	return m&mods == mods
+// HasShift reports whether the Shift modifier is set.
+func (m KeyMod) HasShift() bool {
+	return m&ModShift != 0
+}
+
+// HasAlt reports whether the Alt modifier is set.
+func (m KeyMod) HasAlt() bool {
+	return m&ModAlt != 0
+}
+
+// HasCtrl reports whether the Ctrl modifier is set.
+func (m KeyMod) HasCtrl() bool {
+	return m&ModCtrl != 0
+}
+
+// HasMeta reports whether the Meta modifier is set.
+func (m KeyMod) HasMeta() bool {
+	return m&ModMeta != 0
+}
+
+// HasHyper reports whether the Hyper modifier is set.
+func (m KeyMod) HasHyper() bool {
+	return m&ModHyper != 0
+}
+
+// HasSuper reports whether the Super modifier is set.
+func (m KeyMod) HasSuper() bool {
+	return m&ModSuper != 0
+}
+
+// HasCapsLock reports whether the CapsLock key is enabled.
+func (m KeyMod) HasCapsLock() bool {
+	return m&ModCapsLock != 0
+}
+
+// HasNumLock reports whether the NumLock key is enabled.
+func (m KeyMod) HasNumLock() bool {
+	return m&ModNumLock != 0
+}
+
+// HasScrollLock reports whether the ScrollLock key is enabled.
+func (m KeyMod) HasScrollLock() bool {
+	return m&ModScrollLock != 0
 }
diff --git a/mouse.go b/mouse.go
index f3460fa..710770d 100644
--- a/mouse.go
+++ b/mouse.go
@@ -63,13 +63,13 @@ type Mouse struct {
 
 // String returns a string representation of the mouse message.
 func (m Mouse) String() (s string) {
-	if m.Mod.Contains(ModCtrl) {
+	if m.Mod.HasCtrl() {
 		s += "ctrl+"
 	}
-	if m.Mod.Contains(ModAlt) {
+	if m.Mod.HasAlt() {
 		s += "alt+"
 	}
-	if m.Mod.Contains(ModShift) {
+	if m.Mod.HasShift() {
 		s += "shift+"
 	}
 
diff --git a/mouse_deprecated.go b/mouse_deprecated.go
index 714b63f..fb2c41d 100644
--- a/mouse_deprecated.go
+++ b/mouse_deprecated.go
@@ -154,9 +154,9 @@ func toMouseMsg(m Mouse) MouseMsg {
 	return MouseMsg{
 		X:      m.X,
 		Y:      m.Y,
-		Shift:  m.Mod.Contains(ModShift),
-		Alt:    m.Mod.Contains(ModAlt),
-		Ctrl:   m.Mod.Contains(ModCtrl),
+		Shift:  m.Mod.HasShift(),
+		Alt:    m.Mod.HasAlt(),
+		Ctrl:   m.Mod.HasCtrl(),
 		Button: m.Button,
 	}
 }
diff --git a/options.go b/options.go
index a810fe1..cb2ee41 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/tea.go b/tea.go
index d9b95a6..29e1bef 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 9ec3d2e..52c1e1f 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()
@@ -116,14 +119,14 @@ func readInputs(ctx context.Context, msgs chan<- Msg, reader *driver) error {
 				incomingMsgs = append(incomingMsgs, k)
 			case KeyPressMsg:
 				k := KeyMsg{
-					Alt:   e.Mod.Contains(ModAlt),
+					Alt:   e.Mod.HasAlt(),
 					Runes: e.Runes,
 					Type:  e.Type,
 				}
 
 				// Backwards compatibility for ctrl- and shift- keys
 				switch {
-				case e.Mod.Contains(ModCtrl | ModShift):
+				case e.Mod.HasCtrl() && e.Mod.HasShift():
 					switch e.Type {
 					case KeyUp, KeyDown, KeyRight, KeyLeft:
 						k.Runes = nil
@@ -132,7 +135,7 @@ func readInputs(ctx context.Context, msgs chan<- Msg, reader *driver) error {
 						k.Runes = nil
 						k.Type = KeyCtrlShiftHome - e.Type + KeyHome
 					}
-				case e.Mod.Contains(ModCtrl):
+				case e.Mod.HasCtrl():
 					switch e.Type {
 					case KeyRunes: // KeyRunes
 						switch r := e.Rune(); r {
@@ -158,7 +161,7 @@ func readInputs(ctx context.Context, msgs chan<- Msg, reader *driver) error {
 						k.Runes = nil
 						k.Type = KeyCtrlUp - e.Type + KeyUp
 					}
-				case e.Mod.Contains(ModShift):
+				case e.Mod.HasShift():
 					switch e.Type {
 					case KeyTab:
 						k.Runes = nil


Test patch:
diff --git a/key_test.go b/key_test.go
index d46da05..bea16ed 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/screen_test.go b/screen_test.go
index 728cd97..a62a100 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 {