From 16c343c09ef49e826f9f9d9ad0d78bc03fa5cbd0 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Tue, 30 Jun 2026 17:54:42 +0200 Subject: [PATCH] fix(tui): accept macOS Option-key chars for file picker toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS the OS substitutes a Unicode character for Option+key before the terminal receives the event, so the file picker alt+h / alt+i visibility toggles never fire. Accept the characters that the standard US and US-International layouts emit (Option+H -> "˙" U+02D9, Option+I -> "ˆ" U+02C6) as aliases for the existing bindings, keeping alt+h / alt+i as the primary keys. Implements Option B from #2611. --- pkg/tui/dialog/file_picker.go | 35 +++++++++++++++- pkg/tui/dialog/file_picker_test.go | 67 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/pkg/tui/dialog/file_picker.go b/pkg/tui/dialog/file_picker.go index 0cc415681a..90b4e6f8ce 100644 --- a/pkg/tui/dialog/file_picker.go +++ b/pkg/tui/dialog/file_picker.go @@ -44,9 +44,39 @@ var filePickerLayout = pickerLayout{ ListStartOffset: filePickerListStartY, } +// filePickerKeyMap holds the file-picker-specific visibility toggles, on top +// of the navigation bindings provided by the shared pickerKeyMap. +// +// On macOS the OS substitutes a Unicode character for Option+key at the +// input-system level before the terminal sees the event, so the alt+h / alt+i +// ESC sequences never reach us. Each toggle therefore also accepts the +// characters that the standard US and US-International layouts emit (Option+H +// -> "˙" U+02D9, Option+I -> "ˆ" U+02C6). Option+I is a dead key on macOS, so +// the first press may arrive as an escaped or combining circumflex. See issue +// #2611. The visible help labels are rendered separately (and dynamically) by +// filePickerHelpKeysRows. +type filePickerKeyMap struct { + ToggleHidden key.Binding + ToggleIgnored key.Binding +} + +func defaultFilePickerKeyMap() filePickerKeyMap { + return filePickerKeyMap{ + ToggleHidden: key.NewBinding( + key.WithKeys("alt+h", "˙", "alt+˙"), + key.WithHelp("alt+h", "toggle hidden"), + ), + ToggleIgnored: key.NewBinding( + key.WithKeys("alt+i", "ˆ", "alt+ˆ", "\u0302", "alt+\u0302"), + key.WithHelp("alt+i", "toggle ignored"), + ), + } +} + type filePickerDialog struct { pickerCore + fpKeyMap filePickerKeyMap currentDir string entries []fileEntry filtered []fileEntry @@ -89,6 +119,7 @@ func NewFilePickerDialog(initialPath string) Dialog { d := &filePickerDialog{ pickerCore: newPickerCore(filePickerLayout, "Type to filter files…"), + fpKeyMap: defaultFilePickerKeyMap(), currentDir: startDir, } @@ -209,10 +240,10 @@ func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Enter): cmd := d.activateSelected() return d, cmd - case msg.String() == "alt+h": + case key.Matches(msg, d.fpKeyMap.ToggleHidden): d.toggleHidden() return d, nil - case msg.String() == "alt+i": + case key.Matches(msg, d.fpKeyMap.ToggleIgnored): d.toggleIgnored() return d, nil default: diff --git a/pkg/tui/dialog/file_picker_test.go b/pkg/tui/dialog/file_picker_test.go index 85ac141691..0380023d96 100644 --- a/pkg/tui/dialog/file_picker_test.go +++ b/pkg/tui/dialog/file_picker_test.go @@ -15,6 +15,7 @@ import ( func newTestFilePickerDialog(dir string) *filePickerDialog { d := &filePickerDialog{ pickerCore: newPickerCore(filePickerLayout, "Type to filter files…"), + fpKeyMap: defaultFilePickerKeyMap(), currentDir: dir, } d.loadDirectory() @@ -131,6 +132,72 @@ func TestFilePickerToggleIgnoredViaAltI(t *testing.T) { require.False(t, d.showIgnored) } +// On macOS the OS substitutes a Unicode character for Option+key before the +// terminal sees it, so the file picker also accepts those characters as +// aliases for the alt+h / alt+i toggles. See issue #2611. +func TestFilePickerToggleHiddenViaMacOSOptionChar(t *testing.T) { + t.Parallel() + dir := setupTestDir(t) + + d := newTestFilePickerDialog(dir) + d.SetSize(100, 50) + + require.False(t, d.showHidden) + names := entryNames(d.filtered) + require.NotContains(t, names, ".hidden_file") + + // macOS emits "˙" (U+02D9) for Option+H. + optionH := tea.KeyPressMsg{Code: '˙', Text: "˙"} + updated, _ := d.Update(optionH) + d = updated.(*filePickerDialog) + + require.True(t, d.showHidden) + names = entryNames(d.filtered) + assert.Contains(t, names, ".hidden_dir/") + assert.Contains(t, names, ".hidden_file") + + // Pressing it again toggles hidden files back off. + updated, _ = d.Update(optionH) + d = updated.(*filePickerDialog) + + require.False(t, d.showHidden) + names = entryNames(d.filtered) + assert.NotContains(t, names, ".hidden_file") +} + +func TestFilePickerToggleIgnoredViaMacOSOptionChar(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + msg tea.KeyPressMsg + }{ + {name: "standalone circumflex", msg: tea.KeyPressMsg{Code: 'ˆ', Text: "ˆ"}}, + {name: "escaped circumflex dead key", msg: tea.KeyPressMsg{Code: 'ˆ', Mod: tea.ModAlt}}, + {name: "combining circumflex dead key", msg: tea.KeyPressMsg{Code: '\u0302', Text: "\u0302"}}, + {name: "escaped combining circumflex dead key", msg: tea.KeyPressMsg{Code: '\u0302', Mod: tea.ModAlt}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := setupTestDir(t) + + d := newTestFilePickerDialog(dir) + d.SetSize(100, 50) + + require.False(t, d.showIgnored) + + updated, _ := d.Update(tc.msg) + d = updated.(*filePickerDialog) + require.True(t, d.showIgnored) + + updated, _ = d.Update(tc.msg) + d = updated.(*filePickerDialog) + require.False(t, d.showIgnored) + }) + } +} + func TestFilePickerShowIgnoredInGitRepo(t *testing.T) { t.Parallel()