feat(theme): tint complete status bars
- commit
- 84daa2c24dc3c82147537e117b2f25083b2c17a5
- author
- brice · · 2026-07-18 08:50 UTC
- parent
- d4234db
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +159 -41
M internal/usecase/daemon/status.go +17 -20
| @@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { | |
| 48 | - | clearStatusRow(row) | |
| 48 | + | clearStatusRow(row, styles.SurfaceBar) | |
| 49 | 49 | x := 0 | |
| 50 | 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 51 | 51 | for i, w := range status.tabs { | |
| 52 | - | baseStyle := styles.StatusBar | |
| 53 | - | nameStyle := styles.TabName | |
| 54 | - | titleStyle := styles.TabTitle | |
| 52 | + | baseStyle := styles.SurfaceInactive | |
| 53 | + | nameStyle := styles.TabInactive | |
| 54 | + | titleStyle := styles.TabInactiveTitle | |
| 55 | 55 | if w.active { | |
| 56 | - | baseStyle = styles.Accent | |
| 57 | - | nameStyle = styles.TabNameActive | |
| 58 | - | titleStyle = styles.TabTitleActive | |
| 56 | + | baseStyle = styles.SurfaceActive | |
| 57 | + | nameStyle = styles.TabActive | |
| 58 | + | titleStyle = styles.TabActiveTitle | |
| 59 | 59 | } | |
| 60 | 60 | label := labels[i] | |
| 61 | 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| @@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 66 | 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 67 | 67 | writeStatusText(row, &x, " ", baseStyle) | |
| 68 | 68 | } | |
| 69 | - | drawRightPlainText(row, topRight, x, styles.StatusBar) | |
| 69 | + | drawRightPlainText(row, topRight, x, styles.SurfaceBar) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | |
| 73 | - | clearStatusRow(row) | |
| 73 | + | clearStatusRow(row, styles.SurfaceBar) | |
| 74 | 74 | x := 0 | |
| 75 | 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 76 | - | writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent) | |
| 76 | + | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) | |
| 77 | 77 | if state.rankedRecent != nil { | |
| 78 | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | |
| 79 | - | style := styles.StatusBar // contextual ranks deliberately do not fade. | |
| 79 | + | style := styles.SurfaceInactive // contextual ranks deliberately do not fade. | |
| 80 | 80 | if sess.selected { | |
| 81 | - | style = styles.Accent | |
| 81 | + | style = styles.SurfaceActive | |
| 82 | 82 | } | |
| 83 | 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 84 | 84 | } | |
| 85 | 85 | } else { | |
| 86 | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 87 | 87 | for i, sess := range fittedMRU { | |
| 88 | - | style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU)) | |
| 88 | + | style := styles.MRUStyle(i, len(fittedMRU)) | |
| 89 | 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | |
| 90 | 90 | } | |
| 91 | 91 | } | |
| 92 | - | drawRightPlainText(row, rightText, x, styles.StatusBar) | |
| 92 | + | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | type rankedRecent struct { | |
| @@ -147,9 +147,10 @@ func statusTextWidth(text string) int { | |||
| 147 | 147 | return width | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | - | func clearStatusRow(row []renderer.Cell) { | |
| 150 | + | func clearStatusRow(row []renderer.Cell, style renderer.Style) { | |
| 151 | + | blank := renderer.Cell{Rune: ' ', Style: style} | |
| 151 | 152 | for i := range row { | |
| 152 | - | row[i] = renderer.BlankCell() | |
| 153 | + | row[i] = blank | |
| 153 | 154 | } | |
| 154 | 155 | } | |
| 155 | 156 | ||
| @@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int { | |||
| 426 | 427 | return reserve | |
| 427 | 428 | } | |
| 428 | 429 | ||
| 429 | - | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | |
| 430 | - | return themeui.MRUFade(base, t, i, count) | |
| 431 | - | } | |
| 432 | - | ||
| 433 | 430 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 434 | 431 | // base's style on its blank beat, so the bell never leaves a bare | |
| 435 | 432 | // default-background hole in a themed bar. | |
| @@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { | 47 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { | ||
| 48 | - | clearStatusRow(row) | 48 | + | clearStatusRow(row, styles.SurfaceBar) |
| 49 | x := 0 | 49 | x := 0 | ||
| 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | ||
| 51 | for i, w := range status.tabs { | 51 | for i, w := range status.tabs { | ||
| 52 | - | baseStyle := styles.StatusBar | 52 | + | baseStyle := styles.SurfaceInactive |
| 53 | - | nameStyle := styles.TabName | 53 | + | nameStyle := styles.TabInactive |
| 54 | - | titleStyle := styles.TabTitle | 54 | + | titleStyle := styles.TabInactiveTitle |
| 55 | if w.active { | 55 | if w.active { | ||
| 56 | - | baseStyle = styles.Accent | 56 | + | baseStyle = styles.SurfaceActive |
| 57 | - | nameStyle = styles.TabNameActive | 57 | + | nameStyle = styles.TabActive |
| 58 | - | titleStyle = styles.TabTitleActive | 58 | + | titleStyle = styles.TabActiveTitle |
| 59 | } | 59 | } | ||
| 60 | label := labels[i] | 60 | label := labels[i] | ||
| 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | ||
| @@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | ||
| 67 | writeStatusText(row, &x, " ", baseStyle) | 67 | writeStatusText(row, &x, " ", baseStyle) | ||
| 68 | } | 68 | } | ||
| 69 | - | drawRightPlainText(row, topRight, x, styles.StatusBar) | 69 | + | drawRightPlainText(row, topRight, x, styles.SurfaceBar) |
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | ||
| 73 | - | clearStatusRow(row) | 73 | + | clearStatusRow(row, styles.SurfaceBar) |
| 74 | x := 0 | 74 | x := 0 | ||
| 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | ||
| 76 | - | writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent) | 76 | + | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) |
| 77 | if state.rankedRecent != nil { | 77 | if state.rankedRecent != nil { | ||
| 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | ||
| 79 | - | style := styles.StatusBar // contextual ranks deliberately do not fade. | 79 | + | style := styles.SurfaceInactive // contextual ranks deliberately do not fade. |
| 80 | if sess.selected { | 80 | if sess.selected { | ||
| 81 | - | style = styles.Accent | 81 | + | style = styles.SurfaceActive |
| 82 | } | 82 | } | ||
| 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | ||
| 84 | } | 84 | } | ||
| 85 | } else { | 85 | } else { | ||
| 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | ||
| 87 | for i, sess := range fittedMRU { | 87 | for i, sess := range fittedMRU { | ||
| 88 | - | style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU)) | 88 | + | style := styles.MRUStyle(i, len(fittedMRU)) |
| 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | ||
| 90 | } | 90 | } | ||
| 91 | } | 91 | } | ||
| 92 | - | drawRightPlainText(row, rightText, x, styles.StatusBar) | 92 | + | drawRightPlainText(row, rightText, x, styles.SurfaceBar) |
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | type rankedRecent struct { | 95 | type rankedRecent struct { | ||
| @@ -147,9 +147,10 @@ func statusTextWidth(text string) int { | |||||
| 147 | return width | 147 | return width | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | - | func clearStatusRow(row []renderer.Cell) { | 150 | + | func clearStatusRow(row []renderer.Cell, style renderer.Style) { |
| 151 | + | blank := renderer.Cell{Rune: ' ', Style: style} | |||
| 151 | for i := range row { | 152 | for i := range row { | ||
| 152 | - | row[i] = renderer.BlankCell() | 153 | + | row[i] = blank |
| 153 | } | 154 | } | ||
| 154 | } | 155 | } | ||
| 155 | 156 | ||||
| @@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int { | |||||
| 426 | return reserve | 427 | return reserve | ||
| 427 | } | 428 | } | ||
| 428 | 429 | ||||
| 429 | - | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | |||
| 430 | - | return themeui.MRUFade(base, t, i, count) | |||
| 431 | - | } | |||
| 432 | - | ||||
| 433 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | 430 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | ||
| 434 | // base's style on its blank beat, so the bell never leaves a bare | 431 | // base's style on its blank beat, so the bell never leaves a bare | ||
| 435 | // default-background hole in a themed bar. | 432 | // default-background hole in a themed bar. | ||
M internal/usecase/daemon/status_test.go +108 -19
| @@ -1200,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||
| 1200 | 1200 | } | |
| 1201 | 1201 | ||
| 1202 | 1202 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 1203 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 1204 | - | styles := themeui.NewStyles(theme) | |
| 1205 | - | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1203 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 1204 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 1205 | + | theme.Palette[10] = theme.Palette[2] | |
| 1206 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 1207 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 1208 | + | state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1206 | 1209 | row := make([]renderer.Cell, 16) | |
| 1207 | 1210 | ||
| 1208 | - | drawStatusBarState(row, state, styles) | |
| 1211 | + | drawStatusBarState(row, state, resolved.Styles) | |
| 1212 | + | ||
| 1213 | + | for index, cell := range []int{4, 7, 10} { | |
| 1214 | + | require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3))) | |
| 1215 | + | } | |
| 1216 | + | require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar") | |
| 1209 | 1217 | ||
| 1210 | - | firstFG := row[4].Style.ForegroundRGB.R | |
| 1211 | - | secondFG := row[7].Style.ForegroundRGB.R | |
| 1212 | - | thirdFG := row[10].Style.ForegroundRGB.R | |
| 1213 | - | require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG) | |
| 1214 | - | require.Greater(t, firstFG, secondFG) | |
| 1215 | - | require.Greater(t, secondFG, thirdFG) | |
| 1216 | - | ||
| 1217 | - | firstBG := row[4].Style.BackgroundRGB.R | |
| 1218 | - | secondBG := row[7].Style.BackgroundRGB.R | |
| 1219 | - | thirdBG := row[10].Style.BackgroundRGB.R | |
| 1220 | - | require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG) | |
| 1221 | - | require.Greater(t, firstBG, secondBG) | |
| 1222 | - | require.Greater(t, secondBG, thirdBG) | |
| 1223 | - | ||
| 1224 | - | plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3) | |
| 1218 | + | plain := resolveStyles(nil).MRUStyle(1, 3) | |
| 1225 | 1219 | require.False(t, plain.HasForegroundRGB) | |
| 1226 | 1220 | require.False(t, plain.HasBackgroundRGB) | |
| 1227 | 1221 | } | |
| @@ -1403,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |||
| 1403 | 1397 | } | |
| 1404 | 1398 | } | |
| 1405 | 1399 | ||
| 1400 | + | func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |
| 1401 | + | theme := themeui.Theme{ | |
| 1402 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, | |
| 1403 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 1404 | + | HasFG: true, | |
| 1405 | + | HasBG: true, | |
| 1406 | + | TrueColor: true, | |
| 1407 | + | Known: true, | |
| 1408 | + | UsePalette: true, | |
| 1409 | + | } | |
| 1410 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 1411 | + | theme.Palette[10] = theme.Palette[2] | |
| 1412 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 1413 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 1414 | + | styles := resolved.Styles | |
| 1415 | + | ||
| 1416 | + | t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) { | |
| 1417 | + | row := make([]renderer.Cell, 48) | |
| 1418 | + | status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}} | |
| 1419 | + | drawTopBarSnapshot(row, status, 0, "clock", styles) | |
| 1420 | + | ||
| 1421 | + | for _, cell := range row[28:40] { | |
| 1422 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar") | |
| 1423 | + | } | |
| 1424 | + | require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive") | |
| 1425 | + | require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text") | |
| 1426 | + | selected := strings.Index(rowText(row), "selected") | |
| 1427 | + | require.GreaterOrEqual(t, selected, 0) | |
| 1428 | + | require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling") | |
| 1429 | + | require.True(t, row[selected].Style.Bold, "active tab stays bold") | |
| 1430 | + | }) | |
| 1431 | + | ||
| 1432 | + | t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) { | |
| 1433 | + | row := make([]renderer.Cell, 48) | |
| 1434 | + | state := barState{ | |
| 1435 | + | status: statusSnapshot{session: "current"}, | |
| 1436 | + | bottomRight: "script ok", | |
| 1437 | + | mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}}, | |
| 1438 | + | } | |
| 1439 | + | drawStatusBarState(row, state, styles) | |
| 1440 | + | for i := range len(" current ") { | |
| 1441 | + | require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive") | |
| 1442 | + | } | |
| 1443 | + | for index, name := range []string{"new", "middle", "old"} { | |
| 1444 | + | at := strings.Index(rowText(row), name) | |
| 1445 | + | require.GreaterOrEqual(t, at, 0) | |
| 1446 | + | require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name) | |
| 1447 | + | } | |
| 1448 | + | for _, cell := range row[30:38] { | |
| 1449 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar") | |
| 1450 | + | } | |
| 1451 | + | at := strings.Index(rowText(row), "script ok") | |
| 1452 | + | require.GreaterOrEqual(t, at, 0) | |
| 1453 | + | require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar") | |
| 1454 | + | ||
| 1455 | + | ranked := make([]renderer.Cell, 48) | |
| 1456 | + | drawStatusBarState(ranked, barState{ | |
| 1457 | + | status: statusSnapshot{session: "current"}, | |
| 1458 | + | rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}}, | |
| 1459 | + | }, styles) | |
| 1460 | + | inactive := strings.Index(rowText(ranked), "inactive") | |
| 1461 | + | chosen := strings.Index(rowText(ranked), "chosen") | |
| 1462 | + | require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive") | |
| 1463 | + | require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling") | |
| 1464 | + | }) | |
| 1465 | + | ||
| 1466 | + | t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) { | |
| 1467 | + | empty := make([]renderer.Cell, 20) | |
| 1468 | + | drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles) | |
| 1469 | + | for _, cell := range empty[len(" current "):] { | |
| 1470 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar") | |
| 1471 | + | } | |
| 1472 | + | ||
| 1473 | + | single := make([]renderer.Cell, 20) | |
| 1474 | + | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles) | |
| 1475 | + | only := strings.Index(rowText(single), "only") | |
| 1476 | + | require.GreaterOrEqual(t, only, 0) | |
| 1477 | + | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | |
| 1478 | + | }) | |
| 1479 | + | ||
| 1480 | + | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | |
| 1481 | + | row := make([]renderer.Cell, 32) | |
| 1482 | + | drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles) | |
| 1483 | + | bells := make([]int, 0, 2) | |
| 1484 | + | for i, cell := range row { | |
| 1485 | + | if cell.Rune == ui.AttentionGlyph { | |
| 1486 | + | bells = append(bells, i) | |
| 1487 | + | } | |
| 1488 | + | } | |
| 1489 | + | require.Len(t, bells, 2) | |
| 1490 | + | require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention") | |
| 1491 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface") | |
| 1492 | + | }) | |
| 1493 | + | } | |
| 1494 | + | ||
| 1406 | 1495 | func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |
| 1407 | 1496 | p1, releasePTY1 := newBlockingPTY(t) | |
| 1408 | 1497 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -1200,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||||
| 1200 | } | 1200 | } | ||
| 1201 | 1201 | ||||
| 1202 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | 1202 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | ||
| 1203 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | 1203 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} |
| 1204 | - | styles := themeui.NewStyles(theme) | 1204 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} |
| 1205 | - | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1205 | + | theme.Palette[10] = theme.Palette[2] |
| 1206 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |||
| 1207 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 1208 | + | state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |||
| 1206 | row := make([]renderer.Cell, 16) | 1209 | row := make([]renderer.Cell, 16) | ||
| 1207 | 1210 | ||||
| 1208 | - | drawStatusBarState(row, state, styles) | 1211 | + | drawStatusBarState(row, state, resolved.Styles) |
| 1212 | + | ||||
| 1213 | + | for index, cell := range []int{4, 7, 10} { | |||
| 1214 | + | require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3))) | |||
| 1215 | + | } | |||
| 1216 | + | require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar") | |||
| 1209 | 1217 | ||||
| 1210 | - | firstFG := row[4].Style.ForegroundRGB.R | 1218 | + | plain := resolveStyles(nil).MRUStyle(1, 3) |
| 1211 | - | secondFG := row[7].Style.ForegroundRGB.R | |||
| 1212 | - | thirdFG := row[10].Style.ForegroundRGB.R | |||
| 1213 | - | require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG) | |||
| 1214 | - | require.Greater(t, firstFG, secondFG) | |||
| 1215 | - | require.Greater(t, secondFG, thirdFG) | |||
| 1216 | - | ||||
| 1217 | - | firstBG := row[4].Style.BackgroundRGB.R | |||
| 1218 | - | secondBG := row[7].Style.BackgroundRGB.R | |||
| 1219 | - | thirdBG := row[10].Style.BackgroundRGB.R | |||
| 1220 | - | require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG) | |||
| 1221 | - | require.Greater(t, firstBG, secondBG) | |||
| 1222 | - | require.Greater(t, secondBG, thirdBG) | |||
| 1223 | - | ||||
| 1224 | - | plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3) | |||
| 1225 | require.False(t, plain.HasForegroundRGB) | 1219 | require.False(t, plain.HasForegroundRGB) | ||
| 1226 | require.False(t, plain.HasBackgroundRGB) | 1220 | require.False(t, plain.HasBackgroundRGB) | ||
| 1227 | } | 1221 | } | ||
| @@ -1403,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |||||
| 1403 | } | 1397 | } | ||
| 1404 | } | 1398 | } | ||
| 1405 | 1399 | ||||
| 1400 | + | func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||
| 1401 | + | theme := themeui.Theme{ | |||
| 1402 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, | |||
| 1403 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |||
| 1404 | + | HasFG: true, | |||
| 1405 | + | HasBG: true, | |||
| 1406 | + | TrueColor: true, | |||
| 1407 | + | Known: true, | |||
| 1408 | + | UsePalette: true, | |||
| 1409 | + | } | |||
| 1410 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 1411 | + | theme.Palette[10] = theme.Palette[2] | |||
| 1412 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |||
| 1413 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 1414 | + | styles := resolved.Styles | |||
| 1415 | + | ||||
| 1416 | + | t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) { | |||
| 1417 | + | row := make([]renderer.Cell, 48) | |||
| 1418 | + | status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}} | |||
| 1419 | + | drawTopBarSnapshot(row, status, 0, "clock", styles) | |||
| 1420 | + | ||||
| 1421 | + | for _, cell := range row[28:40] { | |||
| 1422 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar") | |||
| 1423 | + | } | |||
| 1424 | + | require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive") | |||
| 1425 | + | require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text") | |||
| 1426 | + | selected := strings.Index(rowText(row), "selected") | |||
| 1427 | + | require.GreaterOrEqual(t, selected, 0) | |||
| 1428 | + | require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling") | |||
| 1429 | + | require.True(t, row[selected].Style.Bold, "active tab stays bold") | |||
| 1430 | + | }) | |||
| 1431 | + | ||||
| 1432 | + | t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) { | |||
| 1433 | + | row := make([]renderer.Cell, 48) | |||
| 1434 | + | state := barState{ | |||
| 1435 | + | status: statusSnapshot{session: "current"}, | |||
| 1436 | + | bottomRight: "script ok", | |||
| 1437 | + | mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}}, | |||
| 1438 | + | } | |||
| 1439 | + | drawStatusBarState(row, state, styles) | |||
| 1440 | + | for i := range len(" current ") { | |||
| 1441 | + | require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive") | |||
| 1442 | + | } | |||
| 1443 | + | for index, name := range []string{"new", "middle", "old"} { | |||
| 1444 | + | at := strings.Index(rowText(row), name) | |||
| 1445 | + | require.GreaterOrEqual(t, at, 0) | |||
| 1446 | + | require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name) | |||
| 1447 | + | } | |||
| 1448 | + | for _, cell := range row[30:38] { | |||
| 1449 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar") | |||
| 1450 | + | } | |||
| 1451 | + | at := strings.Index(rowText(row), "script ok") | |||
| 1452 | + | require.GreaterOrEqual(t, at, 0) | |||
| 1453 | + | require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar") | |||
| 1454 | + | ||||
| 1455 | + | ranked := make([]renderer.Cell, 48) | |||
| 1456 | + | drawStatusBarState(ranked, barState{ | |||
| 1457 | + | status: statusSnapshot{session: "current"}, | |||
| 1458 | + | rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}}, | |||
| 1459 | + | }, styles) | |||
| 1460 | + | inactive := strings.Index(rowText(ranked), "inactive") | |||
| 1461 | + | chosen := strings.Index(rowText(ranked), "chosen") | |||
| 1462 | + | require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive") | |||
| 1463 | + | require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling") | |||
| 1464 | + | }) | |||
| 1465 | + | ||||
| 1466 | + | t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) { | |||
| 1467 | + | empty := make([]renderer.Cell, 20) | |||
| 1468 | + | drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles) | |||
| 1469 | + | for _, cell := range empty[len(" current "):] { | |||
| 1470 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar") | |||
| 1471 | + | } | |||
| 1472 | + | ||||
| 1473 | + | single := make([]renderer.Cell, 20) | |||
| 1474 | + | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles) | |||
| 1475 | + | only := strings.Index(rowText(single), "only") | |||
| 1476 | + | require.GreaterOrEqual(t, only, 0) | |||
| 1477 | + | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | |||
| 1478 | + | }) | |||
| 1479 | + | ||||
| 1480 | + | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | |||
| 1481 | + | row := make([]renderer.Cell, 32) | |||
| 1482 | + | drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles) | |||
| 1483 | + | bells := make([]int, 0, 2) | |||
| 1484 | + | for i, cell := range row { | |||
| 1485 | + | if cell.Rune == ui.AttentionGlyph { | |||
| 1486 | + | bells = append(bells, i) | |||
| 1487 | + | } | |||
| 1488 | + | } | |||
| 1489 | + | require.Len(t, bells, 2) | |||
| 1490 | + | require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention") | |||
| 1491 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface") | |||
| 1492 | + | }) | |||
| 1493 | + | } | |||
| 1494 | + | ||||
| 1406 | func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | 1495 | func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | ||
| 1407 | p1, releasePTY1 := newBlockingPTY(t) | 1496 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 1408 | p2, releasePTY2 := newBlockingPTY(t) | 1497 | p2, releasePTY2 := newBlockingPTY(t) | ||
M internal/usecase/theme/resolved_styles.go +14 -2
| @@ -61,7 +61,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 61 | 61 | CopyStatus: ramp.SurfaceBar, | |
| 62 | 62 | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 63 | 63 | } | |
| 64 | - | return withLegacyAliases(styles, t) | |
| 64 | + | return withMRUStyles(withLegacyAliases(styles, t), ramp) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | func secondarySurface(base renderer.Style) renderer.Style { | |
| @@ -137,7 +137,19 @@ func neutralStyles(t Theme) Styles { | |||
| 137 | 137 | CopyStatus: accent, | |
| 138 | 138 | SearchSelection: accent, | |
| 139 | 139 | } | |
| 140 | - | return withLegacyAliases(styles, t) | |
| 140 | + | styles = withLegacyAliases(styles, t) | |
| 141 | + | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | |
| 142 | + | // MRU entries deliberately share the neutral recent surface. | |
| 143 | + | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func withMRUStyles(styles Styles, ramp Ramp) Styles { | |
| 147 | + | for count := 1; count <= len(styles.mruStyles); count++ { | |
| 148 | + | for index := 0; index < count; index++ { | |
| 149 | + | styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | return styles | |
| 141 | 153 | } | |
| 142 | 154 | ||
| 143 | 155 | func neutralBorderStyle(t Theme) renderer.Style { | |
| @@ -61,7 +61,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 61 | CopyStatus: ramp.SurfaceBar, | 61 | CopyStatus: ramp.SurfaceBar, | ||
| 62 | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | 62 | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | ||
| 63 | } | 63 | } | ||
| 64 | - | return withLegacyAliases(styles, t) | 64 | + | return withMRUStyles(withLegacyAliases(styles, t), ramp) |
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | func secondarySurface(base renderer.Style) renderer.Style { | 67 | func secondarySurface(base renderer.Style) renderer.Style { | ||
| @@ -137,7 +137,19 @@ func neutralStyles(t Theme) Styles { | |||||
| 137 | CopyStatus: accent, | 137 | CopyStatus: accent, | ||
| 138 | SearchSelection: accent, | 138 | SearchSelection: accent, | ||
| 139 | } | 139 | } | ||
| 140 | - | return withLegacyAliases(styles, t) | 140 | + | styles = withLegacyAliases(styles, t) |
| 141 | + | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | |||
| 142 | + | // MRU entries deliberately share the neutral recent surface. | |||
| 143 | + | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func withMRUStyles(styles Styles, ramp Ramp) Styles { | |||
| 147 | + | for count := 1; count <= len(styles.mruStyles); count++ { | |||
| 148 | + | for index := 0; index < count; index++ { | |||
| 149 | + | styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | return styles | |||
| 141 | } | 153 | } | ||
| 142 | 154 | ||||
| 143 | func neutralBorderStyle(t Theme) renderer.Style { | 155 | func neutralBorderStyle(t Theme) renderer.Style { | ||
M internal/usecase/theme/styles.go +20 -0
| @@ -62,6 +62,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||
| 62 | 62 | // Styles is the complete semantic set of terminal chrome styles. The legacy | |
| 63 | 63 | // aliases remain until daemon renderers consume the semantic fields directly. | |
| 64 | 64 | type Styles struct { | |
| 65 | + | // mruStyles are all position/count combinations that status composition | |
| 66 | + | // can display. They keep OKLab interpolation out of render paths. | |
| 67 | + | mruStyles [9][9]renderer.Style | |
| 68 | + | ||
| 65 | 69 | SurfaceBar renderer.Style | |
| 66 | 70 | SurfaceInactive renderer.Style | |
| 67 | 71 | SurfaceRecent renderer.Style | |
| @@ -103,3 +107,19 @@ type Styles struct { | |||
| 103 | 107 | func NewStyles(t Theme) Styles { | |
| 104 | 108 | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 105 | 109 | } | |
| 110 | + | ||
| 111 | + | // MRUStyle returns the position-dependent recent-session surface. Color | |
| 112 | + | // interpolation stays owned by theme; renderers consume only this cached | |
| 113 | + | // semantic style snapshot. | |
| 114 | + | func (s Styles) MRUStyle(index, count int) renderer.Style { | |
| 115 | + | if count <= 0 || count > len(s.mruStyles) { | |
| 116 | + | return s.MRURecent | |
| 117 | + | } | |
| 118 | + | if index < 0 { | |
| 119 | + | index = 0 | |
| 120 | + | } | |
| 121 | + | if index >= count { | |
| 122 | + | index = count - 1 | |
| 123 | + | } | |
| 124 | + | return s.mruStyles[count-1][index] | |
| 125 | + | } | |
| @@ -62,6 +62,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||||
| 62 | // Styles is the complete semantic set of terminal chrome styles. The legacy | 62 | // Styles is the complete semantic set of terminal chrome styles. The legacy | ||
| 63 | // aliases remain until daemon renderers consume the semantic fields directly. | 63 | // aliases remain until daemon renderers consume the semantic fields directly. | ||
| 64 | type Styles struct { | 64 | type Styles struct { | ||
| 65 | + | // mruStyles are all position/count combinations that status composition | |||
| 66 | + | // can display. They keep OKLab interpolation out of render paths. | |||
| 67 | + | mruStyles [9][9]renderer.Style | |||
| 68 | + | ||||
| 65 | SurfaceBar renderer.Style | 69 | SurfaceBar renderer.Style | ||
| 66 | SurfaceInactive renderer.Style | 70 | SurfaceInactive renderer.Style | ||
| 67 | SurfaceRecent renderer.Style | 71 | SurfaceRecent renderer.Style | ||
| @@ -103,3 +107,19 @@ type Styles struct { | |||||
| 103 | func NewStyles(t Theme) Styles { | 107 | func NewStyles(t Theme) Styles { | ||
| 104 | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | 108 | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | ||
| 105 | } | 109 | } | ||
| 110 | + | ||||
| 111 | + | // MRUStyle returns the position-dependent recent-session surface. Color | |||
| 112 | + | // interpolation stays owned by theme; renderers consume only this cached | |||
| 113 | + | // semantic style snapshot. | |||
| 114 | + | func (s Styles) MRUStyle(index, count int) renderer.Style { | |||
| 115 | + | if count <= 0 || count > len(s.mruStyles) { | |||
| 116 | + | return s.MRURecent | |||
| 117 | + | } | |||
| 118 | + | if index < 0 { | |||
| 119 | + | index = 0 | |||
| 120 | + | } | |||
| 121 | + | if index >= count { | |||
| 122 | + | index = count - 1 | |||
| 123 | + | } | |||
| 124 | + | return s.mruStyles[count-1][index] | |||
| 125 | + | } | |||