From 4e77166b675f9228f294118aaea61a1327ba4005 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sat, 22 Aug 2026 22:14:45 +0300 Subject: [PATCH 01/17] fix column black bars for file names with multibyte characters --- internal/ui/bookmarks/bookmarks.go | 12 +++++----- internal/ui/completion/completion.go | 5 +++-- internal/ui/copypath/copypath.go | 5 +++-- internal/ui/dialog/dialog.go | 33 +++++++++++----------------- internal/ui/fuzzy/fuzzy.go | 12 +++++----- internal/ui/menubar/menubar.go | 8 ++++--- internal/ui/overlay/overlay.go | 28 +++++++++++++++++++++++ internal/ui/panel/panel_view.go | 22 +++++++++++-------- internal/ui/quickview/quickview.go | 13 ++++++----- 9 files changed, 86 insertions(+), 52 deletions(-) diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index d87e532..1c6206e 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.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/bookmark" "github.com/kooler/MiddayCommander/internal/ui/overlay" @@ -288,8 +289,8 @@ 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 @@ -334,8 +335,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } func padStr(s string, width int) string { - if len(s) >= width { - return s[:width] + w := ansi.StringWidth(s) + if w >= width { + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/completion/completion.go b/internal/ui/completion/completion.go index 89aef52..13f5f56 100644 --- a/internal/ui/completion/completion.go +++ b/internal/ui/completion/completion.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) var ( @@ -52,9 +53,9 @@ func CommonPrefix(strs []string) string { func PadOrTrim(s string, width int) string { if lipgloss.Width(s) > width { if width > 3 { - return s[:width-3] + "..." + return ansi.Truncate(s, width-3, "") + "..." } - return s[:width] + return ansi.Truncate(s, width, "") } return s + strings.Repeat(" ", width-lipgloss.Width(s)) } diff --git a/internal/ui/copypath/copypath.go b/internal/ui/copypath/copypath.go index fe7b91f..f847368 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" @@ -154,8 +155,8 @@ 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) if i == m.cursor { diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2c96e73..29b16b2 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.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/platform" "github.com/kooler/MiddayCommander/internal/ui/completion" @@ -529,41 +530,33 @@ 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. +// truncateLeft keeps the right-most cells, prefixing with an ellipsis if clipped. func truncateLeft(s string, width int) string { - if width < 1 { - return "" - } - if len(s) <= width { - return s - } - if width == 1 { - return "…" - } - return "…" + s[len(s)-width+1:] + return overlay.TruncateLeftEllipsis(s, width) } func padRight(s string, width int) string { - if len(s) >= width { - return s[:width] + w := ansi.StringWidth(s) + if w >= width { + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } 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) } lines = append(lines, text[:cut]) text = strings.TrimLeft(text[cut:], " ") diff --git a/internal/ui/fuzzy/fuzzy.go b/internal/ui/fuzzy/fuzzy.go index cbe149e..8da9db0 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,8 +198,8 @@ 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 @@ -391,8 +392,9 @@ func renderWithHighlights(s string, matchIdxs []int, normal, highlight lipgloss. } func padStr(s string, width int) string { - if len(s) >= width { - return s[:width] + w := ansi.StringWidth(s) + if w >= width { + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/menubar/menubar.go b/internal/ui/menubar/menubar.go index 159cfe2..737cbc2 100644 --- a/internal/ui/menubar/menubar.go +++ b/internal/ui/menubar/menubar.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/config" "github.com/kooler/MiddayCommander/internal/ui/theme" @@ -170,8 +171,9 @@ func View(th theme.Theme, width int, items []Item) string { } func padOrTrunc(s string, width int) string { - if len(s) > width { - return s[:width] + w := ansi.StringWidth(s) + if w > width { + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index 9e3d9d3..7f27386 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -101,3 +101,31 @@ 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. +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 inside a wide character (CJK, emoji) TruncateLeft keeps + // the whole character and pads with a space. So the tail can come + // out one cell wider than the budget. Shifting the cut one cell at + // a time guarantees the final width + for ansi.StringWidth(tail) > width-1 { + n++ + tail = ansi.TruncateLeft(s, n, "") + } + return ellipsis + tail +} diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 0e27d2b..0df4df4 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" ) @@ -37,8 +39,8 @@ 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)+" ") + @@ -155,20 +157,22 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { } func truncOrPad(s string, width int) string { - if len(s) > width { + w := ansi.StringWidth(s) + if w > width { if width > 3 { - return s[:width-3] + "..." + return ansi.Truncate(s, width-3, "") + "..." } - return s[:width] + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } func padLeft(s string, width int) string { - if len(s) >= width { - return s[:width] + w := ansi.StringWidth(s) + if w >= width { + return ansi.Truncate(s, width, "") } - return strings.Repeat(" ", width-len(s)) + s + return strings.Repeat(" ", width-w) + s } func isExecutable(mode fs.FileMode) bool { diff --git a/internal/ui/quickview/quickview.go b/internal/ui/quickview/quickview.go index e448bd9..5004c5d 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -245,7 +246,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) @@ -308,14 +309,14 @@ func truncOrPad(s string, width int) string { if width < 0 { width = 0 } - r := []rune(s) - if len(r) > width { + w := ansi.StringWidth(s) + if w > width { if width > 3 { - return string(r[:width-3]) + "..." + return ansi.Truncate(s, width-3, "") + "..." } - return string(r[:width]) + return ansi.Truncate(s, width, "") } - return s + strings.Repeat(" ", width-len(r)) + return s + strings.Repeat(" ", width-w) } func formatSize(n int64) string { From 8b1385811968223b5306324befb49c94a4d4eae8 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 10:40:52 +0300 Subject: [PATCH 02/17] pad after truncate so columns keep exact width --- internal/ui/bookmarks/bookmarks.go | 6 +++++- internal/ui/completion/completion.go | 9 +++++++-- internal/ui/dialog/dialog.go | 6 +++++- internal/ui/fuzzy/fuzzy.go | 6 +++++- internal/ui/menubar/menubar.go | 6 +++++- internal/ui/panel/panel_view.go | 15 ++++++++++++--- internal/ui/quickview/quickview.go | 6 ++++-- 7 files changed, 43 insertions(+), 11 deletions(-) diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index 1c6206e..400490c 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -335,9 +335,13 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } func padStr(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w >= width { - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/completion/completion.go b/internal/ui/completion/completion.go index 13f5f56..813e20b 100644 --- a/internal/ui/completion/completion.go +++ b/internal/ui/completion/completion.go @@ -51,11 +51,16 @@ func CommonPrefix(strs []string) string { } func PadOrTrim(s string, width int) string { + if width < 1 { + return "" + } if lipgloss.Width(s) > width { if width > 3 { - return ansi.Truncate(s, width-3, "") + "..." + out := ansi.Truncate(s, width-3, "") + "..." + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-lipgloss.Width(s)) } diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 29b16b2..d4c032e 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -536,9 +536,13 @@ func truncateLeft(s string, width int) string { } func padRight(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w >= width { - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/fuzzy/fuzzy.go b/internal/ui/fuzzy/fuzzy.go index 8da9db0..8a1ee1b 100644 --- a/internal/ui/fuzzy/fuzzy.go +++ b/internal/ui/fuzzy/fuzzy.go @@ -392,9 +392,13 @@ func renderWithHighlights(s string, matchIdxs []int, normal, highlight lipgloss. } func padStr(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w >= width { - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/menubar/menubar.go b/internal/ui/menubar/menubar.go index 737cbc2..8fc1b80 100644 --- a/internal/ui/menubar/menubar.go +++ b/internal/ui/menubar/menubar.go @@ -171,9 +171,13 @@ func View(th theme.Theme, width int, items []Item) string { } func padOrTrunc(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w > width { - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 0df4df4..f71ab1a 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -157,20 +157,29 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { } func truncOrPad(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w > width { if width > 3 { - return ansi.Truncate(s, width-3, "") + "..." + out := ansi.Truncate(s, width-3, "") + "..." + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } func padLeft(s string, width int) string { + if width < 1 { + return "" + } w := ansi.StringWidth(s) if w >= width { - return ansi.Truncate(s, 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/quickview/quickview.go b/internal/ui/quickview/quickview.go index 5004c5d..6f4b94e 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -312,9 +312,11 @@ func truncOrPad(s string, width int) string { w := ansi.StringWidth(s) if w > width { if width > 3 { - return ansi.Truncate(s, width-3, "") + "..." + out := ansi.Truncate(s, width-3, "") + "..." + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } - return ansi.Truncate(s, width, "") + out := ansi.Truncate(s, width, "") + return out + strings.Repeat(" ", width-ansi.StringWidth(out)) } return s + strings.Repeat(" ", width-w) } From 9ff0c96d14f408aea7bd82bb1b58e1a5e8a1e25b Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 13:29:05 +0300 Subject: [PATCH 03/17] consolidate pad/truncate helpers into overlay --- internal/ui/bookmarks/bookmarks.go | 16 ++-------- internal/ui/completion/completion.go | 16 ---------- internal/ui/dialog/dialog.go | 20 +++--------- internal/ui/fuzzy/fuzzy.go | 14 +-------- internal/ui/menubar/menubar.go | 16 ++-------- internal/ui/overlay/overlay.go | 47 ++++++++++++++++++++++++++++ internal/ui/panel/panel_view.go | 38 +++------------------- internal/ui/quickview/quickview.go | 23 +++----------- 8 files changed, 65 insertions(+), 125 deletions(-) diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index 400490c..c4c4085 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -295,9 +295,9 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { 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)))) } } @@ -333,15 +333,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 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) -} diff --git a/internal/ui/completion/completion.go b/internal/ui/completion/completion.go index 813e20b..f07bf6e 100644 --- a/internal/ui/completion/completion.go +++ b/internal/ui/completion/completion.go @@ -8,7 +8,6 @@ import ( "sync" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" ) var ( @@ -50,21 +49,6 @@ func CommonPrefix(strs []string) string { return prefix } -func PadOrTrim(s string, width int) string { - if width < 1 { - return "" - } - if lipgloss.Width(s) > 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-lipgloss.Width(s)) -} - func FormatSuggestions(suggestions []string, width, maxLines int, basename bool) []string { if len(suggestions) == 0 { return nil diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index d4c032e..0ddbb15 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -383,7 +383,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.PadOrTruncEllipsis(suggLine, innerW-1) contentLines = append(contentLines, bgStyle.Render(" "+sugLine)) } } @@ -391,7 +391,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) } } @@ -422,7 +422,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } fileLabel = truncateLeft(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 @@ -445,7 +445,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { } totalLabel = truncateLeft(totalLabel, innerW-2) contentLines = append(contentLines, - bgStyle.Render(" "+padRight(totalLabel, innerW-1))) + bgStyle.Render(" "+overlay.PadOrTrunc(totalLabel, innerW-1))) // Total bar var totalFrac float64 @@ -535,18 +535,6 @@ func truncateLeft(s string, width int) string { return overlay.TruncateLeftEllipsis(s, width) } -func padRight(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) -} - func wrapText(text string, width int) []string { if ansi.StringWidth(text) <= width { return []string{text} diff --git a/internal/ui/fuzzy/fuzzy.go b/internal/ui/fuzzy/fuzzy.go index 8a1ee1b..08dc028 100644 --- a/internal/ui/fuzzy/fuzzy.go +++ b/internal/ui/fuzzy/fuzzy.go @@ -204,7 +204,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { 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) } @@ -390,15 +390,3 @@ func renderWithHighlights(s string, matchIdxs []int, normal, highlight lipgloss. } return rendered } - -func padStr(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) -} diff --git a/internal/ui/menubar/menubar.go b/internal/ui/menubar/menubar.go index 8fc1b80..6762e84 100644 --- a/internal/ui/menubar/menubar.go +++ b/internal/ui/menubar/menubar.go @@ -6,9 +6,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" "github.com/kooler/MiddayCommander/internal/config" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -161,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) @@ -169,15 +169,3 @@ func View(th theme.Theme, width int, items []Item) string { return b.String() } - -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) -} diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index 7f27386..dee6267 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -129,3 +129,50 @@ func TruncateLeftEllipsis(s string, width int) string { } return ellipsis + 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) +} + +// PadOrTruncEllipsis is PadOrTrunc, but clipped values end with "..." so the +// cut stays visible. The result is exactly width cells when width >= 1. +func PadOrTruncEllipsis(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/panel/panel_view.go b/internal/ui/panel/panel_view.go index f71ab1a..71478bf 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -43,7 +43,7 @@ func (m Model) View(th theme.Theme) string { header = overlay.TruncateLeftEllipsis(header, innerWidth-4) } headerLine := borderStyle.Render("┌") + - headerStyle.Render(" "+truncOrPad(header, innerWidth-2)+" ") + + headerStyle.Render(" "+overlay.PadOrTruncEllipsis(header, innerWidth-2)+" ") + borderStyle.Render("┐") // File list rows. The vertical bar is styled once and reused: it is the @@ -82,7 +82,7 @@ func (m Model) View(th theme.Theme) string { } } footerLine := borderStyle.Render("└") + - headerStyle.Render(truncOrPad(footerText, innerWidth)) + + headerStyle.Render(overlay.PadOrTruncEllipsis(footerText, innerWidth)) + borderStyle.Render("┘") // Assemble @@ -124,9 +124,9 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { nameWidth = 4 } - namePart := truncOrPad(name, nameWidth) - sizePart := padLeft(sizeStr, sizeWidth) - timePart := truncOrPad(timeStr, timeWidth) + namePart := overlay.PadOrTruncEllipsis(name, nameWidth) + sizePart := overlay.PadLeft(sizeStr, sizeWidth) + timePart := overlay.PadOrTruncEllipsis(timeStr, timeWidth) line := namePart + " " + sizePart + " " + timePart @@ -156,34 +156,6 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { return style.Render(line) } -func truncOrPad(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) -} - -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 -} - 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 6f4b94e..5350b33 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/kooler/MiddayCommander/internal/ui/overlay" "github.com/kooler/MiddayCommander/internal/ui/theme" ) @@ -181,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.PadOrTruncEllipsis(header, innerWidth-2)+" ") + borderStyle.Render("┐") // Body. @@ -201,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.PadOrTruncEllipsis(m.footerText(), innerWidth)) + borderStyle.Render("┘") parts := []string{headerLine} @@ -215,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.PadOrTruncEllipsis(s, width)) } return out } @@ -305,22 +306,6 @@ func splitLines(b []byte) []string { return strings.Split(s, "\n") } -func truncOrPad(s string, width int) string { - if width < 0 { - width = 0 - } - 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) -} - func formatSize(n int64) string { const unit = 1024 if n < unit { From 3160d69eddbdda611d92a61c87be22398c28b1ac Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 15:55:00 +0300 Subject: [PATCH 04/17] drop size and time columns on narrow panels --- internal/ui/panel/panel_view.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 71478bf..5c69921 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -28,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 @@ -121,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 := overlay.PadOrTruncEllipsis(name, nameWidth) - sizePart := overlay.PadLeft(sizeStr, sizeWidth) - timePart := overlay.PadOrTruncEllipsis(timeStr, timeWidth) - - line := namePart + " " + sizePart + " " + timePart + line := overlay.PadOrTruncEllipsis(name, nameWidth) + if sizeWidth > 0 { + line += " " + overlay.PadLeft(sizeStr, sizeWidth) + if timeWidth > 0 { + line += " " + overlay.PadOrTruncEllipsis(timeStr, timeWidth) + } + } // Style based on state var style lipgloss.Style From fd12b675032c5ac312f44cab6436aa5d8523f3f4 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 16:53:08 +0300 Subject: [PATCH 05/17] fix dialog freeze wrapping wide characters and cut grapheme boundaries --- internal/ui/dialog/dialog.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 0ddbb15..bc5744c 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -536,6 +536,9 @@ func truncateLeft(s string, width int) string { } func wrapText(text string, width int) []string { + if width < 1 { + width = 1 + } if ansi.StringWidth(text) <= width { return []string{text} } @@ -549,6 +552,18 @@ func wrapText(text string, width int) []string { } if cut == 0 { 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:], " ") From c0c5002dd17c92fd1cf34775459cc56e0ac925b5 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 20:26:57 +0300 Subject: [PATCH 06/17] fix multibyte input handling and emoji window sizing in dialogs --- internal/ui/copypath/copypath.go | 5 +- internal/ui/dialog/dialog.go | 141 +++++++++++++++++++++---------- internal/ui/overlay/overlay.go | 6 +- 3 files changed, 104 insertions(+), 48 deletions(-) diff --git a/internal/ui/copypath/copypath.go b/internal/ui/copypath/copypath.go index f847368..fd54e46 100644 --- a/internal/ui/copypath/copypath.go +++ b/internal/ui/copypath/copypath.go @@ -104,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 } } diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index bc5744c..55f0a6d 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -53,13 +53,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 @@ -203,23 +203,27 @@ 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 := prevCluster(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 := nextCluster(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 := prevCluster(m.input, m.inputPos) + if start >= 0 { + m.inputPos = start } case "right": - if m.inputPos < len(m.input) { - m.inputPos++ + end := nextCluster(m.input, m.inputPos) + if end > m.inputPos { + m.inputPos = end } case "home": m.inputPos = 0 @@ -235,6 +239,38 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { return nil } +// prevCluster returns the byte index of the grapheme cluster boundary +// before pos, or -1 if pos is already at the start +func prevCluster(s string, pos int) int { + if pos <= 0 { + return -1 + } + if pos > len(s) { + pos = len(s) + } + prev, b := 0, 0 + for b < pos { + c, _ := ansi.FirstGraphemeCluster(s[b:], ansi.GraphemeWidth) + next := b + len(c) + if next >= pos { + break + } + prev = next + b = next + } + return prev +} + +// nextCluster returns the byte index after the grapheme cluster at pos, or +// pos when already at the end +func nextCluster(s string, pos int) int { + if pos >= len(s) { + return pos + } + c, _ := ansi.FirstGraphemeCluster(s[pos:], ansi.GraphemeWidth) + return pos + len(c) +} + func (m *Model) updateSuggestions() { if m.tag != "goto" { m.suggestions = nil @@ -334,40 +370,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 := lipgloss.Width(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 } - visEnd = visStart + maxInput - if visEnd > len(m.input) { - visEnd = len(m.input) - visStart = visEnd - maxInput - if visStart < 0 { - visStart = 0 + 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++ + } + // 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) + @@ -420,7 +480,7 @@ 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(" "+overlay.PadOrTrunc(fileLabel, innerW-1))) @@ -443,7 +503,7 @@ 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(" "+overlay.PadOrTrunc(totalLabel, innerW-1))) @@ -530,11 +590,6 @@ func formatBytes(n int64) string { return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) } -// truncateLeft keeps the right-most cells, prefixing with an ellipsis if clipped. -func truncateLeft(s string, width int) string { - return overlay.TruncateLeftEllipsis(s, width) -} - func wrapText(text string, width int) []string { if width < 1 { width = 1 diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index dee6267..e9ea2ac 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -132,7 +132,7 @@ func TruncateLeftEllipsis(s string, width int) string { // 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. +// width >= 1 and empty otherwise func PadOrTrunc(s string, width int) string { if width < 1 { return "" @@ -146,7 +146,7 @@ func PadOrTrunc(s string, width int) string { } // PadOrTruncEllipsis is PadOrTrunc, but clipped values end with "..." so the -// cut stays visible. The result is exactly width cells when width >= 1. +// cut stays visible. The result is exactly width cells when width >= 1 func PadOrTruncEllipsis(s string, width int) string { if width < 1 { return "" @@ -164,7 +164,7 @@ func PadOrTruncEllipsis(s string, width int) string { } // 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. +// wider. The result is exactly width cells when width >= 1 func PadLeft(s string, width int) string { if width < 1 { return "" From 4129cec7761ed313294fdbf3ba914d0b93886c92 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 23:36:36 +0300 Subject: [PATCH 07/17] fix byte-based truncation in cmdexec, themepicker and help --- internal/ui/cmdexec/cmdexec.go | 9 +++++---- internal/ui/help/help.go | 7 ++++--- internal/ui/themepicker/themepicker.go | 7 ++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 723178d..2dae05e 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.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/completion" "github.com/kooler/MiddayCommander/internal/ui/overlay" @@ -238,8 +239,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) @@ -286,8 +287,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/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/themepicker/themepicker.go b/internal/ui/themepicker/themepicker.go index 110dc9f..258d5da 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.PadOrTruncEllipsis(display, maxNameW) } if isCursor { From 340a737e612034ec98c6ef67aa0d97c685449905 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Sun, 30 Aug 2026 23:51:42 +0300 Subject: [PATCH 08/17] document the historical ellipsis glyph in TruncateLeftEllipsis --- internal/ui/overlay/overlay.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index e9ea2ac..ec45576 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -104,6 +104,10 @@ func RenderBox(title string, contentLines []string, footer string, width, height // 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 +// PadOrTruncEllipsis). 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 { From 3b584dc472d8c558c1b24461c4634a98bee085cb Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 00:02:33 +0300 Subject: [PATCH 09/17] rename PadOrTruncEllipsis to PadOrTruncDots --- internal/ui/copypath/copypath.go | 11 ++--------- internal/ui/dialog/dialog.go | 4 ++-- internal/ui/overlay/overlay.go | 6 +++--- internal/ui/panel/panel_view.go | 8 ++++---- internal/ui/quickview/quickview.go | 6 +++--- internal/ui/themepicker/themepicker.go | 2 +- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/internal/ui/copypath/copypath.go b/internal/ui/copypath/copypath.go index fd54e46..a186390 100644 --- a/internal/ui/copypath/copypath.go +++ b/internal/ui/copypath/copypath.go @@ -147,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 { @@ -159,7 +159,7 @@ func (m Model) View(_ theme.Theme, screenWidth, screenHeight int) string { 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 { @@ -181,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 55f0a6d..3b0a14d 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -370,7 +370,7 @@ 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 := lipgloss.Width(label) + labelW := ansi.StringWidth(label) maxInput := innerW - labelW if maxInput < 1 { maxInput = 1 @@ -443,7 +443,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 := overlay.PadOrTruncEllipsis(suggLine, innerW-1) + sugLine := overlay.PadOrTruncDots(suggLine, innerW-1) contentLines = append(contentLines, bgStyle.Render(" "+sugLine)) } } diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index ec45576..a02269e 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -107,7 +107,7 @@ func RenderBox(title string, contentLines []string, footer string, width, height // // The U+2026 glyph is intentional: left-truncation of paths has used it // since the initial import, right-truncation keeps ASCII "..." (see -// PadOrTruncEllipsis). Counted as one cell, matching the renderer +// 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 { @@ -149,9 +149,9 @@ func PadOrTrunc(s string, width int) string { return s + strings.Repeat(" ", width-w) } -// PadOrTruncEllipsis is PadOrTrunc, but clipped values end with "..." so the +// PadOrTruncDots is PadOrTrunc, but clipped values end with "..." so the // cut stays visible. The result is exactly width cells when width >= 1 -func PadOrTruncEllipsis(s string, width int) string { +func PadOrTruncDots(s string, width int) string { if width < 1 { return "" } diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 5c69921..f99378d 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -46,7 +46,7 @@ func (m Model) View(th theme.Theme) string { header = overlay.TruncateLeftEllipsis(header, innerWidth-4) } headerLine := borderStyle.Render("┌") + - headerStyle.Render(" "+overlay.PadOrTruncEllipsis(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 @@ -85,7 +85,7 @@ func (m Model) View(th theme.Theme) string { } } footerLine := borderStyle.Render("└") + - headerStyle.Render(overlay.PadOrTruncEllipsis(footerText, innerWidth)) + + headerStyle.Render(overlay.PadOrTruncDots(footerText, innerWidth)) + borderStyle.Render("┘") // Assemble @@ -135,11 +135,11 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { return "" } - line := overlay.PadOrTruncEllipsis(name, nameWidth) + line := overlay.PadOrTruncDots(name, nameWidth) if sizeWidth > 0 { line += " " + overlay.PadLeft(sizeStr, sizeWidth) if timeWidth > 0 { - line += " " + overlay.PadOrTruncEllipsis(timeStr, timeWidth) + line += " " + overlay.PadOrTruncDots(timeStr, timeWidth) } } diff --git a/internal/ui/quickview/quickview.go b/internal/ui/quickview/quickview.go index 5350b33..6e87232 100644 --- a/internal/ui/quickview/quickview.go +++ b/internal/ui/quickview/quickview.go @@ -182,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(" "+overlay.PadOrTruncEllipsis(header, innerWidth-2)+" ") + + headerStyle.Render(" "+overlay.PadOrTruncDots(header, innerWidth-2)+" ") + borderStyle.Render("┐") // Body. @@ -202,7 +202,7 @@ func (m Model) View(th theme.Theme, focused bool) string { // Footer. footerLine := borderStyle.Render("└") + - headerStyle.Render(overlay.PadOrTruncEllipsis(m.footerText(), innerWidth)) + + headerStyle.Render(overlay.PadOrTruncDots(m.footerText(), innerWidth)) + borderStyle.Render("┘") parts := []string{headerLine} @@ -216,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(overlay.PadOrTruncEllipsis(s, width)) + out[i] = normal.Render(overlay.PadOrTruncDots(s, width)) } return out } diff --git a/internal/ui/themepicker/themepicker.go b/internal/ui/themepicker/themepicker.go index 258d5da..cb07778 100644 --- a/internal/ui/themepicker/themepicker.go +++ b/internal/ui/themepicker/themepicker.go @@ -237,7 +237,7 @@ func (m Model) View(th theme.Theme, screenWidth, screenHeight int) string { display := entry.Name maxNameW := innerW - 4 - ansi.StringWidth(tag) // 3-cell gutter + 1-col right margin if ansi.StringWidth(display) > maxNameW { - display = overlay.PadOrTruncEllipsis(display, maxNameW) + display = overlay.PadOrTruncDots(display, maxNameW) } if isCursor { From 657952f9297492b5f78ca925c5397177876188e2 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 00:43:54 +0300 Subject: [PATCH 10/17] add tests for width helpers, wrapText and cluster editing --- internal/ui/dialog/dialog_test.go | 182 ++++++++++++++++++++++++++++ internal/ui/overlay/overlay.go | 4 +- internal/ui/overlay/overlay_test.go | 123 +++++++++++++++++++ 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 internal/ui/dialog/dialog_test.go create mode 100644 internal/ui/overlay/overlay_test.go diff --git a/internal/ui/dialog/dialog_test.go b/internal/ui/dialog/dialog_test.go new file mode 100644 index 0000000..d3c8cbb --- /dev/null +++ b/internal/ui/dialog/dialog_test.go @@ -0,0 +1,182 @@ +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) + } +} + +// 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/overlay/overlay.go b/internal/ui/overlay/overlay.go index a02269e..549a78b 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -131,7 +131,9 @@ func TruncateLeftEllipsis(s string, width int) string { n++ tail = ansi.TruncateLeft(s, n, "") } - return ellipsis + 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 diff --git a/internal/ui/overlay/overlay_test.go b/internal/ui/overlay/overlay_test.go new file mode 100644 index 0000000..d8a6f70 --- /dev/null +++ b/internal/ui/overlay/overlay_test.go @@ -0,0 +1,123 @@ +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) + } + }) + } +} From cbf0d7bd986c4333d765a12e287f83a5c6e1e985 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 01:21:19 +0300 Subject: [PATCH 11/17] fix narrow-width truncation for wide graphemes --- internal/ui/overlay/overlay.go | 13 ++++++++----- internal/ui/overlay/overlay_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index 549a78b..071f108 100644 --- a/internal/ui/overlay/overlay.go +++ b/internal/ui/overlay/overlay.go @@ -123,14 +123,17 @@ func TruncateLeftEllipsis(s string, width int) string { n := w - (width - 1) tail := ansi.TruncateLeft(s, n, "") - // If the cut inside a wide character (CJK, emoji) TruncateLeft keeps - // the whole character and pads with a space. So the tail can come - // out one cell wider than the budget. Shifting the cut one cell at - // a time guarantees the final width - for ansi.StringWidth(tail) > width-1 { + // 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 diff --git a/internal/ui/overlay/overlay_test.go b/internal/ui/overlay/overlay_test.go index d8a6f70..12d399d 100644 --- a/internal/ui/overlay/overlay_test.go +++ b/internal/ui/overlay/overlay_test.go @@ -121,3 +121,27 @@ func TestTruncateLeftEllipsis(t *testing.T) { }) } } + +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) + } + }) + } +} From 288c87a7e195c80ec107ad34bdb9ff81c17c5d7c Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 01:34:18 +0300 Subject: [PATCH 12/17] fix Unicode and space input in dialogs --- internal/ui/dialog/dialog.go | 23 ++++++++++++++++++++--- internal/ui/dialog/dialog_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 3b0a14d..17da8e3 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -230,9 +230,26 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { 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 msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + s := string(msg.Runes) + if msg.Type == tea.KeySpace { + s = " " + } + if s == "" { + break + } + for _, r := range s { + if r < 32 { + return nil + } + } + 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() } } diff --git a/internal/ui/dialog/dialog_test.go b/internal/ui/dialog/dialog_test.go index d3c8cbb..e0424d3 100644 --- a/internal/ui/dialog/dialog_test.go +++ b/internal/ui/dialog/dialog_test.go @@ -142,6 +142,36 @@ func TestInputCursorPlacement(t *testing.T) { } } +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) { From 899e4f6e76fd548f521288a91a39723b9366e779 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 02:03:12 +0300 Subject: [PATCH 13/17] refactor shared grapheme editing helpers --- internal/ui/cmdexec/cmdexec.go | 43 +++++++++++++++++++-------- internal/ui/cmdexec/cmdexec_test.go | 46 +++++++++++++++++++++++++++++ internal/ui/dialog/dialog.go | 41 ++++--------------------- internal/ui/text/grapheme.go | 35 ++++++++++++++++++++++ internal/ui/text/grapheme_test.go | 30 +++++++++++++++++++ 5 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 internal/ui/cmdexec/cmdexec_test.go create mode 100644 internal/ui/text/grapheme.go create mode 100644 internal/ui/text/grapheme_test.go diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 2dae05e..42a6561 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -115,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 @@ -125,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 @@ -134,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": @@ -180,15 +185,26 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } default: - s := msg.String() - if len(s) == 1 && s[0] >= 32 { + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + s := string(msg.Runes) + if msg.Type == tea.KeySpace { + s = " " + } + if s == "" { + break + } + for _, r := range s { + if r < 32 { + return m, nil + } + } 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 @@ -252,7 +268,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 + "█" } 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/dialog/dialog.go b/internal/ui/dialog/dialog.go index 17da8e3..bf6e911 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -203,25 +204,25 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { }) } case "backspace": - start := prevCluster(m.input, 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": - end := nextCluster(m.input, m.inputPos) + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) if end > m.inputPos { m.input = m.input[:m.inputPos] + m.input[end:] } m.updateSuggestions() case "left": - start := prevCluster(m.input, m.inputPos) + start := uitext.PreviousGraphemeBoundary(m.input, m.inputPos) if start >= 0 { m.inputPos = start } case "right": - end := nextCluster(m.input, m.inputPos) + end := uitext.NextGraphemeBoundary(m.input, m.inputPos) if end > m.inputPos { m.inputPos = end } @@ -256,38 +257,6 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { return nil } -// prevCluster returns the byte index of the grapheme cluster boundary -// before pos, or -1 if pos is already at the start -func prevCluster(s string, pos int) int { - if pos <= 0 { - return -1 - } - if pos > len(s) { - pos = len(s) - } - prev, b := 0, 0 - for b < pos { - c, _ := ansi.FirstGraphemeCluster(s[b:], ansi.GraphemeWidth) - next := b + len(c) - if next >= pos { - break - } - prev = next - b = next - } - return prev -} - -// nextCluster returns the byte index after the grapheme cluster at pos, or -// pos when already at the end -func nextCluster(s string, pos int) int { - if pos >= len(s) { - return pos - } - c, _ := ansi.FirstGraphemeCluster(s[pos:], ansi.GraphemeWidth) - return pos + len(c) -} - func (m *Model) updateSuggestions() { if m.tag != "goto" { m.suggestions = nil diff --git a/internal/ui/text/grapheme.go b/internal/ui/text/grapheme.go new file mode 100644 index 0000000..85feb28 --- /dev/null +++ b/internal/ui/text/grapheme.go @@ -0,0 +1,35 @@ +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..0b50daf --- /dev/null +++ b/internal/ui/text/grapheme_test.go @@ -0,0 +1,30 @@ +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) + } + }) + } +} From 015635a3347795b87a2f636901a9940153abfa0b Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 02:08:54 +0300 Subject: [PATCH 14/17] fix Unicode editing in bookmarks --- internal/ui/bookmarks/bookmarks.go | 50 +++++++++++++++++-------- internal/ui/bookmarks/bookmarks_test.go | 44 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 internal/ui/bookmarks/bookmarks_test.go diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index c4c4085..5e19340 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -23,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. @@ -71,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 @@ -88,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 := printableInput(msg); ok { m.filter += s m.refilter() } @@ -154,19 +154,37 @@ 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 := printableInput(msg); ok { m.addName += s } return m, nil } } +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 +} + func (m *Model) refilter() { all := m.store.Sorted() if m.filter == "" { 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) + } +} From bf215f2d09e86542a8980b52935d30c25bef33d4 Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 02:18:24 +0300 Subject: [PATCH 15/17] refactor shared printable input helper --- internal/ui/bookmarks/bookmarks.go | 23 ++--------------------- internal/ui/cmdexec/cmdexec.go | 14 +------------- internal/ui/dialog/dialog.go | 14 +------------- internal/ui/text/grapheme.go | 27 ++++++++++++++++++++++++++- internal/ui/text/grapheme_test.go | 28 +++++++++++++++++++++++++++- 5 files changed, 57 insertions(+), 49 deletions(-) diff --git a/internal/ui/bookmarks/bookmarks.go b/internal/ui/bookmarks/bookmarks.go index 5e19340..b3896af 100644 --- a/internal/ui/bookmarks/bookmarks.go +++ b/internal/ui/bookmarks/bookmarks.go @@ -89,7 +89,7 @@ func (m Model) Update(msg tea.KeyMsg) (Model, tea.Cmd) { m.clampOffset() } default: - if s, ok := printableInput(msg); ok { + if s, ok := uitext.PrintableInput(msg); ok { m.filter += s m.refilter() } @@ -159,32 +159,13 @@ func (m Model) updateAdding(msg tea.KeyMsg) (Model, tea.Cmd) { } return m, nil default: - if s, ok := printableInput(msg); ok { + if s, ok := uitext.PrintableInput(msg); ok { m.addName += s } return m, nil } } -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 -} - func (m *Model) refilter() { all := m.store.Sorted() if m.filter == "" { diff --git a/internal/ui/cmdexec/cmdexec.go b/internal/ui/cmdexec/cmdexec.go index 42a6561..fc3877f 100644 --- a/internal/ui/cmdexec/cmdexec.go +++ b/internal/ui/cmdexec/cmdexec.go @@ -185,19 +185,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } default: - if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { - s := string(msg.Runes) - if msg.Type == tea.KeySpace { - s = " " - } - if s == "" { - break - } - for _, r := range s { - if r < 32 { - return m, nil - } - } + if s, ok := uitext.PrintableInput(msg); ok { if m.inputPos < 0 { m.inputPos = 0 } else if m.inputPos > len(m.input) { diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index bf6e911..e0e1241 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -231,19 +231,7 @@ func (m *Model) updateInput(msg tea.KeyMsg) tea.Cmd { case "end": m.inputPos = len(m.input) default: - if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { - s := string(msg.Runes) - if msg.Type == tea.KeySpace { - s = " " - } - if s == "" { - break - } - for _, r := range s { - if r < 32 { - return nil - } - } + if s, ok := uitext.PrintableInput(msg); ok { if m.inputPos < 0 { m.inputPos = 0 } else if m.inputPos > len(m.input) { diff --git a/internal/ui/text/grapheme.go b/internal/ui/text/grapheme.go index 85feb28..57608f3 100644 --- a/internal/ui/text/grapheme.go +++ b/internal/ui/text/grapheme.go @@ -1,6 +1,31 @@ package text -import "github.com/charmbracelet/x/ansi" +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// 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 +} // PreviousGraphemeBoundary returns the byte index before the grapheme cluster // at pos, or -1 when pos is already at the start. diff --git a/internal/ui/text/grapheme_test.go b/internal/ui/text/grapheme_test.go index 0b50daf..c0225c2 100644 --- a/internal/ui/text/grapheme_test.go +++ b/internal/ui/text/grapheme_test.go @@ -1,6 +1,32 @@ package text -import "testing" +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}, + {"control rune", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a', '\n'}}, "", false}, + {"enter", tea.KeyMsg{Type: tea.KeyEnter}, "", 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) + } + }) + } +} func TestGraphemeBoundaries(t *testing.T) { input := "a👨‍👩‍👧‍👦日本" From 083d73bbedcfebc8424ced90cb1d7fdf328586ad Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 02:32:41 +0300 Subject: [PATCH 16/17] clarify Place slicing comment --- internal/ui/overlay/overlay.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/ui/overlay/overlay.go b/internal/ui/overlay/overlay.go index 071f108..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) From c2f932584db512ff790cd695ae83423bdf03d4fb Mon Sep 17 00:00:00 2001 From: Serg Baburin Date: Mon, 31 Aug 2026 05:44:15 +0300 Subject: [PATCH 17/17] split text package into grapheme and input --- internal/ui/text/grapheme.go | 26 +++---------------------- internal/ui/text/grapheme_test.go | 24 ----------------------- internal/ui/text/input.go | 27 ++++++++++++++++++++++++++ internal/ui/text/input_test.go | 32 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 47 deletions(-) create mode 100644 internal/ui/text/input.go create mode 100644 internal/ui/text/input_test.go diff --git a/internal/ui/text/grapheme.go b/internal/ui/text/grapheme.go index 57608f3..2bd0576 100644 --- a/internal/ui/text/grapheme.go +++ b/internal/ui/text/grapheme.go @@ -1,32 +1,12 @@ +// 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 ( - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" ) -// 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 -} - // 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 { diff --git a/internal/ui/text/grapheme_test.go b/internal/ui/text/grapheme_test.go index c0225c2..3fc7352 100644 --- a/internal/ui/text/grapheme_test.go +++ b/internal/ui/text/grapheme_test.go @@ -2,32 +2,8 @@ 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}, - {"control rune", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a', '\n'}}, "", false}, - {"enter", tea.KeyMsg{Type: tea.KeyEnter}, "", 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) - } - }) - } -} - func TestGraphemeBoundaries(t *testing.T) { input := "a👨‍👩‍👧‍👦日本" familyStart := len("a") 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) + } + }) + } +}