Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions docs/plans/2026-04-28-tui-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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.
Expand Down
62 changes: 41 additions & 21 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 {
Expand All @@ -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"),
Expand All @@ -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),
)
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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":
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions internal/tui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down
10 changes: 2 additions & 8 deletions internal/tui/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
}

Expand Down
22 changes: 22 additions & 0 deletions internal/tui/theme/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down