feat(tui): scroll archive panes independently
This commit is contained in:
parent
8903120584
commit
67bb486d97
253
tui/tui.go
253
tui/tui.go
@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@ -321,6 +322,8 @@ type model struct {
|
||||
query string
|
||||
filterMode bool
|
||||
focus paneFocus
|
||||
contextOffset int
|
||||
detailOffset int
|
||||
sourceKind string
|
||||
sourceLocation string
|
||||
}
|
||||
@ -374,9 +377,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ensureVisible()
|
||||
case tea.MouseMsg:
|
||||
if typed.Type == tea.MouseWheelUp {
|
||||
m.move(-3)
|
||||
m.scrollFocused(-3)
|
||||
} else if typed.Type == tea.MouseWheelDown {
|
||||
m.move(3)
|
||||
m.scrollFocused(3)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
if m.filterMode {
|
||||
@ -408,18 +411,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "up", "k":
|
||||
if m.focus == focusRows {
|
||||
m.move(-1)
|
||||
} else {
|
||||
m.scrollFocused(-1)
|
||||
}
|
||||
case "down", "j":
|
||||
if m.focus == focusRows {
|
||||
m.move(1)
|
||||
} else {
|
||||
m.scrollFocused(1)
|
||||
}
|
||||
case "pgup", "ctrl+b":
|
||||
if m.focus == focusRows {
|
||||
m.move(-m.pageSize())
|
||||
} else {
|
||||
m.scrollFocused(-m.focusedPageSize())
|
||||
}
|
||||
case "pgdown", "ctrl+f":
|
||||
if m.focus == focusRows {
|
||||
m.move(m.pageSize())
|
||||
} else {
|
||||
m.scrollFocused(m.focusedPageSize())
|
||||
}
|
||||
case "home", "g":
|
||||
if m.focus == focusRows {
|
||||
@ -492,11 +503,7 @@ func (m model) renderRowsPane(rect rect) string {
|
||||
if selected {
|
||||
prefix = "> "
|
||||
}
|
||||
line := item.Title
|
||||
if item.Depth > 0 {
|
||||
line = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + line
|
||||
}
|
||||
line = truncateCells(prefix+line, paneContentWidth(rect.w))
|
||||
line := prefix + rowListLine(item, paneContentWidth(rect.w)-lipgloss.Width(prefix))
|
||||
lines = append(lines, rowStyle(paneContentWidth(rect.w), selected && m.focus == focusRows).Render(line))
|
||||
}
|
||||
}
|
||||
@ -508,15 +515,8 @@ func (m model) renderContextPane(rect rect) string {
|
||||
if !ok {
|
||||
return pane("Context", "", []string{"No row selected."}, rect, m.focus == focusContext, "#9bc53d")
|
||||
}
|
||||
lines := []string{
|
||||
fieldLine("title", truncateCells(item.Title, maxInt(1, paneContentWidth(rect.w)-6))),
|
||||
fieldLine("subtitle", item.Subtitle),
|
||||
}
|
||||
if len(item.Tags) > 0 {
|
||||
lines = append(lines, "tags="+strings.Join(item.Tags, " "))
|
||||
}
|
||||
lines = compactNonEmpty(lines)
|
||||
return pane("Context", paneFocusLabel(m.focus == focusContext), lines, rect, m.focus == focusContext, "#9bc53d")
|
||||
lines := contextLines(item, paneContentWidth(rect.w))
|
||||
return paneScrolled("Context", paneFocusLabel(m.focus == focusContext), lines, rect, m.focus == focusContext, "#9bc53d", m.contextOffset)
|
||||
}
|
||||
|
||||
func (m model) renderDetailPane(rect rect) string {
|
||||
@ -524,15 +524,8 @@ func (m model) renderDetailPane(rect rect) string {
|
||||
if !ok {
|
||||
return pane("Detail", "", []string{"No row selected."}, rect, m.focus == focusDetail, "#f2c14e")
|
||||
}
|
||||
detail := strings.TrimSpace(item.Detail)
|
||||
if detail == "" {
|
||||
detail = item.Subtitle
|
||||
}
|
||||
lines := wrapLines(detail, paneContentWidth(rect.w))
|
||||
if len(lines) == 0 {
|
||||
lines = []string{"No detail for this row."}
|
||||
}
|
||||
return pane("Detail", paneFocusLabel(m.focus == focusDetail), lines, rect, m.focus == focusDetail, "#f2c14e")
|
||||
lines := detailLines(item)
|
||||
return paneScrolled("Detail", paneFocusLabel(m.focus == focusDetail), lines, rect, m.focus == focusDetail, "#f2c14e", m.detailOffset)
|
||||
}
|
||||
|
||||
func (m model) renderFooter(width int) string {
|
||||
@ -565,9 +558,52 @@ func (m *model) move(delta int) {
|
||||
return
|
||||
}
|
||||
m.selected = clampInt(m.selected+delta, 0, len(m.filtered)-1)
|
||||
m.contextOffset = 0
|
||||
m.detailOffset = 0
|
||||
m.ensureVisible()
|
||||
}
|
||||
|
||||
func (m *model) scrollFocused(delta int) {
|
||||
switch m.focus {
|
||||
case focusContext:
|
||||
m.contextOffset = clampInt(m.contextOffset+delta, 0, m.maxContextOffset())
|
||||
case focusDetail:
|
||||
m.detailOffset = clampInt(m.detailOffset+delta, 0, m.maxDetailOffset())
|
||||
default:
|
||||
m.move(delta)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) focusedPageSize() int {
|
||||
layout := m.layout()
|
||||
switch m.focus {
|
||||
case focusContext:
|
||||
return maxInt(1, paneContentHeight(layout.context.h))
|
||||
case focusDetail:
|
||||
return maxInt(1, paneContentHeight(layout.detail.h))
|
||||
default:
|
||||
return m.pageSize()
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) maxContextOffset() int {
|
||||
item, ok := m.selectedItem()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
layout := m.layout()
|
||||
return maxPaneScroll(contextLines(item, paneContentWidth(layout.context.w)), layout.context)
|
||||
}
|
||||
|
||||
func (m model) maxDetailOffset() int {
|
||||
item, ok := m.selectedItem()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
layout := m.layout()
|
||||
return maxPaneScroll(detailLines(item), layout.detail)
|
||||
}
|
||||
|
||||
func (m *model) applyFilter() {
|
||||
query := strings.ToLower(strings.TrimSpace(m.query))
|
||||
m.filtered = m.filtered[:0]
|
||||
@ -670,10 +706,29 @@ func paneFocusLabel(focused bool) string {
|
||||
}
|
||||
|
||||
func pane(title, subtitle string, lines []string, rect rect, focused bool, accent string) string {
|
||||
return paneScrolled(title, subtitle, lines, rect, focused, accent, 0)
|
||||
}
|
||||
|
||||
func paneScrolled(title, subtitle string, lines []string, rect rect, focused bool, accent string, scrollOffset int) string {
|
||||
width := maxInt(rect.w, 12)
|
||||
height := maxInt(rect.h, 3)
|
||||
contentW := paneContentWidth(width)
|
||||
contentH := paneContentHeight(height)
|
||||
body := flattenedPaneLines(lines, contentW)
|
||||
if len(body) == 0 {
|
||||
body = append(body, "")
|
||||
}
|
||||
maxOffset := maxInt(0, len(body)-contentH)
|
||||
scrollOffset = clampInt(scrollOffset, 0, maxOffset)
|
||||
if maxOffset > 0 {
|
||||
visibleEnd := minInt(len(body), scrollOffset+contentH)
|
||||
scrollLabel := fmt.Sprintf("%d-%d/%d", scrollOffset+1, visibleEnd, len(body))
|
||||
if strings.TrimSpace(subtitle) == "" {
|
||||
subtitle = scrollLabel
|
||||
} else {
|
||||
subtitle += " " + scrollLabel
|
||||
}
|
||||
}
|
||||
borderColor := "#475569"
|
||||
if focused {
|
||||
borderColor = accent
|
||||
@ -688,29 +743,34 @@ func pane(title, subtitle string, lines []string, rect rect, focused bool, accen
|
||||
header := border.Render("|") +
|
||||
headerStyle.Render(padCells(" "+truncateCells(titleLine, maxInt(1, contentW-1)), contentW)) +
|
||||
border.Render("|")
|
||||
body := make([]string, 0, contentH)
|
||||
for _, line := range lines {
|
||||
for _, wrapped := range wrapLines(line, contentW) {
|
||||
body = append(body, wrapped)
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
body = append(body, "")
|
||||
}
|
||||
body = append([]string(nil), body[scrollOffset:minInt(len(body), scrollOffset+contentH)]...)
|
||||
for len(body) < contentH {
|
||||
body = append(body, "")
|
||||
}
|
||||
if len(body) > contentH {
|
||||
body = body[:contentH]
|
||||
}
|
||||
out := []string{border.Render(top), header}
|
||||
for _, line := range body[:maxInt(0, contentH-1)] {
|
||||
for _, line := range body[:contentH] {
|
||||
out = append(out, border.Render("|")+padCells(truncateCells(line, contentW), contentW)+border.Render("|"))
|
||||
}
|
||||
out = append(out, border.Render(top))
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func flattenedPaneLines(lines []string, width int) []string {
|
||||
var body []string
|
||||
for _, line := range lines {
|
||||
body = append(body, wrapLines(line, width)...)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func maxPaneScroll(lines []string, rect rect) int {
|
||||
body := flattenedPaneLines(lines, paneContentWidth(rect.w))
|
||||
if len(body) == 0 {
|
||||
return 0
|
||||
}
|
||||
return maxInt(0, len(body)-paneContentHeight(rect.h))
|
||||
}
|
||||
|
||||
func paneContentWidth(width int) int {
|
||||
return maxInt(1, width-2)
|
||||
}
|
||||
@ -729,6 +789,123 @@ func compactNonEmpty(lines []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func contextLines(item Item, width int) []string {
|
||||
lines := []string{
|
||||
fieldLine("title", truncateCells(item.Title, maxInt(1, width-6))),
|
||||
fieldLine("subtitle", item.Subtitle),
|
||||
}
|
||||
if len(item.Tags) > 0 {
|
||||
lines = append(lines, "tags="+strings.Join(item.Tags, " "))
|
||||
}
|
||||
return compactNonEmpty(lines)
|
||||
}
|
||||
|
||||
func detailLines(item Item) []string {
|
||||
detail := strings.TrimSpace(item.Detail)
|
||||
if detail == "" {
|
||||
detail = item.Subtitle
|
||||
}
|
||||
lines := wrapLines(detail, 1000)
|
||||
if len(lines) == 0 {
|
||||
return []string{"No detail for this row."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func rowListLine(item Item, width int) string {
|
||||
width = maxInt(width, 1)
|
||||
title := item.Title
|
||||
if item.Depth > 0 {
|
||||
title = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + title
|
||||
}
|
||||
if width < 46 {
|
||||
return truncateCells(title, width)
|
||||
}
|
||||
kind := rowKind(item)
|
||||
when := rowWhen(item)
|
||||
where := rowWhere(item)
|
||||
meta := strings.TrimSpace(joinNonEmpty([]string{where, when}, " "))
|
||||
kindW := minInt(maxInt(5, width/9), 10)
|
||||
metaW := minInt(maxInt(12, width/4), 28)
|
||||
titleW := maxInt(1, width-kindW-metaW-2)
|
||||
return padCells(truncateCells(kind, kindW), kindW) + " " +
|
||||
padCells(truncateCells(meta, metaW), metaW) + " " +
|
||||
truncateCells(title, titleW)
|
||||
}
|
||||
|
||||
func rowKind(item Item) string {
|
||||
for _, tag := range item.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return "row"
|
||||
}
|
||||
|
||||
func rowWhen(item Item) string {
|
||||
for _, part := range subtitleParts(item.Subtitle) {
|
||||
if short := shortTimestamp(part); short != "" {
|
||||
return short
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rowWhere(item Item) string {
|
||||
kind := strings.ToLower(rowKind(item))
|
||||
for _, part := range subtitleParts(item.Subtitle) {
|
||||
lower := strings.ToLower(part)
|
||||
if lower == kind || shortTimestamp(part) != "" || looksMachineID(part) {
|
||||
continue
|
||||
}
|
||||
return part
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func subtitleParts(subtitle string) []string {
|
||||
raw := strings.Split(subtitle, " ")
|
||||
parts := make([]string, 0, len(raw))
|
||||
for _, part := range raw {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func shortTimestamp(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
if len(value) >= len("2006-01-02") && value[4] == '-' && value[7] == '-' {
|
||||
return truncateCells(strings.ReplaceAll(value, "T", " "), 16)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksMachineID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 12 || strings.ContainsAny(value, " \t\n") {
|
||||
return false
|
||||
}
|
||||
digits := 0
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits++
|
||||
}
|
||||
}
|
||||
return digits >= 4
|
||||
}
|
||||
|
||||
func titleStyle(width int) lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
|
||||
@ -114,6 +114,47 @@ func TestRowsPaneUsesCompactTitlesAndKeepsMetadataInContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRowsPaneUsesStableColumns(t *testing.T) {
|
||||
line := rowListLine(Item{
|
||||
Title: "Can you check again? Hoping this update worked.",
|
||||
Subtitle: "general vincent 2026-05-02T12:00:00Z",
|
||||
Tags: []string{"message", "discord"},
|
||||
}, 100)
|
||||
for _, want := range []string{"message", "2026-05-02", "general", "Can you check"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("row line missing %q: %q", want, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(line, "vincent 2026") {
|
||||
t.Fatalf("row line should not dump raw subtitle: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedDetailPaneScrollsIndependently(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "discrawl archive",
|
||||
Items: []Item{{
|
||||
Title: "first",
|
||||
Detail: strings.Join([]string{"line one", "line two", "line three", "line four", "line five", "line six"}, "\n"),
|
||||
Tags: []string{"message", "discord"},
|
||||
}},
|
||||
})
|
||||
m.width = 80
|
||||
m.height = 12
|
||||
m.focus = focusDetail
|
||||
m.scrollFocused(1)
|
||||
if m.selected != 0 {
|
||||
t.Fatalf("detail scroll moved row selection to %d", m.selected)
|
||||
}
|
||||
if m.detailOffset == 0 {
|
||||
t.Fatal("detail pane did not scroll")
|
||||
}
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "2-") {
|
||||
t.Fatalf("detail pane missing scroll indicator:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentLayoutPrioritizesURLDetail(t *testing.T) {
|
||||
item := Row{
|
||||
Source: "notion",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user