-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodel.go
More file actions
321 lines (280 loc) · 8.54 KB
/
Copy pathmodel.go
File metadata and controls
321 lines (280 loc) · 8.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
package main
import (
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// ─── Model ──────────────────────────────────────────────────────────
var spinChars = [...]string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
// feedState holds the cached page data and cursor position for a single feed tab.
type feedState struct {
pages [][]Story
pageURLs []string
pageFetchedAt []time.Time
moreURL string
page int
cursor int
offset int
}
type model struct {
// Pages cached as fetched from HN. Each entry is one HN page (~30 stories).
pages [][]Story
pageURLs []string // URL used to fetch each cached page
pageFetchedAt []time.Time // when each page was last fetched
moreURL string // URL for the page after the last cached one ("" = end)
page int // current page index (0-based)
cursor int // cursor within current page (0-based)
offset int // scroll offset within current page
width int
height int
loading bool
err error
feed Feed
tick int
// Per-feed cache so switching tabs preserves state
feedCache [feedCount]feedState
// Comments view
mode viewMode
comments []Comment
commentCursor int // index into visibleComments()
commentOffset int // first visible comment index in visibleComments()
commentStory Story
commentTopSpans []CommentSpan // post body text (self-posts)
commentFetchedAt time.Time
userInfo map[string]UserInfo // user info for current comment thread
// Block list
blocks blockList
confirm bool // true when a confirmation prompt is active
confirmTarget string // username or domain being confirmed for blocking
confirmType confirmKind // what kind of block is being confirmed
// Block list manager
blockEntries []string // cached entries for the active tab
blockCursor int
blockOffset int
blockTab int // 0=Users, 1=Stories, 2=Keywords
prevMode viewMode // mode to return to when leaving block list
// Keyword input within block list manager
kwInput bool // true when typing a keyword
kwPicking bool // true when picking scope after entering keyword
kwText string // keyword being typed
// Domain input within block list manager
domInput bool // true when typing a domain
domText string // domain being typed
// Search
searchQuery string
searchAllStories []Story // all scraped stories across all pages (unfiltered)
searchNextURL string // next page to scrape ("" = done)
searchDone bool // true when all pages have been scraped
searchPages int // pages scraped so far
searchStartedAt time.Time // when the search scrape began
searchCursor int
searchOffset int
// Help overlay
showHelp bool
// Show hidden/blocked items toggle
showBlocked bool
// Bookmarks
bookmarks map[int]Bookmark
bookmarkList []Bookmark // sorted for display (newest first)
bookmarkCursor int
bookmarkOffset int
}
func newModel() model {
return model{loading: true, blocks: loadBlocks(), bookmarks: loadBookmarks()}
}
func (m model) sortedBookmarks() []Bookmark {
list := make([]Bookmark, 0, len(m.bookmarks))
for _, b := range m.bookmarks {
list = append(list, b)
}
sort.Slice(list, func(i, j int) bool {
return list[i].BookmarkedAt.After(list[j].BookmarkedAt)
})
return list
}
func (m model) Init() tea.Cmd {
return tea.Batch(fetchPageCmd(feedBaseURLs[FeedTop], FeedTop), tickCmd())
}
// stories on the currently viewed page, filtered by block list.
func (m model) currentStories() []Story {
if m.page < 0 || m.page >= len(m.pages) {
return nil
}
raw := m.pages[m.page]
if m.showBlocked {
return raw
}
hasBlocks := len(m.blocks.Users) > 0 || len(m.blocks.Stories) > 0 || len(m.blocks.Keywords) > 0 || len(m.blocks.Domains) > 0
if !hasBlocks {
return raw
}
var out []Story
for _, s := range raw {
if m.blocks.isStoryBlocked(s) {
continue
}
out = append(out, s)
}
return out
}
// how many stories fit on the terminal at once
func (m model) visibleCount() int {
avail := m.height - 2 // stories header + separator
n := avail / 2 // 2 lines per story, no spacer
if n < 1 {
return 1
}
return n
}
func (m model) searchVisibleCount() int {
modalInnerH := m.height - 6
if modalInnerH < 10 {
modalInnerH = 10
}
// results area = modalInnerH - title(1) - input(1) - sep(1) - footer sep(1) - footer(1)
resultsH := modalInnerH - 5
if resultsH < 1 {
resultsH = 1
}
n := (resultsH + 1) / 3
if n < 1 {
return 1
}
return n
}
// global 1-based rank for a local index on the current page
func (m model) globalRank(localIdx int) int {
r := 0
for i := 0; i < m.page; i++ {
r += len(m.pages[i])
}
return r + localIdx + 1
}
func (m model) hasNextPage() bool {
return m.page+1 < len(m.pages) || m.moreURL != ""
}
func (m model) hasPrevPage() bool {
return m.page > 0
}
// ─── Comment Helpers ────────────────────────────────────────────────
// visibleComments returns indices into m.comments for non-hidden comments.
// Collapsed comments and blocked users hide their children (deeper comments that follow).
func (m model) visibleComments() []int {
var vis []int
skipDepth := -1
for i, c := range m.comments {
if skipDepth >= 0 && c.Depth > skipDepth {
continue
}
skipDepth = -1
// Skip blocked users and their reply tree
if !m.showBlocked && m.blocks.Users[c.By] {
skipDepth = c.Depth
continue
}
// Skip comments matching keyword blocks
if !m.showBlocked && m.blocks.matchesCommentKeyword(c.Text) {
skipDepth = c.Depth
continue
}
vis = append(vis, i)
if c.Collapsed {
skipDepth = c.Depth
}
}
return vis
}
// childCount returns the number of descendant comments hidden when collapsing.
func (m model) childCount(idx int) int {
d := m.comments[idx].Depth
count := 0
for j := idx + 1; j < len(m.comments); j++ {
if m.comments[j].Depth <= d {
break
}
count++
}
return count
}
// commentHeight returns how many terminal lines a comment occupies.
func (m model) commentHeight(commentIdx int) int {
c := m.comments[commentIdx]
if c.Collapsed || c.Text == "" {
return 2 // header + blank
}
prefixW := 2 + c.Depth*2
textWidth := m.width - prefixW
if textWidth < 10 {
textWidth = 10
}
wrapped := wrapRichSpans(c.Spans, textWidth)
return 1 + len(wrapped) + 1 // header + text lines + blank
}
// commentAvailLines returns terminal lines available for rendering comments.
func (m model) commentAvailLines() int {
// header(1) + sep(1) + banner + sep(1) at top
bannerH := len(m.viewStoryBanner())
return m.height - 3 - bannerH
}
// adjustCommentScroll ensures the cursor is visible and returns the adjusted model.
func (m model) adjustCommentScroll() model {
vis := m.visibleComments()
if len(vis) == 0 {
m.commentCursor = 0
m.commentOffset = 0
return m
}
m.commentCursor = clamp(m.commentCursor, 0, len(vis)-1)
// If cursor is above the viewport, scroll up
if m.commentCursor < m.commentOffset {
m.commentOffset = m.commentCursor
}
// If cursor is below the viewport, scroll down
avail := m.commentAvailLines()
for m.commentOffset < m.commentCursor {
lines := 0
for i := m.commentOffset; i <= m.commentCursor && i < len(vis); i++ {
lines += m.commentHeight(vis[i])
}
if lines <= avail {
break
}
m.commentOffset++
}
// If we're near the end and there is extra room, shift offset upward
// so the viewport stays filled instead of leaving blank space at bottom.
for m.commentOffset > 0 {
lines := 0
for i := m.commentOffset; i < len(vis) && lines < avail; i++ {
lines += m.commentHeight(vis[i])
}
if lines >= avail {
break
}
m.commentOffset--
}
return m
}
// ─── Search Helpers ──────────────────────────────────────────────────
func matchesSearch(s Story, query string) bool {
return strings.Contains(strings.ToLower(s.Title), query) ||
strings.Contains(strings.ToLower(s.Domain), query) ||
strings.Contains(strings.ToLower(s.By), query)
}
func (m model) searchResults() []Story {
query := strings.ToLower(strings.TrimSpace(m.searchQuery))
if query == "" {
return nil
}
var out []Story
for _, s := range m.searchAllStories {
if !m.showBlocked && m.blocks.isStoryBlocked(s) {
continue
}
if matchesSearch(s, query) {
out = append(out, s)
}
}
return out
}