fix(daemon): keep bar background under attention bell and move it beside tab name
The bell glyph (and its blank pulse beat) was drawn on renderer.DefaultStyle() regardless of the bar's actual background, so on a truecolor theme it punched a dark hole into the statusBar/accent/MRU-faded background. pulseStyle and writeBell now take the caller's base style and fade the glyph's foreground from that style's own background to its own foreground, preserving the background (and other attributes) throughout; pulseVisible is split out for the render.go visibility check that doesn't need a style. Also reposition the attention marker to sit right after the tab name instead of after the whole label, in both the top bar and the session picker, so it doesn't visually separate the name from an active pulse.
- commit
- 9829e353e57554434ee4681a4d693303ecb0a9c5
- author
- brice · · 2026-07-11 11:46 UTC
- parent
- dfc7b6e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +160 -64
M internal/usecase/daemon/render.go +1 -1
| @@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 370 | 370 | d.refreshSessionFocusedTitles(sess) | |
| 371 | 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | |
| 372 | 372 | bars.theme = ac.getTheme() | |
| 373 | - | _, attentionVisible := pulseStyle(bars.attentionFrame) | |
| 373 | + | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 374 | 374 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 375 | 375 | ||
| 376 | 376 | styles := newThemeStyles(ac.getTheme()) | |
| @@ -370,7 +370,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 370 | d.refreshSessionFocusedTitles(sess) | 370 | d.refreshSessionFocusedTitles(sess) | ||
| 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | ||
| 372 | bars.theme = ac.getTheme() | 372 | bars.theme = ac.getTheme() | ||
| 373 | - | _, attentionVisible := pulseStyle(bars.attentionFrame) | 373 | + | attentionVisible := pulseVisible(bars.attentionFrame) |
| 374 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | 374 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | ||
| 375 | 375 | ||||
| 376 | styles := newThemeStyles(ac.getTheme()) | 376 | styles := newThemeStyles(ac.getTheme()) | ||
M internal/usecase/daemon/status.go +30 -12
| @@ -44,20 +44,35 @@ const ( | |||
| 44 | 44 | pulseFrameInterval = 120 * time.Millisecond | |
| 45 | 45 | ) | |
| 46 | 46 | ||
| 47 | - | func pulseStyle(frame int) (renderer.Style, bool) { | |
| 48 | - | f := frame % pulseFrameCount | |
| 49 | - | if f == 0 { | |
| 50 | - | return renderer.DefaultStyle(), false | |
| 47 | + | // pulseVisible reports whether the attention bell glyph is showing (as | |
| 48 | + | // opposed to its blank beat) at frame. | |
| 49 | + | func pulseVisible(frame int) bool { | |
| 50 | + | return frame%pulseFrameCount != 0 | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // pulseStyle returns the style for the attention bell glyph at frame, built | |
| 54 | + | // on top of base so the bell always keeps the caller's background (the tab's | |
| 55 | + | // accent/statusBar, or a faded MRU entry's blended colors) instead of | |
| 56 | + | // punching a default-background hole in a themed bar. On the invisible beat | |
| 57 | + | // it returns (base, false) unchanged. | |
| 58 | + | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |
| 59 | + | if !pulseVisible(frame) { | |
| 60 | + | return base, false | |
| 51 | 61 | } | |
| 62 | + | f := frame % pulseFrameCount | |
| 52 | 63 | peak := pulseFrameCount / 2 | |
| 53 | 64 | distance := f - peak | |
| 54 | 65 | if distance < 0 { | |
| 55 | 66 | distance = -distance | |
| 56 | 67 | } | |
| 57 | 68 | intensity := 1 - float64(distance)/float64(peak) | |
| 58 | - | style := renderer.DefaultStyle() | |
| 69 | + | style := base | |
| 59 | 70 | style.Bold = true | |
| 60 | - | style.Foreground = 244 + int(intensity*11) | |
| 71 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 72 | + | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |
| 73 | + | } else { | |
| 74 | + | style.Foreground = 244 + int(intensity*11) | |
| 75 | + | } | |
| 61 | 76 | return style, true | |
| 62 | 77 | } | |
| 63 | 78 | ||
| @@ -76,11 +91,11 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 76 | 91 | } | |
| 77 | 92 | label := labels[i] | |
| 78 | 93 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| 79 | - | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 80 | 94 | if w.attention { | |
| 81 | 95 | writeStatusText(row, &x, " ", baseStyle) | |
| 82 | - | writeBell(row, &x, frame) | |
| 96 | + | writeBell(row, &x, frame, baseStyle) | |
| 83 | 97 | } | |
| 98 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 84 | 99 | writeStatusText(row, &x, " ", baseStyle) | |
| 85 | 100 | } | |
| 86 | 101 | drawRightPlainText(row, topRight, x, styles.statusBar) | |
| @@ -123,7 +138,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, | |||
| 123 | 138 | writeStatusText(row, x, " "+name, style) | |
| 124 | 139 | if attention { | |
| 125 | 140 | writeStatusText(row, x, " ", style) | |
| 126 | - | writeBell(row, x, attentionFrame) | |
| 141 | + | writeBell(row, x, attentionFrame, style) | |
| 127 | 142 | } | |
| 128 | 143 | writeStatusText(row, x, " ", style) | |
| 129 | 144 | } | |
| @@ -539,10 +554,13 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style | |||
| 539 | 554 | return base | |
| 540 | 555 | } | |
| 541 | 556 | ||
| 542 | - | func writeBell(row []renderer.Cell, x *int, frame int) { | |
| 543 | - | style, visible := pulseStyle(frame) | |
| 557 | + | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 558 | + | // base's style on its blank beat, so the bell never leaves a bare | |
| 559 | + | // default-background hole in a themed bar. | |
| 560 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | |
| 561 | + | style, visible := pulseStyle(frame, base) | |
| 544 | 562 | if !visible { | |
| 545 | - | writeStatusText(row, x, " ", renderer.DefaultStyle()) | |
| 563 | + | writeStatusText(row, x, " ", base) | |
| 546 | 564 | return | |
| 547 | 565 | } | |
| 548 | 566 | writeStatusText(row, x, string(attentionGlyph), style) | |
| @@ -44,20 +44,35 @@ const ( | |||||
| 44 | pulseFrameInterval = 120 * time.Millisecond | 44 | pulseFrameInterval = 120 * time.Millisecond | ||
| 45 | ) | 45 | ) | ||
| 46 | 46 | ||||
| 47 | - | func pulseStyle(frame int) (renderer.Style, bool) { | 47 | + | // pulseVisible reports whether the attention bell glyph is showing (as |
| 48 | - | f := frame % pulseFrameCount | 48 | + | // opposed to its blank beat) at frame. |
| 49 | - | if f == 0 { | 49 | + | func pulseVisible(frame int) bool { |
| 50 | - | return renderer.DefaultStyle(), false | 50 | + | return frame%pulseFrameCount != 0 |
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // pulseStyle returns the style for the attention bell glyph at frame, built | |||
| 54 | + | // on top of base so the bell always keeps the caller's background (the tab's | |||
| 55 | + | // accent/statusBar, or a faded MRU entry's blended colors) instead of | |||
| 56 | + | // punching a default-background hole in a themed bar. On the invisible beat | |||
| 57 | + | // it returns (base, false) unchanged. | |||
| 58 | + | func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||
| 59 | + | if !pulseVisible(frame) { | |||
| 60 | + | return base, false | |||
| 51 | } | 61 | } | ||
| 62 | + | f := frame % pulseFrameCount | |||
| 52 | peak := pulseFrameCount / 2 | 63 | peak := pulseFrameCount / 2 | ||
| 53 | distance := f - peak | 64 | distance := f - peak | ||
| 54 | if distance < 0 { | 65 | if distance < 0 { | ||
| 55 | distance = -distance | 66 | distance = -distance | ||
| 56 | } | 67 | } | ||
| 57 | intensity := 1 - float64(distance)/float64(peak) | 68 | intensity := 1 - float64(distance)/float64(peak) | ||
| 58 | - | style := renderer.DefaultStyle() | 69 | + | style := base |
| 59 | style.Bold = true | 70 | style.Bold = true | ||
| 60 | - | style.Foreground = 244 + int(intensity*11) | 71 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { |
| 72 | + | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |||
| 73 | + | } else { | |||
| 74 | + | style.Foreground = 244 + int(intensity*11) | |||
| 75 | + | } | |||
| 61 | return style, true | 76 | return style, true | ||
| 62 | } | 77 | } | ||
| 63 | 78 | ||||
| @@ -76,11 +91,11 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 76 | } | 91 | } | ||
| 77 | label := labels[i] | 92 | label := labels[i] | ||
| 78 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | 93 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | ||
| 79 | - | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |||
| 80 | if w.attention { | 94 | if w.attention { | ||
| 81 | writeStatusText(row, &x, " ", baseStyle) | 95 | writeStatusText(row, &x, " ", baseStyle) | ||
| 82 | - | writeBell(row, &x, frame) | 96 | + | writeBell(row, &x, frame, baseStyle) |
| 83 | } | 97 | } | ||
| 98 | + | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |||
| 84 | writeStatusText(row, &x, " ", baseStyle) | 99 | writeStatusText(row, &x, " ", baseStyle) | ||
| 85 | } | 100 | } | ||
| 86 | drawRightPlainText(row, topRight, x, styles.statusBar) | 101 | drawRightPlainText(row, topRight, x, styles.statusBar) | ||
| @@ -123,7 +138,7 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, | |||||
| 123 | writeStatusText(row, x, " "+name, style) | 138 | writeStatusText(row, x, " "+name, style) | ||
| 124 | if attention { | 139 | if attention { | ||
| 125 | writeStatusText(row, x, " ", style) | 140 | writeStatusText(row, x, " ", style) | ||
| 126 | - | writeBell(row, x, attentionFrame) | 141 | + | writeBell(row, x, attentionFrame, style) |
| 127 | } | 142 | } | ||
| 128 | writeStatusText(row, x, " ", style) | 143 | writeStatusText(row, x, " ", style) | ||
| 129 | } | 144 | } | ||
| @@ -539,10 +554,13 @@ func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style | |||||
| 539 | return base | 554 | return base | ||
| 540 | } | 555 | } | ||
| 541 | 556 | ||||
| 542 | - | func writeBell(row []renderer.Cell, x *int, frame int) { | 557 | + | // writeBell draws the attention glyph on its visible beat, or a plain cell in |
| 543 | - | style, visible := pulseStyle(frame) | 558 | + | // base's style on its blank beat, so the bell never leaves a bare |
| 559 | + | // default-background hole in a themed bar. | |||
| 560 | + | func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) { | |||
| 561 | + | style, visible := pulseStyle(frame, base) | |||
| 544 | if !visible { | 562 | if !visible { | ||
| 545 | - | writeStatusText(row, x, " ", renderer.DefaultStyle()) | 563 | + | writeStatusText(row, x, " ", base) |
| 546 | return | 564 | return | ||
| 547 | } | 565 | } | ||
| 548 | writeStatusText(row, x, string(attentionGlyph), style) | 566 | writeStatusText(row, x, string(attentionGlyph), style) | ||
M internal/usecase/daemon/status_test.go +110 -33
| @@ -240,11 +240,12 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 240 | 240 | tabs := []statusTab{ | |
| 241 | 241 | {name: "api", paneTitle: "shell"}, | |
| 242 | 242 | {name: "logs", active: true, attention: true}, | |
| 243 | + | {name: "err", paneTitle: "build failed", attention: true}, | |
| 243 | 244 | } | |
| 244 | 245 | status := statusSnapshot{tabs: tabs} | |
| 245 | - | const rowLen = 40 | |
| 246 | + | const rowLen = 60 | |
| 246 | 247 | ||
| 247 | - | t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) { | |
| 248 | + | t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) { | |
| 248 | 249 | theme := themeui.Theme{ | |
| 249 | 250 | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 250 | 251 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| @@ -257,29 +258,36 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 257 | 258 | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| 258 | 259 | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |
| 259 | 260 | ||
| 261 | + | labels := fitTabLabels(tabs, rowLen, "") | |
| 262 | + | ||
| 263 | + | // Frame 0 is the bell's blank beat: the attention space and the blank | |
| 264 | + | // bell cell must both be exactly the tab's base style (not | |
| 265 | + | // renderer.DefaultStyle(), which would punch a hole in a themed bar). | |
| 266 | + | // This also pins down segment order: name, then (if attention) | |
| 267 | + | // space+bell, then title, then the trailing space. | |
| 260 | 268 | row := make([]renderer.Cell, rowLen) | |
| 261 | 269 | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 262 | - | ||
| 263 | - | labels := fitTabLabels(tabs, rowLen, "") | |
| 264 | 270 | x := 0 | |
| 265 | 271 | ||
| 266 | - | // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain. | |
| 272 | + | // tab 0: inactive, no attention: name, title, trailing space. | |
| 267 | 273 | nameEnd := x + 1 + labels[0].nameLen | |
| 268 | 274 | for i := x; i < nameEnd; i++ { | |
| 269 | 275 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |
| 270 | 276 | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |
| 271 | 277 | } | |
| 278 | + | x = nameEnd | |
| 272 | 279 | titleLen := len(labels[0].text) - labels[0].nameLen | |
| 273 | - | titleEnd := nameEnd + titleLen | |
| 274 | - | for i := nameEnd; i < titleEnd; i++ { | |
| 280 | + | titleEnd := x + titleLen | |
| 281 | + | for i := x; i < titleEnd; i++ { | |
| 275 | 282 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |
| 276 | 283 | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |
| 277 | 284 | } | |
| 278 | 285 | x = titleEnd | |
| 279 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x) | |
| 286 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) | |
| 280 | 287 | x++ | |
| 281 | 288 | ||
| 282 | - | // tab 1: active, "logs" bold, no title, attention space + bell + trailing space. | |
| 289 | + | // tab 1: active, attention, no title: name, attention space+bell (both | |
| 290 | + | // base style at frame 0), trailing space. | |
| 283 | 291 | nameEnd = x + 1 + labels[1].nameLen | |
| 284 | 292 | for i := x; i < nameEnd; i++ { | |
| 285 | 293 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |
| @@ -287,9 +295,46 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 287 | 295 | } | |
| 288 | 296 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |
| 289 | 297 | x = nameEnd | |
| 290 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x) | |
| 291 | - | x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns | |
| 292 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x) | |
| 298 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | |
| 299 | + | x++ | |
| 300 | + | tab1Bell := x | |
| 301 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |
| 302 | + | x++ | |
| 303 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |
| 304 | + | x++ | |
| 305 | + | ||
| 306 | + | // tab 2: inactive, attention, with title: name, attention space+bell | |
| 307 | + | // (both base style at frame 0), title, trailing space. | |
| 308 | + | nameEnd = x + 1 + labels[2].nameLen | |
| 309 | + | for i := x; i < nameEnd; i++ { | |
| 310 | + | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | |
| 311 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | |
| 312 | + | } | |
| 313 | + | x = nameEnd | |
| 314 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |
| 315 | + | x++ | |
| 316 | + | tab2Bell := x | |
| 317 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |
| 318 | + | x++ | |
| 319 | + | titleLen = len(labels[2].text) - labels[2].nameLen | |
| 320 | + | titleEnd = x + titleLen | |
| 321 | + | for i := x; i < titleEnd; i++ { | |
| 322 | + | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | |
| 323 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | |
| 324 | + | } | |
| 325 | + | x = titleEnd | |
| 326 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |
| 327 | + | ||
| 328 | + | // On a visible pulse frame, the bell glyph itself must keep the same | |
| 329 | + | // background as its tab's base style. | |
| 330 | + | visibleRow := make([]renderer.Cell, rowLen) | |
| 331 | + | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | |
| 332 | + | require.Equal(t, rune(attentionGlyph), visibleRow[tab1Bell].Rune) | |
| 333 | + | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | |
| 334 | + | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |
| 335 | + | require.Equal(t, rune(attentionGlyph), visibleRow[tab2Bell].Rune) | |
| 336 | + | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | |
| 337 | + | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |
| 293 | 338 | }) | |
| 294 | 339 | ||
| 295 | 340 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |
| @@ -302,24 +347,26 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 302 | 347 | row := make([]renderer.Cell, rowLen) | |
| 303 | 348 | drawTopBarSnapshot(row, status, 0, "", styles) | |
| 304 | 349 | ||
| 305 | - | // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention | |
| 306 | - | // space + blank bell (frame 0 is not visible) + trailing space, then | |
| 307 | - | // blank padding to rowLen: byte-for-byte what the pre-change renderer | |
| 350 | + | // " api (shell) " (tab 0) + " logs " (tab 1: name, attention | |
| 351 | + | // space+blank bell, trailing space) + " err (build failed) " (tab 2: | |
| 352 | + | // name, attention space+blank bell, title, trailing space), then blank | |
| 353 | + | // padding to rowLen: byte-for-byte what the pre-change renderer | |
| 308 | 354 | // produced, since every new style field collapses to statusBar/accent. | |
| 309 | 355 | const tab0 = " api (shell) " | |
| 310 | 356 | const tab1 = " logs " | |
| 311 | - | require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row)) | |
| 357 | + | const tab2 = " err (build failed) " | |
| 358 | + | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | |
| 312 | 359 | ||
| 313 | 360 | for i, c := range row[:len(tab0)] { | |
| 314 | 361 | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |
| 315 | 362 | } | |
| 316 | - | activeStart := len(tab0) | |
| 317 | - | bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here | |
| 318 | - | for i := activeStart; i < activeStart+len(tab1); i++ { | |
| 319 | - | if i == bellCell { | |
| 320 | - | continue | |
| 321 | - | } | |
| 322 | - | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i) | |
| 363 | + | tab1Start := len(tab0) | |
| 364 | + | for i := tab1Start; i < tab1Start+len(tab1); i++ { | |
| 365 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) | |
| 366 | + | } | |
| 367 | + | tab2Start := tab1Start + len(tab1) | |
| 368 | + | for i := tab2Start; i < tab2Start+len(tab2); i++ { | |
| 369 | + | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) | |
| 323 | 370 | } | |
| 324 | 371 | }) | |
| 325 | 372 | } | |
| @@ -818,15 +865,44 @@ func TestAttentionConstants(t *testing.T) { | |||
| 818 | 865 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | |
| 819 | 866 | } | |
| 820 | 867 | ||
| 821 | - | func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) { | |
| 822 | - | _, visible := pulseStyle(0) | |
| 823 | - | require.False(t, visible) | |
| 868 | + | func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { | |
| 869 | + | rgbBase := renderer.Style{ | |
| 870 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 871 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 872 | + | } | |
| 873 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |
| 824 | 874 | ||
| 825 | - | low, visible := pulseStyle(1) | |
| 826 | - | require.True(t, visible) | |
| 827 | - | peak, visible := pulseStyle(pulseFrameCount / 2) | |
| 828 | - | require.True(t, visible) | |
| 829 | - | require.Greater(t, peak.Foreground, low.Foreground) | |
| 875 | + | t.Run("invisible frame returns base unchanged", func(t *testing.T) { | |
| 876 | + | for _, base := range []renderer.Style{rgbBase, indexedBase} { | |
| 877 | + | style, visible := pulseStyle(0, base) | |
| 878 | + | require.False(t, visible) | |
| 879 | + | require.True(t, style.Equal(base)) | |
| 880 | + | } | |
| 881 | + | }) | |
| 882 | + | ||
| 883 | + | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | |
| 884 | + | low, visible := pulseStyle(1, rgbBase) | |
| 885 | + | require.True(t, visible) | |
| 886 | + | require.True(t, low.Bold) | |
| 887 | + | require.True(t, low.HasBackgroundRGB) | |
| 888 | + | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | |
| 889 | + | ||
| 890 | + | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | |
| 891 | + | require.True(t, visible) | |
| 892 | + | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | |
| 893 | + | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | |
| 894 | + | }) | |
| 895 | + | ||
| 896 | + | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | |
| 897 | + | low, visible := pulseStyle(1, indexedBase) | |
| 898 | + | require.True(t, visible) | |
| 899 | + | require.True(t, low.Bold) | |
| 900 | + | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | |
| 901 | + | ||
| 902 | + | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) | |
| 903 | + | require.True(t, visible) | |
| 904 | + | require.Greater(t, peak.Foreground, low.Foreground) | |
| 905 | + | }) | |
| 830 | 906 | } | |
| 831 | 907 | ||
| 832 | 908 | func TestTopBarRendersAttentionBell(t *testing.T) { | |
| @@ -846,8 +922,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 846 | 922 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | |
| 847 | 923 | ||
| 848 | 924 | // Tab 2's label is enriched with its focused pane's title ("sh", the | |
| 849 | - | // default shell fallback) and truncated to fit alongside the bell. | |
| 850 | - | require.Contains(t, rowText(frame.Row(0)), "2 (s") | |
| 925 | + | // default shell fallback) and truncated to fit alongside the bell. The | |
| 926 | + | // bell sits right after the name, before the (title) segment. | |
| 927 | + | require.Contains(t, rowText(frame.Row(0)), "2 "+string(attentionGlyph)+" (s") | |
| 851 | 928 | for _, c := range frame.Row(0) { | |
| 852 | 929 | if c.Rune == attentionGlyph { | |
| 853 | 930 | require.True(t, c.Style.Bold) | |
| @@ -240,11 +240,12 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 240 | tabs := []statusTab{ | 240 | tabs := []statusTab{ | ||
| 241 | {name: "api", paneTitle: "shell"}, | 241 | {name: "api", paneTitle: "shell"}, | ||
| 242 | {name: "logs", active: true, attention: true}, | 242 | {name: "logs", active: true, attention: true}, | ||
| 243 | + | {name: "err", paneTitle: "build failed", attention: true}, | |||
| 243 | } | 244 | } | ||
| 244 | status := statusSnapshot{tabs: tabs} | 245 | status := statusSnapshot{tabs: tabs} | ||
| 245 | - | const rowLen = 40 | 246 | + | const rowLen = 60 |
| 246 | 247 | ||||
| 247 | - | t.Run("truecolor theme bolds names and mutes titles", func(t *testing.T) { | 248 | + | t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) { |
| 248 | theme := themeui.Theme{ | 249 | theme := themeui.Theme{ | ||
| 249 | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | 250 | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | ||
| 250 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | 251 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | ||
| @@ -257,29 +258,36 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 257 | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | 258 | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | ||
| 258 | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | 259 | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | ||
| 259 | 260 | ||||
| 261 | + | labels := fitTabLabels(tabs, rowLen, "") | |||
| 262 | + | ||||
| 263 | + | // Frame 0 is the bell's blank beat: the attention space and the blank | |||
| 264 | + | // bell cell must both be exactly the tab's base style (not | |||
| 265 | + | // renderer.DefaultStyle(), which would punch a hole in a themed bar). | |||
| 266 | + | // This also pins down segment order: name, then (if attention) | |||
| 267 | + | // space+bell, then title, then the trailing space. | |||
| 260 | row := make([]renderer.Cell, rowLen) | 268 | row := make([]renderer.Cell, rowLen) | ||
| 261 | drawTopBarSnapshot(row, status, 0, "", styles) | 269 | drawTopBarSnapshot(row, status, 0, "", styles) | ||
| 262 | - | ||||
| 263 | - | labels := fitTabLabels(tabs, rowLen, "") | |||
| 264 | x := 0 | 270 | x := 0 | ||
| 265 | 271 | ||||
| 266 | - | // tab 0: inactive, "api" bold, " (shell)" muted, trailing space plain. | 272 | + | // tab 0: inactive, no attention: name, title, trailing space. |
| 267 | nameEnd := x + 1 + labels[0].nameLen | 273 | nameEnd := x + 1 + labels[0].nameLen | ||
| 268 | for i := x; i < nameEnd; i++ { | 274 | for i := x; i < nameEnd; i++ { | ||
| 269 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | 275 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | ||
| 270 | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | 276 | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | ||
| 271 | } | 277 | } | ||
| 278 | + | x = nameEnd | |||
| 272 | titleLen := len(labels[0].text) - labels[0].nameLen | 279 | titleLen := len(labels[0].text) - labels[0].nameLen | ||
| 273 | - | titleEnd := nameEnd + titleLen | 280 | + | titleEnd := x + titleLen |
| 274 | - | for i := nameEnd; i < titleEnd; i++ { | 281 | + | for i := x; i < titleEnd; i++ { |
| 275 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | 282 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | ||
| 276 | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | 283 | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | ||
| 277 | } | 284 | } | ||
| 278 | x = titleEnd | 285 | x = titleEnd | ||
| 279 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: trailing space must keep the base style", x) | 286 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) |
| 280 | x++ | 287 | x++ | ||
| 281 | 288 | ||||
| 282 | - | // tab 1: active, "logs" bold, no title, attention space + bell + trailing space. | 289 | + | // tab 1: active, attention, no title: name, attention space+bell (both |
| 290 | + | // base style at frame 0), trailing space. | |||
| 283 | nameEnd = x + 1 + labels[1].nameLen | 291 | nameEnd = x + 1 + labels[1].nameLen | ||
| 284 | for i := x; i < nameEnd; i++ { | 292 | for i := x; i < nameEnd; i++ { | ||
| 285 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | 293 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | ||
| @@ -287,9 +295,46 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 287 | } | 295 | } | ||
| 288 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | 296 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | ||
| 289 | x = nameEnd | 297 | x = nameEnd | ||
| 290 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: attention leading space must keep the active base style", x) | 298 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) |
| 291 | - | x += 2 // skip the leading space (checked above) and the bell glyph itself, which pulseStyle owns | 299 | + | x++ |
| 292 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: trailing space must keep the active base style", x) | 300 | + | tab1Bell := x |
| 301 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |||
| 302 | + | x++ | |||
| 303 | + | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |||
| 304 | + | x++ | |||
| 305 | + | ||||
| 306 | + | // tab 2: inactive, attention, with title: name, attention space+bell | |||
| 307 | + | // (both base style at frame 0), title, trailing space. | |||
| 308 | + | nameEnd = x + 1 + labels[2].nameLen | |||
| 309 | + | for i := x; i < nameEnd; i++ { | |||
| 310 | + | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | |||
| 311 | + | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | |||
| 312 | + | } | |||
| 313 | + | x = nameEnd | |||
| 314 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |||
| 315 | + | x++ | |||
| 316 | + | tab2Bell := x | |||
| 317 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |||
| 318 | + | x++ | |||
| 319 | + | titleLen = len(labels[2].text) - labels[2].nameLen | |||
| 320 | + | titleEnd = x + titleLen | |||
| 321 | + | for i := x; i < titleEnd; i++ { | |||
| 322 | + | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | |||
| 323 | + | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | |||
| 324 | + | } | |||
| 325 | + | x = titleEnd | |||
| 326 | + | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |||
| 327 | + | ||||
| 328 | + | // On a visible pulse frame, the bell glyph itself must keep the same | |||
| 329 | + | // background as its tab's base style. | |||
| 330 | + | visibleRow := make([]renderer.Cell, rowLen) | |||
| 331 | + | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | |||
| 332 | + | require.Equal(t, rune(attentionGlyph), visibleRow[tab1Bell].Rune) | |||
| 333 | + | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | |||
| 334 | + | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |||
| 335 | + | require.Equal(t, rune(attentionGlyph), visibleRow[tab2Bell].Rune) | |||
| 336 | + | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | |||
| 337 | + | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |||
| 293 | }) | 338 | }) | ||
| 294 | 339 | ||||
| 295 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | 340 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | ||
| @@ -302,24 +347,26 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 302 | row := make([]renderer.Cell, rowLen) | 347 | row := make([]renderer.Cell, rowLen) | ||
| 303 | drawTopBarSnapshot(row, status, 0, "", styles) | 348 | drawTopBarSnapshot(row, status, 0, "", styles) | ||
| 304 | 349 | ||||
| 305 | - | // " api (shell) " (tab 0, incl. trailing space) + " logs" + attention | 350 | + | // " api (shell) " (tab 0) + " logs " (tab 1: name, attention |
| 306 | - | // space + blank bell (frame 0 is not visible) + trailing space, then | 351 | + | // space+blank bell, trailing space) + " err (build failed) " (tab 2: |
| 307 | - | // blank padding to rowLen: byte-for-byte what the pre-change renderer | 352 | + | // name, attention space+blank bell, title, trailing space), then blank |
| 353 | + | // padding to rowLen: byte-for-byte what the pre-change renderer | |||
| 308 | // produced, since every new style field collapses to statusBar/accent. | 354 | // produced, since every new style field collapses to statusBar/accent. | ||
| 309 | const tab0 = " api (shell) " | 355 | const tab0 = " api (shell) " | ||
| 310 | const tab1 = " logs " | 356 | const tab1 = " logs " | ||
| 311 | - | require.Equal(t, tab0+tab1+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)), rowText(row)) | 357 | + | const tab2 = " err (build failed) " |
| 358 | + | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | |||
| 312 | 359 | ||||
| 313 | for i, c := range row[:len(tab0)] { | 360 | for i, c := range row[:len(tab0)] { | ||
| 314 | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | 361 | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | ||
| 315 | } | 362 | } | ||
| 316 | - | activeStart := len(tab0) | 363 | + | tab1Start := len(tab0) |
| 317 | - | bellCell := activeStart + len(" logs") + 1 // pulseStyle-owned, not asserted here | 364 | + | for i := tab1Start; i < tab1Start+len(tab1); i++ { |
| 318 | - | for i := activeStart; i < activeStart+len(tab1); i++ { | 365 | + | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) |
| 319 | - | if i == bellCell { | 366 | + | } |
| 320 | - | continue | 367 | + | tab2Start := tab1Start + len(tab1) |
| 321 | - | } | 368 | + | for i := tab2Start; i < tab2Start+len(tab2); i++ { |
| 322 | - | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style", i) | 369 | + | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) |
| 323 | } | 370 | } | ||
| 324 | }) | 371 | }) | ||
| 325 | } | 372 | } | ||
| @@ -818,15 +865,44 @@ func TestAttentionConstants(t *testing.T) { | |||||
| 818 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | 865 | require.Equal(t, 120*time.Millisecond, pulseFrameInterval) | ||
| 819 | } | 866 | } | ||
| 820 | 867 | ||||
| 821 | - | func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) { | 868 | + | func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) { |
| 822 | - | _, visible := pulseStyle(0) | 869 | + | rgbBase := renderer.Style{ |
| 823 | - | require.False(t, visible) | 870 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200}, |
| 871 | + | HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}, | |||
| 872 | + | } | |||
| 873 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |||
| 824 | 874 | ||||
| 825 | - | low, visible := pulseStyle(1) | 875 | + | t.Run("invisible frame returns base unchanged", func(t *testing.T) { |
| 826 | - | require.True(t, visible) | 876 | + | for _, base := range []renderer.Style{rgbBase, indexedBase} { |
| 827 | - | peak, visible := pulseStyle(pulseFrameCount / 2) | 877 | + | style, visible := pulseStyle(0, base) |
| 828 | - | require.True(t, visible) | 878 | + | require.False(t, visible) |
| 829 | - | require.Greater(t, peak.Foreground, low.Foreground) | 879 | + | require.True(t, style.Equal(base)) |
| 880 | + | } | |||
| 881 | + | }) | |||
| 882 | + | ||||
| 883 | + | t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) { | |||
| 884 | + | low, visible := pulseStyle(1, rgbBase) | |||
| 885 | + | require.True(t, visible) | |||
| 886 | + | require.True(t, low.Bold) | |||
| 887 | + | require.True(t, low.HasBackgroundRGB) | |||
| 888 | + | require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar") | |||
| 889 | + | ||||
| 890 | + | peak, visible := pulseStyle(pulseFrameCount/2, rgbBase) | |||
| 891 | + | require.True(t, visible) | |||
| 892 | + | require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly") | |||
| 893 | + | require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity") | |||
| 894 | + | }) | |||
| 895 | + | ||||
| 896 | + | t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) { | |||
| 897 | + | low, visible := pulseStyle(1, indexedBase) | |||
| 898 | + | require.True(t, visible) | |||
| 899 | + | require.True(t, low.Bold) | |||
| 900 | + | require.True(t, low.Inverse, "non-color base attributes like inverse must survive") | |||
| 901 | + | ||||
| 902 | + | peak, visible := pulseStyle(pulseFrameCount/2, indexedBase) | |||
| 903 | + | require.True(t, visible) | |||
| 904 | + | require.Greater(t, peak.Foreground, low.Foreground) | |||
| 905 | + | }) | |||
| 830 | } | 906 | } | ||
| 831 | 907 | ||||
| 832 | func TestTopBarRendersAttentionBell(t *testing.T) { | 908 | func TestTopBarRendersAttentionBell(t *testing.T) { | ||
| @@ -846,8 +922,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||||
| 846 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | 922 | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | ||
| 847 | 923 | ||||
| 848 | // Tab 2's label is enriched with its focused pane's title ("sh", the | 924 | // Tab 2's label is enriched with its focused pane's title ("sh", the | ||
| 849 | - | // default shell fallback) and truncated to fit alongside the bell. | 925 | + | // default shell fallback) and truncated to fit alongside the bell. The |
| 850 | - | require.Contains(t, rowText(frame.Row(0)), "2 (s") | 926 | + | // bell sits right after the name, before the (title) segment. |
| 927 | + | require.Contains(t, rowText(frame.Row(0)), "2 "+string(attentionGlyph)+" (s") | |||
| 851 | for _, c := range frame.Row(0) { | 928 | for _, c := range frame.Row(0) { | ||
| 852 | if c.Rune == attentionGlyph { | 929 | if c.Rune == attentionGlyph { | ||
| 853 | require.True(t, c.Style.Bold) | 930 | require.True(t, c.Style.Bold) | ||
M internal/usecase/picker/model.go +8 -7
| @@ -217,9 +217,10 @@ func (m *Model) firstLeaf() int { | |||
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | 219 | // renderList draws each visible row as up to three segments: a name segment | |
| 220 | - | // (bold when styles came from a truecolor theme), a muted detail segment | |
| 221 | - | // (tab rows only), and a base-styled attention marker or "(stopped)" suffix. | |
| 222 | - | // A tight width ellipsizes the detail segment before eating into the name. | |
| 220 | + | // (bold when styles came from a truecolor theme), a base-styled attention | |
| 221 | + | // marker right after the name, and a muted detail segment (tab rows only) — | |
| 222 | + | // or a base-styled "(stopped)" suffix for stopped session headers. A tight | |
| 223 | + | // width ellipsizes the detail segment before eating into the name. | |
| 223 | 224 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |
| 224 | 225 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 225 | 226 | return | |
| @@ -253,12 +254,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 253 | 254 | continue | |
| 254 | 255 | } | |
| 255 | 256 | ||
| 256 | - | detail := ui.TruncateText(r.detail, clipX-x) | |
| 257 | - | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |
| 258 | - | ||
| 259 | 257 | if r.attention { | |
| 260 | - | ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) | |
| 258 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) | |
| 261 | 259 | } | |
| 260 | + | ||
| 261 | + | detail := ui.TruncateText(r.detail, clipX-x) | |
| 262 | + | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |
| 262 | 263 | } | |
| 263 | 264 | } | |
| 264 | 265 | ||
| @@ -217,9 +217,10 @@ func (m *Model) firstLeaf() int { | |||||
| 217 | } | 217 | } | ||
| 218 | 218 | ||||
| 219 | // renderList draws each visible row as up to three segments: a name segment | 219 | // renderList draws each visible row as up to three segments: a name segment | ||
| 220 | - | // (bold when styles came from a truecolor theme), a muted detail segment | 220 | + | // (bold when styles came from a truecolor theme), a base-styled attention |
| 221 | - | // (tab rows only), and a base-styled attention marker or "(stopped)" suffix. | 221 | + | // marker right after the name, and a muted detail segment (tab rows only) — |
| 222 | - | // A tight width ellipsizes the detail segment before eating into the name. | 222 | + | // or a base-styled "(stopped)" suffix for stopped session headers. A tight |
| 223 | + | // width ellipsizes the detail segment before eating into the name. | |||
| 223 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | 224 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | ||
| 224 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 225 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 225 | return | 226 | return | ||
| @@ -253,12 +254,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 253 | continue | 254 | continue | ||
| 254 | } | 255 | } | ||
| 255 | 256 | ||||
| 256 | - | detail := ui.TruncateText(r.detail, clipX-x) | |||
| 257 | - | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |||
| 258 | - | ||||
| 259 | if r.attention { | 257 | if r.attention { | ||
| 260 | - | ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) | 258 | + | x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(attentionGlyph), base) |
| 261 | } | 259 | } | ||
| 260 | + | ||||
| 261 | + | detail := ui.TruncateText(r.detail, clipX-x) | |||
| 262 | + | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |||
| 262 | } | 263 | } | ||
| 263 | } | 264 | } | ||
| 264 | 265 | ||||
M internal/usecase/picker/model_test.go +11 -11
| @@ -191,19 +191,19 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||
| 191 | 191 | require.Equal(t, '(', frame.At(8, 1).Rune) | |
| 192 | 192 | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | |
| 193 | 193 | ||
| 194 | - | // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses | |
| 195 | - | // Detail, and the attention marker after the detail uses the row's base | |
| 196 | - | // style (not muted). | |
| 194 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, then the | |
| 195 | + | // attention marker (right after the name, before detail) uses the row's | |
| 196 | + | // base style (not muted), then detail uses Detail. | |
| 197 | 197 | require.Equal(t, 'b', frame.At(2, 2).Rune) | |
| 198 | 198 | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | |
| 199 | - | require.Equal(t, ' ', frame.At(6, 2).Rune) | |
| 200 | - | require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style") | |
| 201 | - | require.Equal(t, '(', frame.At(7, 2).Rune) | |
| 202 | - | require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style") | |
| 203 | - | require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space") | |
| 204 | - | require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 205 | - | require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune) | |
| 206 | - | require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 199 | + | require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space") | |
| 200 | + | require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 201 | + | require.Equal(t, rune(attentionGlyph), frame.At(7, 2).Rune) | |
| 202 | + | require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | |
| 203 | + | require.Equal(t, ' ', frame.At(8, 2).Rune) | |
| 204 | + | require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style") | |
| 205 | + | require.Equal(t, '(', frame.At(9, 2).Rune) | |
| 206 | + | require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style") | |
| 207 | 207 | } | |
| 208 | 208 | ||
| 209 | 209 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |
| @@ -191,19 +191,19 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||||
| 191 | require.Equal(t, '(', frame.At(8, 1).Rune) | 191 | require.Equal(t, '(', frame.At(8, 1).Rune) | ||
| 192 | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | 192 | require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style") | ||
| 193 | 193 | ||||
| 194 | - | // Row 2 is the non-selected tab ("beta"): name uses Name, detail uses | 194 | + | // Row 2 is the non-selected tab ("beta"): name uses Name, then the |
| 195 | - | // Detail, and the attention marker after the detail uses the row's base | 195 | + | // attention marker (right after the name, before detail) uses the row's |
| 196 | - | // style (not muted). | 196 | + | // base style (not muted), then detail uses Detail. |
| 197 | require.Equal(t, 'b', frame.At(2, 2).Rune) | 197 | require.Equal(t, 'b', frame.At(2, 2).Rune) | ||
| 198 | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | 198 | require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style") | ||
| 199 | - | require.Equal(t, ' ', frame.At(6, 2).Rune) | 199 | + | require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space") |
| 200 | - | require.True(t, frame.At(6, 2).Style.Equal(detailStyle), "detail segment style") | 200 | + | require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") |
| 201 | - | require.Equal(t, '(', frame.At(7, 2).Rune) | 201 | + | require.Equal(t, rune(attentionGlyph), frame.At(7, 2).Rune) |
| 202 | - | require.True(t, frame.At(7, 2).Style.Equal(detailStyle), "detail segment style") | 202 | + | require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") |
| 203 | - | require.Equal(t, ' ', frame.At(13, 2).Rune, "attention marker leading space") | 203 | + | require.Equal(t, ' ', frame.At(8, 2).Rune) |
| 204 | - | require.True(t, frame.At(13, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | 204 | + | require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style") |
| 205 | - | require.Equal(t, rune(attentionGlyph), frame.At(14, 2).Rune) | 205 | + | require.Equal(t, '(', frame.At(9, 2).Rune) |
| 206 | - | require.True(t, frame.At(14, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted") | 206 | + | require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style") |
| 207 | } | 207 | } | ||
| 208 | 208 | ||||
| 209 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | 209 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | ||