diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e749e4..3778fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, and assigns each retro theme its own dashboard layout rather than only swapping colors. +- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks, documents `wizardry` as the selected default theme while retaining `amber`, `dos`, and `green` as alternates, and records that the implementation phase should let users configure their own default TUI theme. ## [1.3.10] - 2026-04-26 diff --git a/docs/plans/2026-04-28-tui-implementation.md b/docs/plans/2026-04-28-tui-implementation.md index 62342cb..befe2ec 100644 --- a/docs/plans/2026-04-28-tui-implementation.md +++ b/docs/plans/2026-04-28-tui-implementation.md @@ -314,7 +314,9 @@ lmm tui --prototype --theme dos 5. Keep alternate themes available if they are cheap to maintain. 6. Document the decision in this plan or a short follow-up note. -**Recommended default:** `wizardry`, with `amber` and `dos` retained as alternates if maintenance stays low. +**Selected default:** `wizardry`. It has the strongest RPG/tool identity for `lmm` and best matches the “Wizardry/Ultima in spirit, useful mod manager in practice” direction. + +**Retained alternates:** `amber`, `dos`, and `green` stay available while they remain cheap to maintain. `amber` is explicitly kept for the VAX/VMS-era terminal nostalgia lane. **Exit criteria:** @@ -334,18 +336,21 @@ lmm tui --prototype --theme dos 2. Implement a real adapter over `*core.Service`. 3. Keep a fake adapter for tests and prototype/demo mode. 4. Wire `lmm tui` without `--prototype` to initialize config and service like existing CLI commands. -5. Load real data for: +5. Load the user's configured default TUI theme when no `--theme` flag is provided, while still defaulting fresh installs to `wizardry`. +6. Add a config path for setting the default TUI theme, analogous to the existing default game workflow, so users can make `amber`, `dos`, `green`, or future themes their personal default. +7. Load real data for: - current/configured games - active/default profile - installed mods - profile list - status/update/conflict summaries where existing core methods support it -6. Preserve `--prototype` as a safe design/demo mode. -7. Add loading/error states for missing config, missing game, auth-required, and empty mod lists. +8. Preserve `--prototype` as a safe design/demo mode. +9. Add loading/error states for missing config, missing game, auth-required, and empty mod lists. **Exit criteria:** - `lmm tui` starts with real config/service initialization. +- A user-configured default TUI theme is honored when `--theme` is omitted; `--theme` remains an explicit per-run override. - Dashboard and Installed Mods views show real local data. - Search/Profile views either show real read-only data or honest placeholder states. - `go test ./...` passes. diff --git a/internal/tui/app.go b/internal/tui/app.go index 54fb438..e2048e3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -245,8 +245,12 @@ func (m Model) dashboardView() string { func (m Model) partyDashboardView() string { width := m.availableWidth() + height := m.availableContentHeight() gap := 1 - panelWidth := max((width-gap)/2, 24) + panelWidth := max((width-gap)/2, 1) + splitHeight := height + topHeight := splitHeight / 2 + menuHeight := splitHeight - topHeight party := strings.Join([]string{ m.theme.PanelTitle.Render("PARTY"), @@ -271,10 +275,10 @@ func (m Model) partyDashboardView() string { }, "\n") return lipgloss.JoinHorizontal(lipgloss.Top, - m.panel(panelWidth).Render(party), + m.panelWithHeight(panelWidth, topHeight).Render(party), " ", - m.panel(panelWidth).Render(quest), - ) + "\n" + m.panel(width).Render(menu) + m.panelWithHeight(panelWidth, topHeight).Render(quest), + ) + "\n" + m.panelWithHeight(width, menuHeight).Render(menu) } func (m Model) terminalDashboardView() string { @@ -290,14 +294,15 @@ func (m Model) terminalDashboardView() string { m.row(2, "LOAD PROFILE ROSTER"), m.row(3, "ASK CONFLICT ORACLE"), } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) commanderDashboardView() string { width := m.availableWidth() + height := m.availableContentHeight() gap := 1 - leftWidth := max((width-gap)/2, 24) - rightWidth := max(width-gap-leftWidth, 24) + leftWidth := max((width-gap)/2, 1) + rightWidth := max(width-gap-leftWidth, 1) left := strings.Join([]string{ m.theme.PanelTitle.Render("ACTIVE PROFILE"), @@ -316,9 +321,9 @@ func (m Model) commanderDashboardView() string { }, "\n") return lipgloss.JoinHorizontal(lipgloss.Top, - m.panel(leftWidth).Render(left), + m.panelWithHeight(leftWidth, height).Render(left), " ", - m.panel(rightWidth).Render(right), + m.panelWithHeight(rightWidth, height).Render(right), ) } @@ -335,7 +340,7 @@ func (m Model) crtDashboardView() string { m.row(2, "Profiles"), m.row(3, "Consult Conflict Oracle"), } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) modsView() string { @@ -344,7 +349,7 @@ func (m Model) modsView() string { for i, mod := range m.data.InstalledMods { rows = append(rows, m.modRow(i, mod)) } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) searchView() string { @@ -353,7 +358,7 @@ func (m Model) searchView() string { for i, mod := range m.data.SearchResults { rows = append(rows, m.modRow(i, mod)) } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) profilesView() string { @@ -366,7 +371,7 @@ func (m Model) profilesView() string { line := fmt.Sprintf("%s %-22s %3d mods", active, profile.Name, profile.ModCount) rows = append(rows, m.row(i, line)) } - return m.panel(min(m.availableWidth(), 54)).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) helpView() string { @@ -398,7 +403,11 @@ func (m Model) modRow(index int, mod prototype.Mod) string { } func (m Model) panel(width int) lipgloss.Style { - return m.theme.Panel.Width(max(width-m.theme.Panel.GetHorizontalFrameSize(), 1)) + return m.theme.Panel.Width(max(width-m.theme.Panel.GetHorizontalBorderSize(), 1)) +} + +func (m Model) panelWithHeight(width, height int) lipgloss.Style { + return m.panel(width).Height(max(height-m.theme.Panel.GetVerticalBorderSize(), 1)) } func (m Model) availableWidth() int { @@ -408,6 +417,24 @@ func (m Model) availableWidth() int { return max(m.width-m.theme.App.GetHorizontalFrameSize(), 40) } +func (m Model) availableContentHeight() int { + if m.height == 0 { + return 12 + } + + return max(m.height-m.theme.App.GetVerticalFrameSize()-m.contentChromeHeight(), 8) +} + +func (m Model) contentChromeHeight() int { + footerHeight := 1 + if m.showHelp { + footerHeight = lipgloss.Height(m.helpView()) + } + + const titleNavAndSpacerHeight = 4 // title, nav, and the spacer lines around content. + return titleNavAndSpacerHeight + footerHeight +} + func layoutForTheme(name string) Layout { switch name { case "amber": @@ -425,13 +452,6 @@ func statusValue(value int, color lipgloss.Color) string { return lipgloss.NewStyle().Foreground(color).Bold(true).Render(fmt.Sprintf("%d", value)) } -func min(a, b int) int { - if a < b { - return a - } - return b -} - func max(a, b int) int { if a > b { return a diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index ac0d16b..67568ef 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -139,6 +139,53 @@ func TestWindowSizeExpandsViewToTerminalBounds(t *testing.T) { require.Equal(t, 30, lipgloss.Height(view)) } +func TestScreenViewsUseAvailableWidth(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, "wizardry", 120, 36) + + for _, screen := range screens { + model.screen = screen + require.Equal(t, model.availableWidth(), lipgloss.Width(model.screenView()), screen.String()) + } +} + +func TestDashboardLayoutsDoNotOverflowNarrowTerminals(t *testing.T) { + t.Parallel() + + for _, themeName := range []string{"wizardry", "dos"} { + t.Run(themeName, func(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, themeName, 40, 24) + require.Equal(t, ScreenDashboard, model.CurrentScreen()) + require.LessOrEqual(t, lipgloss.Width(model.screenView()), model.availableWidth()) + }) + } +} + +func TestScreenViewsUseExactAvailableHeightOnLargeTerminals(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, "wizardry", 120, 36) + + for _, screen := range screens { + model.screen = screen + require.Equal(t, model.availableContentHeight(), lipgloss.Height(model.screenView()), screen.String()) + } +} + +func TestViewFitsTerminalBoundsWithHelpVisible(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, "wizardry", 120, 36) + model = updateWithRunes(t, model, "?") + + view := model.View() + require.Equal(t, 120, lipgloss.Width(view)) + require.Equal(t, 36, lipgloss.Height(view)) +} + func TestThemesUseDistinctLayouts(t *testing.T) { t.Parallel() @@ -161,6 +208,18 @@ func TestThemesUseDistinctLayouts(t *testing.T) { } } +func sizedPrototypeModel(t *testing.T, themeName string, width, height int) Model { + t.Helper() + + model, err := NewPrototypeModel(Options{Theme: themeName, Prototype: true}) + require.NoError(t, err) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + updatedModel, ok := updated.(Model) + require.True(t, ok) + return updatedModel +} + func updateWithRunes(t *testing.T, model Model, key string) Model { t.Helper() diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 67cc2ee..5c631ac 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -61,21 +61,17 @@ func base(name string, foreground, background, accent lipgloss.Color) Theme { Success: success, App: lipgloss.NewStyle(). Foreground(foreground). - Background(background). Padding(1, 2), Title: lipgloss.NewStyle(). Foreground(accent). - Background(background). Bold(true), Panel: lipgloss.NewStyle(). Foreground(foreground). - Background(background). Border(lipgloss.RoundedBorder()). BorderForeground(accent). Padding(0, 1), PanelTitle: lipgloss.NewStyle(). Foreground(accent). - Background(background). Bold(true), Selected: lipgloss.NewStyle(). Foreground(background). @@ -94,11 +90,9 @@ func base(name string, foreground, background, accent lipgloss.Color) Theme { func (t Theme) withMuted(muted lipgloss.Color) Theme { t.Muted = muted t.MutedText = lipgloss.NewStyle(). - Foreground(muted). - Background(t.Background) + Foreground(muted) t.Help = lipgloss.NewStyle(). - Foreground(muted). - Background(t.Background) + Foreground(muted) return t } diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go index 23ea9d1..3548aa0 100644 --- a/internal/tui/theme/theme_test.go +++ b/internal/tui/theme/theme_test.go @@ -40,6 +40,22 @@ func TestByNameRejectsUnknownTheme(t *testing.T) { require.Error(t, err) } +func TestThemeChromeUsesTerminalDefaultBackground(t *testing.T) { + t.Parallel() + + theme, err := ByName("wizardry") + require.NoError(t, err) + + assertNoBackground(t, theme.App) + assertNoBackground(t, theme.Title) + assertNoBackground(t, theme.Panel) + assertNoBackground(t, theme.PanelTitle) + assertNoBackground(t, theme.MutedText) + assertNoBackground(t, theme.Help) + + require.IsType(t, lipgloss.Color(""), theme.Selected.GetBackground()) +} + func TestDarkTerminalThemesKeepMutedTextReadable(t *testing.T) { t.Parallel() @@ -53,6 +69,12 @@ func TestDarkTerminalThemesKeepMutedTextReadable(t *testing.T) { } } +func assertNoBackground(t *testing.T, style lipgloss.Style) { + t.Helper() + + require.IsType(t, lipgloss.NoColor{}, style.GetBackground()) +} + func colorIndex(t *testing.T, color lipgloss.Color) int { t.Helper()