diff --git a/internal/app/context_table.go b/internal/app/context_table.go index bd909a7..7147afe 100644 --- a/internal/app/context_table.go +++ b/internal/app/context_table.go @@ -50,25 +50,56 @@ func newContextTable() table.Model { } func contextTableSelectedStyle() lipgloss.Style { - base := selectedStyle - return base. + return lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("57")) + Foreground(lipgloss.Color("16")). + Background(lipgloss.Color("220")) } func (m *Model) syncContextTable() { m.contextTable.SetColumns(contextTableColumns(m.width)) m.contextTable.SetWidth(contextTableWidth(m.width)) - m.contextTable.SetHeight(contextTableHeight(m.height)) + m.contextTable.SetHeight(m.contextPickerTableHeight()) m.contextTable.SetRows(contextTableRows(m.filteredCtxList)) m.contextTable.Focus() - m.contextTable.SetCursor(m.ctxIdx) + m.setContextTableCursor(m.ctxIdx) if cursor := m.contextTable.Cursor(); cursor >= 0 { m.ctxIdx = cursor } } +func (m *Model) setContextTableCursor(index int) { + m.ctxIdx = clampListIndex(index, len(m.filteredCtxList)) + m.contextTable.GotoTop() + if m.ctxIdx > 0 { + m.contextTable.MoveDown(m.ctxIdx) + } +} + +func (m *Model) moveContextTableUp() { + if len(m.filteredCtxList) == 0 { + return + } + if m.ctxIdx <= 0 { + m.setContextTableCursor(len(m.filteredCtxList) - 1) + return + } + m.contextTable.MoveUp(1) + m.ctxIdx = m.contextTable.Cursor() +} + +func (m *Model) moveContextTableDown() { + if len(m.filteredCtxList) == 0 { + return + } + if m.ctxIdx >= len(m.filteredCtxList)-1 { + m.setContextTableCursor(0) + return + } + m.contextTable.MoveDown(1) + m.ctxIdx = m.contextTable.Cursor() +} + func contextTableRows(contexts []config.ContextInfo) []table.Row { rows := make([]table.Row, 0, len(contexts)) for _, ctx := range contexts { @@ -101,10 +132,34 @@ func contextTableHeight(terminalHeight int) int { if terminalHeight <= 0 { return defaultContextTableHeight } - // Context picker layout overhead: - // title/current/env/filter block (6) + panel border (2) + separator/help bar (2) = 10. - // The table height itself must fit inside the remaining rows. - return max(terminalHeight-10, 3) + return max(terminalHeight-10, 2) +} + +func (m Model) contextPickerTableHeight() int { + if m.contextPickerCompact() { + height := max(m.height-6, 2) + if m.contextPickerFilterVisible() { + height -= 2 + } + return max(height, 2) + } + + height := contextTableHeight(m.height) + if m.contextPickerFilterVisible() { + height -= 2 + } + return max(height, 2) +} + +func (m Model) contextPickerCompact() bool { + return m.height > 0 && m.height <= 14 +} + +func (m Model) contextPickerFilterVisible() bool { + if m.isFiltering(filterContexts) && m.renderFilterValue(filterContexts) != "" { + return true + } + return false } func contextTableColumns(terminalWidth int) []table.Column { diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index 61601e9..d03e533 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -145,11 +145,9 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "/": return m, m.activateFilter(filterContexts) case "up", "k": - m.ctxIdx = previousListIndex(m.ctxIdx, len(m.filteredCtxList)) - m.contextTable.SetCursor(m.ctxIdx) + m.moveContextTableUp() case "down", "j": - m.ctxIdx = nextListIndex(m.ctxIdx, len(m.filteredCtxList)) - m.contextTable.SetCursor(m.ctxIdx) + m.moveContextTableDown() case "enter": cursor := m.contextTable.Cursor() if len(m.filteredCtxList) > 0 && cursor >= 0 && cursor < len(m.filteredCtxList) { @@ -265,12 +263,17 @@ func (m Model) doFinalizeContextSwitch() tea.Cmd { func (m Model) viewContextPicker() string { var b strings.Builder var panel strings.Builder + compact := m.contextPickerCompact() b.WriteString(titleStyle.Render("Select Context")) b.WriteString("\n") - b.WriteString(renderDetailLine("UNIC current", displayContextName(m.cfg.ContextName))) - b.WriteString("\n") - b.WriteString(renderDetailLine("Shell env", m.displayShellEnvContext())) - b.WriteString("\n\n") + if compact { + b.WriteString("\n") + } else { + b.WriteString(renderDetailLine("UNIC current", displayContextName(m.cfg.ContextName))) + b.WriteString("\n") + b.WriteString(renderDetailLine("Shell env", m.displayShellEnvContext())) + b.WriteString("\n\n") + } if filter := m.renderFilterValue(filterContexts); filter != "" { b.WriteString(filter) @@ -293,7 +296,15 @@ func (m Model) viewContextPicker() string { b.WriteString(m.renderListPanel(panel.String())) b.WriteString("\n\n") if m.isFiltering(filterContexts) { - b.WriteString(m.renderHelpBar("filtering: type search • ↑/↓ choose • enter finish • esc clear • ctrl+y copy env • ctrl+s setup")) + if compact { + b.WriteString(m.renderHelpBar("filter • ↑/↓ choose • enter finish • esc clear")) + } else { + b.WriteString(m.renderHelpBar("filtering: type search • ↑/↓ choose • enter finish • esc clear • ctrl+y copy env • ctrl+s setup")) + } + return b.String() + } + if compact { + b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • /: filter • a: add • q: quit")) return b.String() } if m.cfg.ContextName != "" { diff --git a/internal/app/screen_context_test.go b/internal/app/screen_context_test.go index 7b33b65..e72b640 100644 --- a/internal/app/screen_context_test.go +++ b/internal/app/screen_context_test.go @@ -24,6 +24,18 @@ func testContexts() []config.ContextInfo { } } +func manyTestContexts(total int) []config.ContextInfo { + contexts := make([]config.ContextInfo, 0, total) + for i := 0; i < total; i++ { + contexts = append(contexts, config.ContextInfo{ + Name: fmt.Sprintf("ctx-%02d", i), + Region: "us-east-1", + AuthType: "credential", + }) + } + return contexts +} + func writeContextConfig(t *testing.T, contents string) string { t.Helper() dir := t.TempDir() @@ -115,6 +127,89 @@ func TestContextPickerNavigationWraps(t *testing.T) { } } +func TestContextPickerKeepsSelectedTableRowVisibleInSmallWindow(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 10 + + updated, _ := m.Update(contextsLoadedMsg{contexts: manyTestContexts(12)}) + model := updated.(Model) + for i := 0; i < 8; i++ { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + model = updated.(Model) + } + + view := stripANSI(model.View()) + lines := strings.Split(view, "\n") + if len(lines) != model.height { + t.Fatalf("expected fitted view height %d, got %d lines", model.height, len(lines)) + } + if !strings.Contains(view, "ctx-08") { + t.Fatalf("expected compact context picker to keep selected row visible, got %q", view) + } + if strings.Contains(view, "...") { + t.Fatalf("expected compact context picker to fit without middle truncation, got %q", view) + } +} + +func TestContextPickerMoveUpFromBottomVisibleRowDoesNotScroll(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 10 + + updated, _ := m.Update(contextsLoadedMsg{contexts: manyTestContexts(12)}) + model := updated.(Model) + for i := 0; i < 8; i++ { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + model = updated.(Model) + } + before := stripANSI(model.View()) + if !strings.Contains(before, "ctx-08") { + t.Fatalf("expected ctx-08 visible before moving up, got %q", before) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + model = updated.(Model) + after := stripANSI(model.View()) + if model.ctxIdx != 7 { + t.Fatalf("expected selected index 7 after moving up once, got %d", model.ctxIdx) + } + if !strings.Contains(after, "ctx-08") { + t.Fatalf("expected bottom visible row to remain visible after moving up once, got %q", after) + } +} + +func TestContextPickerKeepsFilteredSelectedTableRowVisibleInSmallWindow(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 10 + + updated, _ := m.Update(contextsLoadedMsg{contexts: manyTestContexts(12)}) + model := updated.(Model) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = updated.(Model) + for _, ch := range []rune("ctx") { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + model = updated.(Model) + } + for i := 0; i < 8; i++ { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(Model) + } + + view := stripANSI(model.View()) + lines := strings.Split(view, "\n") + if len(lines) != model.height { + t.Fatalf("expected fitted view height %d, got %d lines", model.height, len(lines)) + } + if !strings.Contains(view, "ctx-08") { + t.Fatalf("expected filtered compact context picker to keep selected row visible, got %q", view) + } + if strings.Contains(view, "...") { + t.Fatalf("expected filtered compact context picker to fit without middle truncation, got %q", view) + } +} + func TestContextPickerFilterUpdatesTableRows(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 80 diff --git a/internal/app/styles.go b/internal/app/styles.go index 6d9b37e..3c6d897 100644 --- a/internal/app/styles.go +++ b/internal/app/styles.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "regexp" "strings" "github.com/charmbracelet/lipgloss" @@ -9,9 +10,11 @@ import ( "unic/internal/update" ) +var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) @@ -197,6 +200,9 @@ func (m Model) fitToHeight(s string) string { } return strings.Join(lines, "\n") } + if selectedLine := selectedRenderedLine(lines); selectedLine >= 0 { + return strings.Join(trimLinesAroundAnchor(lines, m.height, selectedLine), "\n") + } // Content overflows: keep first (height-2) lines + last 1 line (footer) // with a "..." indicator footerLines := 1 @@ -211,6 +217,127 @@ func (m Model) fitToHeight(s string) string { return strings.Join(result, "\n") } +func selectedRenderedLine(lines []string) int { + for i, line := range lines { + line = strings.TrimSpace(ansiEscapePattern.ReplaceAllString(line, "")) + line = strings.TrimPrefix(line, "│") + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "> ") { + return i + } + } + return -1 +} + +func trimLinesAroundAnchor(lines []string, height, anchor int) []string { + if height <= 0 || len(lines) <= height { + return lines + } + + if height == 1 { + return []string{lines[anchor]} + } + if height == 2 { + return []string{lines[anchor], lines[len(lines)-1]} + } + if height == 3 { + return []string{lines[0], lines[anchor], lines[len(lines)-1]} + } + + if anchor <= 0 || anchor >= len(lines)-1 { + return trimLinesWithFixedEdges(lines, height) + } + + windowSize := height - 2 // first line + footer + if anchor > 1 { + windowSize-- + } + if anchor < len(lines)-2 { + windowSize-- + } + + contextBefore := windowSize / 2 + contextAfter := windowSize - contextBefore - 1 + start := anchor - contextBefore + end := anchor + contextAfter + 1 + + if start < 1 { + end += 1 - start + start = 1 + } + if end > len(lines)-1 { + start -= end - (len(lines) - 1) + end = len(lines) - 1 + } + if start < 1 { + start = 1 + } + + for { + resultLen := 2 + (end - start) + if start > 1 { + resultLen++ + } + if end < len(lines)-1 { + resultLen++ + } + if resultLen >= height { + break + } + switch { + case end < len(lines)-1: + end++ + case start > 1: + start-- + default: + break + } + } + + return buildAnchoredLines(lines, height, start, end) +} + +func trimLinesWithFixedEdges(lines []string, height int) []string { + result := make([]string, 0, height) + headerLines := height - 2 + if headerLines < 1 { + headerLines = 1 + } + if headerLines > len(lines)-1 { + headerLines = len(lines) - 1 + } + result = append(result, lines[:headerLines]...) + result = append(result, dimStyle.Render(" ...")) + result = append(result, lines[len(lines)-1]) + if len(result) > height { + return result[:height] + } + for len(result) < height { + result = append(result, "") + } + return result +} + +func buildAnchoredLines(lines []string, height, start, end int) []string { + result := make([]string, 0, height) + result = append(result, lines[0]) + if start > 1 { + result = append(result, dimStyle.Render(" ...")) + } + result = append(result, lines[start:end]...) + if end < len(lines)-1 { + result = append(result, dimStyle.Render(" ...")) + } + result = append(result, lines[len(lines)-1]) + if len(result) > height { + result = result[:height] + } + for len(result) < height { + result = append(result, "") + } + return result +} + func (m Model) viewLoading() string { title := m.loadingTitle if strings.TrimSpace(title) == "" { diff --git a/internal/app/styles_test.go b/internal/app/styles_test.go index f220c83..16bf093 100644 --- a/internal/app/styles_test.go +++ b/internal/app/styles_test.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "reflect" "regexp" "strings" @@ -26,6 +27,15 @@ func styleTestContexts() []config.ContextInfo { } } +func containsSelectedRow(view, label string) bool { + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, ">") && strings.Contains(line, label) { + return true + } + } + return false +} + func TestRenderStatusBarUsesFullWidthAndUpdateHint(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 120 @@ -210,13 +220,125 @@ func TestContextPickerKeepsSelectionVisibleInCompactTerminal(t *testing.T) { } } +func TestServiceListKeepsSelectionVisibleInCompactTerminal(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 10 + m.screen = screenServiceList + services := m.serviceList() + if len(services) < 8 { + t.Fatalf("expected enough services for compact selection test, got %d", len(services)) + } + m.svcIdx = 7 + selected := string(services[m.svcIdx].Name) + + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + if len(lines) != m.height { + t.Fatalf("expected fitted view height %d, got %d lines", m.height, len(lines)) + } + if !containsSelectedRow(view, selected) { + t.Fatalf("expected compact service list to keep selected service %q visible, got %q", selected, view) + } +} + +func TestResourceListKeepsSelectionVisibleInCompactTerminal(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 10 + m.screen = screenRDSList + for i := 0; i < 10; i++ { + m.rds.instances = append(m.rds.instances, awsservice.RDSInstance{ + DBInstanceID: fmt.Sprintf("db-%02d", i), + Status: "available", + }) + } + m.rds.filtered = m.rds.instances + m.rds.idx = 8 + + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + if len(lines) != m.height { + t.Fatalf("expected fitted view height %d, got %d lines", m.height, len(lines)) + } + if !containsSelectedRow(view, "db-08") { + t.Fatalf("expected compact resource list to keep selected instance visible, got %q", view) + } +} + +func TestFeatureListKeepsSelectionVisibleInCompactTerminal(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 8 + m.screen = screenFeatureList + services := m.serviceList() + for i, service := range services { + if len(service.Features) >= 3 { + m.svcIdx = i + m.features = service.Features + break + } + } + if len(m.features) < 3 { + t.Fatalf("expected enough features for compact selection test, got %d", len(m.features)) + } + m.featIdx = 2 + selected := string(m.features[m.featIdx].Kind) + + view := stripANSI(m.View()) + lines := strings.Split(view, "\n") + if len(lines) != m.height { + t.Fatalf("expected fitted view height %d, got %d lines", m.height, len(lines)) + } + if !strings.Contains(view, "> "+selected) { + t.Fatalf("expected compact feature list to keep selected feature %q visible, got %q", selected, view) + } +} + +func TestTrimLinesAroundAnchorReturnsExactHeightNearEdges(t *testing.T) { + lines := []string{ + "line 0", + "> line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "footer", + } + + for _, tc := range []struct { + name string + height int + anchor int + }{ + {name: "near top", height: 8, anchor: 1}, + {name: "middle", height: 8, anchor: 5}, + {name: "near bottom", height: 8, anchor: 8}, + {name: "tiny", height: 3, anchor: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got := trimLinesAroundAnchor(lines, tc.height, tc.anchor) + if len(got) != tc.height { + t.Fatalf("expected %d lines, got %d: %#v", tc.height, len(got), got) + } + plain := stripANSI(strings.Join(got, "\n")) + if !strings.Contains(plain, lines[tc.anchor]) && + !strings.Contains(plain, strings.TrimPrefix(lines[tc.anchor], "> ")) { + t.Fatalf("expected anchor line %q to stay visible, got %#v", lines[tc.anchor], got) + } + }) + } +} + func TestContextTableSelectedStyleUsesHighContrast(t *testing.T) { - base := selectedStyle - want := base. + want := lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("57")) + Foreground(lipgloss.Color("16")). + Background(lipgloss.Color("220")) if !reflect.DeepEqual(contextTableSelectedStyle(), want) { - t.Fatal("expected context table selected style to use high-contrast foreground/background") + t.Fatal("expected context table selected style to use amber high-contrast foreground/background") } }