summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:30:09 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:30:09 +0200
commit3eb99c1dd63565fbc1f5bf5af7fc8e835aad182e (patch)
tree582de9d901edf8c6efe541e69ee3075c6eedf714
parent19e02a3290dc78de088f9a72fb9c16dbf6517ffc (diff)
downloadlectio-3eb99c1dd63565fbc1f5bf5af7fc8e835aad182e.tar.gz
lectio-3eb99c1dd63565fbc1f5bf5af7fc8e835aad182e.zip
tui reader: bookmark note+tags as a prominent centered modal (tab switches fields), delete confirmation as a modal; v0.20.0
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/i18n/i18n.go15
-rw-r--r--internal/tui/reader.go97
-rw-r--r--internal/tui/reader_test.go11
4 files changed, 101 insertions, 24 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 4adf44e..d21cf5c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -25,7 +25,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.19.0"
+const Version = "0.20.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index 92fec55..ed03548 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -32,7 +32,8 @@ type UI struct {
// Reader bookmarks: the mark-note prompt, the bookmarks-list title/keybar,
// and the empty-list note.
ReaderMarkPrompt, ReaderBookmarksTitle, ReaderBookmarksKeys, ReaderNoBookmarks string
- ReaderMarkVerseKeys, ReaderConfirmDelete string
+ ReaderMarkVerseKeys, ReaderConfirmDelete, ReaderConfirmKeys string
+ ReaderMarkNote, ReaderMarkTags, ReaderMarkHelp string
// CLI banner label words and connective: the banner is
// "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" /
@@ -106,7 +107,11 @@ var enUI = UI{
ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
ReaderNoBookmarks: "no bookmarks yet — press m while reading",
ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel",
- ReaderConfirmDelete: "delete this bookmark? y/n",
+ ReaderConfirmDelete: "Delete this bookmark?",
+ ReaderConfirmKeys: "y = delete n = cancel",
+ ReaderMarkNote: "note",
+ ReaderMarkTags: "tags",
+ ReaderMarkHelp: "enter save · tab switch field · esc cancel",
BannerGospel: "Gospel",
BannerReadings: "Readings",
BannerConnective: "for",
@@ -164,7 +169,11 @@ var plUI = UI{
ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń 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ę? y/n",
+ ReaderConfirmDelete: "Usunąć zakładkę?",
+ ReaderConfirmKeys: "y = usuń n = anuluj",
+ ReaderMarkNote: "notatka",
+ ReaderMarkTags: "tagi",
+ ReaderMarkHelp: "enter zapisz · tab pole · esc anuluj",
BannerGospel: "Ewangelia",
BannerReadings: "Czytania",
BannerConnective: "na",
diff --git a/internal/tui/reader.go b/internal/tui/reader.go
index 09d3210..72b7d0a 100644
--- a/internal/tui/reader.go
+++ b/internal/tui/reader.go
@@ -31,6 +31,25 @@ const (
// selStyle marks the picker's selected row (reverse video, legible on any theme).
var selStyle = lipgloss.NewStyle().Reverse(true)
+// modalStyle / modalTitleStyle render the bookmark note box and the delete
+// confirmation as a prominent centered, bordered dialog (not a footer line).
+var (
+ modalStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3)
+ modalTitleStyle = lipgloss.NewStyle().Bold(true)
+)
+
+// modal centres inner in a bordered box over the whole screen.
+func (m ReaderModel) modal(inner string) string {
+ w, h := m.width, m.height
+ if w <= 0 {
+ w = 80
+ }
+ if h <= 0 {
+ h = 24
+ }
+ return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, modalStyle.Render(inner))
+}
+
// ReaderModel is lectio-ui's --reader mode: a fuzzy book picker over the sigla
// dialect's book names, then a scrolling view of a chapter from the embedded
// corpora with chapter navigation and version cycling. It implements tea.Model
@@ -62,6 +81,8 @@ type ReaderModel struct {
markVerseIdx int // verse cursor in modeMarkVerse (index into verses)
markVerse int // the chosen verse number carried into modeMark
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
markTop int // list scroll offset
@@ -328,6 +349,8 @@ func (m ReaderModel) updateMarkVerse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.markVerseIdx >= 0 && m.markVerseIdx < len(m.verses) {
m.markVerse = m.verses[m.markVerseIdx].Verse
m.markNote = ""
+ m.markTags = ""
+ m.markField = 0
m.mode = modeMark
}
return m, nil
@@ -335,31 +358,39 @@ func (m ReaderModel) updateMarkVerse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
-// updateMark handles the note box for the chosen verse: type an optional note,
-// Enter saves (an empty note is fine -- "skip"), Esc cancels.
+// updateMark handles the note/tags box for the chosen verse: type into the
+// active field, Tab switches note<->tags, Enter saves (both optional), Esc
+// cancels.
func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ field := &m.markNote
+ if m.markField == 1 {
+ field = &m.markTags
+ }
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.mode = modeRead
- m.markNote = ""
+ m.markNote, m.markTags = "", ""
+ return m, nil
+ case tea.KeyTab, tea.KeyShiftTab, tea.KeyDown, tea.KeyUp:
+ m.markField = 1 - m.markField
return m, nil
case tea.KeyEnter:
- m = m.saveBookmark(m.markVerse, m.markNote)
- m.markNote = ""
+ m = m.saveBookmark(m.markVerse, m.markNote, m.markTags)
+ m.markNote, m.markTags = "", ""
m.mode = modeRead
return m, nil
case tea.KeyBackspace:
- if r := []rune(m.markNote); len(r) > 0 {
- m.markNote = string(r[:len(r)-1])
+ if r := []rune(*field); len(r) > 0 {
+ *field = string(r[:len(r)-1])
}
return m, nil
case tea.KeySpace:
- m.markNote += " "
+ *field += " "
return m, nil
case tea.KeyRunes:
- m.markNote += string(msg.Runes)
+ *field += string(msg.Runes)
return m, nil
}
return m, nil
@@ -524,7 +555,7 @@ func (m ReaderModel) scrollToVerse(verse int) int {
// saveBookmark stores a bookmark of the current book+chapter at the chosen
// verse with an optional note, and sets a flash message.
-func (m ReaderModel) saveBookmark(verse int, note string) ReaderModel {
+func (m ReaderModel) saveBookmark(verse int, note, tags string) ReaderModel {
if m.store == nil || len(m.verses) == 0 {
return m
}
@@ -538,6 +569,7 @@ func (m ReaderModel) saveBookmark(verse int, note string) ReaderModel {
Chapter: chap,
Verse: verse,
Note: strings.TrimSpace(note),
+ Tags: bookmarks.ParseTags(tags),
})
m.flash = fmt.Sprintf("bookmarked %s %d:%d", book.Name, chap, verse)
return m
@@ -699,13 +731,31 @@ func (m ReaderModel) View() string {
switch m.mode {
case modePick:
return m.viewPick()
+ case modeMark:
+ return m.viewMark()
case modeBookmarks:
return m.viewBookmarks()
- default: // modeRead + modeMark (the mark prompt overlays the reading footer)
+ default: // modeRead + modeMarkVerse (verse cursor highlighted in the reading)
return m.viewRead()
}
}
+// viewMark renders the bookmark note/tags box as a prominent centered dialog.
+func (m ReaderModel) viewMark() string {
+ ui := i18n.Get(m.cfg.UILanguage)
+ b := m.books[m.bookIdx]
+ title := modalTitleStyle.Render(fmt.Sprintf("★ %s %d:%d", b.Name, m.currentChapter(), m.markVerse))
+ note := " " + ui.ReaderMarkNote + ": " + m.markNote
+ tags := " " + ui.ReaderMarkTags + ": " + m.markTags
+ if m.markField == 0 {
+ note += "▏"
+ } else {
+ tags += "▏"
+ }
+ inner := title + "\n\n" + note + "\n" + tags + "\n\n" + citationStyle.Render(ui.ReaderMarkHelp)
+ return m.modal(inner)
+}
+
// viewBookmarks renders the saved-bookmarks list.
func (m ReaderModel) viewBookmarks() string {
w := m.width
@@ -713,12 +763,27 @@ func (m ReaderModel) viewBookmarks() string {
w = 80
}
ui := i18n.Get(m.cfg.UILanguage)
- header := headerStyle.Width(w).Render(ui.ReaderBookmarksTitle)
- footerKeys := ui.ReaderBookmarksKeys
- if m.confirmDelete {
- footerKeys = ui.ReaderConfirmDelete
+
+ // 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)
+ return m.modal(inner)
}
- footer := footerStyle.Width(w).Render(footerKeys)
+
+ header := headerStyle.Width(w).Render(ui.ReaderBookmarksTitle)
+ footer := footerStyle.Width(w).Render(ui.ReaderBookmarksKeys)
if len(m.marks) == 0 {
return header + "\n" + citationStyle.Render(ui.ReaderNoBookmarks) + "\n" + footer
}
diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go
index f3e43d5..904836e 100644
--- a/internal/tui/reader_test.go
+++ b/internal/tui/reader_test.go
@@ -124,8 +124,10 @@ func TestReaderBookmarkFlow(t *testing.T) {
if m.mode != modeMark {
t.Fatal("enter did not open the note box")
}
- m = key(m, runes("hi"))
- m = key(m, tea.KeyMsg{Type: tea.KeyEnter})
+ m = key(m, runes("hi")) // note field
+ m = key(m, tea.KeyMsg{Type: tea.KeyTab}) // switch to tags field
+ m = key(m, runes("grace")) // tags field
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // save
if m.mode != modeRead {
t.Fatal("enter did not save + return to reading")
}
@@ -134,8 +136,9 @@ func TestReaderBookmarkFlow(t *testing.T) {
if m.mode != modeBookmarks || len(m.marks) != 1 {
t.Fatalf("bookmarks mode=%v n=%d", m.mode, len(m.marks))
}
- if m.marks[0].Book != "John" || m.marks[0].Verse != 2 || m.marks[0].Note != "hi" {
- t.Errorf("mark = %+v (want John 1:2 note hi)", m.marks[0])
+ if m.marks[0].Book != "John" || m.marks[0].Verse != 2 || m.marks[0].Note != "hi" ||
+ len(m.marks[0].Tags) != 1 || m.marks[0].Tags[0] != "grace" {
+ t.Errorf("mark = %+v (want John 1:2 note hi tag grace)", m.marks[0])
}
if !strings.Contains(m.View(), "John") {
t.Errorf("bookmarks view missing John:\n%s", m.View())