diff options
| -rw-r--r-- | internal/i18n/golden_test.go | 4 | ||||
| -rw-r--r-- | internal/i18n/lang/en.ini | 2 | ||||
| -rw-r--r-- | internal/i18n/lang/pl.ini | 2 | ||||
| -rw-r--r-- | internal/tui/reader.go | 145 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 49 |
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 { |
