diff --git a/examples/go.mod b/examples/go.mod index cbb2b57c4f..84f181c865 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -22,7 +22,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.2.2 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index 576ef80ca7..da4f788da5 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -20,10 +20,10 @@ github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -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.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0= -github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= +github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= +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/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28 h1:sOWKNRjt8uOEVgPiJVIJCse1+mUDM2F/vYY6W0Go640= diff --git a/examples/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden index 28dc7991b3..2c245f9c58 100644 --- a/examples/simple/testdata/TestApp.golden +++ b/examples/simple/testdata/TestApp.golden @@ -1,4 +1,4 @@ -[?25l[?2004h Hi. This program will exit in 10 seconds. +[?25l[?2004h[?2027h[?2027$p Hi. This program will exit in 10 seconds. To quit sooner press ctrl-c, or press ctrl-z to suspend... Hi. This program will exit in 9 seconds. diff --git a/go.mod b/go.mod index 525893be79..1102923739 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/charmbracelet/bubbletea go 1.18 require ( + github.com/charmbracelet/x/ansi v0.2.3 github.com/charmbracelet/lipgloss v0.13.0 - github.com/charmbracelet/x/ansi v0.2.2 github.com/charmbracelet/x/term v0.2.0 github.com/charmbracelet/x/windows v0.2.0 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 diff --git a/go.sum b/go.sum index 9d522079a9..f1cf38a664 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ +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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 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.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0= -github.com/charmbracelet/x/ansi v0.2.2/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/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= diff --git a/options.go b/options.go index c0d054f30b..b270a9d49e 100644 --- a/options.go +++ b/options.go @@ -327,3 +327,18 @@ func _WithWindowsInputMode() ProgramOption { //nolint:unused p.win32Input = true } } + +// WithoutGraphemeClustering disables grapheme clustering. This is useful if you +// want to disable grapheme clustering for your program. +// +// Grapheme clustering is a character width calculation method that accurately +// calculates the width of wide characters in a terminal. This is useful for +// properly rendering double width characters such as emojis and CJK +// characters. +// +// See https://mitchellh.com/writing/grapheme-clusters-in-terminals +func WithoutGraphemeClustering() ProgramOption { + return func(p *Program) { + p.startupOptions |= withoutGraphemeClustering + } +} diff --git a/renderer.go b/renderer.go index 50c6f2fe73..1b3eab5714 100644 --- a/renderer.go +++ b/renderer.go @@ -51,6 +51,7 @@ type repaintMsg struct{} // Terminal modes used by SetMode and Mode in Bubble Tea. const ( - altScreenMode = 1049 - hideCursor = 25 + graphemeClustering = 2027 + altScreenMode = 1049 + hideCursor = 25 ) diff --git a/screen.go b/screen.go index 7e3ce3783d..f285f1a7e6 100644 --- a/screen.go +++ b/screen.go @@ -146,6 +146,27 @@ func DisableBracketedPaste() Msg { // disableBracketedPasteMsg with DisableBracketedPaste. type disableBracketedPasteMsg struct{} +// enableGraphemeClusteringMsg is an internal message that signals that +// grapheme clustering should be enabled. +type enableGraphemeClusteringMsg struct{} + +// EnableGraphemeClustering is a special command that tells the Bubble Tea +// program to enable grapheme clustering. This is enabled by default. +func EnableGraphemeClustering() Msg { + return enableGraphemeClusteringMsg{} +} + +// disableGraphemeClusteringMsg is an internal message that signals that +// grapheme clustering should be disabled. +type disableGraphemeClusteringMsg struct{} + +// DisableGraphemeClustering is a special command that tells the Bubble Tea +// program to disable grapheme clustering. This mode will be disabled +// automatically when the program quits. +func DisableGraphemeClustering() Msg { + return disableGraphemeClusteringMsg{} +} + // enableReportFocusMsg is an internal message that signals that focus // reporting should be enabled. type enableReportFocusMsg struct{} diff --git a/screen_test.go b/screen_test.go index a24382fd80..2e52e97215 100644 --- a/screen_test.go +++ b/screen_test.go @@ -15,67 +15,67 @@ func TestClearMsg(t *testing.T) { { name: "clear_screen", cmds: []Cmd{ClearScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[1;1H\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[2J\x1b[1;1H\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "altscreen", cmds: []Cmd{EnterAltScreen, ExitAltScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "altscreen_autoexit", cmds: []Cmd{EnterAltScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\rsuccess\r\n\x1b[2;0H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1049l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[?25l\rsuccess\r\n\x1b[2;0H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1049l\x1b[?25h", }, { name: "mouse_cellmotion", cmds: []Cmd{EnableMouseCellMotion}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?1002h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_allmotion", cmds: []Cmd{EnableMouseAllMotion}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?1003h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_disable", cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "cursor_hide", cmds: []Cmd{HideCursor}, - expected: "\x1b[?25l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "cursor_hideshow", cmds: []Cmd{HideCursor, ShowCursor}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?25h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?25h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l", }, { name: "bp_stop_start", 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", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "read_set_clipboard", cmds: []Cmd{ReadClipboard, SetClipboard("success")}, - expected: "\x1b[?25l\x1b[?2004h\x1b]52;c;?\a\x1b]52;c;c3VjY2Vzcw==\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b]52;c;?\a\x1b]52;c;c3VjY2Vzcw==\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "bg_fg_cur_color", cmds: []Cmd{ForegroundColor, BackgroundColor, CursorColor}, - expected: "\x1b[?25l\x1b[?2004h\x1b]10;?\a\x1b]11;?\a\x1b]12;?\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b]10;?\a\x1b]11;?\a\x1b]12;?\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { name: "bg_set_color", cmds: []Cmd{SetBackgroundColor(color.RGBA{255, 255, 255, 255})}, - expected: "\x1b[?25l\x1b[?2004h\x1b]11;#ffffff\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b]11;#ffffff\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", }, { 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[>0u", + expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\x1b[>u\x1b[>3u\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[>0u", }, } diff --git a/tea.go b/tea.go index 55d6455dd7..deba183848 100644 --- a/tea.go +++ b/tea.go @@ -103,6 +103,7 @@ const ( withKittyKeyboard withModifyOtherKeys withWindowsInputMode + withoutGraphemeClustering ) // channelHandlers manages the series of channels returned by various processes. @@ -174,6 +175,8 @@ type Program struct { bpActive bool // was the bracketed paste mode active before releasing the terminal? + graphemeClustering bool // whether grapheme clustering is enabled + cursorHidden bool // the cursor visibility state mouseEnabled bool // whether mouse reporting is enabled @@ -390,6 +393,16 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.suspend() } + case ReportModeMsg: + switch msg.Mode { + case graphemeClustering: + // 1 means mode is set (see DECRPM). + p.graphemeClustering = msg.Value == 1 + if p.graphemeClustering { + p.renderer.SetMode(graphemeClustering, true) + } + } + case clearScreenMsg: p.renderer.ClearScreen() @@ -428,6 +441,19 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.execute(ansi.DisableBracketedPaste) p.bpActive = false + case enableGraphemeClusteringMsg: + p.execute(ansi.EnableGraphemeClustering) + p.execute(ansi.RequestGraphemeClustering) + // We store the state of grapheme clustering after we enable it + // and get a response in the eventLoop. + + case disableGraphemeClusteringMsg: + if p.graphemeClustering { + // We only disable grapheme clustering if it was enabled. + p.execute(ansi.DisableGraphemeClustering) + p.renderer.SetMode(graphemeClustering, false) + } + case enableReportFocusMsg: p.execute(ansi.EnableReportFocus) p.reportFocus = true @@ -701,6 +727,12 @@ func (p *Program) Run() (Model, error) { p.execute(ansi.EnableBracketedPaste) p.bpActive = true } + if p.startupOptions&withoutGraphemeClustering == 0 { + p.execute(ansi.EnableGraphemeClustering) + p.execute(ansi.RequestGraphemeClustering) + // We store the state of grapheme clustering after we query it and get + // a response in the eventLoop. + } if p.startupOptions&withMouseCellMotion != 0 { p.execute(ansi.EnableMouseCellMotion) p.execute(ansi.EnableMouseSgrExt) @@ -868,6 +900,7 @@ func (p *Program) ReleaseTerminal() error { if p.renderer != nil { p.stopRenderer(false) + // TODO: store these values when they're set in the eventLoop and [Run]. p.altScreenWasActive = p.renderer.Mode(altScreenMode) p.cursorHidden = p.renderer.Mode(hideCursor) } @@ -925,6 +958,9 @@ func (p *Program) RestoreTerminal() error { p.execute(ansi.EnableMouseSgrExt) } } + if p.graphemeClustering { + p.execute(ansi.EnableGraphemeClustering) + } // 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 a7f554b811..18a603f316 100644 --- a/tty.go +++ b/tty.go @@ -53,6 +53,9 @@ func (p *Program) restoreTerminalState() error { if p.reportFocus { p.execute(ansi.DisableReportFocus) } + if p.graphemeClustering { + p.execute(ansi.DisableGraphemeClustering) + } if p.renderer != nil { if p.renderer.Mode(altScreenMode) {