summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-29 13:45:19 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-29 13:45:19 +0200
commit6964ee56de01f9769a39ba480dc787339b642f7a (patch)
treece20ba33e56a414e96e81a3d9d438c373534027e /internal
parent8ab0502569264478697bf3dbd060eadb82459647 (diff)
downloadlectio-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')
-rw-r--r--internal/i18n/golden_test.go4
-rw-r--r--internal/i18n/lang/en.ini2
-rw-r--r--internal/i18n/lang/pl.ini2
-rw-r--r--internal/tui/reader.go145
-rw-r--r--internal/tui/reader_test.go49
5 files changed, 157 insertions, 45 deletions
diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go
index e6dc00a..0dc87dd 100644
--- a/internal/i18n/golden_test.go
+++ b/internal/i18n/golden_test.go
@@ -36,7 +36,7 @@ var enUI = UI{
ReaderNoMatch: "(no matching books)",
ReaderMarkPrompt: "note (enter to save, esc to cancel)",
ReaderBookmarksTitle: "bookmarks",
- ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
+ ReaderBookmarksKeys: "j/k move enter open d delete / tag esc back q quit",
ReaderNoBookmarks: "no bookmarks yet — press m while reading",
ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel",
ReaderConfirmDelete: "Delete this bookmark?",
@@ -122,7 +122,7 @@ var plUI = UI{
ReaderNoMatch: "(brak pasujących ksiąg)",
ReaderMarkPrompt: "notatka (enter zapisz, esc anuluj)",
ReaderBookmarksTitle: "zakładki",
- ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń esc wróć q wyjście",
+ ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń / tag esc wróć q wyjście",
ReaderNoBookmarks: "brak zakładek — naciśnij m podczas czytania",
ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj",
ReaderConfirmDelete: "Usunąć zakładkę?",
diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini
index 4f2d023..87face3 100644
--- a/internal/i18n/lang/en.ini
+++ b/internal/i18n/lang/en.ini
@@ -20,7 +20,7 @@ reader_no_text = (no text in this version)
reader_no_match = (no matching books)
reader_mark_prompt = note (enter to save, esc to cancel)
reader_bookmarks_title = bookmarks
-reader_bookmarks_keys = j/k move enter open d delete esc back q quit
+reader_bookmarks_keys = j/k move enter open d delete / tag esc back q quit
reader_no_bookmarks = no bookmarks yet — press m while reading
reader_mark_verse_keys = ↑/↓ pick verse enter add note esc cancel
reader_confirm_delete = Delete this bookmark?
diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini
index 1a202dd..cce1854 100644
--- a/internal/i18n/lang/pl.ini
+++ b/internal/i18n/lang/pl.ini
@@ -20,7 +20,7 @@ reader_no_text = (brak tekstu w tej wersji)
reader_no_match = (brak pasujących ksiąg)
reader_mark_prompt = notatka (enter zapisz, esc anuluj)
reader_bookmarks_title = zakładki
-reader_bookmarks_keys = j/k ruch enter otwórz d usuń esc wróć q wyjście
+reader_bookmarks_keys = j/k ruch enter otwórz d usuń / tag esc wróć q wyjście
reader_no_bookmarks = brak zakładek — naciśnij m podczas czytania
reader_mark_verse_keys = ↑/↓ wybierz werset enter notatka esc anuluj
reader_confirm_delete = Usunąć zakładkę?
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 {