Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4e77166
fix column black bars for file names with multibyte characters
gmist Aug 22, 2026
8b13858
pad after truncate so columns keep exact width
gmist Aug 30, 2026
9ff0c96
consolidate pad/truncate helpers into overlay
gmist Aug 30, 2026
3160d69
drop size and time columns on narrow panels
gmist Aug 30, 2026
fd12b67
fix dialog freeze wrapping wide characters and cut grapheme boundaries
gmist Aug 30, 2026
c0c5002
fix multibyte input handling and emoji window sizing in dialogs
gmist Aug 30, 2026
4129cec
fix byte-based truncation in cmdexec, themepicker and help
gmist Aug 30, 2026
340a737
document the historical ellipsis glyph in TruncateLeftEllipsis
gmist Aug 30, 2026
3b584dc
rename PadOrTruncEllipsis to PadOrTruncDots
gmist Aug 30, 2026
657952f
add tests for width helpers, wrapText and cluster editing
gmist Aug 30, 2026
cbf0d7b
fix narrow-width truncation for wide graphemes
gmist Aug 30, 2026
288c87a
fix Unicode and space input in dialogs
gmist Aug 30, 2026
899e4f6
refactor shared grapheme editing helpers
gmist Aug 30, 2026
015635a
fix Unicode editing in bookmarks
gmist Aug 30, 2026
bf215f2
refactor shared printable input helper
gmist Aug 30, 2026
083d73b
clarify Place slicing comment
gmist Aug 30, 2026
c2f9325
split text package into grapheme and input
gmist Aug 31, 2026
001522f
add panel row width and column alignment tests
gmist Sep 1, 2026
6f72c4e
optimize TruncateLeftEllipsis
gmist Sep 6, 2026
eb8eccf
Merge branch 'main' into unicode_fix
kongoncharuk Sep 14, 2026
d06ee65
clamp visEnd so narrow input windows don't slice past the cluster list
kongoncharuk Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 12 additions & 19 deletions internal/ui/bookmarks/bookmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))))
}
}

Expand Down Expand Up @@ -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))
}
44 changes: 44 additions & 0 deletions internal/ui/bookmarks/bookmarks_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 23 additions & 17 deletions internal/ui/cmdexec/cmdexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -114,32 +116,36 @@ 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
m.outputOffset = 0
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
m.outputOffset = 0
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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 + "█"
}
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions internal/ui/cmdexec/cmdexec_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 0 additions & 10 deletions internal/ui/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 8 additions & 13 deletions internal/ui/copypath/copypath.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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))
}
Loading