Skip to content

Commit 47c960f

Browse files
committed
feat: Add search functionality to help menu (#1009)
1 parent 77a63fe commit 47c960f

File tree

6 files changed

+155
-41
lines changed

6 files changed

+155
-41
lines changed

src/internal/default_config.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,7 @@ func defaultModelConfig(toggleDotFile, toggleFooter, firstUse bool, firstFilePan
3232
},
3333
width: 10,
3434
},
35-
helpMenu: helpMenuModal{
36-
renderIndex: 0,
37-
cursor: 1,
38-
data: getHelpMenuData(),
39-
open: false,
40-
},
35+
helpMenu: newHelpMenuModal(),
4136
imagePreviewer: imagePreviewer,
4237
promptModal: prompt.DefaultModel(prompt.PromptMinHeight, prompt.PromptMinWidth),
4338
modelQuitState: notQuitting,
@@ -48,6 +43,19 @@ func defaultModelConfig(toggleDotFile, toggleFooter, firstUse bool, firstFilePan
4843
}
4944
}
5045

46+
func newHelpMenuModal() helpMenuModal {
47+
helpMenuData := getHelpMenuData()
48+
49+
return helpMenuModal{
50+
renderIndex: 0,
51+
cursor: 1,
52+
data: helpMenuData,
53+
filteredData: helpMenuData,
54+
open: false,
55+
searchBar: common.GenerateSearchBar(),
56+
}
57+
}
58+
5159
// Return help menu for Hotkeys
5260
func getHelpMenuData() []helpMenuModalData { //nolint: funlen // This should be self contained
5361
data := []helpMenuModalData{

src/internal/handle_modal.go

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"path/filepath"
77
"strings"
8+
9+
"github.com/yorukot/superfile/src/internal/utils"
810
)
911

1012
// Cancel typing modal e.g. create file or directory
@@ -144,34 +146,42 @@ func (m *model) helpMenuListUp() {
144146
m.helpMenu.cursor--
145147
if m.helpMenu.cursor < m.helpMenu.renderIndex {
146148
m.helpMenu.renderIndex--
147-
if m.helpMenu.data[m.helpMenu.cursor].subTitle != "" {
149+
if m.helpMenu.filteredData[m.helpMenu.cursor].subTitle != "" {
148150
m.helpMenu.renderIndex--
149151
}
150152
}
151-
if m.helpMenu.data[m.helpMenu.cursor].subTitle != "" {
153+
if m.helpMenu.filteredData[m.helpMenu.cursor].subTitle != "" {
152154
m.helpMenu.cursor--
153155
}
154156
} else {
155-
m.helpMenu.cursor = len(m.helpMenu.data) - 1
156-
m.helpMenu.renderIndex = len(m.helpMenu.data) - m.helpMenu.height
157+
// Set the cursor to the last item in the list.
158+
// We use max(..., 0) as a safeguard to prevent a negative cursor index
159+
// in case the filtered list is empty.
160+
m.helpMenu.cursor = max(len(m.helpMenu.filteredData)-1, 0)
161+
162+
// Adjust the render index to show the bottom of the list.
163+
// Similarly, we use max(..., 0) to ensure the renderIndex doesn't become negative,
164+
// which can happen if the number of items is less than the view height.
165+
// This prevents a potential out-of-bounds panic during rendering.
166+
m.helpMenu.renderIndex = max(len(m.helpMenu.filteredData)-m.helpMenu.height, 0)
157167
}
158168
}
159169

160170
// Help menu panel list down
161171
func (m *model) helpMenuListDown() {
162-
if len(m.helpMenu.data) == 0 {
172+
if len(m.helpMenu.filteredData) == 0 {
163173
return
164174
}
165175

166-
if m.helpMenu.cursor < len(m.helpMenu.data)-1 {
176+
if m.helpMenu.cursor < len(m.helpMenu.filteredData)-1 {
167177
m.helpMenu.cursor++
168-
if m.helpMenu.cursor > m.helpMenu.renderIndex+m.helpMenu.height-1 {
178+
if m.helpMenu.cursor > m.helpMenu.renderIndex+m.helpMenu.height-2 {
169179
m.helpMenu.renderIndex++
170-
if m.helpMenu.data[m.helpMenu.cursor].subTitle != "" {
180+
if m.helpMenu.filteredData[m.helpMenu.cursor].subTitle != "" {
171181
m.helpMenu.renderIndex++
172182
}
173183
}
174-
if m.helpMenu.data[m.helpMenu.cursor].subTitle != "" {
184+
if m.helpMenu.filteredData[m.helpMenu.cursor].subTitle != "" {
175185
m.helpMenu.cursor++
176186
}
177187
} else {
@@ -180,17 +190,88 @@ func (m *model) helpMenuListDown() {
180190
}
181191
}
182192

193+
func removeOrphanSections(items []helpMenuModalData) []helpMenuModalData {
194+
var result []helpMenuModalData
195+
// Since we can't know beforehand which section are we actually filtering
196+
// we may end up in a scenario where there are two sections (General, Panel navigation)
197+
// with no hotkeys between them, so we need to remove the section which its hotkeys was
198+
// completely filtered out (Orphan sections)
199+
for i := range items {
200+
if items[i].subTitle != "" {
201+
// Look ahead: is the next item a real hotkey?
202+
if i+1 < len(items) && items[i+1].subTitle == "" {
203+
result = append(result, items[i])
204+
}
205+
// Else: skip this subtitle because no children
206+
} else {
207+
result = append(result, items[i])
208+
}
209+
}
210+
return result
211+
}
212+
213+
func (m *model) filterHelpMenu(query string) {
214+
filtered := fuzzySearch(query, m.helpMenu.data)
215+
filtered = removeOrphanSections(filtered)
216+
217+
m.helpMenu.filteredData = filtered
218+
m.helpMenu.cursor = 1
219+
m.helpMenu.renderIndex = 0
220+
}
221+
222+
// Fuzzy search function for a list of helpMenuModalData.
223+
// inspired from: sidebar/directory_utils.go
224+
func fuzzySearch(query string, data []helpMenuModalData) []helpMenuModalData {
225+
if len(data) == 0 {
226+
return []helpMenuModalData{}
227+
}
228+
229+
// Optimization - This haystack can be kept precomputed based on description
230+
// instead of re computing it in each call
231+
haystack := []string{}
232+
idxMap := []int{}
233+
for i, item := range data {
234+
if item.subTitle == "" {
235+
haystack = append(haystack, item.description)
236+
idxMap = append(idxMap, i)
237+
}
238+
}
239+
240+
matchedIdx := map[int]struct{}{}
241+
for _, match := range utils.FzfSearch(query, haystack) {
242+
matchedIdx[idxMap[match.HayIndex]] = struct{}{}
243+
// if d, ok := idx[match.Key]; ok {
244+
// filteredDirs = append(filteredDirs, d)
245+
// }
246+
}
247+
results := []helpMenuModalData{}
248+
for i, d := range data {
249+
if d.subTitle != "" {
250+
results = append(results, d)
251+
continue
252+
}
253+
if _, ok := matchedIdx[i]; ok {
254+
results = append(results, d)
255+
}
256+
}
257+
258+
return results
259+
}
260+
183261
// Toggle help menu
184262
func (m *model) openHelpMenu() {
185263
if m.helpMenu.open {
186264
m.helpMenu.open = false
187265
return
188266
}
189267

268+
// Reset filteredData to the full data whenever the helpMenu is opened
269+
m.helpMenu.filteredData = m.helpMenu.data
190270
m.helpMenu.open = true
191271
}
192272

193273
// Quit help menu
194274
func (m *model) quitHelpMenu() {
275+
m.helpMenu.searchBar.Reset()
195276
m.helpMenu.open = false
196277
}

src/internal/key_function.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,25 @@ func (m *model) focusOnSearchbarKey(msg string) {
300300
// Check hotkey input in help menu. Possible actions are moving up, down
301301
// and quiting the menu
302302
func (m *model) helpMenuKey(msg string) {
303-
switch {
304-
case slices.Contains(common.Hotkeys.ListUp, msg):
305-
m.helpMenuListUp()
306-
case slices.Contains(common.Hotkeys.ListDown, msg):
307-
m.helpMenuListDown()
308-
case slices.Contains(common.Hotkeys.Quit, msg):
309-
m.quitHelpMenu()
303+
if m.helpMenu.searchBar.Focused() {
304+
switch {
305+
case slices.Contains(common.Hotkeys.ConfirmTyping, msg):
306+
m.helpMenu.searchBar.Blur()
307+
case slices.Contains(common.Hotkeys.CancelTyping, msg):
308+
m.helpMenu.searchBar.Blur()
309+
default:
310+
m.filterHelpMenu(m.helpMenu.searchBar.Value())
311+
}
312+
} else {
313+
switch {
314+
case slices.Contains(common.Hotkeys.ListUp, msg):
315+
m.helpMenuListUp()
316+
case slices.Contains(common.Hotkeys.ListDown, msg):
317+
m.helpMenuListDown()
318+
case slices.Contains(common.Hotkeys.Quit, msg):
319+
m.quitHelpMenu()
320+
case slices.Contains(common.Hotkeys.SearchBar, msg):
321+
m.helpMenu.searchBar.Focus()
322+
}
310323
}
311324
}

src/internal/model.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6767
gotModelUpdateMsg := false
6868

6969
sidebarCmd = m.sidebarModel.UpdateState(msg)
70+
71+
// this is similar to m.sidebarModel.UpdateState(msg) but since helpMenu is not a Model
72+
// we call .Update() manually here
73+
var helpMenuCmd tea.Cmd
74+
if m.helpMenu.searchBar.Focused() {
75+
m.helpMenu.searchBar, helpMenuCmd = m.helpMenu.searchBar.Update(msg)
76+
}
77+
7078
forcePreviewRender := false
7179

7280
switch msg := msg.(type) {
@@ -104,7 +112,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104112
filePreviewCmd = m.getFilePreviewCmd(forcePreviewRender)
105113
}
106114

107-
return m, tea.Batch(sidebarCmd, inputCmd, updateCmd, panelCmd, metadataCmd, filePreviewCmd)
115+
return m, tea.Batch(sidebarCmd, helpMenuCmd, inputCmd, updateCmd, panelCmd, metadataCmd, filePreviewCmd)
108116
}
109117

110118
func (m *model) handleMouseMsg(msg tea.MouseMsg) {
@@ -278,6 +286,7 @@ func (m *model) setHelpMenuSize() {
278286
if m.fullWidth > 95 {
279287
m.helpMenu.width = 90
280288
}
289+
m.helpMenu.searchBar.Width = m.helpMenu.width - 4
281290
}
282291

283292
func (m *model) setPromptModelSize() {

src/internal/model_render.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ func (m *model) promptModalRender() string {
340340
func (m *model) helpMenuRender() string {
341341
maxKeyLength := 0
342342

343-
for _, data := range m.helpMenu.data {
343+
for _, data := range m.helpMenu.filteredData {
344344
totalKeyLen := 0
345345
for _, key := range data.hotkey {
346346
totalKeyLen += len(key)
@@ -359,7 +359,7 @@ func (m *model) helpMenuRender() string {
359359
totalTitleCount := 0
360360
cursorBeenTitleCount := 0
361361

362-
for i, data := range m.helpMenu.data {
362+
for i, data := range m.helpMenu.filteredData {
363363
if data.subTitle != "" {
364364
if i < m.helpMenu.cursor {
365365
cursorBeenTitleCount++
@@ -370,24 +370,25 @@ func (m *model) helpMenuRender() string {
370370

371371
renderHotkeyLength := m.getRenderHotkeyLengthHelpmenuModal()
372372
helpMenuContent := m.getHelpMenuContent(renderHotkeyLength, valueLength)
373-
373+
searchBar := m.helpMenu.searchBar.View()
374+
helpMenuContent = lipgloss.JoinVertical(lipgloss.Left, searchBar, helpMenuContent)
374375
bottomBorder := common.GenerateFooterBorder(fmt.Sprintf("%s/%s",
375376
strconv.Itoa(m.helpMenu.cursor+1-cursorBeenTitleCount),
376-
strconv.Itoa(len(m.helpMenu.data)-totalTitleCount)), m.helpMenu.width-2)
377+
strconv.Itoa(len(m.helpMenu.filteredData)-totalTitleCount)), m.helpMenu.width-2)
377378

378379
return common.HelpMenuModalBorderStyle(m.helpMenu.height, m.helpMenu.width, bottomBorder).Render(helpMenuContent)
379380
}
380381

381382
func (m *model) getRenderHotkeyLengthHelpmenuModal() int {
382383
renderHotkeyLength := 0
383-
for i := m.helpMenu.renderIndex; i < m.helpMenu.height+m.helpMenu.renderIndex && i < len(m.helpMenu.data); i++ {
384+
for i := m.helpMenu.renderIndex; i < m.helpMenu.height+m.helpMenu.renderIndex && i < len(m.helpMenu.filteredData); i++ {
384385
hotkey := ""
385386

386-
if m.helpMenu.data[i].subTitle != "" {
387+
if m.helpMenu.filteredData[i].subTitle != "" {
387388
continue
388389
}
389390

390-
for i, key := range m.helpMenu.data[i].hotkey {
391+
for i, key := range m.helpMenu.filteredData[i].hotkey {
391392
if i != 0 {
392393
hotkey += " | "
393394
}
@@ -401,20 +402,20 @@ func (m *model) getRenderHotkeyLengthHelpmenuModal() int {
401402

402403
func (m *model) getHelpMenuContent(renderHotkeyLength int, valueLength int) string {
403404
helpMenuContent := ""
404-
for i := m.helpMenu.renderIndex; i < m.helpMenu.height+m.helpMenu.renderIndex && i < len(m.helpMenu.data); i++ {
405+
for i := m.helpMenu.renderIndex; i < m.helpMenu.height+m.helpMenu.renderIndex && i < len(m.helpMenu.filteredData); i++ {
405406
if i != m.helpMenu.renderIndex {
406407
helpMenuContent += "\n"
407408
}
408409

409-
if m.helpMenu.data[i].subTitle != "" {
410-
helpMenuContent += common.HelpMenuTitleStyle.Render(" " + m.helpMenu.data[i].subTitle)
410+
if m.helpMenu.filteredData[i].subTitle != "" {
411+
helpMenuContent += common.HelpMenuTitleStyle.Render(" " + m.helpMenu.filteredData[i].subTitle)
411412
continue
412413
}
413414

414415
hotkey := ""
415-
description := common.TruncateText(m.helpMenu.data[i].description, valueLength, "...")
416+
description := common.TruncateText(m.helpMenu.filteredData[i].description, valueLength, "...")
416417

417-
for i, key := range m.helpMenu.data[i].hotkey {
418+
for i, key := range m.helpMenu.filteredData[i].hotkey {
418419
if i != 0 {
419420
hotkey += " | "
420421
}

src/internal/type.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,14 @@ type model struct {
115115

116116
// Modal
117117
type helpMenuModal struct {
118-
height int
119-
width int
120-
open bool
121-
renderIndex int
122-
cursor int
123-
data []helpMenuModalData
118+
height int
119+
width int
120+
open bool
121+
renderIndex int
122+
cursor int
123+
data []helpMenuModalData
124+
filteredData []helpMenuModalData
125+
searchBar textinput.Model
124126
}
125127

126128
type helpMenuModalData struct {

0 commit comments

Comments
 (0)