Skip to content

Commit c7315ac

Browse files
authored
Redesign TUI and polish layout alignment (#44)
* Redesign TUI with coherent visual identity - Add theme.go: three-color palette (azure accent, near-black dim, mid-grey subtle) used consistently across all views - Top bar: competition name (bold) + right-aligned round info, separated from body by a dim '─' rule - Status bar: right-aligned fetch time, '·' separators, cleaner key names - League view: dim '│' divider between panes; standings starts with subtle column header instead of a 'Standings' title; fixture pane starts with round label instead of 'Fixtures [focus]' title - Match view: same divider treatment; score formatted with en-dash ('–') and extra visual weight; HT/FT dividers use '─' box-drawing chars - Selector popup: rounded border, accent-colored focused column header, vertical '│' divider between season and league panes — no '[focus]' label - Fixture cursor: accent-colored '›' marker instead of ASCII '> ' - Standings selected rows: bold instead of '> ' prefix - Section dividers: '─' fills with subtle-styled header label (Lineups / Details) - Update tests to match new design: en-dash in HT/FT labels, removed pipe in when-info, ANSI-aware alignment checks, updated status-bar hint text * Polish redesign layout and selector sizing * Update docs for redesigned match view
1 parent 2cc4a3d commit c7315ac

File tree

5 files changed

+533
-231
lines changed

5 files changed

+533
-231
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ Terminal UI for browsing `90minut.pl` (Polish football archive) without an API.
1717
- Match details view with:
1818
- centered score line with scorer rows anchored to a shared minute column
1919
- side-aware timeline with halftime/full-time dividers
20-
- compact event markers: `` goal, `` substitution, `🟨` yellow, `🟥` red, plus status markers like `HT`, `FT`, `AET`, `PPD`, and `OFF`
21-
- side-by-side lineups aligned around the same center axis
22-
- match metadata grouped into a separate `Details` block
20+
- compact event markers: `` goal plus color-coded card blocks for yellow/red cards, alongside status markers like `HT`, `FT`, `AET`, `PPD`, and `OFF`
21+
- side-by-side lineups aligned around the same center axis, with substitutions shown inline in lineup annotations rather than as separate timeline markers
22+
- match date/details rendered directly under the score header
2323
- persistent standings/fixture context beside match details
2424

2525
## Run

internal/ui/render_helpers.go

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,34 @@ func renderFixtureWindow(fixtures []site.Fixture, cursor, maxItems, width int, c
9191

9292
start, end := windowBounds(len(fixtures), cursor, maxItems)
9393
whenWidth := 0
94+
scoreWidth := 0
95+
suffixWidth := 0
9496
for i := start; i < end; i++ {
9597
whenWidth = max(whenWidth, len([]rune(formatFixtureWhenInfo(fixtures[i].WhenInfo))))
98+
scoreWidth = max(scoreWidth, ansi.StringWidth(normalizeScore(fixtures[i].Score)))
99+
suffixWidth = max(suffixWidth, ansi.StringWidth(fixtureAvailabilitySuffix(&fixtures[i], width-2, compact)))
96100
}
97101
lines := make([]string, 0, end-start)
98102
for i := start; i < end; i++ {
99-
prefix := " "
100-
if i == cursor {
101-
prefix = "> "
103+
isCursor := i == cursor
104+
// "› " and " " are both 2 visible chars; accent marker on cursor row.
105+
var prefix string
106+
if isCursor {
107+
prefix = styleAccent.Render("›") + " "
108+
} else {
109+
prefix = " "
102110
}
103111

104-
lineWidth := width - len([]rune(prefix))
112+
lineWidth := width - 2 // 2-char prefix in both cases
105113
suffix := fixtureAvailabilitySuffix(&fixtures[i], lineWidth, compact)
106-
line := prefix + fixtureLine(&fixtures[i], lineWidth-ansi.StringWidth(suffix), whenWidth, compact)
114+
line := prefix + fixtureLine(&fixtures[i], lineWidth-suffixWidth, whenWidth, scoreWidth, compact)
107115
if suffix != "" {
108-
line += suffix
116+
line += styleDim.Render(suffix)
117+
} else if suffixWidth > 0 {
118+
line += strings.Repeat(" ", suffixWidth)
109119
}
110120
if whenInfo := formatFixtureWhenInfo(fixtures[i].WhenInfo); whenInfo != "" {
111-
line += " | " + whenInfo
121+
line += styleDim.Render(" " + whenInfo)
112122
}
113123
lines = append(lines, line)
114124
}
@@ -125,10 +135,11 @@ func renderStandingsWindow(rows []site.StandingRow, fixture *site.Fixture, width
125135
}
126136

127137
start, end := anchoredWindowBounds(len(rows), standingSelectionIndices(rows, fixture), maxItems)
138+
teamWidth := standingsTeamWidth(rows, width)
128139
lines := make([]string, 0, end-start)
129140
for i := start; i < end; i++ {
130141
selected := fixture != nil && (strings.EqualFold(rows[i].Team, fixture.Home) || strings.EqualFold(rows[i].Team, fixture.Away))
131-
lines = append(lines, formatStandingRow(rows[i], selected, width))
142+
lines = append(lines, formatStandingRow(rows[i], selected, teamWidth, width))
132143
}
133144

134145
return lines
@@ -355,9 +366,9 @@ func eventPrefix(kind string) string {
355366
case "SUB":
356367
return "↕"
357368
case "YC":
358-
return "🟨"
369+
return styleYellow.Render("■")
359370
case "RC":
360-
return "🟥"
371+
return styleRed.Render("■")
361372
default:
362373
return "•"
363374
}
@@ -463,7 +474,7 @@ func formatMatchMinute(minute string) string {
463474
func renderDividerLabel(label string, width int) string {
464475
cleaned := normalizeDisplayText(label)
465476
if cleaned == "" {
466-
cleaned = "-"
477+
cleaned = ""
467478
}
468479
if width <= len([]rune(cleaned))+2 {
469480
return cleaned
@@ -472,7 +483,7 @@ func renderDividerLabel(label string, width int) string {
472483
pad := width - len([]rune(cleaned)) - 2
473484
left := pad / 2
474485
right := pad - left
475-
return strings.Repeat("-", left) + " " + cleaned + " " + strings.Repeat("-", right)
486+
return strings.Repeat("", left) + " " + cleaned + " " + strings.Repeat("", right)
476487
}
477488

478489
// Align the divider score dash with the event-minute column when width allows.
@@ -482,7 +493,11 @@ func renderMatchDividerRow(label string, width int) string {
482493
}
483494

484495
label = truncate(label, max(1, width-2))
485-
dashOffset := strings.Index(label, " - ") + 1
496+
dashOffset := strings.Index(label, " – ") + 1
497+
if dashOffset < 1 {
498+
// Fall back to ASCII dash for labels that don't contain an en-dash.
499+
dashOffset = strings.Index(label, " - ") + 1
500+
}
486501
if dashOffset < 1 {
487502
return renderDividerLabel(label, width)
488503
}
@@ -491,7 +506,7 @@ func renderMatchDividerRow(label string, width int) string {
491506
leftWidth := max(0, minuteAxis-1-dashOffset)
492507
rightWidth := max(0, width-leftWidth-ansi.StringWidth(label)-2)
493508

494-
return strings.Repeat("-", leftWidth) + " " + label + " " + strings.Repeat("-", rightWidth)
509+
return strings.Repeat("", leftWidth) + " " + label + " " + strings.Repeat("", rightWidth)
495510
}
496511

497512
func matchStatus(page *site.MatchPage) string {
@@ -866,7 +881,7 @@ func lineupPlayerWidth(width int) int {
866881
return max(8, (width-3-2)/2)
867882
}
868883

869-
const eventWidth = 2
884+
const eventWidth = 1
870885
const gap = 0
871886
return max(8, (width-1-2*eventWidth-2*gap)/2)
872887
}
@@ -887,7 +902,7 @@ func renderAnnotatedLineupRow(homePlayer, homeEvents, awayPlayer, awayEvents str
887902
return renderLineupRow(home, away, width)
888903
}
889904

890-
const eventWidth = 2 // one emoji wide (YC/RC or empty)
905+
const eventWidth = 1 // one char wide (YC/RC block or empty)
891906
const gap = 0 // names sit directly against the event column
892907
playerWidth := lineupPlayerWidth(width)
893908

@@ -927,7 +942,7 @@ func halftimeScore(events []site.MatchEvent) string {
927942
return ""
928943
}
929944

930-
return fmt.Sprintf("HT %d - %d", homeGoals, awayGoals)
945+
return fmt.Sprintf("HT %d %d", homeGoals, awayGoals)
931946
}
932947

933948
func finalScoreLine(page *site.MatchPage) string {
@@ -960,7 +975,7 @@ func dividerScore(score string) string {
960975
return normalizeScore(trimmed)
961976
}
962977

963-
return left + " - " + right
978+
return left + " " + right
964979
}
965980

966981
func matchMetaParts(meta, weather string) []string {
@@ -977,7 +992,10 @@ func matchMetaParts(meta, weather string) []string {
977992
remainder = strings.TrimSpace(strings.TrimPrefix(remainder, matches[0]))
978993
}
979994
if remainder != "" {
980-
parts = append(parts, "Ref. "+translatePolishDateText(remainder))
995+
translated := translatePolishDateText(remainder)
996+
// Drop trailing "(City)" — only venue/city name, not part of the ref's identity.
997+
refName := strings.TrimSpace(trailingParenRe.ReplaceAllString(translated, "$1"))
998+
parts = append(parts, "Ref. "+refName)
981999
}
9821000
if len(parts) == 0 {
9831001
parts = append(parts, translatePolishDateText(cleanedMeta))
@@ -1016,9 +1034,8 @@ func renderMatchDetailRow(left, middle, right string, width int) string {
10161034
return renderSideBySide(left, middle, right, width)
10171035
}
10181036

1019-
// midWidth=7 with gap=0: padCenter of a 3-char minute leaves exactly 2 leading
1020-
// spaces between the icon and the first digit, while keeping the minute centre
1021-
// aligned with the dash in the HT/FT divider (which uses midWidth=11, gap=1).
1037+
// Keep the minute column visually centered and close to the HT/FT score dash,
1038+
// even as left/right event labels vary in width.
10221039
midWidth := 7
10231040
gap := 0
10241041
sideWidth := max(8, (width-midWidth-(gap*2))/2)
@@ -1185,17 +1202,18 @@ func abbreviateTeamName(name string) string {
11851202
return padRight(string(letters), 3)
11861203
}
11871204

1188-
func abbreviatedFixtureLine(fixture *site.Fixture) string {
1205+
func abbreviatedFixtureLine(fixture *site.Fixture, scoreWidth int) string {
11891206
if fixture == nil {
1190-
return "--- ?-? ---"
1207+
return "--- " + padCenter("?-?", scoreWidth) + " ---"
11911208
}
11921209

1193-
return fmt.Sprintf("%s %s %s", abbreviateTeamName(fixture.Home), normalizeScore(fixture.Score), abbreviateTeamName(fixture.Away))
1210+
score := padCenter(normalizeScore(fixture.Score), scoreWidth)
1211+
return fmt.Sprintf("%s %s %s", abbreviateTeamName(fixture.Home), score, abbreviateTeamName(fixture.Away))
11941212
}
11951213

1196-
func fixtureLine(fixture *site.Fixture, width, whenWidth int, compact bool) string {
1214+
func fixtureLine(fixture *site.Fixture, width, whenWidth, scoreWidth int, compact bool) string {
11971215
if compact {
1198-
return abbreviatedFixtureLine(fixture)
1216+
return abbreviatedFixtureLine(fixture, scoreWidth)
11991217
}
12001218
if fixture == nil {
12011219
return "--- ?-? ---"
@@ -1204,8 +1222,8 @@ func fixtureLine(fixture *site.Fixture, width, whenWidth int, compact bool) stri
12041222
return fmt.Sprintf("%s %s %s", fixture.Home, normalizeScore(fixture.Score), fixture.Away)
12051223
}
12061224

1207-
score := normalizeScore(fixture.Score)
1208-
reserved := len([]rune(score)) + 2
1225+
score := padCenter(normalizeScore(fixture.Score), scoreWidth)
1226+
reserved := scoreWidth + 2
12091227
if whenWidth > 0 {
12101228
reserved += 3 + whenWidth
12111229
}
@@ -1247,14 +1265,31 @@ func formatFetchTime(ts time.Time) string {
12471265
return ts.Format("15:04:05")
12481266
}
12491267

1250-
func formatStandingRow(row site.StandingRow, selected bool, width int) string {
1251-
prefix := " "
1252-
if selected {
1253-
prefix = "> "
1268+
func standingsTeamWidth(rows []site.StandingRow, width int) int {
1269+
const reserved = 21
1270+
minWidth := ansi.StringWidth("Team")
1271+
maxWidth := max(minWidth, width-reserved)
1272+
if maxWidth <= minWidth {
1273+
return minWidth
12541274
}
12551275

1256-
line := fmt.Sprintf("%s%2d %-18s %2d %2d %2d %2d %3d", prefix, row.Position, truncate(row.Team, 18), row.Played, row.Won, row.Drawn, row.Lost, row.Points)
1257-
return truncate(line, max(12, width))
1276+
teamWidth := minWidth
1277+
for _, row := range rows {
1278+
teamWidth = max(teamWidth, ansi.StringWidth(row.Team))
1279+
}
1280+
if teamWidth > maxWidth {
1281+
return maxWidth
1282+
}
1283+
return teamWidth
1284+
}
1285+
1286+
func formatStandingRow(row site.StandingRow, selected bool, teamWidth, width int) string {
1287+
line := fmt.Sprintf(" %2d %-*s %2d %2d %2d %2d %3d", row.Position, teamWidth, truncate(row.Team, teamWidth), row.Played, row.Won, row.Drawn, row.Lost, row.Points)
1288+
line = truncate(line, max(12, width))
1289+
if selected {
1290+
return styleBold.Render(line)
1291+
}
1292+
return line
12581293
}
12591294

12601295
func parseRoundNumber(name string, fallback int) string {
@@ -1325,6 +1360,24 @@ func displayMatchMeta(meta, weather string) string {
13251360
return strings.Join(matchMetaParts(meta, weather), " | ")
13261361
}
13271362

1363+
// matchMetaDisplay splits meta parts into a prominent date line and a secondary
1364+
// details line (attendance, ref, weather). Returns empty strings when absent.
1365+
func matchMetaDisplay(meta, weather string) (date, details string) {
1366+
parts := matchMetaParts(meta, weather)
1367+
if len(parts) == 0 {
1368+
return "", ""
1369+
}
1370+
if matchDatePrefixRe.MatchString(parts[0]) {
1371+
date = parts[0]
1372+
if len(parts) > 1 {
1373+
details = strings.Join(parts[1:], " · ")
1374+
}
1375+
return
1376+
}
1377+
details = strings.Join(parts, " · ")
1378+
return
1379+
}
1380+
13281381
func trimEventMinute(event site.MatchEvent) string {
13291382
text := eventPlayerText(event)
13301383
return formatPlayerLabel(text)

internal/ui/theme.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package ui
2+
3+
import "github.com/charmbracelet/lipgloss"
4+
5+
var (
6+
// Three-color palette. accent = cursor/focus, dim = chrome, subtle = secondary labels.
7+
colorAccent = lipgloss.Color("39") // bright azure
8+
colorDim = lipgloss.Color("240") // dark grey
9+
colorSubtle = lipgloss.Color("243") // mid grey
10+
11+
// Semantic card colors for YC/RC markers across match detail views.
12+
colorYellow = lipgloss.Color("226")
13+
colorRed = lipgloss.Color("196")
14+
15+
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
16+
styleDim = lipgloss.NewStyle().Foreground(colorDim)
17+
styleSubtle = lipgloss.NewStyle().Foreground(colorSubtle)
18+
styleBold = lipgloss.NewStyle().Bold(true)
19+
styleYellow = lipgloss.NewStyle().Foreground(colorYellow)
20+
styleRed = lipgloss.NewStyle().Foreground(colorRed)
21+
)

0 commit comments

Comments
 (0)