diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index d87e532..b3896af 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -6,9 +6,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/bookmark" "github.com/kooler/MiddayCommander/internal/ui/overlay" + uitext "github.com/kooler/MiddayCommander/internal/ui/text" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -22,17 +24,17 @@ type DismissMsg struct{} // Model is the bookmark list overlay. type Model struct { - store *bookmark.Store - items []bookmark.Bookmark - cursor int - offset int - width int - height int + store *bookmark.Store + items []bookmark.Bookmark + cursor int + offset int + width int + height int filter string // search/filter query filtering bool // true when filter input is active adding bool // true when prompting for bookmark name - addPath string // path being bookmarked - addName string // name being typed + addPath string // path being bookmarked + addName string // name being typed } // New creates a new bookmark list overlay. @@ -70,8 +72,8 @@ func (m Model) Update(msg tea.KeyMsg) (Model, tea.Cmd) { } m.filtering = false case "backspace": - if len(m.filter) > 0 { - m.filter = m.filter[:len(m.filter)-1] + if start := uitext.PreviousGraphemeBoundary(m.filter, len(m.filter)); start >= 0 { + m.filter = m.filter[:start] m.refilter() } else { m.filtering = false @@ -87,8 +89,7 @@ func (m Model) Update(msg tea.KeyMsg) (Model, tea.Cmd) { m.clampOffset() } default: - s := msg.String() - if len(s) == 1 && s[0] >= 32 { + if s, ok := uitext.PrintableInput(msg); ok { m.filter += s m.refilter() } @@ -153,13 +154,12 @@ func (m Model) updateAdding(msg tea.KeyMsg) (Model, tea.Cmd) { m.adding = false return m, nil case "backspace": - if len(m.addName) > 0 { - m.addName = m.addName[:len(m.addName)-1] + if start := uitext.PreviousGraphemeBoundary(m.addName, len(m.addName)); start >= 0 { + m.addName = m.addName[:start] } return m, nil default: - s := msg.String() - if len(s) == 1 && s[0] >= 32 { + if s, ok := uitext.PrintableInput(msg); ok { m.addName += s } return m, nil @@ -288,15 +288,15 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { if b.Name != "" { display = b.Name + " → " + b.Path } - if len(display) > innerW-4 { - display = "…" + display[len(display)-innerW+5:] + if ansi.StringWidth(display) > innerW-4 { + display = overlay.TruncateLeftEllipsis(display, innerW-4) } line := prefix + display if isCursor { - contentLines = append(contentLines, cursorStyle.Render(padStr(line, innerW))) + contentLines = append(contentLines, cursorStyle.Render(overlay.PadOrTrunc(line, innerW))) } else { - contentLines = append(contentLines, numStyle.Render(prefix)+bgStyle.Render(padStr(display, innerW-len(prefix)))) + contentLines = append(contentLines, numStyle.Render(prefix)+bgStyle.Render(overlay.PadOrTrunc(display, innerW-len(prefix)))) } } @@ -332,10 +332,3 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { return overlay.RenderBox("Bookmarks", contentLines, footer, boxW, boxH, accent, bg, highlight) } - -func padStr(s string, width int) string { - if len(s) >= width { - return s[:width] - } - return s + strings.Repeat(" ", width-len(s)) -} diff --git a/internal/ui/bookmarks/bookmarks_test.go b/internal/ui/bookmarks/bookmarks_test.go new file mode 100644 index 0000000..f4cfca4 --- /dev/null +++ b/internal/ui/bookmarks/bookmarks_test.go @@ -0,0 +1,44 @@ +package bookmarks + +import ( + "testing" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kooler/MiddayCommander/internal/bookmark" +) + +func bookmarkKey(m Model, key tea.KeyType, runes ...rune) Model { + var msg tea.KeyMsg + if runes != nil { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: runes} + } else { + msg = tea.KeyMsg{Type: key} + } + updated, _ := m.Update(msg) + return updated +} + +func TestFilterUnicodeEditing(t *testing.T) { + m := New(&bookmark.Store{}, ".", 80, 24) + m.filtering = true + m = bookmarkKey(m, tea.KeyRunes, '日', '本', '🎉') + m = bookmarkKey(m, tea.KeyBackspace) + if m.filter != "日本" || !utf8.ValidString(m.filter) { + t.Errorf("filter after backspace = %q, want 日本", m.filter) + } + m = bookmarkKey(m, tea.KeySpace) + if m.filter != "日本 " { + t.Errorf("filter after space = %q, want 日本 ", m.filter) + } +} + +func TestAddNameUnicodeEditing(t *testing.T) { + m := New(&bookmark.Store{}, ".", 80, 24) + m.adding = true + m = bookmarkKey(m, tea.KeyRunes, '👨', '\u200d', '👩') + m = bookmarkKey(m, tea.KeyBackspace) + if m.addName != "" { + t.Errorf("name after backspace = %q, want empty", m.addName) + } +} diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 723178d..fc3877f 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -9,9 +9,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/ui/completion" "github.com/kooler/MiddayCommander/internal/ui/overlay" + uitext "github.com/kooler/MiddayCommander/internal/ui/text" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -114,9 +116,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.updateSuggestions(), nil case "backspace": - if m.inputPos > 0 { - m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] - m.inputPos-- + start := uitext.PreviousGraphemeBoundary(m.input, m.inputPos) + if start >= 0 { + m.input = m.input[:start] + m.input[m.inputPos:] + m.inputPos = start } m.output = "" m.outputLines = nil @@ -124,8 +127,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { m = m.updateSuggestions() case "delete": - if m.inputPos < len(m.input) { - m.input = m.input[:m.inputPos] + m.input[m.inputPos+1:] + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) + if end > m.inputPos { + m.input = m.input[:m.inputPos] + m.input[end:] } m.output = "" m.outputLines = nil @@ -133,13 +137,15 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { m = m.updateSuggestions() case "left": - if m.inputPos > 0 { - m.inputPos-- + start := uitext.PreviousGraphemeBoundary(m.input, m.inputPos) + if start >= 0 { + m.inputPos = start } case "right": - if m.inputPos < len(m.input) { - m.inputPos++ + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) + if end > m.inputPos { + m.inputPos = end } case "home": @@ -179,15 +185,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } default: - s := msg.String() - if len(s) == 1 && s[0] >= 32 { + if s, ok := uitext.PrintableInput(msg); ok { if m.inputPos < 0 { m.inputPos = 0 } else if m.inputPos > len(m.input) { m.inputPos = len(m.input) } m.input = m.input[:m.inputPos] + s + m.input[m.inputPos:] - m.inputPos++ + m.inputPos += len(s) m.output = "" m.outputLines = nil m.outputOffset = 0 @@ -238,8 +243,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // Directory line dir := m.dir - if len(dir) > innerW-2 { - dir = "..." + dir[len(dir)-innerW+5:] + if ansi.StringWidth(dir) > innerW-2 { + dir = overlay.TruncateLeftEllipsis(dir, innerW-2) } dirLine := dimStyle.Render(" " + dir) dirWidth := lipgloss.Width(dirLine) @@ -251,7 +256,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // Input line with cursor var inputDisplay string if m.inputPos < len(m.input) { - inputDisplay = m.input[:m.inputPos] + "█" + m.input[m.inputPos:] + cluster, _ := ansi.FirstGraphemeCluster(m.input[m.inputPos:], ansi.GraphemeWidth) + inputDisplay = m.input[:m.inputPos] + "█" + m.input[m.inputPos+len(cluster):] } else { inputDisplay = m.input + "█" } @@ -286,8 +292,8 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } for i := m.outputOffset; i < end; i++ { line := " " + m.outputLines[i] - if lipgloss.Width(line) > innerW { - line = line[:innerW] + if ansi.StringWidth(line) > innerW { + line = ansi.Truncate(line, innerW, "") } rendered := bgStyle.Render(line) renderedWidth := lipgloss.Width(rendered) diff --git a/internal/ui/cmdexec/cmdexec_test.go b/internal/ui/cmdexec/cmdexec_test.go new file mode 100644 index 0000000..8728613 --- /dev/null +++ b/internal/ui/cmdexec/cmdexec_test.go @@ -0,0 +1,46 @@ +package cmdexec + +import ( + "testing" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" +) + +func press(m *Model, key tea.KeyType, runes ...rune) { + var msg tea.KeyMsg + if runes != nil { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: runes} + } else { + msg = tea.KeyMsg{Type: key} + } + updated, _ := m.handleKey(msg) + *m = updated +} + +func TestUnicodeInputEditing(t *testing.T) { + m := New(".", 80, 24) + press(&m, tea.KeyRunes, '日', '本') + press(&m, tea.KeyRunes, '🎉') + if m.input != "日本🎉" { + t.Fatalf("input = %q, want 日本🎉", m.input) + } + + press(&m, tea.KeyBackspace) + if m.input != "日本" || m.inputPos != len(m.input) { + t.Errorf("after backspace: input = %q, position = %d", m.input, m.inputPos) + } + press(&m, tea.KeyLeft) + press(&m, tea.KeyDelete) + if m.input != "日" || !utf8.ValidString(m.input) { + t.Errorf("after delete: input = %q, want 日", m.input) + } +} + +func TestSpaceInput(t *testing.T) { + m := New(".", 80, 24) + press(&m, tea.KeySpace) + if m.input != " " { + t.Errorf("input = %q, want a space", m.input) + } +} diff --git a/internal/ui/completion/completion.go b/internal/ui/completion/completion.go index 89aef52..f07bf6e 100644 --- a/internal/ui/completion/completion.go +++ b/internal/ui/completion/completion.go @@ -49,16 +49,6 @@ func CommonPrefix(strs []string) string { return prefix } -func PadOrTrim(s string, width int) string { - if lipgloss.Width(s) > width { - if width > 3 { - return s[:width-3] + "..." - } - return s[:width] - } - return s + strings.Repeat(" ", width-lipgloss.Width(s)) -} - func FormatSuggestions(suggestions []string, width, maxLines int, basename bool) []string { if len(suggestions) == 0 { return nil diff --git a/internal/ui/copypath/copypath.go b/internal/ui/copypath/copypath.go index fe7b91f..a186390 100644 --- a/internal/ui/copypath/copypath.go +++ b/internal/ui/copypath/copypath.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/platform" "github.com/kooler/MiddayCommander/internal/ui/overlay" @@ -103,9 +104,10 @@ func dismiss() tea.Msg { return DismissMsg{} } // BoxSize returns the desired box dimensions. func (m Model) BoxSize(screenWidth, screenHeight int) (int, int) { - maxLen := len(helpText) + maxLen := ansi.StringWidth(helpText) for _, p := range m.paths { - if l := len(p) + 2; l > maxLen { // +2 for the cursor/selection prefix + l := ansi.StringWidth(p) + 2 // +2 for the cursor/selection prefix + if l > maxLen { maxLen = l } } @@ -145,7 +147,7 @@ func (m Model) View(_ theme.Theme, screenWidth, screenHeight int) string { dimStyle := lipgloss.NewStyle().Background(bg).Foreground(subtle) var contentLines []string - contentLines = append(contentLines, dimStyle.Render(padStr(" "+helpText, innerW))) + contentLines = append(contentLines, dimStyle.Render(overlay.PadOrTrunc(" "+helpText, innerW))) contentLines = append(contentLines, bgStyle.Render(strings.Repeat(" ", innerW))) for i, p := range m.paths { @@ -154,10 +156,10 @@ func (m Model) View(_ theme.Theme, screenWidth, screenHeight int) string { prefix = "> " } display := p - if len(display) > innerW-len(prefix) { - display = "…" + display[len(display)-(innerW-len(prefix))+1:] + if ansi.StringWidth(display) > innerW-len(prefix) { + display = overlay.TruncateLeftEllipsis(display, innerW-len(prefix)) } - line := padStr(prefix+display, innerW) + line := overlay.PadOrTrunc(prefix+display, innerW) if i == m.cursor { contentLines = append(contentLines, cursorStyle.Render(line)) } else { @@ -179,10 +181,3 @@ func (m Model) View(_ theme.Theme, screenWidth, screenHeight int) string { return overlay.RenderBox("Copy Path", contentLines, footer, boxW, boxH, accent, bg, highlight) } - -func padStr(s string, width int) string { - if lipgloss.Width(s) >= width { - return s - } - return s + strings.Repeat(" ", width-lipgloss.Width(s)) -} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2c96e73..e0e1241 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -7,11 +7,13 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/platform" "github.com/kooler/MiddayCommander/internal/ui/completion" "github.com/kooler/MiddayCommander/internal/ui/overlay" + uitext "github.com/kooler/MiddayCommander/internal/ui/text" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -52,13 +54,13 @@ type Model struct { suggestions []string // Progress dialog - totalFiles int - doneFiles int - totalBytes int64 - doneBytes int64 - fileTotalBytes int64 - fileDoneBytes int64 - current string + totalFiles int + doneFiles int + totalBytes int64 + doneBytes int64 + fileTotalBytes int64 + fileDoneBytes int64 + current string cancelRequested bool // State @@ -202,32 +204,41 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { }) } case "backspace": - if m.inputPos > 0 { - m.input = m.input[:m.inputPos-1] + m.input[m.inputPos:] - m.inputPos-- + start := uitext.PreviousGraphemeBoundary(m.input, m.inputPos) + if start >= 0 { + m.input = m.input[:start] + m.input[m.inputPos:] + m.inputPos = start } m.updateSuggestions() case "delete": - if m.inputPos < len(m.input) { - m.input = m.input[:m.inputPos] + m.input[m.inputPos+1:] + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) + if end > m.inputPos { + m.input = m.input[:m.inputPos] + m.input[end:] } m.updateSuggestions() case "left": - if m.inputPos > 0 { - m.inputPos-- + start := uitext.PreviousGraphemeBoundary(m.input, m.inputPos) + if start >= 0 { + m.inputPos = start } case "right": - if m.inputPos < len(m.input) { - m.inputPos++ + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) + if end > m.inputPos { + m.inputPos = end } case "home": m.inputPos = 0 case "end": m.inputPos = len(m.input) default: - if len(msg.String()) == 1 && msg.String()[0] >= 32 { - m.input = m.input[:m.inputPos] + msg.String() + m.input[m.inputPos:] - m.inputPos++ + if s, ok := uitext.PrintableInput(msg); ok { + if m.inputPos < 0 { + m.inputPos = 0 + } else if m.inputPos > len(m.input) { + m.inputPos = len(m.input) + } + m.input = m.input[:m.inputPos] + s + m.input[m.inputPos:] + m.inputPos += len(s) m.updateSuggestions() } } @@ -333,40 +344,64 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { case KindInput: // Message label and input with cursor at inputPos label := " " + m.message + " " - labelW := len(label) + labelW := ansi.StringWidth(label) maxInput := innerW - labelW if maxInput < 1 { maxInput = 1 } - // Determine visible window of text around the cursor. - visStart := 0 - visEnd := len(m.input) - if visEnd-visStart > maxInput { - // Keep cursor visible with some context on both sides. - visStart = m.inputPos - maxInput/2 - if visStart < 0 { - visStart = 0 + // Determine visible window of the input around the cursor + // Cutting happens on grapheme cluster boundaries only so emoji + // sequences (ZWJ, VS16, flags) are never split mid-emoji + var clusters []string + for i := 0; i < len(m.input); { + c, _ := ansi.FirstGraphemeCluster(m.input[i:], ansi.GraphemeWidth) + clusters = append(clusters, c) + i += len(c) + } + widthOf := func(ss []string) int { + return ansi.StringWidth(strings.Join(ss, "")) + } + // Keep the cursor cell in mind - at the end of input it shows + // as a space next to the window so the window width shrinks by 1 + if m.inputPos >= len(m.input) && maxInput > 1 { + maxInput-- + } + // Cursor cluster index: the cluster containing m.inputPos + cursorIdx := len(clusters) + for i, byteAt := 0, 0; i < len(clusters); i++ { + if byteAt == m.inputPos { + cursorIdx = i + break + } + byteAt += len(clusters[i]) + } + visStart, visEnd := 0, len(clusters) + w := widthOf(clusters) + if w > maxInput { + // Walk the left edge right while the cursor side overflows + for widthOf(clusters[visStart:]) > maxInput && visStart < cursorIdx { + visStart++ } - visEnd = visStart + maxInput - if visEnd > len(m.input) { - visEnd = len(m.input) - visStart = visEnd - maxInput - if visStart < 0 { - visStart = 0 + // Extend the right edge while the window fits + for visEnd = visStart + 1; visEnd < len(clusters); visEnd++ { + if widthOf(clusters[visStart:visEnd+1]) > maxInput { + break } } } + visStartByte := len(strings.Join(clusters[:visStart], "")) + visEndByte := len(strings.Join(clusters[:visEnd], "")) cursorStyle := lipgloss.NewStyle().Background(highlight).Foreground(bg) - before := m.input[visStart:m.inputPos] + before := m.input[visStartByte:m.inputPos] after := "" cursorCh := " " if m.inputPos < len(m.input) { - cursorCh = string(m.input[m.inputPos]) - after = m.input[m.inputPos+1 : visEnd] - } else if visEnd < len(m.input) { - after = m.input[m.inputPos:visEnd] + cursorCh = clusters[cursorIdx] + after = m.input[m.inputPos+len(cursorCh) : visEndByte] + } else if visEndByte < len(m.input) { + after = m.input[m.inputPos:visEndByte] } line := dimStyle.Render(label) + inputStyle.Render(before) + @@ -382,7 +417,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { // Format suggestions compactly (multiple per line) like Ctrl+R formatted := completion.FormatSuggestions(m.suggestions, innerW-2, 6, true) for _, suggLine := range formatted { - sugLine := completion.PadOrTrim(suggLine, innerW-1) + sugLine := overlay.PadOrTruncDots(suggLine, innerW-1) contentLines = append(contentLines, bgStyle.Render(" "+sugLine)) } } @@ -390,7 +425,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { default: // Message on its own line(s) for non-input dialogs for _, msgLine := range wrapText(m.message, innerW-2) { - line := bgStyle.Render(" " + padRight(msgLine, innerW-1)) + line := bgStyle.Render(" " + overlay.PadOrTrunc(msgLine, innerW-1)) contentLines = append(contentLines, line) } } @@ -419,9 +454,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { fileLabel += fmt.Sprintf(" (%s / %s)", formatBytes(m.fileDoneBytes), formatBytes(m.fileTotalBytes)) } - fileLabel = truncateLeft(fileLabel, innerW-2) + fileLabel = overlay.TruncateLeftEllipsis(fileLabel, innerW-2) contentLines = append(contentLines, - bgStyle.Render(" "+padRight(fileLabel, innerW-1))) + bgStyle.Render(" "+overlay.PadOrTrunc(fileLabel, innerW-1))) // Per-file bar var fileFrac float64 @@ -442,9 +477,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { if m.cancelRequested { totalLabel += " [cancelling…]" } - totalLabel = truncateLeft(totalLabel, innerW-2) + totalLabel = overlay.TruncateLeftEllipsis(totalLabel, innerW-2) contentLines = append(contentLines, - bgStyle.Render(" "+padRight(totalLabel, innerW-1))) + bgStyle.Render(" "+overlay.PadOrTrunc(totalLabel, innerW-1))) // Total bar var totalFrac float64 @@ -529,41 +564,35 @@ func formatBytes(n int64) string { return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) } -// truncateLeft keeps the right-most characters, prefixing with … if clipped. -// Useful for long file paths where the trailing name matters more. -func truncateLeft(s string, width int) string { +func wrapText(text string, width int) []string { if width < 1 { - return "" - } - if len(s) <= width { - return s + width = 1 } - if width == 1 { - return "…" - } - return "…" + s[len(s)-width+1:] -} - -func padRight(s string, width int) string { - if len(s) >= width { - return s[:width] - } - return s + strings.Repeat(" ", width-len(s)) -} - -func wrapText(text string, width int) []string { - if len(text) <= width { + if ansi.StringWidth(text) <= width { return []string{text} } var lines []string - for len(text) > width { + for ansi.StringWidth(text) > width { // Find last space before width - cut := width - for cut > 0 && text[cut] != ' ' { + head := ansi.Truncate(text, width, "") + cut := len(head) + for cut > 0 && text[cut-1] != ' ' { cut-- } if cut == 0 { - cut = width + cut = len(head) + if cut == 0 { + n := 1 + tail := ansi.TruncateLeft(text, n, "") + for len(tail) == len(text) && n < ansi.StringWidth(text) { + n++ + tail = ansi.TruncateLeft(text, n, "") + } + cut = len(text) - len(tail) + if cut == 0 { + cut = 1 + } + } } lines = append(lines, text[:cut]) text = strings.TrimLeft(text[cut:], " ") diff --git a/internal/ui/dialog/dialog_test.go b/internal/ui/dialog/dialog_test.go new file mode 100644 index 0000000..e0424d3 --- /dev/null +++ b/internal/ui/dialog/dialog_test.go @@ -0,0 +1,212 @@ +package dialog + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + + "github.com/kooler/MiddayCommander/internal/ui/theme" +) + +// TestWrapTextNoHang guards the regression where a wide first grapheme at +// width 1 made ansi.Truncate return "" and the loop never advanced. +func TestWrapTextNoHang(t *testing.T) { + done := make(chan []string, 1) + go func() { done <- wrapText("Delete 日本語.txt?", 1) }() + select { + case lines := <-done: + if len(lines) == 0 { + t.Fatal("expected lines, got none") + } + case <-time.After(2 * time.Second): + t.Fatal("wrapText(\"Delete 日本語.txt?\", 1) did not finish in 2s: infinite loop") + } +} + +// TestWrapText: every line is valid UTF-8 and fits the budget, except a +// single wide grapheme (emoji at width 1) which is kept whole on purpose. +func TestWrapText(t *testing.T) { + tests := []struct { + name string + text string + width int + }{ + {"plain word wrap", "hello world foo", 5}, + {"cjk at width 2", "日本語のテキスト", 2}, + {"emoji first at width 1", "🎉🎉🎉 party", 1}, + {"zwj family", "👨‍👩‍👧‍👦семья", 1}, + {"empty text", "", 3}, + {"clamped width", "abc", 0}, + {"negative width", "abc", -5}, + {"mixed", "größe 日本語 datei", 8}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + budget := tt.width + if budget < 1 { + budget = 1 + } + lines := wrapText(tt.text, tt.width) + if len(lines) == 0 { + t.Fatal("no lines produced") + } + for i, l := range lines { + if !utf8.ValidString(l) { + t.Errorf("line %d is invalid UTF-8: %q", i, l) + } + if w := ansi.StringWidth(l); w > budget && w > 2 { + t.Errorf("line %d width %d exceeds budget %d and is not a single wide grapheme: %q", i, w, budget, l) + } + } + }) + } +} + +// TestWrapTextWordBoundaries checks that ASCII words stay intact when spaces +// allow a break within the budget. +func TestWrapTextWordBoundaries(t *testing.T) { + lines := wrapText("hello world foo", 5) + want := []string{"hello", "world", "foo"} + if len(lines) != len(want) { + t.Fatalf("got %v, want %v", lines, want) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, lines[i], want[i]) + } + } +} + +// TestWrapTextKeepsGraphemes verifies that wide characters are never split: +// every CJK rune must reappear whole across the produced lines. +func TestWrapTextKeepsGraphemes(t *testing.T) { + lines := wrapText("日本語のテキスト", 2) + joined := "" + for _, l := range lines { + joined += l + } + if joined != "日本語のテキスト" { + t.Errorf("graphemes lost or duplicated: %q", joined) + } +} + +func press(m *Model, t tea.KeyType, runes ...rune) { + if runes != nil { + m.updateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: runes}) + return + } + m.updateInput(tea.KeyMsg{Type: t}) +} + +// TestInputClusterEditing walks the rename dialog through the byte-splitting +// scenario from the review: select 日本語.txt, backspace 5 times. +func TestInputClusterEditing(t *testing.T) { + m := NewInput("Rename", "New name:", "日本語.txt", "rename") + for range 5 { + press(&m, tea.KeyBackspace) + } + if !utf8.ValidString(m.input) { + t.Fatalf("input became invalid UTF-8: %q", m.input) + } + if m.input != "日本" { + t.Errorf("input = %q, want 日本", m.input) + } + press(&m, tea.KeyEnter) + if m.result.Text != "日本" { + t.Errorf("result = %q, want 日本", m.result.Text) + } +} + +// TestInputCursorPlacement checks insertion around wide characters. +func TestInputCursorPlacement(t *testing.T) { + m := NewInput("Rename", "New name:", "日本語", "rename") + press(&m, tea.KeyLeft) + press(&m, tea.KeyLeft) + press(&m, tea.KeyRunes, 'X') + if m.input != "日X本語" { + t.Errorf("middle insert: %q, want 日X本語", m.input) + } + press(&m, tea.KeyHome) + press(&m, tea.KeyRunes, '=') + if m.input != "=日X本語" { + t.Errorf("home insert: %q, want =日X本語", m.input) + } + press(&m, tea.KeyEnd) + press(&m, tea.KeyBackspace) + if m.input != "=日X本" { + t.Errorf("end+backspace: %q, want =日X本", m.input) + } +} + +func TestInputUnicodeInsertion(t *testing.T) { + m := NewInput("Rename", "New name:", "ab", "rename") + press(&m, tea.KeyHome) + press(&m, tea.KeyRunes, '日', '本') + if m.input != "日本ab" { + t.Errorf("CJK insertion = %q, want 日本ab", m.input) + } + if m.inputPos != len("日本") { + t.Errorf("CJK input position = %d, want %d", m.inputPos, len("日本")) + } + + press(&m, tea.KeyEnd) + press(&m, tea.KeyRunes, '🎉') + if m.input != "日本ab🎉" { + t.Errorf("emoji insertion = %q, want 日本ab🎉", m.input) + } + if m.inputPos != len(m.input) { + t.Errorf("emoji input position = %d, want %d", m.inputPos, len(m.input)) + } +} + +func TestInputSpaceInsertion(t *testing.T) { + m := NewInput("Rename", "New name:", "ab", "rename") + press(&m, tea.KeyHome) + press(&m, tea.KeySpace) + if m.input != " ab" { + t.Errorf("space insertion = %q, want %q", m.input, " ab") + } +} + +// TestInputEmojiSequenceIntegrity ensures a ZWJ emoji sequence is removed +// whole by a single backspace, never split mid-cluster. +func TestInputEmojiSequenceIntegrity(t *testing.T) { + family := "👨‍👩‍👧‍👦" + m := NewInput("Rename", "New name:", family+"x.txt", "rename") + for range 5 { // removes t, t, ., x - one per grapheme + press(&m, tea.KeyBackspace) + } + if m.input != family { + t.Fatalf("after 5 backspaces = %q, want the family only", m.input) + } + press(&m, tea.KeyBackspace) // removes the WHOLE family cluster at once + if m.input != "" { + t.Errorf("after 6th backspace = %q, want \"\" (family removed whole)", m.input) + } + if !utf8.ValidString(m.input) { + t.Errorf("input became invalid UTF-8") + } +} +func TestInputViewWidth(t *testing.T) { + tests := []string{ + "📁 My Folder", + "❤️fire.txt", + "👨‍👩‍👧‍👦family.jpg", + "🇷🇺🇩🇪 flags.png", + strings.Repeat("🎉", 25) + ".pdf", + } + for _, name := range tests { + m := NewInput("Copy", "Copy to:", name, "copyas") + m.width = 50 + v := m.View(theme.Theme{}, 120, 40) + for i, line := range strings.Split(v, "\n") { + if w := ansi.StringWidth(line); w != 50 { + t.Errorf("%q: line %d width %d, want 50", name, i, w) + } + } + } +} diff --git a/internal/ui/fuzzy/fuzzy.go b/internal/ui/fuzzy/fuzzy.go index cbe149e..08dc028 100644 --- a/internal/ui/fuzzy/fuzzy.go +++ b/internal/ui/fuzzy/fuzzy.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" @@ -197,13 +198,13 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { rel = mt.path } display := rel - if len(display) > innerW-1 { - display = "…" + display[len(display)-innerW+2:] + if ansi.StringWidth(display) > innerW-1 { + display = overlay.TruncateLeftEllipsis(display, innerW-1) } var line string if isCursor { - line = cursorStyle.Render(padStr(" "+display, innerW)) + line = cursorStyle.Render(overlay.PadOrTrunc(" "+display, innerW)) } else { line = renderWithHighlights(" "+display, shiftIdxs(mt.matchIdxs, 1), normalStyle, matchHLStyle, innerW) } @@ -389,10 +390,3 @@ func renderWithHighlights(s string, matchIdxs []int, normal, highlight lipgloss. } return rendered } - -func padStr(s string, width int) string { - if len(s) >= width { - return s[:width] - } - return s + strings.Repeat(" ", width-len(s)) -} diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go index 1f0ebb9..26b5b45 100644 --- a/internal/ui/help/help.go +++ b/internal/ui/help/help.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/config" "github.com/kooler/MiddayCommander/internal/ui/overlay" @@ -168,9 +169,9 @@ func renderColumn(entries []entry, colW int, bgStyle, headStyle, keyStyle, dimSt if labelWidth < 1 { labelWidth = 1 } - label := fmt.Sprintf(" %-*s", labelWidth, e.label) - if len(label) > labelWidth+1 { - label = label[:labelWidth+1] + label := " " + e.label + if ansi.StringWidth(label) > labelWidth+1 { + label = ansi.Truncate(label, labelWidth+1, "") } line := dimStyle.Render(label) + keysStr + bgStyle.Render(" ") lineW := lipgloss.Width(line) diff --git a/internal/ui/menubar/menubar.go b/internal/ui/menubar/menubar.go index 159cfe2..6762e84 100644 --- a/internal/ui/menubar/menubar.go +++ b/internal/ui/menubar/menubar.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/kooler/MiddayCommander/internal/config" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -160,7 +161,7 @@ func View(th theme.Theme, width int, items []Item) string { if labelWidth < 0 { labelWidth = 0 } - labelStr := th.StatusBar.Render(padOrTrunc(itm.Label, labelWidth)) + labelStr := th.StatusBar.Render(overlay.PadOrTrunc(itm.Label, labelWidth)) b.WriteString(keyStr) b.WriteString(labelStr) @@ -168,10 +169,3 @@ func View(th theme.Theme, width int, items []Item) string { return b.String() } - -func padOrTrunc(s string, width int) string { - if len(s) > width { - return s[:width] - } - return s + strings.Repeat(" ", width-len(s)) -} diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index 9e3d9d3..7d88c41 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -34,7 +34,8 @@ func Place(bg string, boxContent string, bgWidth, bgHeight, boxWidth, boxHeight bgLine := bgLines[bgIdx] - // ANSI-aware slicing: left portion of bg, then the fg box line, then right portion of bg + // Cell- and grapheme-aware slicing: keep the left and right portions of + // the background around the foreground box without breaking ANSI escapes left := ansi.Truncate(bgLine, xOff, "") right := ansi.Cut(bgLine, xOff+boxWidth, bgWidth) @@ -101,3 +102,87 @@ func RenderBox(title string, contentLines []string, footer string, width, height return strings.Join(lines, "\n") } + +// TruncateLeftEllipsis keeps the right-most cells of s, prefixing with an +// ellipsis if clipped. It never returns wider than width cells. +// +// The U+2026 glyph is intentional: left-truncation of paths has used it +// since the initial import, right-truncation keeps ASCII "..." (see +// PadOrTruncDots). Counted as one cell, matching the renderer +func TruncateLeftEllipsis(s string, width int) string { + const ellipsis = "…" // U+2026 horizontal ellipsis - one cell wide + if width < 1 { + return "" + } + w := ansi.StringWidth(s) + if w <= width { + return s + } + if width == 1 { + return ellipsis + } + + n := w - (width - 1) + tail := ansi.TruncateLeft(s, n, "") + // If the cut intersects a wide grapheme, TruncateLeft keeps the whole + // grapheme, so the result can be wider than the requested cell count. + // Search only through the input's actual width; for a suffix that is + // itself wider than the available space, use an empty tail instead. + for ansi.StringWidth(tail) > width-1 && n < w { + n++ + tail = ansi.TruncateLeft(s, n, "") + } + if ansi.StringWidth(tail) > width-1 { + tail = "" + } + // A cluster boundary can also land short of the budget, leaving the + // result one cell narrower. Left-pad the tail so the total is exact + return ellipsis + strings.Repeat(" ", width-1-ansi.StringWidth(tail)) + tail +} + +// PadOrTrunc pads s with trailing spaces to exactly width cells, truncating +// from the right when s is wider. The result is exactly width cells when +// width >= 1 and empty otherwise +func PadOrTrunc(s string, width int) string { + if width < 1 { + return "" + } + w := ansi.StringWidth(s) + if w > width { + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) + } + return s + strings.Repeat(" ", width-w) +} + +// PadOrTruncDots is PadOrTrunc, but clipped values end with "..." so the +// cut stays visible. The result is exactly width cells when width >= 1 +func PadOrTruncDots(s string, width int) string { + if width < 1 { + return "" + } + w := ansi.StringWidth(s) + if w > width { + if width > 3 { + out := ansi.Truncate(s, width-3, "") + "..." + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) + } + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) + } + return s + strings.Repeat(" ", width-w) +} + +// PadLeft right-aligns s in width cells, clipping on the left edge when s is +// wider. The result is exactly width cells when width >= 1 +func PadLeft(s string, width int) string { + if width < 1 { + return "" + } + w := ansi.StringWidth(s) + if w >= width { + out := ansi.Truncate(s, width, "") + return strings.Repeat(" ", width-ansi.StringWidth(out)) + out + } + return strings.Repeat(" ", width-w) + s +} diff --git a/internal/ui/overlay/overlay_test.go b/internal/ui/overlay/overlay_test.go new file mode 100644 index 0000000..12d399d --- /dev/null +++ b/internal/ui/overlay/overlay_test.go @@ -0,0 +1,147 @@ +package overlay + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// TestPadOrTrunc checks padding and right-truncation to exact cell widths. +func TestPadOrTrunc(t *testing.T) { + tests := []struct { + name string + s string + width int + want string + }{ + {"pad short", "abc", 6, "abc "}, + {"no change", "abc", 3, "abc"}, + {"pad empty", "", 3, " "}, + {"truncate ascii", "abcdef", 4, "abcd"}, + {"truncate ascii exact", "abcdef", 3, "abc"}, + {"pad cjk", "日本", 6, "日本 "}, + {"truncate cjk drops straddler", "日本語のファイル", 5, "日本 "}, + {"truncate cjk pads back", "日本語のファイル名", 6, "日本語"}, + {"zero width", "abc", 0, ""}, + {"negative width", "abc", -1, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PadOrTrunc(tt.s, tt.width) + if got != tt.want { + t.Errorf("PadOrTrunc(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) + } + if w := ansi.StringWidth(got); tt.width >= 1 && w != tt.width { + t.Errorf("PadOrTrunc(%q, %d) width = %d, want %d", tt.s, tt.width, w, tt.width) + } + }) + } +} + +// TestPadOrTruncDots ensures clipped values end with ASCII "..." and the +// result is exactly width cells, even when a wide grapheme straddles the cut. +func TestPadOrTruncDots(t *testing.T) { + tests := []struct { + name string + s string + width int + }{ + {"ascii fits", "report.pdf", 12}, + {"ascii truncate", "report.pdf", 8}, + {"cjk truncate", "日本語のファイル名前です.txt", 10}, + {"emoji truncate", "🎉🎉🎉🎉🎉report.pdf", 10}, + {"cjk at 3-cell width", "日本語", 3}, + {"single wide char", "日", 1}, + {"pad short", "ab", 6}, + {"empty", "", 4}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PadOrTruncDots(tt.s, tt.width) + if w := ansi.StringWidth(got); tt.width >= 1 && w != tt.width { + t.Errorf("PadOrTruncDots(%q, %d) = %q width %d, want %d", tt.s, tt.width, got, w, tt.width) + } + }) + } +} + +// TestPadLeft checks right-alignment to exact cell widths. +func TestPadLeft(t *testing.T) { + tests := []struct { + name string + s string + width int + want string + }{ + {"pad", "100", 6, " 100"}, + {"no change", "100", 3, "100"}, + {"cjk pad", "日本", 8, " 日本"}, + {"truncate left", "日本語の", 5, " 日本"}, + {"empty", "", 3, " "}, + {"zero width", "a", 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PadLeft(tt.s, tt.width) + if got != tt.want { + t.Errorf("PadLeft(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) + } + if w := ansi.StringWidth(got); tt.width >= 1 && w != tt.width { + t.Errorf("PadLeft(%q, %d) width = %d, want %d", tt.s, tt.width, w, tt.width) + } + }) + } +} + +// TestTruncateLeftEllipsis checks left-truncation with the legacy U+2026 +// glyph ("..." in sources) and exact result width. +func TestTruncateLeftEllipsis(t *testing.T) { + const ellipsis = "…" // U+2026, one cell wide + tests := []struct { + name string + s string + width int + want string + }{ + {"fits", "/usr/bin", 10, "/usr/bin"}, + {"left cut", "/home/user/documents/report.pdf", 12, ellipsis + "/report.pdf"}, + {"width 1", "/long/path", 1, ellipsis}, + {"zero width", "/path", 0, ""}, + {"cjk tail padded to exact width", "日本語のとても長いファイル名.txt", 8, ellipsis + " 名.txt"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateLeftEllipsis(tt.s, tt.width) + if got != tt.want { + t.Errorf("TruncateLeftEllipsis(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) + } + if w := ansi.StringWidth(got); tt.width >= 1 && w > tt.width { + t.Errorf("TruncateLeftEllipsis(%q, %d) = %q width %d, want <= %d", tt.s, tt.width, got, w, tt.width) + } + }) + } +} + +func TestTruncateLeftEllipsisNarrowWideTail(t *testing.T) { + tests := []struct { + name string + s string + width int + want string + }{ + {"cjk tail does not loop", "日本", 2, "… "}, + {"emoji tail does not loop", "report🎉", 2, "… "}, + {"wide tail fits with two cells", "日本", 3, "…本"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateLeftEllipsis(tt.s, tt.width) + if got != tt.want { + t.Errorf("TruncateLeftEllipsis(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) + } + if w := ansi.StringWidth(got); w != tt.width { + t.Errorf("TruncateLeftEllipsis(%q, %d) width = %d, want %d", tt.s, tt.width, w, tt.width) + } + }) + } +} diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 0e27d2b..f99378d 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -7,7 +7,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -26,6 +28,9 @@ func (m Model) View(th theme.Theme) string { } innerWidth := m.width - 2 // account for left+right border chars + if innerWidth < 0 { + innerWidth = 0 + } // Header: current path (show archive name when inside one) header := m.path @@ -37,11 +42,11 @@ func (m Model) View(th theme.Theme) string { header = archName + "://" + m.path } } - if len(header) > innerWidth-4 { - header = "..." + header[len(header)-innerWidth+7:] + if ansi.StringWidth(header) > innerWidth-4 { + header = overlay.TruncateLeftEllipsis(header, innerWidth-4) } headerLine := borderStyle.Render("┌") + - headerStyle.Render(" "+truncOrPad(header, innerWidth-2)+" ") + + headerStyle.Render(" "+overlay.PadOrTruncDots(header, innerWidth-2)+" ") + borderStyle.Render("┐") // File list rows. The vertical bar is styled once and reused: it is the @@ -80,7 +85,7 @@ func (m Model) View(th theme.Theme) string { } } footerLine := borderStyle.Render("└") + - headerStyle.Render(truncOrPad(footerText, innerWidth)) + + headerStyle.Render(overlay.PadOrTruncDots(footerText, innerWidth)) + borderStyle.Render("┘") // Assemble @@ -119,14 +124,24 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { sizeWidth := 7 nameWidth := width - sizeWidth - timeWidth - 2 // 2 spaces between columns if nameWidth < 4 { - nameWidth = 4 + timeWidth = 0 + nameWidth = width - sizeWidth - 1 + if nameWidth < 4 { + sizeWidth = 0 + nameWidth = width + } + } + if nameWidth < 1 { + return "" } - namePart := truncOrPad(name, nameWidth) - sizePart := padLeft(sizeStr, sizeWidth) - timePart := truncOrPad(timeStr, timeWidth) - - line := namePart + " " + sizePart + " " + timePart + line := overlay.PadOrTruncDots(name, nameWidth) + if sizeWidth > 0 { + line += " " + overlay.PadLeft(sizeStr, sizeWidth) + if timeWidth > 0 { + line += " " + overlay.PadOrTruncDots(timeStr, timeWidth) + } + } // Style based on state var style lipgloss.Style @@ -154,23 +169,6 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { return style.Render(line) } -func truncOrPad(s string, width int) string { - if len(s) > width { - if width > 3 { - return s[:width-3] + "..." - } - return s[:width] - } - return s + strings.Repeat(" ", width-len(s)) -} - -func padLeft(s string, width int) string { - if len(s) >= width { - return s[:width] - } - return strings.Repeat(" ", width-len(s)) + s -} - func isExecutable(mode fs.FileMode) bool { return mode&0111 != 0 } diff --git a/internal/ui/quickview/quickview.go b/internal/ui/quickview/quickview.go index e448bd9..6e87232 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -14,7 +14,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -180,7 +182,7 @@ func (m Model) View(th theme.Theme, focused bool) string { // Header: filename + preview tag. header := m.name + " [preview]" headerLine := borderStyle.Render("┌") + - headerStyle.Render(" "+truncOrPad(header, innerWidth-2)+" ") + + headerStyle.Render(" "+overlay.PadOrTruncDots(header, innerWidth-2)+" ") + borderStyle.Render("┐") // Body. @@ -200,7 +202,7 @@ func (m Model) View(th theme.Theme, focused bool) string { // Footer. footerLine := borderStyle.Render("└") + - headerStyle.Render(truncOrPad(m.footerText(), innerWidth)) + + headerStyle.Render(overlay.PadOrTruncDots(m.footerText(), innerWidth)) + borderStyle.Render("┘") parts := []string{headerLine} @@ -214,7 +216,7 @@ func (m Model) contentLines(width int, normal lipgloss.Style) []string { render := func(ss []string) []string { out := make([]string, len(ss)) for i, s := range ss { - out[i] = normal.Render(truncOrPad(s, width)) + out[i] = normal.Render(overlay.PadOrTruncDots(s, width)) } return out } @@ -245,7 +247,7 @@ func (m Model) centered(width int, msgs ...string) []string { lines = append(lines, "") } for _, s := range msgs { - if pad := (width - len([]rune(s))) / 2; pad > 0 { + if pad := (width - ansi.StringWidth(s)) / 2; pad > 0 { s = strings.Repeat(" ", pad) + s } lines = append(lines, s) @@ -304,20 +306,6 @@ func splitLines(b []byte) []string { return strings.Split(s, "\n") } -func truncOrPad(s string, width int) string { - if width < 0 { - width = 0 - } - r := []rune(s) - if len(r) > width { - if width > 3 { - return string(r[:width-3]) + "..." - } - return string(r[:width]) - } - return s + strings.Repeat(" ", width-len(r)) -} - func formatSize(n int64) string { const unit = 1024 if n < unit { diff --git a/internal/ui/text/grapheme.go b/internal/ui/text/grapheme.go new file mode 100644 index 0000000..2bd0576 --- /dev/null +++ b/internal/ui/text/grapheme.go @@ -0,0 +1,40 @@ +// Package text provides grapheme-aware helpers shared by the TUI overlays: +// cluster boundary navigation for text editing and key-message-to-text +// mapping for input fields. +package text + +import ( + "github.com/charmbracelet/x/ansi" +) + +// PreviousGraphemeBoundary returns the byte index before the grapheme cluster +// at pos, or -1 when pos is already at the start. +func PreviousGraphemeBoundary(s string, pos int) int { + if pos <= 0 { + return -1 + } + if pos > len(s) { + pos = len(s) + } + previous, offset := 0, 0 + for offset < pos { + cluster, _ := ansi.FirstGraphemeCluster(s[offset:], ansi.GraphemeWidth) + next := offset + len(cluster) + if next >= pos { + break + } + previous = next + offset = next + } + return previous +} + +// NextGraphemeBoundary returns the byte index after the grapheme cluster at +// pos, or pos when already at the end. +func NextGraphemeBoundary(s string, pos int) int { + if pos >= len(s) { + return pos + } + cluster, _ := ansi.FirstGraphemeCluster(s[pos:], ansi.GraphemeWidth) + return pos + len(cluster) +} diff --git a/internal/ui/text/grapheme_test.go b/internal/ui/text/grapheme_test.go new file mode 100644 index 0000000..3fc7352 --- /dev/null +++ b/internal/ui/text/grapheme_test.go @@ -0,0 +1,32 @@ +package text + +import ( + "testing" +) + +func TestGraphemeBoundaries(t *testing.T) { + input := "a👨‍👩‍👧‍👦日本" + familyStart := len("a") + japanStart := familyStart + len("👨‍👩‍👧‍👦") + lastCJKStart := japanStart + len("日") + + tests := []struct { + name string + got int + want int + }{ + {"previous from end", PreviousGraphemeBoundary(input, len(input)), lastCJKStart}, + {"previous from family end", PreviousGraphemeBoundary(input, japanStart), familyStart}, + {"previous from start", PreviousGraphemeBoundary(input, 0), -1}, + {"next from family", NextGraphemeBoundary(input, familyStart), japanStart}, + {"next from Japan", NextGraphemeBoundary(input, japanStart), lastCJKStart}, + {"next from end", NextGraphemeBoundary(input, len(input)), len(input)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("got %d, want %d", tt.got, tt.want) + } + }) + } +} diff --git a/internal/ui/text/input.go b/internal/ui/text/input.go new file mode 100644 index 0000000..404c830 --- /dev/null +++ b/internal/ui/text/input.go @@ -0,0 +1,27 @@ +package text + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// PrintableInput returns the printable text represented by a key message. +// Bubble Tea reports a regular space as KeySpace, while other text uses +// KeyRunes and may contain more than one rune when pasted or entered via IME. +func PrintableInput(msg tea.KeyMsg) (string, bool) { + if msg.Type != tea.KeyRunes && msg.Type != tea.KeySpace { + return "", false + } + s := string(msg.Runes) + if msg.Type == tea.KeySpace { + s = " " + } + if s == "" { + return "", false + } + for _, r := range s { + if r < 32 { + return "", false + } + } + return s, true +} diff --git a/internal/ui/text/input_test.go b/internal/ui/text/input_test.go new file mode 100644 index 0000000..e1f1307 --- /dev/null +++ b/internal/ui/text/input_test.go @@ -0,0 +1,32 @@ +package text + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestPrintableInput(t *testing.T) { + tests := []struct { + name string + msg tea.KeyMsg + want string + ok bool + }{ + {"runes", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("日本🎉")}, "日本🎉", true}, + {"space", tea.KeyMsg{Type: tea.KeySpace}, " ", true}, + {"multi-rune paste", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("日本🎉")}, "日本🎉", true}, + {"control rune", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a', '\n'}}, "", false}, + {"control only", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\t'}}, "", false}, + {"enter", tea.KeyMsg{Type: tea.KeyEnter}, "", false}, + {"esc", tea.KeyMsg{Type: tea.KeyEscape}, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := PrintableInput(tt.msg) + if got != tt.want || ok != tt.ok { + t.Errorf("PrintableInput() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok) + } + }) + } +} diff --git a/internal/ui/themepicker/themepicker.go b/internal/ui/themepicker/themepicker.go index 110dc9f..cb07778 100644 --- a/internal/ui/themepicker/themepicker.go +++ b/internal/ui/themepicker/themepicker.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" @@ -234,9 +235,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } display := entry.Name - maxNameW := innerW - 4 - len(tag) // 3-cell gutter + 1-col right margin - if len(display) > maxNameW { - display = display[:maxNameW-3] + "..." + maxNameW := innerW - 4 - ansi.StringWidth(tag) // 3-cell gutter + 1-col right margin + if ansi.StringWidth(display) > maxNameW { + display = overlay.PadOrTruncDots(display, maxNameW) } if isCursor {