diff --git a/go.mod b/go.mod index be0799b..019289a 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/muesli/gamut v0.3.1 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.14.0 + github.com/rivo/uniseg v0.4.7 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.1 @@ -33,6 +34,7 @@ replace ( github.com/charmbracelet/bubbles => github.com/mikelorant/bubbles v0.0.0-20221206230145-c5687de7af43 github.com/charmbracelet/lipgloss => github.com/mikelorant/lipgloss v0.0.0-20230212060525-24ffefde7d62 github.com/muesli/reflow => github.com/mikelorant/reflow v0.0.0-20230112022445-408368584af4 + github.com/rivo/uniseg => github.com/mikelorant/uniseg v0.0.0-20240303073632-27c86650ffa5 ) require ( @@ -67,7 +69,6 @@ require ( github.com/nightlyone/lockfile v1.0.0 // indirect github.com/pjbgf/sha1cd v0.2.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.3 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect diff --git a/go.sum b/go.sum index d3bc465..2d4bcf3 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/mikelorant/lipgloss v0.0.0-20230212060525-24ffefde7d62 h1:2qFaTxCkNSX github.com/mikelorant/lipgloss v0.0.0-20230212060525-24ffefde7d62/go.mod h1:epOnQm/VM6q8Z5R+TR+EYI1ZgEULOU7eUR8DapUVFMc= github.com/mikelorant/reflow v0.0.0-20230112022445-408368584af4 h1:yIT0phMB7rOQ7ZZ3KaTlRv/xi/mhvFApL/+7S0U6Hl0= github.com/mikelorant/reflow v0.0.0-20230112022445-408368584af4/go.mod h1:mEMWZ0nzoGlTCHkXp5ljOWhHi1tjvtDGh7wuT1Thhsk= +github.com/mikelorant/uniseg v0.0.0-20240303073632-27c86650ffa5 h1:CRvVC4vrkEti+XI0DdksOAspVl0/9xG4BLF/bemiKfM= +github.com/mikelorant/uniseg v0.0.0-20240303073632-27c86650ffa5/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a h1:jlDOeO5TU0pYlbc/y6PFguab5IjANI0Knrpg3u/ton4= github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= @@ -161,9 +163,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000..760c130 --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,68 @@ +package terminal + +import ( + "github.com/mikelorant/committed/internal/config" + + "github.com/rivo/uniseg" +) + +type graphemes struct { + codepoints []rune + width int +} + +func Set(c config.Compatibility) { + uniseg.GraphemeClusterWidthOverrides = overrideGraphemeClusterWidth(c) +} + +func Clear() { + uniseg.GraphemeClusterWidthOverrides = nil +} + +func overrideGraphemeClusterWidth(c config.Compatibility) map[string]int { + gs := make([]graphemes, 0) + + switch c { + case config.CompatibilityTtyd: + case config.CompatibilityKitty: + default: + gs = append(gs, overrideVS16()...) + } + + return overrides(gs) +} + +// Grapheme clusters using variant selector 16 +// had their widths changed as part of Unicode 14. +// Unicode < 14 = 1. +// Unicode >= 14 = 2. +// Required for: +// - macOS Terminal (2.12.7) +// - iTerm2 (3.4.23) +// - VSCode (1.87.0) +// - Alacritty (0.13.1) +// - WezTerm (20240203) +func overrideVS16() []graphemes { + return []graphemes{ + {codepoints: []rune{0x203c, 0xfe0f}, width: 1}, // ‼️ + {codepoints: []rune{0x21a9, 0xfe0f}, width: 1}, // ↩️ + {codepoints: []rune{0x2601, 0xfe0f}, width: 1}, // ☁️ + {codepoints: []rune{0x267b, 0xfe0f}, width: 1}, // ♻️ + {codepoints: []rune{0x2697, 0xfe0f}, width: 1}, // ⚗️ + {codepoints: []rune{0x2699, 0xfe0f}, width: 1}, // ⚙️ + {codepoints: []rune{0x26b0, 0xfe0f}, width: 1}, // ⚰️ + {codepoints: []rune{0x270f, 0xfe0f}, width: 1}, // ✏️ + {codepoints: []rune{0x2b06, 0xfe0f}, width: 1}, // ⬆️ + {codepoints: []rune{0x2b07, 0xfe0f}, width: 1}, // ⬇️ + } +} + +func overrides(gs []graphemes) map[string]int { + overrides := make(map[string]int, len(gs)) + for _, g := range gs { + key := string(g.codepoints) + overrides[key] = g.width + } + + return overrides +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go new file mode 100644 index 0000000..e84dd48 --- /dev/null +++ b/internal/terminal/terminal_test.go @@ -0,0 +1,146 @@ +package terminal_test + +import ( + "testing" + + "github.com/mikelorant/committed/internal/config" + "github.com/mikelorant/committed/internal/terminal" + + "github.com/rivo/uniseg" + "github.com/stretchr/testify/assert" +) + +func TestSet(t *testing.T) { + tests := []struct { + name string + compat config.Compatibility + value string + width int + }{ + { + name: "default empty", + width: 0, + }, + { + name: "default simple", + value: "test", + width: 4, + }, + { + name: "default single emoji without override", + value: "❤️", + width: 2, + }, + { + name: "default single emoji with override", + value: "⬆️", + width: 1, + }, + { + name: "default multiple emojis without override", + value: "❤️❤️", + width: 4, + }, + { + name: "default multiple emojis with override", + value: "⬆️⬆️", + width: 2, + }, + { + name: "default mixed emojis", + value: "⬆️❤️", + width: 3, + }, + { + name: "default multiple mixed emojis", + value: "⬆️❤️❤️⬆️", + width: 6, + }, + { + name: "ttyd empty", + compat: config.CompatibilityTtyd, + value: "", + width: 0, + }, + { + name: "ttyd simple", + compat: config.CompatibilityTtyd, + value: "test", + width: 4, + }, + { + name: "ttyd multiple emojis", + compat: config.CompatibilityTtyd, + value: "⬆️❤️", + width: 4, + }, + { + name: "kitty empty", + compat: config.CompatibilityKitty, + value: "", + width: 0, + }, + { + name: "ttyd simple", + compat: config.CompatibilityKitty, + value: "test", + width: 4, + }, + { + name: "ttyd multiple emojis", + compat: config.CompatibilityKitty, + value: "⬆️❤️", + width: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + terminal.Set(tt.compat) + + width := uniseg.StringWidth(tt.value) + assert.Equal(t, tt.width, width) + + terminal.Clear() + }) + } +} + +func TestClear(t *testing.T) { + tests := []struct { + name string + value string + width int + orWidth int + }{ + { + name: "default without override", + value: "❤️", + width: 2, + orWidth: 2, + }, + { + name: "default with override", + value: "⬆️", + width: 2, + orWidth: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + width := uniseg.StringWidth(tt.value) + assert.Equal(t, tt.width, width) + + terminal.Set(config.CompatibilityDefault) + + width = uniseg.StringWidth(tt.value) + assert.Equal(t, tt.orWidth, width) + + terminal.Clear() + + width = uniseg.StringWidth(tt.value) + assert.Equal(t, tt.width, width) + }) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5f37891..0cf7a06 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -8,6 +8,7 @@ import ( "github.com/mikelorant/committed/internal/commit" "github.com/mikelorant/committed/internal/config" "github.com/mikelorant/committed/internal/emoji" + "github.com/mikelorant/committed/internal/terminal" "github.com/mikelorant/committed/internal/ui/body" "github.com/mikelorant/committed/internal/ui/colour" "github.com/mikelorant/committed/internal/ui/footer" @@ -494,6 +495,8 @@ func (m *Model) resetCursor() { } func (m *Model) setCompatibility() { + terminal.Set(m.state.Config.View.Compatibility) + switch m.state.Config.View.Compatibility { case config.CompatibilityTtyd: os.Setenv("LIPGLOSS_TERMINAL", "ttyd")