gotextlog

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

commit 510baee3ba1ecd14c340fce8902d61c02746ef0f
parent 0d72a4ceb3a67beecde238aeaa05df782dd9d496
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 25 Aug 2026 21:52:40 -0700

Increase TUI layout density

Diffstat:
Minternal/tui/model_test.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/view.go | 246++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
2 files changed, 246 insertions(+), 64 deletions(-)

diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "strings" "testing" "time" @@ -199,3 +200,66 @@ func TestPausedLiveScreenIgnoresStreamClosure(t *testing.T) { t.Fatalf("paused stream reconnected: state=%q cmd=%v", model.current().streamState, cmd != nil) } } +func TestPostListUsesDenseRowsAndShowsActivityContext(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) + model.width, model.height = 80, 24 + s := model.current() + s.loading = false + s.entries = []ActivityEntry{{Post: textlog.Post{ID: 1, Body: "hello", Author: textlog.UserReference{Handle: "alice"}}, Context: "mentioned you"}} + s.posts = []textlog.Post{s.entries[0].Post} + view := model.View() + if strings.Contains(view, "╭") || !strings.Contains(view, "┃") { + t.Fatalf("post list is not using a dense selected row:\n%s", view) + } + if !strings.Contains(view, "mentioned you") { + t.Fatalf("activity context is missing:\n%s", view) + } +} +func TestPostListFillsAvailableHeight(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) + model.width, model.height = 120, 40 + s := model.current() + s.loading = false + for i := 1; i <= 15; i++ { + s.posts = append(s.posts, textlog.Post{ID: i, Body: "body", Author: textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)}}) + } + if view := model.View(); !strings.Contains(view, "@user-15") { + t.Fatalf("list did not fill the viewport:\n%s", view) + } +} +func TestEditorUsesOneCompactHintAndStableField(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) + model.width, model.height = 80, 24 + s := model.newScreen(screenSearch) + s.phase = "input" + model.stack = append(model.stack, s) + view := model.View() + if strings.Count(view, "Enter continue") != 1 || strings.Contains(view, "╭") || !strings.Contains(view, "┃") { + t.Fatalf("editor is not compact and coherent:\n%s", view) + } +} +func TestProfileHeaderDoesNotRepeatScreenIdentity(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) + profile := model.newScreen(screenProfile) + profile.handle = "alice" + profile.profile = &textlog.Profile{Handle: "alice", Bio: "short bio"} + model.stack = append(model.stack, profile) + if view := model.View(); strings.Count(view, "@alice") != 1 { + t.Fatalf("profile identity is repeated:\n%s", view) + } +} +func TestReferenceListKeepsSelectionVisible(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{}) + model.width, model.height = 65, 30 + s := model.newScreen(screenProfile) + s.handle = "alice" + s.profile = &textlog.Profile{Handle: "alice"} + s.activeTab, s.selected = 2, 30 + for i := 0; i < 40; i++ { + s.users = append(s.users, textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)}) + } + model.stack = append(model.stack, s) + if view := model.View(); !strings.Contains(view, "› @user-30") { + t.Fatalf("selected reference is outside the viewport:\n%s", view) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -44,27 +44,29 @@ func (m Model) View() string { } func (m *Model) header(s *screen, p palette) string { active := m.title(s) - auth := m.config.Token != "" - tabs := []string{} - if auth { + tabs := []string{"1 hot", "2 latest", "3 live"} + if m.config.Token != "" { tabs = []string{"1 for you", "2 to me", "3 hot", "4 latest", "5 live"} - } else { - tabs = []string{"1 hot", "2 latest", "3 live"} } - for i, t := range tabs { - if strings.Contains(t, active) { - tabs[i] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(t) - } else { - tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(t) + activeTab := strings.ReplaceAll(active, "-", " ") + for i, tab := range tabs { + style := lipgloss.NewStyle().Foreground(p.muted) + if strings.Contains(tab, activeTab) { + style = style.Bold(true).Foreground(p.accent) } + tabs[i] = style.Render(tab) } who := "guest" if m.me != nil { who = "0 @" + m.me.Handle } - left := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(">_ textlog") + " " + strings.Join(tabs, " ") + brand := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog") + left := brand + " " + strings.Join(tabs, " ") + if s.kind != screenFeed && s.kind != screenLive { + left += lipgloss.NewStyle().Foreground(p.soft).Render(" │ ") + lipgloss.NewStyle().Foreground(p.ink).Render(active) + } space := max(1, m.width-lipgloss.Width(left)-lipgloss.Width(who)-2) - return lipgloss.NewStyle().BorderBottom(true).BorderForeground(p.soft).Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who)) + return lipgloss.NewStyle().Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who)) } func (m *Model) title(s *screen) string { switch s.kind { @@ -97,13 +99,22 @@ func editingView(s *screen) bool { } func (m *Model) footer(s *screen) string { + if s.kind == screenCompose || (s.kind == screenAccount && s.phase == "bio") { + return " Enter newline · Ctrl+S preview/submit · Esc cancel" + } if editingView(s) { - return " Enter continue · Esc cancel" + return " Enter continue · Esc cancel" } if s.kind == screenPost { - return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete" + if m.width < 90 { + return " Tab target · Enter open · r reply · u user · ! actions · q back" + } + return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete" } - return " ↑↓/jk move enter open / search w write ? help q back" + if s.kind == screenHelp { + return " q back" + } + return " j/k move · Enter open · / search · w write · ? help · q back" } func (m *Model) screenView(s *screen, p palette) string { if s.loading { @@ -117,7 +128,7 @@ func (m *Model) screenView(s *screen, p palette) string { return m.postsView(s, p) case screenSearch: if s.query == "" { - return editor("search public notes", s.input, false, p) + return editor("search public notes", s.input, false, false, m.width, p) } return lipgloss.NewStyle().Foreground(p.muted).Render(" results for “"+s.query+"”") + "\n" + m.postsView(s, p) case screenPost: @@ -135,7 +146,7 @@ func (m *Model) screenView(s *screen, p palette) string { if s.phase == "code" { label = "code sent to " + s.query } - return editor(label, s.input, s.phase == "code", p) + return editor(label, s.input, s.phase == "code", false, m.width, p) case screenAccount: return m.accountView(s, p) case screenSettings: @@ -148,19 +159,10 @@ func (m *Model) screenView(s *screen, p palette) string { func (m *Model) postsView(s *screen, p palette) string { if len(s.posts) == 0 { - return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("No notes here.") - } - rows := max(1, (m.height-6)/5) - start := s.selected - rows/2 - if start < 0 { - start = 0 + return lipgloss.NewStyle().Foreground(p.muted).Padding(0, 1).Render("No notes here.") } - end := min(len(s.posts), start+rows) - if end-start < rows { - start = max(0, end-rows) - } - parts := make([]string, 0, end-start+1) - for i := start; i < end; i++ { + rendered := make([]string, len(s.posts)) + for i := range s.posts { depth := 0 if i < len(s.depths) { depth = s.depths[i] @@ -169,7 +171,15 @@ func (m *Model) postsView(s *screen, p palette) string { if i == s.selected { focused = s.target } - note := renderNote(s.posts[i], i == s.selected, focused, max(20, m.width-depth*2), p) + context := "" + if i < len(s.entries) { + context = s.entries[i].Context + } + bodyLines := 3 + if s.kind == screenPost && i == s.selected { + bodyLines = 8 + } + note := renderNote(s.posts[i], i == s.selected, focused, context, max(20, m.width-depth*2), bodyLines, p) if depth > 0 { lines := strings.Split(note, "\n") for j := range lines { @@ -177,32 +187,95 @@ func (m *Model) postsView(s *screen, p palette) string { } note = strings.Join(lines, "\n") } - parts = append(parts, note) if i < len(s.more) && s.more[i] > 0 { - parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more (%d replies)", s.more[i]))) + note += "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more · %d replies", s.more[i])) + } + rendered[i] = note + } + + available := max(1, m.height-4) + switch s.kind { + case screenSearch, screenLive, screenTag: + available-- + case screenProfile: + available -= 1 + strings.Count(s.profile.Bio, "\n") + if s.profile.Bio != "" { + available-- + } + } + available = max(1, available) + if s.kind == screenFeed || s.kind == screenSearch { + available-- + } + selected := clamp(s.selected, len(rendered)) + start := selected + used := lipgloss.Height(rendered[selected]) + for start > 0 { + height := lipgloss.Height(rendered[start-1]) + if used+height > available/2 { + break + } + start-- + used += height + } + end := start + used = 0 + for end < len(rendered) { + height := lipgloss.Height(rendered[end]) + if end > start && used+height > available { + break + } + used += height + end++ + } + for start > 0 { + height := lipgloss.Height(rendered[start-1]) + if used+height > available { + break } + start-- + used += height } + parts := append([]string(nil), rendered[start:end]...) if s.kind == screenFeed || s.kind == screenSearch { - parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(fmt.Sprintf(" page %d%s", s.page+1, map[bool]string{true: " · n next", false: ""}[s.nextCursor != ""]))) + page := fmt.Sprintf("page %d", s.page+1) + if s.page > 0 { + page = "p previous · " + page + } + if s.nextCursor != "" { + page += " · n next" + } + parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).PaddingLeft(2).Render(page)) } return strings.Join(parts, "\n") } -func renderNote(post textlog.Post, selected bool, focused, width int, p palette) string { + +func renderNote(post textlog.Post, selected bool, focused int, context string, width, maxBodyLines int, p palette) string { replies := fmt.Sprintf("%d replies", post.ReplyCount) if post.ReplyCount == 1 { replies = "1 reply" } - when := relative(post.CreatedAt) - authorStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 0) + authorStyle := lipgloss.NewStyle().Foreground(p.accent).Bold(selected).Reverse(focused == 0) threadStyle := lipgloss.NewStyle().Foreground(p.muted).Reverse(focused == 1) replyStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 2) - meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(when+" · "+replies) + " " + replyStyle.Render("reply") - body := renderRich(post.Body, post.Tags, focused-3, p) - border := p.soft + meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(relative(post.CreatedAt)+" · "+replies) + if context != "" { + meta += lipgloss.NewStyle().Foreground(p.muted).Render(" · " + context) + } + meta += " " + replyStyle.Render("reply") + content := meta + if post.Body != "" { + bodyWidth := max(8, width-4) + body := lipgloss.NewStyle().Width(bodyWidth).MaxHeight(maxBodyLines).Render(renderRich(post.Body, post.Tags, focused-3, p)) + content += "\n" + body + } + style := lipgloss.NewStyle().Width(max(10, width-2)) if selected { - border = p.accent + style = style.BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(p.accent).PaddingLeft(1) + } else { + style = style.PaddingLeft(2) } - return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(0, 1).Width(max(10, width-2)).Render(meta + "\n" + body) + return style.Render(content) } func relative(value time.Time) string { if value.IsZero() { @@ -249,13 +322,20 @@ func renderRich(body string, tags []string, focused int, p palette) string { } return b.String() } -func editor(label, value string, secret bool, p palette) string { +func editor(label, value string, secret, multiline bool, width int, p palette) string { shown := value if secret { shown = strings.Repeat("•", domain.CodePointLength(value)) } - box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.accent).Padding(0, 1).Render(shown + lipgloss.NewStyle().Reverse(true).Render(" ")) - return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + box + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Enter continue · Esc cancel")) + field := shown + lipgloss.NewStyle().Reverse(true).Render(" ") + style := lipgloss.NewStyle().BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderBottom(true).BorderForeground(p.accent). + PaddingLeft(1).Width(max(20, width-4)) + if multiline { + style = style.Height(4) + } + return lipgloss.NewStyle().Padding(0, 1).Render( + lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + style.Render(field), + ) } func (m *Model) composeView(s *screen, p palette) string { @@ -267,7 +347,7 @@ func (m *Model) composeView(s *screen, p palette) string { label = "edit note" } if !s.preview { - return editor(label, s.input, false, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input)) + return editor(label, s.input, false, true, m.width, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input)) } preview := textlog.Post{Body: s.input, CreatedAt: time.Now(), Author: textlog.UserReference{Handle: m.me.Handle}} items := []string{"publish", "keep editing", "cancel"} @@ -280,14 +360,14 @@ func (m *Model) composeView(s *screen, p palette) string { items[i] = prefix + items[i] } } - return lipgloss.NewStyle().Padding(1).Bold(true).Render(label) + "\n" + renderNote(preview, true, -1, m.width, p) + "\n" + lipgloss.NewStyle().Bold(true).Render("Publish this note?") + "\n" + strings.Join(items, "\n") + 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") } func (m *Model) profileView(s *screen, p palette) string { if s.profile == nil { return "Profile not found" } profile := s.profile - tabs := []string{fmt.Sprintf("%d notes", profile.PostCount), fmt.Sprintf("%d replies", profile.RepliesCount), fmt.Sprintf("%d following users", profile.FollowingUserCount), fmt.Sprintf("%d following tags", profile.FollowingTagCount), fmt.Sprintf("%d followers", profile.FollowerCount)} + 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)} if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) { tabs = append(tabs, fmt.Sprintf("%d blocked", profile.BlockedUserCount)) } @@ -298,14 +378,19 @@ func (m *Model) profileView(s *screen, p palette) string { tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(tabs[i]) } } - header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("@"+profile.Handle) + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(profile.Bio) + "\n" + strings.Join(tabs, " ")) + headerText := strings.Join(tabs, " ") + if profile.Bio != "" { + headerText = renderRich(profile.Bio, nil, -1, p) + "\n" + headerText + } + header := lipgloss.NewStyle().Padding(0, 1).Render(headerText) if s.activeTab < 2 { return header + "\n" + m.postsView(s, p) } + height := max(1, m.height-3-lipgloss.Height(header)) if s.activeTab == 3 { - return header + "\n" + referenceTags(s.tags, s.selected, p) + return header + "\n" + referenceTags(s.tags, s.selected, height, p) } - return header + "\n" + referenceUsers(s.users, s.selected, p) + return header + "\n" + referenceUsers(s.users, s.selected, height, p) } func (m *Model) tagView(s *screen, p palette) string { if len(s.tags) == 0 { @@ -314,45 +399,59 @@ func (m *Model) tagView(s *screen, p palette) string { details := &s.tags[0] tabs := []string{fmt.Sprintf("%d notes", details.PostCount), fmt.Sprintf("%d followers", details.FollowerCount)} tabs[s.activeTab] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[s.activeTab]) - header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("#"+details.Tag) + "\n" + strings.Join(tabs, " ")) + header := lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(tabs, " ")) if s.activeTab == 0 { return header + "\n" + m.postsView(s, p) } - return header + "\n" + referenceUsers(s.users, s.selected, p) + return header + "\n" + referenceUsers(s.users, s.selected, max(1, m.height-3-lipgloss.Height(header)), p) } -func referenceUsers(items []textlog.UserReference, selected int, p palette) string { +func referenceUsers(items []textlog.UserReference, selected, height int, p palette) string { if len(items) == 0 { return lipgloss.NewStyle().Foreground(p.muted).Render(" No users here.") } - lines := make([]string, len(items)) - for i, v := range items { + start, end := referenceWindow(selected, len(items), height) + lines := make([]string, 0, end-start) + for i := start; i < end; i++ { prefix := " " if i == selected { prefix = "› " - lines[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + "@" + v.Handle) + lines = append(lines, lipgloss.NewStyle().Foreground(p.accent).Render(prefix+"@"+items[i].Handle)) } else { - lines[i] = prefix + "@" + v.Handle + lines = append(lines, prefix+"@"+items[i].Handle) } } return strings.Join(lines, "\n") } -func referenceTags(items []textlog.TagReference, selected int, p palette) string { + +func referenceTags(items []textlog.TagReference, selected, height int, p palette) string { if len(items) == 0 { return lipgloss.NewStyle().Foreground(p.muted).Render(" No tags here.") } - lines := make([]string, len(items)) - for i, v := range items { + start, end := referenceWindow(selected, len(items), height) + lines := make([]string, 0, end-start) + for i := start; i < end; i++ { prefix := " " if i == selected { prefix = "› " } - lines[i] = prefix + "#" + v.Tag + lines = append(lines, prefix+"#"+items[i].Tag) } return strings.Join(lines, "\n") } + +func referenceWindow(selected, length, height int) (int, int) { + height = max(1, height) + start := max(0, clamp(selected, length)-height/2) + end := min(length, start+height) + if end-start < height { + start = max(0, end-height) + } + return start, end +} + func (m *Model) accountView(s *screen, p palette) string { if s.phase == "bio" { - return editor("edit bio", s.input, false, p) + return editor("edit bio", s.input, false, true, m.width, p) } if m.me == nil { return "You are not signed in." @@ -361,7 +460,7 @@ func (m *Model) accountView(s *screen, p palette) string { } func (m *Model) settingsView(s *screen, p palette) string { if s.phase == "url" { - return editor("Textlog instance URL", s.input, false, p) + return editor("Textlog instance URL", s.input, false, false, m.width, p) } items := []string{"theme: " + string(m.config.Theme), "change instance URL", "open web app", "back"} for i := range items { @@ -376,5 +475,24 @@ func (m *Model) settingsView(s *screen, p palette) string { return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Render("settings · "+m.config.BaseURL) + "\n" + strings.Join(items, "\n")) } func (m *Model) helpView(p palette) string { - return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog · a quieter place for your thoughts") + "\n\n0 profile 1-5 feeds / search w write a account\n↑↓ j/k move Enter/l open h/q back g/G first/last p/n page R refresh\nTab/Shift+Tab changes profile and tag tabs.\nr reply u author profile o browser f/F follow b/B block e edit d delete ! report\n\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Activity tabs require sign-in. API: "+m.config.BaseURL+"/api")) + feeds := "1–3 feeds" + if m.config.Token != "" { + feeds = "1–5 feeds" + } + label := func(value string) string { + return lipgloss.NewStyle().Width(9).Foreground(p.muted).Render(value) + } + lines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("a quieter place for your thoughts"), + "", + label("global") + feeds + " · / search · w write · a account · 0 profile", + label("move") + "↑↓ j/k · Enter/l open · h/q back · g/G first/last", + label("pages") + "p/n previous/next · R refresh", + label("notes") + "Tab target · r reply · u author · t tag · o browser", + label("actions") + "f/F follow · b/B block · e edit · d delete · ! report", + label("tabs") + "Tab or ←/→ on profiles and tags", + "", + lipgloss.NewStyle().Foreground(p.muted).Render("Activity feeds require sign-in · API " + m.config.BaseURL + "/api"), + } + return lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(lines, "\n")) }