Skip to content

Commit b76eebd

Browse files
authored
Merge pull request #470 from bborn/task/1483-improve-task-form-project-picker-with-se
Add fuzzy search to task form project picker
2 parents 00dc902 + c351b51 commit b76eebd

File tree

2 files changed

+372
-15
lines changed

2 files changed

+372
-15
lines changed

internal/ui/form.go

Lines changed: 227 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ type FormModel struct {
5353
project string
5454
projectIdx int
5555
projects []string
56+
projectSearchMode bool // true when typing to search/filter projects
57+
projectSearchQuery string // current search query
58+
projectFiltered []string // filtered project list (fuzzy matched)
59+
projectFilteredIdx int // selected index in filtered list
5660
taskType string
5761
typeIdx int
5862
types []string
@@ -510,6 +514,61 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
510514
return m, nil
511515
}
512516

517+
// Handle project search mode when active
518+
if m.projectSearchMode && m.focused == FieldProject {
519+
switch msg.String() {
520+
case "esc":
521+
m.exitProjectSearch()
522+
return m, nil
523+
case "enter", "tab":
524+
m.selectProjectFromSearch()
525+
if msg.String() == "tab" {
526+
m.focusNext()
527+
}
528+
return m, nil
529+
case "up", "ctrl+p":
530+
if m.projectFilteredIdx > 0 {
531+
m.projectFilteredIdx--
532+
}
533+
return m, nil
534+
case "down", "ctrl+n":
535+
if m.projectFilteredIdx < len(m.projectFiltered)-1 {
536+
m.projectFilteredIdx++
537+
}
538+
return m, nil
539+
case "backspace", "ctrl+h":
540+
if len(m.projectSearchQuery) > 0 {
541+
m.projectSearchQuery = m.projectSearchQuery[:len(m.projectSearchQuery)-1]
542+
m.filterProjects()
543+
} else {
544+
m.exitProjectSearch()
545+
}
546+
return m, nil
547+
case "ctrl+c":
548+
m.cancelled = true
549+
return m, nil
550+
case "ctrl+w":
551+
// Delete word backward
552+
m.projectSearchQuery = ""
553+
m.filterProjects()
554+
return m, nil
555+
case "ctrl+u":
556+
// Clear line
557+
m.projectSearchQuery = ""
558+
m.filterProjects()
559+
return m, nil
560+
default:
561+
key := msg.String()
562+
// Only accept printable single characters
563+
if len(key) == 1 && key[0] >= 32 && key[0] < 127 {
564+
m.projectSearchQuery += key
565+
m.filterProjects()
566+
return m, nil
567+
}
568+
}
569+
return m, nil
570+
}
571+
513572
// Handle task reference autocomplete when active
514573
if m.showTaskRefAutocomplete && m.taskRefAutocomplete != nil && m.taskRefAutocomplete.HasResults() {
515574
switch msg.String() {
@@ -638,6 +697,11 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
638697
if m.focused == FieldBody {
639698
break
640699
}
700+
// On project field, enter opens search mode
701+
if m.focused == FieldProject {
702+
m.enterProjectSearch()
703+
return m, nil
704+
}
641705
// On last visible field, submit
642706
if m.isLastVisibleField() {
643707
m.parseAttachments()
@@ -724,12 +788,29 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
724788
return m, nil
725789
}
726790

791+
case "/":
792+
// Slash to enter project search mode when on project field
793+
if m.focused == FieldProject {
794+
m.enterProjectSearch()
795+
return m, nil
796+
}
797+
727798
default:
728799
if m.handleAttachmentRemovalKey(msg) {
729800
return m, nil
730801
}
731-
// Type-to-select for selector fields
732-
if m.focused == FieldProject || m.focused == FieldType || m.focused == FieldExecutor {
802+
// Project field: any letter enters search mode
803+
if m.focused == FieldProject {
804+
key := msg.String()
805+
if len(key) == 1 && key[0] >= 32 && key[0] < 127 {
806+
m.enterProjectSearch()
807+
m.projectSearchQuery = key
808+
m.filterProjects()
809+
return m, nil
810+
}
811+
}
812+
// Type-to-select for other selector fields
813+
if m.focused == FieldType || m.focused == FieldExecutor {
733814
key := msg.String()
734815
if len(key) == 1 && unicode.IsLetter(rune(key[0])) {
735816
m.selectByPrefix(strings.ToLower(key))
@@ -897,19 +978,85 @@ func (m *FormModel) acceptGhostText() {
897978
m.ghostFullText = ""
898979
}
899980

900-
func (m *FormModel) selectByPrefix(prefix string) {
901-
switch m.focused {
902-
case FieldProject:
903-
for i, p := range m.projects {
904-
if strings.HasPrefix(strings.ToLower(p), prefix) {
905-
m.projectIdx = i
906-
m.project = p
907-
m.loadLastTaskTypeForProject()
908-
m.rebuildExecutorListForProject() // Re-sort by usage for new project
909-
m.loadLastExecutorForProject()
910-
return
981+
// enterProjectSearch activates the project search mode.
982+
func (m *FormModel) enterProjectSearch() {
983+
m.projectSearchMode = true
984+
m.projectSearchQuery = ""
985+
m.projectFilteredIdx = 0
986+
m.filterProjects()
987+
}
988+
989+
// exitProjectSearch deactivates project search mode without selecting.
990+
func (m *FormModel) exitProjectSearch() {
991+
m.projectSearchMode = false
992+
m.projectSearchQuery = ""
993+
m.projectFiltered = nil
994+
m.projectFilteredIdx = 0
995+
}
996+
997+
// selectProjectFromSearch selects the currently highlighted project in search results.
998+
func (m *FormModel) selectProjectFromSearch() {
999+
if len(m.projectFiltered) == 0 {
1000+
m.exitProjectSearch()
1001+
return
1002+
}
1003+
if m.projectFilteredIdx >= len(m.projectFiltered) {
1004+
m.projectFilteredIdx = 0
1005+
}
1006+
selected := m.projectFiltered[m.projectFilteredIdx]
1007+
m.project = selected
1008+
// Update projectIdx to match
1009+
for i, p := range m.projects {
1010+
if p == selected {
1011+
m.projectIdx = i
1012+
break
1013+
}
1014+
}
1015+
m.exitProjectSearch()
1016+
m.loadLastTaskTypeForProject()
1017+
m.rebuildExecutorListForProject()
1018+
m.loadLastExecutorForProject()
1019+
}
1020+
1021+
// filterProjects updates the filtered project list based on the search query.
1022+
func (m *FormModel) filterProjects() {
1023+
query := m.projectSearchQuery
1024+
if query == "" {
1025+
// Show all projects, with current project first
1026+
m.projectFiltered = make([]string, len(m.projects))
1027+
copy(m.projectFiltered, m.projects)
1028+
// Pre-select the current project
1029+
m.projectFilteredIdx = 0
1030+
for i, p := range m.projectFiltered {
1031+
if p == m.project {
1032+
m.projectFilteredIdx = i
1033+
break
9111034
}
9121035
}
1036+
return
1037+
}
1038+
1039+
type scored struct {
1040+
name string
1041+
score int
1042+
}
1043+
var results []scored
1044+
for _, p := range m.projects {
1045+
s := fuzzyScore(p, query)
1046+
if s > 0 {
1047+
results = append(results, scored{p, s})
1048+
}
1049+
}
1050+
sort.Slice(results, func(i, j int) bool { return results[i].score > results[j].score })
1051+
m.projectFiltered = make([]string, len(results))
1052+
for i, r := range results {
1053+
m.projectFiltered[i] = r.name
1054+
}
1055+
m.projectFilteredIdx = 0
1056+
}
1057+
1058+
func (m *FormModel) selectByPrefix(prefix string) {
1059+
switch m.focused {
9131060
case FieldType:
9141061
for i, t := range m.types {
9151062
label := t
@@ -1075,6 +1222,9 @@ func (m *FormModel) blurAll() {
10751222
m.bodyInput.Blur()
10761223
m.attachmentsInput.Blur()
10771224
m.clearAttachmentSelection()
1225+
if m.projectSearchMode {
1226+
m.exitProjectSearch()
1227+
}
10781228
}
10791229

10801230
func (m *FormModel) focusCurrent() {
@@ -1347,8 +1497,70 @@ func (m *FormModel) View() string {
13471497
if m.focused == FieldProject {
13481498
cursor = cursorStyle.Render("▸")
13491499
}
1350-
b.WriteString(cursor + " " + labelStyle.Render("Project") + m.renderSelector(m.projects, m.projectIdx, m.focused == FieldProject, selectedStyle, optionStyle, dimStyle))
1351-
b.WriteString("\n\n")
1500+
if m.projectSearchMode && m.focused == FieldProject {
1501+
// Search mode: show search input and filtered dropdown
1502+
searchInputStyle := lipgloss.NewStyle().Foreground(ColorPrimary)
1503+
queryDisplay := m.projectSearchQuery
1504+
cursorChar := lipgloss.NewStyle().Background(ColorPrimary).Foreground(lipgloss.Color("0")).Render(" ")
1505+
b.WriteString(cursor + " " + labelStyle.Render("Project") + searchInputStyle.Render(queryDisplay) + cursorChar)
1506+
b.WriteString("\n")
1507+
1508+
// Show filtered results as a vertical dropdown
1509+
maxShow := 8
1510+
if len(m.projectFiltered) < maxShow {
1511+
maxShow = len(m.projectFiltered)
1512+
}
1513+
1514+
// Calculate scroll window
1515+
scrollStart := 0
1516+
if m.projectFilteredIdx >= maxShow {
1517+
scrollStart = m.projectFilteredIdx - maxShow + 1
1518+
}
1519+
scrollEnd := scrollStart + maxShow
1520+
if scrollEnd > len(m.projectFiltered) {
1521+
scrollEnd = len(m.projectFiltered)
1522+
scrollStart = scrollEnd - maxShow
1523+
if scrollStart < 0 {
1524+
scrollStart = 0
1525+
}
1526+
}
1527+
1528+
// Scroll-up indicator
1529+
if scrollStart > 0 {
1530+
b.WriteString(" " + dimStyle.Render("↑ more") + "\n")
1531+
}
1532+
1533+
for i := scrollStart; i < scrollEnd; i++ {
1534+
p := m.projectFiltered[i]
1535+
if i == m.projectFilteredIdx {
1536+
b.WriteString(" " + selectedStyle.Render(" "+p+" ") + "\n")
1537+
} else {
1538+
b.WriteString(" " + optionStyle.Render(" "+p) + "\n")
1539+
}
1540+
}
1541+
1542+
// Scroll-down indicator
1543+
if scrollEnd < len(m.projectFiltered) {
1544+
b.WriteString(" " + dimStyle.Render("↓ more") + "\n")
1545+
}
1546+
1547+
if len(m.projectFiltered) == 0 {
1548+
b.WriteString(" " + dimStyle.Render("no matches") + "\n")
1549+
}
1550+
b.WriteString("\n")
1551+
} else {
1552+
// Normal mode: show current project with hint
1553+
projectDisplay := m.project
1554+
if projectDisplay == "" {
1555+
projectDisplay = "none"
1556+
}
1557+
if m.focused == FieldProject {
1558+
b.WriteString(cursor + " " + labelStyle.Render("Project") + selectedStyle.Render(" "+projectDisplay+" ") + " " + dimStyle.Render("type to search · "+IconArrowLeft()+"/"+IconArrowRight()+" cycle"))
1559+
} else {
1560+
b.WriteString(cursor + " " + labelStyle.Render("Project") + optionStyle.Bold(true).Render(projectDisplay))
1561+
}
1562+
b.WriteString("\n\n")
1563+
}
13521564
}
13531565

13541566
// Title

0 commit comments

Comments
 (0)