feat(tui): add tag autocomplete and query composition #43
+53
-12
@@ -4,19 +4,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Body string
|
Body string
|
||||||
Glyph string
|
Glyph string
|
||||||
Title *string
|
Title *string
|
||||||
Description *string
|
Description *string
|
||||||
TimeAnchor *string
|
TimeAnchor *string
|
||||||
Tags []string
|
Tags []string
|
||||||
FilterTags []string
|
FilterTags []string
|
||||||
CardSuffix *string
|
CardSuffix *string
|
||||||
Pin bool
|
Pin bool
|
||||||
Query bool
|
Query bool
|
||||||
|
QueryDateFrom *string
|
||||||
|
QueryDateTo *string
|
||||||
|
QueryCardType *string
|
||||||
}
|
}
|
||||||
|
|
||||||
var validCardTypes = map[string]string{
|
var validCardTypes = map[string]string{
|
||||||
@@ -66,11 +70,48 @@ func Parse(input string) (*Result, error) {
|
|||||||
r.Glyph = ""
|
r.Glyph = ""
|
||||||
tokens := strings.Fields(remaining)
|
tokens := strings.Fields(remaining)
|
||||||
var bodyParts []string
|
var bodyParts []string
|
||||||
|
now := time.Now()
|
||||||
for _, tok := range tokens {
|
for _, tok := range tokens {
|
||||||
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
|
switch {
|
||||||
|
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
|
||||||
tag := strings.ToLower(tok[1:])
|
tag := strings.ToLower(tok[1:])
|
||||||
r.FilterTags = append(r.FilterTags, tag)
|
r.FilterTags = append(r.FilterTags, tag)
|
||||||
} else {
|
case tok == "@today":
|
||||||
|
d := now.Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
r.QueryDateTo = &d
|
||||||
|
case tok == "@yesterday":
|
||||||
|
d := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
r.QueryDateTo = &d
|
||||||
|
case tok == "@week":
|
||||||
|
d := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
case tok == "@month":
|
||||||
|
d := now.AddDate(0, -1, 0).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
|
||||||
|
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||||
|
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||||
|
r.QueryDateTo = &d
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
|
||||||
|
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
|
||||||
|
d := now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||||
|
r.QueryDateFrom = &d
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||||
|
suffix := tok[1:]
|
||||||
|
if ct, ok := validCardTypes[suffix]; ok {
|
||||||
|
r.QueryCardType = &ct
|
||||||
|
} else {
|
||||||
|
bodyParts = append(bodyParts, tok)
|
||||||
|
}
|
||||||
|
default:
|
||||||
bodyParts = append(bodyParts, tok)
|
bodyParts = append(bodyParts, tok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,67 @@ func TestParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseQueryComposition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantBody string
|
||||||
|
wantTags []string
|
||||||
|
wantDateFrom bool
|
||||||
|
wantDateTo bool
|
||||||
|
wantCardType *string
|
||||||
|
}{
|
||||||
|
{"today", "?@today", "", nil, true, true, nil},
|
||||||
|
{"yesterday", "?@yesterday", "", nil, true, true, nil},
|
||||||
|
{"week", "?@week", "", nil, true, false, nil},
|
||||||
|
{"month", "?@month", "", nil, true, false, nil},
|
||||||
|
{"newer than", "?<7d", "", nil, true, false, nil},
|
||||||
|
{"older than", "?>30d", "", nil, false, true, nil},
|
||||||
|
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
|
||||||
|
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
|
||||||
|
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
|
||||||
|
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
|
||||||
|
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
|
||||||
|
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
|
||||||
|
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
|
||||||
|
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Query {
|
||||||
|
t.Fatal("expected Query=true")
|
||||||
|
}
|
||||||
|
if got.Body != tt.wantBody {
|
||||||
|
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
|
||||||
|
}
|
||||||
|
if !tagsEq(got.FilterTags, tt.wantTags) {
|
||||||
|
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
|
||||||
|
}
|
||||||
|
if tt.wantDateFrom && got.QueryDateFrom == nil {
|
||||||
|
t.Error("expected QueryDateFrom to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateFrom && got.QueryDateFrom != nil {
|
||||||
|
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
|
||||||
|
}
|
||||||
|
if tt.wantDateTo && got.QueryDateTo == nil {
|
||||||
|
t.Error("expected QueryDateTo to be set")
|
||||||
|
}
|
||||||
|
if !tt.wantDateTo && got.QueryDateTo != nil {
|
||||||
|
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
|
||||||
|
}
|
||||||
|
if !ptrEq(got.QueryCardType, tt.wantCardType) {
|
||||||
|
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ptrEq(a, b *string) bool {
|
func ptrEq(a, b *string) bool {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxSuggestions = 5
|
||||||
|
|
||||||
|
type autocompleteModel struct {
|
||||||
|
suggestions []string
|
||||||
|
cursor int
|
||||||
|
active bool
|
||||||
|
prefix string
|
||||||
|
tokenStart int
|
||||||
|
tokenEnd int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveUp() {
|
||||||
|
if a.cursor > 0 {
|
||||||
|
a.cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *autocompleteModel) moveDown() {
|
||||||
|
if a.cursor < len(a.suggestions)-1 {
|
||||||
|
a.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) selected() string {
|
||||||
|
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.suggestions[a.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) visibleCount() int {
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
return maxSuggestions
|
||||||
|
}
|
||||||
|
return len(a.suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a autocompleteModel) view(width int) string {
|
||||||
|
if !a.active || len(a.suggestions) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
n := a.visibleCount()
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
tag := "#" + a.suggestions[i]
|
||||||
|
if i == a.cursor {
|
||||||
|
b.WriteString(acSelectedStyle.Render(" › " + tag))
|
||||||
|
} else {
|
||||||
|
b.WriteString(acItemStyle.Render(" " + tag))
|
||||||
|
}
|
||||||
|
if i < n-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.suggestions) > maxSuggestions {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(acItemStyle.Render(" …"))
|
||||||
|
}
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Width(min(30, width)).
|
||||||
|
Render(b.String())
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
|
||||||
|
if cursorPos > len(val) {
|
||||||
|
cursorPos = len(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := cursorPos
|
||||||
|
for start > 0 && val[start-1] != ' ' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
|
||||||
|
if start >= len(val) || val[start] != '#' {
|
||||||
|
return 0, 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
end := cursorPos
|
||||||
|
for end < len(val) && val[end] != ' ' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = strings.ToLower(val[start+1 : cursorPos])
|
||||||
|
return start, end, prefix, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
|
||||||
|
if prefix == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix = strings.ToLower(prefix)
|
||||||
|
var result []string
|
||||||
|
for _, tc := range tags {
|
||||||
|
lower := strings.ToLower(tc.Tag)
|
||||||
|
if strings.HasPrefix(lower, prefix) && lower != prefix {
|
||||||
|
result = append(result, tc.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagTokenAtCursor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
cursor int
|
||||||
|
wantStart int
|
||||||
|
wantEnd int
|
||||||
|
wantPfx string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
|
||||||
|
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
|
||||||
|
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
|
||||||
|
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
|
||||||
|
{"not in tag", "hello world", 5, 0, 0, "", false},
|
||||||
|
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
|
||||||
|
{"cursor at hash", "#ops", 1, 0, 4, "", true},
|
||||||
|
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
|
||||||
|
{"empty string", "", 0, 0, 0, "", false},
|
||||||
|
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
|
||||||
|
if ok != tt.wantOk {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if start != tt.wantStart || end != tt.wantEnd {
|
||||||
|
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if pfx != tt.wantPfx {
|
||||||
|
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterTagSuggestions(t *testing.T) {
|
||||||
|
tags := []db.TagCount{
|
||||||
|
{Tag: "ops", Count: 5},
|
||||||
|
{Tag: "ops-deploy", Count: 3},
|
||||||
|
{Tag: "infra", Count: 2},
|
||||||
|
{Tag: "ops-team", Count: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"empty prefix", "", nil},
|
||||||
|
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
|
||||||
|
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"no match", "zzz", nil},
|
||||||
|
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
|
||||||
|
{"single match", "inf", []string{"infra"}},
|
||||||
|
{"full match excluded", "infra", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterTagSuggestions(tags, tt.prefix)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,18 @@ func renderHelp(width, height int) string {
|
|||||||
{"Capture Bar", [][2]string{
|
{"Capture Bar", [][2]string{
|
||||||
{"enter", "submit (or browse if empty)"},
|
{"enter", "submit (or browse if empty)"},
|
||||||
{"?…", "search (type ?query)"},
|
{"?…", "search (type ?query)"},
|
||||||
|
{"#…", "tag (autocomplete with tab)"},
|
||||||
{"-", "todo prefix"},
|
{"-", "todo prefix"},
|
||||||
{"@", "event prefix"},
|
{"@", "event prefix"},
|
||||||
{"!", "reminder prefix"},
|
{"!", "reminder prefix"},
|
||||||
}},
|
}},
|
||||||
|
{"Query Operators", [][2]string{
|
||||||
|
{"?text", "substring search"},
|
||||||
|
{"?#tag1 #tag2", "filter by tags (AND)"},
|
||||||
|
{"?@today @week", "date filter (@yesterday @month)"},
|
||||||
|
{"?<7d >30d", "newer/older than N days"},
|
||||||
|
{"?^snippet", "card type filter"},
|
||||||
|
}},
|
||||||
{"Navigation", [][2]string{
|
{"Navigation", [][2]string{
|
||||||
{"j/k ↑/↓", "move cursor"},
|
{"j/k ↑/↓", "move cursor"},
|
||||||
{"g/G home/end", "top / bottom"},
|
{"g/G home/end", "top / bottom"},
|
||||||
|
|||||||
+27
-8
@@ -11,10 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type inputResult struct {
|
type inputResult struct {
|
||||||
entity *db.Entity
|
entity *db.Entity
|
||||||
query bool
|
query bool
|
||||||
body string
|
body string
|
||||||
tags []string
|
tags []string
|
||||||
|
dateFrom *string
|
||||||
|
dateTo *string
|
||||||
|
cardType *db.CardType
|
||||||
}
|
}
|
||||||
|
|
||||||
type inputModel struct {
|
type inputModel struct {
|
||||||
@@ -47,11 +50,18 @@ func (i inputModel) submit() *inputResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Query {
|
if parsed.Query {
|
||||||
return &inputResult{
|
r := &inputResult{
|
||||||
query: true,
|
query: true,
|
||||||
body: parsed.Body,
|
body: parsed.Body,
|
||||||
tags: parsed.FilterTags,
|
tags: parsed.FilterTags,
|
||||||
|
dateFrom: parsed.QueryDateFrom,
|
||||||
|
dateTo: parsed.QueryDateTo,
|
||||||
}
|
}
|
||||||
|
if parsed.QueryCardType != nil {
|
||||||
|
ct := db.CardType(*parsed.QueryCardType)
|
||||||
|
r.cardType = &ct
|
||||||
|
}
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
e := &db.Entity{
|
e := &db.Entity{
|
||||||
@@ -120,6 +130,15 @@ func (i inputModel) previewText() string {
|
|||||||
for _, t := range p.FilterTags {
|
for _, t := range p.FilterTags {
|
||||||
q += " #" + t
|
q += " #" + t
|
||||||
}
|
}
|
||||||
|
if p.QueryDateFrom != nil {
|
||||||
|
q += " from:" + *p.QueryDateFrom
|
||||||
|
}
|
||||||
|
if p.QueryDateTo != nil {
|
||||||
|
q += " to:" + *p.QueryDateTo
|
||||||
|
}
|
||||||
|
if p.QueryCardType != nil {
|
||||||
|
q += " ^" + *p.QueryCardType
|
||||||
|
}
|
||||||
return "search: " + q
|
return "search: " + q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+135
-18
@@ -79,26 +79,30 @@ type model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
list listModel
|
list listModel
|
||||||
cards cardsModel
|
cards cardsModel
|
||||||
detail detailModel
|
detail detailModel
|
||||||
input inputModel
|
input inputModel
|
||||||
filter filterModel
|
filter filterModel
|
||||||
promote promoteModel
|
promote promoteModel
|
||||||
absorb absorbModel
|
absorb absorbModel
|
||||||
tagRail tagRailModel
|
tagRail tagRailModel
|
||||||
stumble stumbleModel
|
stumble stumbleModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
autocomplete autocompleteModel
|
||||||
|
|
||||||
focus focusPane
|
focus focusPane
|
||||||
splitDetail bool
|
splitDetail bool
|
||||||
showTagRail bool
|
showTagRail bool
|
||||||
|
|
||||||
filterTag string
|
filterTag string
|
||||||
confirmID string
|
confirmID string
|
||||||
cardsSort cardsSort
|
cardsSort cardsSort
|
||||||
searchQuery string
|
searchQuery string
|
||||||
searchTags []string
|
searchTags []string
|
||||||
|
queryDateFrom *string
|
||||||
|
queryDateTo *string
|
||||||
|
queryCardType *db.CardType
|
||||||
|
|
||||||
status string
|
status string
|
||||||
statusSeq int
|
statusSeq int
|
||||||
@@ -149,6 +153,15 @@ func (m model) listParams() db.ListParams {
|
|||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
p.Tag = &m.filterTag
|
p.Tag = &m.filterTag
|
||||||
}
|
}
|
||||||
|
if m.queryDateFrom != nil {
|
||||||
|
p.From = m.queryDateFrom
|
||||||
|
}
|
||||||
|
if m.queryDateTo != nil {
|
||||||
|
p.To = m.queryDateTo
|
||||||
|
}
|
||||||
|
if m.queryCardType != nil {
|
||||||
|
p.CardTypeFilter = m.queryCardType
|
||||||
|
}
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
p.CardsOnly = true
|
p.CardsOnly = true
|
||||||
switch m.cardsSort {
|
switch m.cardsSort {
|
||||||
@@ -167,7 +180,7 @@ func (m model) listParams() db.ListParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) hasSearch() bool {
|
func (m model) hasSearch() bool {
|
||||||
return m.searchQuery != "" || len(m.searchTags) > 0
|
return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) applySearch() {
|
func (m *model) applySearch() {
|
||||||
@@ -363,6 +376,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
|
if m.autocomplete.active {
|
||||||
|
m.acceptAutocomplete()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
val := m.input.ti.Value()
|
val := m.input.ti.Value()
|
||||||
if val == "" {
|
if val == "" {
|
||||||
cmd := m.setFocus(focusList)
|
cmd := m.setFocus(focusList)
|
||||||
@@ -375,23 +392,92 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if result.query {
|
if result.query {
|
||||||
m.searchQuery = result.body
|
m.searchQuery = result.body
|
||||||
m.searchTags = result.tags
|
m.searchTags = result.tags
|
||||||
|
m.queryDateFrom = result.dateFrom
|
||||||
|
m.queryDateTo = result.dateTo
|
||||||
|
m.queryCardType = result.cardType
|
||||||
m.input.clearText()
|
m.input.clearText()
|
||||||
|
m.autocomplete.active = false
|
||||||
|
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
|
||||||
|
cmd := m.setFocus(focusList)
|
||||||
|
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
|
||||||
|
}
|
||||||
m.applySearch()
|
m.applySearch()
|
||||||
cmd := m.setFocus(focusList)
|
cmd := m.setFocus(focusList)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
if result.entity != nil {
|
if result.entity != nil {
|
||||||
|
m.autocomplete.active = false
|
||||||
return m, createEntity(m.store, result.entity)
|
return m, createEntity(m.store, result.entity)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "esc", "tab":
|
case "tab":
|
||||||
|
if m.autocomplete.active {
|
||||||
|
m.acceptAutocomplete()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
cmd := m.setFocus(focusList)
|
cmd := m.setFocus(focusList)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
case "esc":
|
||||||
|
if m.autocomplete.active {
|
||||||
|
m.autocomplete.active = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
cmd := m.setFocus(focusList)
|
||||||
|
return m, cmd
|
||||||
|
case "up":
|
||||||
|
if m.autocomplete.active {
|
||||||
|
m.autocomplete.moveUp()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "down":
|
||||||
|
if m.autocomplete.active {
|
||||||
|
m.autocomplete.moveDown()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.input = m.input.updateKey(msg)
|
m.input = m.input.updateKey(msg)
|
||||||
|
m.updateAutocompleteSuggestions()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *model) updateAutocompleteSuggestions() {
|
||||||
|
val := m.input.ti.Value()
|
||||||
|
pos := m.input.ti.Position()
|
||||||
|
start, end, prefix, ok := tagTokenAtCursor(val, pos)
|
||||||
|
if !ok || prefix == "" {
|
||||||
|
m.autocomplete.active = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
m.autocomplete.active = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.autocomplete.suggestions = suggestions
|
||||||
|
m.autocomplete.prefix = prefix
|
||||||
|
m.autocomplete.tokenStart = start
|
||||||
|
m.autocomplete.tokenEnd = end
|
||||||
|
m.autocomplete.active = true
|
||||||
|
if m.autocomplete.cursor >= len(suggestions) {
|
||||||
|
m.autocomplete.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) acceptAutocomplete() {
|
||||||
|
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected := m.autocomplete.selected()
|
||||||
|
if selected == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := m.input.ti.Value()
|
||||||
|
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
|
||||||
|
m.input.ti.SetValue(newVal)
|
||||||
|
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
|
||||||
|
m.autocomplete.active = false
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
// Tag rail focus handling
|
// Tag rail focus handling
|
||||||
if m.focus == focusTagRail && m.state == stateList {
|
if m.focus == focusTagRail && m.state == stateList {
|
||||||
@@ -636,9 +722,16 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.state == stateList && m.hasSearch() {
|
if m.state == stateList && m.hasSearch() {
|
||||||
|
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
|
||||||
m.searchQuery = ""
|
m.searchQuery = ""
|
||||||
m.searchTags = nil
|
m.searchTags = nil
|
||||||
|
m.queryDateFrom = nil
|
||||||
|
m.queryDateTo = nil
|
||||||
|
m.queryCardType = nil
|
||||||
m.status = ""
|
m.status = ""
|
||||||
|
if hadDBFilters {
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
if m.mode == modeCards {
|
if m.mode == modeCards {
|
||||||
m.cards.applyFilter()
|
m.cards.applyFilter()
|
||||||
} else {
|
} else {
|
||||||
@@ -953,6 +1046,10 @@ func (m model) View() string {
|
|||||||
|
|
||||||
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
|
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
|
||||||
|
|
||||||
|
acView := m.autocomplete.view(m.width)
|
||||||
|
if acView != "" {
|
||||||
|
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
|
||||||
|
}
|
||||||
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
|
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,6 +1091,15 @@ func (m model) headerView() string {
|
|||||||
for _, t := range m.searchTags {
|
for _, t := range m.searchTags {
|
||||||
pill += " #" + t
|
pill += " #" + t
|
||||||
}
|
}
|
||||||
|
if m.queryDateFrom != nil {
|
||||||
|
pill += " from:" + *m.queryDateFrom
|
||||||
|
}
|
||||||
|
if m.queryDateTo != nil {
|
||||||
|
pill += " to:" + *m.queryDateTo
|
||||||
|
}
|
||||||
|
if m.queryCardType != nil {
|
||||||
|
pill += " ^" + string(*m.queryCardType)
|
||||||
|
}
|
||||||
header += " " + searchPillStyle.Render(pill)
|
header += " " + searchPillStyle.Render(pill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,7 +1127,18 @@ func (m model) statusLine() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) contentHeight() int {
|
func (m model) contentHeight() int {
|
||||||
return m.height - 4
|
h := m.height - 4
|
||||||
|
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
|
||||||
|
n := m.autocomplete.visibleCount()
|
||||||
|
if len(m.autocomplete.suggestions) > maxSuggestions {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
h -= n + 1
|
||||||
|
}
|
||||||
|
if h < 1 {
|
||||||
|
h = 1
|
||||||
|
}
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) recalcSizes() {
|
func (m *model) recalcSizes() {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ var (
|
|||||||
railActiveTagStyle lipgloss.Style
|
railActiveTagStyle lipgloss.Style
|
||||||
railCountStyle lipgloss.Style
|
railCountStyle lipgloss.Style
|
||||||
stumbleAgeStyle lipgloss.Style
|
stumbleAgeStyle lipgloss.Style
|
||||||
|
acSelectedStyle lipgloss.Style
|
||||||
|
acItemStyle lipgloss.Style
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -96,4 +98,6 @@ func applyTheme() {
|
|||||||
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
|
||||||
railCountStyle = lipgloss.NewStyle().Foreground(dim)
|
railCountStyle = lipgloss.NewStyle().Foreground(dim)
|
||||||
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
|
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
|
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user