diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-29 13:45:19 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-29 13:45:19 +0200 |
| commit | 6964ee56de01f9769a39ba480dc787339b642f7a (patch) | |
| tree | ce20ba33e56a414e96e81a3d9d438c373534027e /internal/tui | |
| parent | 8ab0502569264478697bf3dbd060eadb82459647 (diff) | |
| download | lectio-6964ee56de01f9769a39ba480dc787339b642f7a.tar.gz lectio-6964ee56de01f9769a39ba480dc787339b642f7a.zip | |
tui(reader): filter bookmarks by tag, and show tags in the list
Bookmarks stored tags but the list neither showed them nor let you use them.
Now each row shows "Ref — note #tag1 #tag2", and `/` opens a live tag filter
(case-insensitive substring over a bookmark's tags): type to narrow, Enter keeps
the filter while j/k navigate the filtered list, Esc clears it. Navigation,
open and delete all act on the visible (filtered) list. Reuses the store's
existing tag data; help keybar (en/pl) updated; test covers filter/keys.
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/reader.go | 145 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 49 |
2 files changed, 153 insertions, 41 deletions
diff --git a/internal/tui/reader.go b/internal/tui/reader.go index dc9141a..de89a40 100644 --- a/internal/tui/reader.go +++ b/internal/tui/reader.go @@ -86,9 +86,11 @@ type ReaderModel struct { markNote string // note being typed in modeMark markTags string // tags being typed in modeMark markField int // 0 = note field, 1 = tags field - marks []bookmarks.Bookmark // loaded list for modeBookmarks - markSel int // selection in the bookmarks list + marks []bookmarks.Bookmark // loaded list for modeBookmarks (unfiltered) + markSel int // selection into the VISIBLE (filtered) list markTop int // list scroll offset + markFilter string // active tag filter (substring, case-insensitive) + markFiltering bool // typing into the tag filter confirmDelete bool // bookmarks list is awaiting delete confirmation flash string // transient status line (e.g. "bookmarked ...") @@ -297,6 +299,8 @@ func (m ReaderModel) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.markSel = 0 m.markTop = 0 + m.markFilter = "" + m.markFiltering = false m.mode = modeBookmarks return m, nil case "tab": @@ -407,19 +411,41 @@ func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// visMarks is the bookmarks currently shown: all of them, or, when a tag filter +// is set, those with a tag containing it (case-insensitive substring). markSel +// indexes into this, so navigation/open/delete all act on the visible list. +func (m ReaderModel) visMarks() []bookmarks.Bookmark { + if m.markFilter == "" { + return m.marks + } + q := strings.ToLower(m.markFilter) + var out []bookmarks.Bookmark + for _, bm := range m.marks { + for _, t := range bm.Tags { + if strings.Contains(strings.ToLower(t), q) { + out = append(out, bm) + break + } + } + } + return out +} + // updateBookmarks handles the saved-bookmarks list: navigate, open (jump to that -// book+chapter+verse), delete, or go back. +// book+chapter+verse), delete, filter by tag (/), or go back. func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + vis := m.visMarks() + if m.confirmDelete { switch msg.String() { case "y": - if m.markSel < len(m.marks) && m.store != nil { - _ = m.store.Delete(m.marks[m.markSel].ID) + if m.markSel < len(vis) && m.store != nil { + _ = m.store.Delete(vis[m.markSel].ID) if marks, err := m.store.List(""); err == nil { m.marks = marks } - if m.markSel >= len(m.marks) { - m.markSel = len(m.marks) - 1 + if v := m.visMarks(); m.markSel >= len(v) { + m.markSel = len(v) - 1 } if m.markSel < 0 { m.markSel = 0 @@ -431,15 +457,43 @@ func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + + // Tag-filter input: type to narrow live, Enter keeps it (then j/k navigate + // the filtered list), Esc clears it. + if m.markFiltering { + switch msg.Type { + case tea.KeyEnter: + m.markFiltering = false + case tea.KeyEsc: + m.markFiltering, m.markFilter, m.markSel = false, "", 0 + case tea.KeyBackspace: + if r := []rune(m.markFilter); len(r) > 0 { + m.markFilter, m.markSel = string(r[:len(r)-1]), 0 + } + case tea.KeySpace: + m.markFilter, m.markSel = m.markFilter+" ", 0 + case tea.KeyRunes: + m.markFilter, m.markSel = m.markFilter+string(msg.Runes), 0 + } + return m, nil + } + switch msg.String() { case "q", "ctrl+c": m.savePlace() return m, tea.Quit case "esc", "b": + if m.markFilter != "" { // first esc clears an active filter, then back + m.markFilter, m.markSel = "", 0 + return m, nil + } m.mode = modeRead return m, nil + case "/": + m.markFiltering = true + return m, nil case "j", "down": - if m.markSel < len(m.marks)-1 { + if m.markSel < len(vis)-1 { m.markSel++ } return m, nil @@ -449,13 +503,13 @@ func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "d": - if len(m.marks) > 0 { + if len(vis) > 0 { m.confirmDelete = true } return m, nil case "enter": - if m.markSel < len(m.marks) { - bm := m.marks[m.markSel] + if m.markSel < len(vis) { + bm := vis[m.markSel] if idx := m.bookIndex(bm.Book); idx >= 0 { m.bookIdx = idx m = m.openAt(bm.Chapter, bm.Verse) @@ -919,37 +973,58 @@ func hardWrap(s string, width int) []string { return lines } -// viewBookmarks renders the saved-bookmarks list. +// markLine renders one bookmark as "Book Chap:Verse — note #tag1 #tag2" +// (note and tags shown only when present). +func (m ReaderModel) markLine(bm bookmarks.Bookmark) string { + name := bm.Book + if idx := m.bookIndex(bm.Book); idx >= 0 { + name = m.books[idx].Name + } + s := fmt.Sprintf("%s %d", name, bm.Chapter) + if bm.Verse > 0 { + s += fmt.Sprintf(":%d", bm.Verse) + } + if bm.Note != "" { + s += " — " + bm.Note + } + if len(bm.Tags) > 0 { + s += " #" + strings.Join(bm.Tags, " #") + } + return s +} + +// viewBookmarks renders the saved-bookmarks list (with the tag filter, if any). func (m ReaderModel) viewBookmarks() string { w := m.width if w <= 0 { w = 80 } ui := i18n.Get(m.cfg.UILanguage) + vis := m.visMarks() // Deleting -> a prominent centered confirmation dialog naming the bookmark. - if m.confirmDelete && m.markSel >= 0 && m.markSel < len(m.marks) { - bm := m.marks[m.markSel] - name := bm.Book - if idx := m.bookIndex(bm.Book); idx >= 0 { - name = m.books[idx].Name - } - ref := fmt.Sprintf("%s %d", name, bm.Chapter) - if bm.Verse > 0 { - ref += fmt.Sprintf(":%d", bm.Verse) - } - if bm.Note != "" { - ref += " — " + bm.Note - } - inner := modalTitleStyle.Render(ui.ReaderConfirmDelete) + "\n\n " + ref + "\n\n" + citationStyle.Render(ui.ReaderConfirmKeys) + if m.confirmDelete && m.markSel >= 0 && m.markSel < len(vis) { + inner := modalTitleStyle.Render(ui.ReaderConfirmDelete) + "\n\n " + m.markLine(vis[m.markSel]) + "\n\n" + citationStyle.Render(ui.ReaderConfirmKeys) return m.modal(inner) } - header := headerStyle.Width(w).Render(ui.ReaderBookmarksTitle) + title := ui.ReaderBookmarksTitle + if m.markFilter != "" || m.markFiltering { + cursor := "" + if m.markFiltering { + cursor = "_" + } + title += " /" + m.markFilter + cursor + } + header := headerStyle.Width(w).Render(title) footer := footerStyle.Width(w).Render(ui.ReaderBookmarksKeys) + if len(m.marks) == 0 { return header + "\n" + citationStyle.Render(ui.ReaderNoBookmarks) + "\n" + footer } + if len(vis) == 0 { // a tag filter that matches nothing -- the header shows it + return header + "\n" + footer + } visible := m.height - 3 if visible < 3 { @@ -961,20 +1036,8 @@ func (m ReaderModel) viewBookmarks() string { } var rows []string - for i := top; i < len(m.marks) && i < top+visible; i++ { - bm := m.marks[i] - name := bm.Book - if idx := m.bookIndex(bm.Book); idx >= 0 { - name = m.books[idx].Name - } - ref := fmt.Sprintf("%s %d", name, bm.Chapter) - if bm.Verse > 0 { - ref += fmt.Sprintf(":%d", bm.Verse) - } - row := ref - if bm.Note != "" { - row += " — " + bm.Note - } + for i := top; i < len(vis) && i < top+visible; i++ { + row := m.markLine(vis[i]) if i == m.markSel { row = selStyle.Render("› " + row) } else { diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go index 7bce759..da8d4d4 100644 --- a/internal/tui/reader_test.go +++ b/internal/tui/reader_test.go @@ -49,6 +49,55 @@ func win(m ReaderModel, w, h int) ReaderModel { return nm.(ReaderModel) } +func TestReaderBookmarkTagFilter(t *testing.T) { + m := enReader(t) + m.marks = []bookmarks.Bookmark{ + {ID: "1", Book: "John", Chapter: 3, Verse: 16, Tags: []string{"faith"}}, + {ID: "2", Book: "Psalms", Chapter: 23, Verse: 1, Tags: []string{"work", "prayer"}}, + {ID: "3", Book: "Matthew", Chapter: 5, Verse: 3, Tags: []string{"prayer"}}, + } + m.mode = modeBookmarks + + if got := len(m.visMarks()); got != 3 { + t.Fatalf("unfiltered visible = %d, want 3", got) + } + + // "/" enters filter input; typing narrows to the two 'prayer' bookmarks. + m = key(m, runes("/")) + if !m.markFiltering { + t.Fatal(`"/" did not enter filter input`) + } + for _, c := range []string{"p", "r", "a", "y"} { + m = key(m, runes(c)) + } + if got := len(m.visMarks()); got != 2 { + t.Fatalf(`filter "pray" visible = %d, want 2 (Psalms, Matthew)`, got) + } + + // Enter keeps the filter and leaves input mode. + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.markFiltering || m.markFilter != "pray" { + t.Fatalf("after enter: filtering=%v filter=%q", m.markFiltering, m.markFilter) + } + + // Case-insensitive substring: "WORK" matches the 'work' tag (1 bookmark). + m.markFilter = "WORK" + if got := len(m.visMarks()); got != 1 { + t.Fatalf(`filter "WORK" visible = %d, want 1`, got) + } + + // Esc clears an active filter, back to all. + m.markFilter = "work" + m.markFiltering = false + m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.markFilter != "" { + t.Fatalf("esc did not clear filter, got %q", m.markFilter) + } + if got := len(m.visMarks()); got != 3 { + t.Fatalf("after clearing filter, visible = %d, want 3", got) + } +} + func TestReaderInit(t *testing.T) { m := enReader(t) if m.mode != modePick { |
