view.go (19430B)
1 package tui 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 8 "git.ryansepassi.com/git/gotextlog.git/internal/config" 9 "git.ryansepassi.com/git/gotextlog.git/internal/domain" 10 "git.ryansepassi.com/git/gotextlog.git/internal/textlog" 11 "github.com/charmbracelet/lipgloss" 12 ) 13 14 type palette struct{ ink, muted, soft, accent, danger, success, quote lipgloss.Color } 15 16 func colors(theme config.Theme, noColor bool) palette { 17 if noColor { 18 return palette{} 19 } 20 if theme == config.ThemeAuto { 21 // The terminal owns automatic colors. COLORFGBG is often stale inside 22 // tmux and can otherwise select dark text on a dark background. 23 return palette{"", "8", "8", "2", "1", "2", "8"} 24 } 25 if theme == config.ThemeLight { 26 return palette{"#20231f", "#747c72", "#d9dbd4", "#55734a", "#7a3f39", "#466342", "#e9e9e3"} 27 } 28 return palette{"#f4f7f2", "#a9b3a9", "#536053", "#a9d99c", "#ffb4ab", "#b8e0ae", "#293229"} 29 } 30 func (m Model) View() string { 31 if len(m.stack) == 0 { 32 return "" 33 } 34 p := colors(m.config.Theme, m.config.NoColor) 35 s := m.current() 36 header := m.header(s, p) 37 body := m.screenView(s, p) 38 if m.status != "" { 39 style := lipgloss.NewStyle().Foreground(p.success) 40 if m.statusError { 41 style = style.Foreground(p.danger) 42 } 43 body = style.Render(" "+m.status) + "\n" + body 44 } 45 footer := lipgloss.NewStyle().Foreground(p.muted).BorderTop(true).BorderForeground(p.soft).Width(max(1, m.width)).Render(m.footer(s)) 46 available := max(1, m.height-lipgloss.Height(header)-lipgloss.Height(footer)) 47 body = lipgloss.NewStyle().Height(available).MaxHeight(available).Width(max(1, m.width)).Render(body) 48 return header + "\n" + body + "\n" + footer 49 } 50 func (m *Model) header(s *screen, p palette) string { 51 active := m.title(s) 52 tabs := []string{"1 hot", "2 latest", "3 live"} 53 if m.config.Token != "" { 54 tabs = []string{"1 for you", "2 to me", "3 hot", "4 latest", "5 live"} 55 if m.width < 80 { 56 tabs = []string{"1 you", "2 me", "3 hot", "4 latest", "5 live"} 57 } 58 } 59 if m.width < 80 && s.kind != screenFeed && s.kind != screenLive { 60 tabs = nil 61 } 62 activeTab := strings.ReplaceAll(active, "-", " ") 63 activeNumber := "" 64 if s.kind == screenFeed || s.kind == screenLive { 65 if m.config.Token != "" { 66 activeNumber = map[string]string{"for-you": "1", "to-me": "2", "hot": "3", "latest": "4", "live": "5"}[active] 67 } else { 68 activeNumber = map[string]string{"hot": "1", "latest": "2", "live": "3"}[active] 69 } 70 } 71 for i, tab := range tabs { 72 style := lipgloss.NewStyle().Foreground(p.muted) 73 if strings.Contains(tab, activeTab) || activeNumber != "" && strings.HasPrefix(tab, activeNumber+" ") { 74 style = style.Bold(true).Foreground(p.accent) 75 } 76 tabs[i] = style.Render(tab) 77 } 78 who := "guest" 79 if m.me != nil { 80 who = "0 @" + m.me.Handle 81 } 82 brand := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog") 83 left := brand + " " + strings.Join(tabs, " ") 84 if s.kind != screenFeed && s.kind != screenLive { 85 left += lipgloss.NewStyle().Foreground(p.soft).Render(" │ ") + lipgloss.NewStyle().Foreground(p.ink).Render(active) 86 } 87 space := max(1, m.width-lipgloss.Width(left)-lipgloss.Width(who)-2) 88 return lipgloss.NewStyle().Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who)) 89 } 90 func (m *Model) title(s *screen) string { 91 switch s.kind { 92 case screenFeed: 93 return string(s.feed) 94 case screenPost: 95 return fmt.Sprintf("note #%d", s.id) 96 case screenProfile: 97 return "@" + s.handle 98 case screenTag: 99 return "#" + s.tag 100 case screenSearch: 101 return "search" 102 case screenLive: 103 return "live" 104 case screenCompose: 105 return "compose" 106 case screenLogin: 107 return "login" 108 case screenAccount: 109 return "account" 110 case screenSettings: 111 return "settings" 112 default: 113 return "help" 114 } 115 } 116 func editingView(s *screen) bool { 117 return (s.kind == screenSearch && s.query == "") || s.kind == screenCompose || s.kind == screenLogin || (s.kind == screenAccount && s.phase == "bio") || (s.kind == screenSettings && s.phase == "url") 118 } 119 120 func (m *Model) footer(s *screen) string { 121 if s.kind == screenCompose || (s.kind == screenAccount && s.phase == "bio") { 122 return " Enter newline · Ctrl+S preview/submit · Esc cancel" 123 } 124 if s.kind == screenLogin { 125 return " Enter continue · Tab email/SSH key · Esc back" 126 } 127 if editingView(s) { 128 return " Enter continue · Esc cancel" 129 } 130 if s.kind == screenPost { 131 if m.width < 90 { 132 return " j/k move · Space fold · Enter focus · r reply · u user · q back" 133 } 134 return " j/k move · Space fold/unfold · Enter focus · r reply · u profile · ! actions · q back" 135 } 136 if s.kind == screenHelp { 137 return " q back" 138 } 139 return " j/k move · Enter open · / search · w write · ? help · q back" 140 } 141 func (m *Model) screenView(s *screen, p palette) string { 142 if s.loading { 143 return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("loading···") 144 } 145 if s.err != "" { 146 return lipgloss.NewStyle().Foreground(p.danger).Padding(1).Render(s.err) 147 } 148 switch s.kind { 149 case screenFeed: 150 return m.postsView(s, p) 151 case screenSearch: 152 if s.query == "" { 153 return editor("search public notes", s.input, false, false, m.width, p) 154 } 155 return lipgloss.NewStyle().Foreground(p.muted).Render(" results for “"+s.query+"”") + "\n" + m.postsView(s, p) 156 case screenPost: 157 return m.postsView(s, p) 158 case screenProfile: 159 return m.profileView(s, p) 160 case screenTag: 161 return m.tagView(s, p) 162 case screenLive: 163 return lipgloss.NewStyle().Foreground(p.success).Render(" ● "+s.streamState+" · Space pause/resume") + "\n" + m.postsView(s, p) 164 case screenCompose: 165 return m.composeView(s, p) 166 case screenLogin: 167 switch s.phase { 168 case "key": 169 if len(s.keys) == 0 { 170 return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("No Ed25519 SSH public keys found in ~/.ssh") 171 } 172 lines := []string{"Select an Ed25519 SSH key:"} 173 for i, key := range s.keys { 174 marker := " " 175 if i == s.selected { 176 marker = "› " 177 } 178 lines = append(lines, marker+key.Path+" "+key.Fingerprint) 179 } 180 return lipgloss.NewStyle().Padding(1).Render(strings.Join(lines, "\n")) 181 case "handle": 182 key := s.keys[s.selected] 183 return lipgloss.NewStyle().Foreground(p.muted).Padding(0, 1).Render(key.Path+" "+key.Fingerprint) + "\n" + 184 editor("handle (required for first use)", s.input, false, false, m.width, p) 185 default: 186 label := "email address" 187 if s.phase == "code" { 188 label = "code sent to " + s.query 189 } 190 return editor(label, s.input, s.phase == "code", false, m.width, p) 191 } 192 case screenAccount: 193 return m.accountView(s, p) 194 case screenSettings: 195 return m.settingsView(s, p) 196 case screenHelp: 197 return m.helpView(p) 198 } 199 return "" 200 } 201 202 func (m *Model) postsView(s *screen, p palette) string { 203 if len(s.posts) == 0 { 204 return lipgloss.NewStyle().Foreground(p.muted).Padding(0, 1).Render("No notes here.") 205 } 206 rendered := make([]string, len(s.posts)) 207 for i := range s.posts { 208 depth := 0 209 if i < len(s.depths) { 210 depth = s.depths[i] 211 } 212 focused := -1 213 if i == s.selected { 214 focused = s.target 215 } 216 context := "" 217 if i < len(s.entries) { 218 context = s.entries[i].Context 219 } 220 if s.kind == screenPost && threadHasChildren(s.thread, s.posts[i].ID) { 221 context = "▾ expanded" 222 if s.folded[s.posts[i].ID] { 223 context = "▸ folded" 224 } 225 } 226 bodyLines := 3 227 if s.kind == screenPost && i == s.selected { 228 bodyLines = 8 229 } 230 note := renderNote(s.posts[i], i == s.selected, focused, context, max(20, m.width-depth*2), bodyLines, p) 231 if depth > 0 { 232 note = indentNote(note, depth) 233 } 234 if s.kind == screenFeed && s.feed == string(config.FeedLatest) && s.posts[i].Parent != nil { 235 parent := renderNote(*s.posts[i].Parent, false, -1, "parent", m.width, 3, p) 236 note = parent + "\n" + indentNote(note, 1) 237 } 238 if i < len(s.more) && s.more[i] > 0 { 239 label := fmt.Sprintf("more · %d replies", s.more[i]) 240 if s.folded[s.posts[i].ID] { 241 label = fmt.Sprintf("%d replies folded", s.more[i]) 242 } 243 note += "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+label) 244 } 245 rendered[i] = note 246 } 247 248 available := max(1, m.height-4) 249 switch s.kind { 250 case screenSearch, screenLive, screenTag: 251 available-- 252 case screenProfile: 253 available -= 1 + strings.Count(s.profile.Bio, "\n") 254 if s.profile.Bio != "" { 255 available-- 256 } 257 } 258 available = max(1, available) 259 if s.kind == screenFeed || s.kind == screenSearch { 260 available-- 261 } 262 selected := clamp(s.selected, len(rendered)) 263 start := selected 264 used := lipgloss.Height(rendered[selected]) 265 for start > 0 { 266 height := lipgloss.Height(rendered[start-1]) 267 if used+height > available/2 { 268 break 269 } 270 start-- 271 used += height 272 } 273 end := start 274 used = 0 275 for end < len(rendered) { 276 height := lipgloss.Height(rendered[end]) 277 if end > start && used+height > available { 278 break 279 } 280 used += height 281 end++ 282 } 283 for start > 0 { 284 height := lipgloss.Height(rendered[start-1]) 285 if used+height > available { 286 break 287 } 288 start-- 289 used += height 290 } 291 parts := append([]string(nil), rendered[start:end]...) 292 if s.kind == screenFeed || s.kind == screenSearch { 293 page := fmt.Sprintf("page %d", s.page+1) 294 if s.page > 0 { 295 page = "p previous · " + page 296 } 297 if s.nextCursor != "" { 298 page += " · n next" 299 } 300 parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).PaddingLeft(2).Render(page)) 301 } 302 return strings.Join(parts, "\n") 303 } 304 305 func indentNote(note string, depth int) string { 306 lines := strings.Split(note, "\n") 307 prefix := strings.Repeat("│ ", depth) 308 for i := range lines { 309 lines[i] = prefix + lines[i] 310 } 311 return strings.Join(lines, "\n") 312 } 313 314 func renderNote(post textlog.Post, selected bool, focused int, context string, width, maxBodyLines int, p palette) string { 315 if post.Body == "↑ top" && post.Author.Handle == "" && post.CreatedAt.IsZero() { 316 style := lipgloss.NewStyle().Foreground(p.accent).Width(max(10, width-2)) 317 if selected { 318 style = style.BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(p.accent).PaddingLeft(1) 319 } else { 320 style = style.PaddingLeft(2) 321 } 322 return style.Render(post.Body) 323 } 324 replies := fmt.Sprintf("%d replies", post.ReplyCount) 325 if post.ReplyCount == 1 { 326 replies = "1 reply" 327 } 328 authorStyle := lipgloss.NewStyle().Foreground(p.accent).Bold(selected).Reverse(focused == 0) 329 threadStyle := lipgloss.NewStyle().Foreground(p.muted).Reverse(focused == 1) 330 replyStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 2) 331 meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(relative(post.CreatedAt)+" · "+replies) 332 if context != "" { 333 meta += lipgloss.NewStyle().Foreground(p.muted).Render(" · " + context) 334 } 335 meta += " " + replyStyle.Render("reply") 336 content := meta 337 if post.Body != "" { 338 bodyWidth := max(8, width-4) 339 body := lipgloss.NewStyle().Width(bodyWidth).MaxHeight(maxBodyLines).Render(renderRich(post.Body, post.Tags, focused-3, p)) 340 content += "\n" + body 341 } 342 style := lipgloss.NewStyle().Width(max(10, width-2)) 343 if selected { 344 style = style.BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(p.accent).PaddingLeft(1) 345 } else { 346 style = style.PaddingLeft(2) 347 } 348 return style.Render(content) 349 } 350 func relative(value time.Time) string { 351 if value.IsZero() { 352 return "now" 353 } 354 seconds := int(time.Since(value).Seconds()) 355 if seconds < 0 { 356 seconds = 0 357 } 358 if seconds < 60 { 359 return fmt.Sprintf("%ds", seconds) 360 } 361 if seconds < 3600 { 362 return fmt.Sprintf("%dm", seconds/60) 363 } 364 if seconds < 86400 { 365 return fmt.Sprintf("%dh", seconds/3600) 366 } 367 if seconds < 86400*30 { 368 return fmt.Sprintf("%dd", seconds/86400) 369 } 370 return value.Format("2006-01-02") 371 } 372 func renderRich(body string, tags []string, focused int, p palette) string { 373 body = domain.TerminalSafeText(body) 374 if domain.IsASCIIArt(body, tags) { 375 return body 376 } 377 var b strings.Builder 378 interactive := -1 379 for _, token := range domain.TokenizeRichText(body) { 380 style := lipgloss.NewStyle().Foreground(p.ink) 381 switch token.Kind { 382 case domain.TokenLink: 383 interactive++ 384 style = style.Foreground(p.accent).Underline(true).Reverse(focused == interactive) 385 case domain.TokenReference: 386 interactive++ 387 style = style.Foreground(p.accent).Bold(true).Reverse(focused == interactive) 388 case domain.TokenCode: 389 style = style.Foreground(p.muted) 390 } 391 b.WriteString(style.Render(token.Text)) 392 } 393 return b.String() 394 } 395 func editor(label, value string, secret, multiline bool, width int, p palette) string { 396 shown := value 397 if secret { 398 shown = strings.Repeat("•", domain.CodePointLength(value)) 399 } 400 field := shown + lipgloss.NewStyle().Reverse(true).Render(" ") 401 style := lipgloss.NewStyle().BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderBottom(true).BorderForeground(p.accent). 402 PaddingLeft(1).Width(max(20, width-4)) 403 if multiline { 404 style = style.Height(4) 405 } 406 return lipgloss.NewStyle().Padding(0, 1).Render( 407 lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + style.Render(field), 408 ) 409 } 410 411 func (m *Model) composeView(s *screen, p palette) string { 412 label := "What's on your mind?" 413 if s.parent != nil { 414 label = "reply to @" + s.parent.Author.Handle 415 } 416 if s.edit != nil { 417 label = "edit note" 418 } 419 if !s.preview { 420 return editor(label, s.input, false, true, m.width, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input)) 421 } 422 preview := textlog.Post{Body: s.input, CreatedAt: time.Now(), Author: textlog.UserReference{Handle: m.me.Handle}} 423 items := []string{"publish", "keep editing", "cancel"} 424 for i := range items { 425 prefix := " " 426 if i == s.menuSelected { 427 prefix = "› " 428 items[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + items[i]) 429 } else { 430 items[i] = prefix + items[i] 431 } 432 } 433 return lipgloss.NewStyle().Padding(1).Bold(true).Render(label) + "\n" + renderNote(preview, true, -1, "", m.width, 8, p) + "\n" + lipgloss.NewStyle().Bold(true).Render("Publish this note?") + "\n" + strings.Join(items, "\n") 434 } 435 func (m *Model) profileView(s *screen, p palette) string { 436 if s.profile == nil { 437 return "Profile not found" 438 } 439 profile := s.profile 440 tabs := []string{fmt.Sprintf("%d notes", profile.PostCount), fmt.Sprintf("%d replies", profile.RepliesCount), fmt.Sprintf("%d users", profile.FollowingUserCount), fmt.Sprintf("%d tags", profile.FollowingTagCount), fmt.Sprintf("%d followers", profile.FollowerCount)} 441 if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) { 442 tabs = append(tabs, fmt.Sprintf("%d blocked", profile.BlockedUserCount)) 443 } 444 for i := range tabs { 445 if i == s.activeTab { 446 tabs[i] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[i]) 447 } else { 448 tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(tabs[i]) 449 } 450 } 451 headerText := strings.Join(tabs, " ") 452 if profile.Bio != "" { 453 headerText = renderRich(profile.Bio, nil, -1, p) + "\n" + headerText 454 } 455 header := lipgloss.NewStyle().Padding(0, 1).Render(headerText) 456 if s.activeTab < 2 { 457 return header + "\n" + m.postsView(s, p) 458 } 459 height := max(1, m.height-3-lipgloss.Height(header)) 460 if s.activeTab == 3 { 461 return header + "\n" + referenceTags(s.tags, s.selected, height, p) 462 } 463 return header + "\n" + referenceUsers(s.users, s.selected, height, p) 464 } 465 func (m *Model) tagView(s *screen, p palette) string { 466 if len(s.tags) == 0 { 467 return "Tag not found" 468 } 469 details := &s.tags[0] 470 tabs := []string{fmt.Sprintf("%d notes", details.PostCount), fmt.Sprintf("%d followers", details.FollowerCount)} 471 tabs[s.activeTab] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[s.activeTab]) 472 header := lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(tabs, " ")) 473 if s.activeTab == 0 { 474 return header + "\n" + m.postsView(s, p) 475 } 476 return header + "\n" + referenceUsers(s.users, s.selected, max(1, m.height-3-lipgloss.Height(header)), p) 477 } 478 func referenceUsers(items []textlog.UserReference, selected, height int, p palette) string { 479 if len(items) == 0 { 480 return lipgloss.NewStyle().Foreground(p.muted).Render(" No users here.") 481 } 482 start, end := referenceWindow(selected, len(items), height) 483 lines := make([]string, 0, end-start) 484 for i := start; i < end; i++ { 485 prefix := " " 486 if i == selected { 487 prefix = "› " 488 lines = append(lines, lipgloss.NewStyle().Foreground(p.accent).Render(prefix+"@"+items[i].Handle)) 489 } else { 490 lines = append(lines, prefix+"@"+items[i].Handle) 491 } 492 } 493 return strings.Join(lines, "\n") 494 } 495 496 func referenceTags(items []textlog.TagReference, selected, height int, p palette) string { 497 if len(items) == 0 { 498 return lipgloss.NewStyle().Foreground(p.muted).Render(" No tags here.") 499 } 500 start, end := referenceWindow(selected, len(items), height) 501 lines := make([]string, 0, end-start) 502 for i := start; i < end; i++ { 503 prefix := " " 504 if i == selected { 505 prefix = "› " 506 } 507 lines = append(lines, prefix+"#"+items[i].Tag) 508 } 509 return strings.Join(lines, "\n") 510 } 511 512 func referenceWindow(selected, length, height int) (int, int) { 513 height = max(1, height) 514 start := max(0, clamp(selected, length)-height/2) 515 end := min(length, start+height) 516 if end-start < height { 517 start = max(0, end-height) 518 } 519 return start, end 520 } 521 522 func (m *Model) accountView(s *screen, p palette) string { 523 if s.phase == "bio" { 524 return editor("edit bio", s.input, false, true, m.width, p) 525 } 526 if m.me == nil { 527 return "You are not signed in." 528 } 529 return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("@"+m.me.Handle) + "\n" + m.me.Email + "\n" + m.me.Bio + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(map[bool]string{true: "email verified", false: "email not verified"}[m.me.EmailVerified]) + "\n\n" + lipgloss.NewStyle().Foreground(p.accent).Render("e edit bio · u profile · , settings · s sign out")) 530 } 531 func (m *Model) settingsView(s *screen, p palette) string { 532 if s.phase == "url" { 533 return editor("Textlog instance URL", s.input, false, false, m.width, p) 534 } 535 items := []string{"theme: " + string(m.config.Theme), "change instance URL", "open web app", "back"} 536 for i := range items { 537 prefix := " " 538 if i == s.menuSelected { 539 prefix = "› " 540 items[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + items[i]) 541 } else { 542 items[i] = prefix + items[i] 543 } 544 } 545 return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Render("settings · "+m.config.BaseURL) + "\n" + strings.Join(items, "\n")) 546 } 547 func (m *Model) helpView(p palette) string { 548 feeds := "1–3 feeds" 549 if m.config.Token != "" { 550 feeds = "1–5 feeds" 551 } 552 label := func(value string) string { 553 return lipgloss.NewStyle().Width(9).Foreground(p.muted).Render(value) 554 } 555 lines := []string{ 556 lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("a quieter place for your thoughts"), 557 "", 558 label("global") + feeds + " · / search · w write · a account · 0 profile", 559 label("move") + "↑↓ j/k · Enter/l open · h/q back · g/G first/last", 560 label("pages") + "p/n previous/next · R refresh", 561 label("notes") + "Tab target · r reply · u author · t tag · o browser", 562 label("actions") + "f/F follow · b/B block · e edit · d delete · ! report", 563 label("tabs") + "Tab or ←/→ on profiles and tags", 564 "", 565 lipgloss.NewStyle().Foreground(p.muted).Render("Activity feeds require sign-in · API " + m.config.BaseURL + "/api"), 566 } 567 return lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(lines, "\n")) 568 }