gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

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 }