detached 8b2d766

merge(main): integrate truecolor fader

commit
8b2d76617fc9b23a799b75f4d9223c320a30ba36
author
brice · · 2026-07-15 05:20 UTC
merge of
1956fce + 694827a · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +224 -39
M internal/usecase/daemon/floating_render.go +3 -3
@@ -107,10 +107,10 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
107107 full := input.full
108108
109109 frame := base.Clone()
110- legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
111- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
110+ layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
111+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
112112 geometry := floating.geometry.translate(content.X, content.Y)
113- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
113+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
114114 damage := append([]renderer.Damage(nil), baseDamage...)
115115 for _, d := range floating.pane.damage {
116116 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -107,10 +107,10 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
107 full := input.full107 full := input.full
108 108
109 frame := base.Clone()109 frame := base.Clone()
110- legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}110+ layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
111- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)111+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
112 geometry := floating.geometry.translate(content.X, content.Y)112 geometry := floating.geometry.translate(content.X, content.Y)
113- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)113+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
114 damage := append([]renderer.Damage(nil), baseDamage...)114 damage := append([]renderer.Damage(nil), baseDamage...)
115 for _, d := range floating.pane.damage {115 for _, d := range floating.pane.damage {
116 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)116 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
M internal/usecase/daemon/overlay_backdrop.go +2 -1
@@ -36,6 +36,7 @@ func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, la
3636 }
3737
3838 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ dimmer := themeui.NewDimmer(theme)
3940 x0 := max(rect.X, 0)
4041 y0 := max(rect.Y, 0)
4142 x1 := min(rect.X+rect.Width, frame.Width)
@@ -43,7 +44,7 @@ func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
4344 for y := y0; y < y1; y++ {
4445 for x := x0; x < x1; x++ {
4546 cell := frame.At(x, y)
46- cell.Style = themeui.DimStyle(cell.Style, theme)
47+ cell.Style = dimmer.Dim(cell.Style)
4748 frame.Set(x, y, cell)
4849 }
4950 }
@@ -36,6 +36,7 @@ func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, la
36 }36 }
37 37
38 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {38 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ dimmer := themeui.NewDimmer(theme)
39 x0 := max(rect.X, 0)40 x0 := max(rect.X, 0)
40 y0 := max(rect.Y, 0)41 y0 := max(rect.Y, 0)
41 x1 := min(rect.X+rect.Width, frame.Width)42 x1 := min(rect.X+rect.Width, frame.Width)
@@ -43,7 +44,7 @@ func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
43 for y := y0; y < y1; y++ {44 for y := y0; y < y1; y++ {
44 for x := x0; x < x1; x++ {45 for x := x0; x < x1; x++ {
45 cell := frame.At(x, y)46 cell := frame.At(x, y)
46- cell.Style = themeui.DimStyle(cell.Style, theme)47+ cell.Style = dimmer.Dim(cell.Style)
47 frame.Set(x, y, cell)48 frame.Set(x, y, cell)
48 }49 }
49 }50 }
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
@@ -71,7 +71,7 @@ func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
7171 (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
7272
7373 for _, point := range tt.affected {
74- require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
74+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
7575 }
7676 for _, point := range tt.unaffected {
7777 require.Equal(t, original, frame.At(point[0], point[1]).Style)
@@ -71,7 +71,7 @@ func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
71 (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())71 (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72 72
73 for _, point := range tt.affected {73 for _, point := range tt.affected {
74- require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)74+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
75 }75 }
76 for _, point := range tt.unaffected {76 for _, point := range tt.unaffected {
77 require.Equal(t, original, frame.At(point[0], point[1]).Style)77 require.Equal(t, original, frame.At(point[0], point[1]).Style)
M internal/usecase/daemon/render.go +2 -2
@@ -430,14 +430,14 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
430430 return pl
431431 }
432432
433-func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
433+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
434434 rows := min(r.Height, src.Height)
435435 cols := min(r.Width, src.Width)
436436 for y := range rows {
437437 for x := range cols {
438438 cell := src.At(x, y)
439439 if dim {
440- cell.Style = themeui.DimStyle(cell.Style, theme)
440+ cell.Style = dimmer.Dim(cell.Style)
441441 }
442442 dst.Set(r.X+x, r.Y+y, cell)
443443 }
@@ -430,14 +430,14 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
430 return pl430 return pl
431 }431 }
432 432
433-func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {433+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
434 rows := min(r.Height, src.Height)434 rows := min(r.Height, src.Height)
435 cols := min(r.Width, src.Width)435 cols := min(r.Width, src.Width)
436 for y := range rows {436 for y := range rows {
437 for x := range cols {437 for x := range cols {
438 cell := src.At(x, y)438 cell := src.At(x, y)
439 if dim {439 if dim {
440- cell.Style = themeui.DimStyle(cell.Style, theme)440+ cell.Style = dimmer.Dim(cell.Style)
441 }441 }
442 dst.Set(r.X+x, r.Y+y, cell)442 dst.Set(r.X+x, r.Y+y, cell)
443 }443 }
M internal/usecase/daemon/render_pipeline.go +11 -8
@@ -52,6 +52,8 @@ type composedRenderFrame struct {
5252 // composeFrame is pure with respect to daemon ownership: it consumes only the
5353 // capture, the last committed cache, and an independent attachment-owned
5454 // scratch cache. It returns a replacement cache without mutating committed.
55+const inactivePaneForegroundDimming = 55
56+
5557 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
5658 scratch := composeCacheInput{}
5759 if len(scratchIn) > 0 {
@@ -74,11 +76,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7476 }
7577 }
7678 styles := newThemeStyles(state.theme)
79+ defaultDimmer := themeui.NewDimmer(state.theme)
80+ inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
7781 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
7882 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
7983 content := domain.Rect{Y: 1, Width: width, Height: rows}
8084 if state.layout.valid && state.layout.root != nil {
81- drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
85+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border))
8286 }
8387
8488 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
@@ -91,14 +95,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
9195 for _, pane := range state.panes {
9296 pl := offsetPlacement(pane.placement, 0, 1)
9397 if pl.TitleBar.Height > 0 {
94- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
98+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
9599 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
96100 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
97101 }
98102 titles[pane.id] = pane.titleGeneration
99103 }
100104 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
101- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
105+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
102106 }
103107 for _, d := range pane.damage {
104108 if d.Kind != renderer.DamageFullRedraw {
@@ -151,13 +155,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
151155 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
152156 }
153157
154-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
155- styles := newThemeStyles(theme)
158+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
156159 style := styles.border
157160 if focused {
158161 style = styles.statusBar
159162 } else {
160- style = themeui.DimStyle(style, theme)
163+ style = dimmer.Dim(style)
161164 }
162165 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
163166 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -235,9 +238,9 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
235238 }
236239 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
237240 }
238- legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
241+ layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
239242 if o.paletteActive && !state.floating.visible {
240- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)
243+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
241244 }
242245 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
243246 if modal.inner.Width == 0 && modal.inner.Height == 0 {
@@ -52,6 +52,8 @@ type composedRenderFrame struct {
52 // composeFrame is pure with respect to daemon ownership: it consumes only the52 // composeFrame is pure with respect to daemon ownership: it consumes only the
53 // capture, the last committed cache, and an independent attachment-owned53 // capture, the last committed cache, and an independent attachment-owned
54 // scratch cache. It returns a replacement cache without mutating committed.54 // scratch cache. It returns a replacement cache without mutating committed.
55+const inactivePaneForegroundDimming = 55
56+
55 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {57 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
56 scratch := composeCacheInput{}58 scratch := composeCacheInput{}
57 if len(scratchIn) > 0 {59 if len(scratchIn) > 0 {
@@ -74,11 +76,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
74 }76 }
75 }77 }
76 styles := newThemeStyles(state.theme)78 styles := newThemeStyles(state.theme)
79+ defaultDimmer := themeui.NewDimmer(state.theme)
80+ inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
77 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)81 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
78 drawStatusBarState(frame.Row(rows+1), state.bars, styles)82 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
79 content := domain.Rect{Y: 1, Width: width, Height: rows}83 content := domain.Rect{Y: 1, Width: width, Height: rows}
80 if state.layout.valid && state.layout.root != nil {84 if state.layout.valid && state.layout.root != nil {
81- drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))85+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border))
82 }86 }
83 87
84 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible88 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
@@ -91,14 +95,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
91 for _, pane := range state.panes {95 for _, pane := range state.panes {
92 pl := offsetPlacement(pane.placement, 0, 1)96 pl := offsetPlacement(pane.placement, 0, 1)
93 if pl.TitleBar.Height > 0 {97 if pl.TitleBar.Height > 0 {
94- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)98+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
95 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {99 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
96 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})100 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
97 }101 }
98 titles[pane.id] = pane.titleGeneration102 titles[pane.id] = pane.titleGeneration
99 }103 }
100 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {104 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
101- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)105+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
102 }106 }
103 for _, d := range pane.damage {107 for _, d := range pane.damage {
104 if d.Kind != renderer.DamageFullRedraw {108 if d.Kind != renderer.DamageFullRedraw {
@@ -151,13 +155,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
151 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}155 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
152 }156 }
153 157
154-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {158+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
155- styles := newThemeStyles(theme)
156 style := styles.border159 style := styles.border
157 if focused {160 if focused {
158 style = styles.statusBar161 style = styles.statusBar
159 } else {162 } else {
160- style = themeui.DimStyle(style, theme)163+ style = dimmer.Dim(style)
161 }164 }
162 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {165 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
163 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})166 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -235,9 +238,9 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
235 }238 }
236 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)239 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
237 }240 }
238- legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}241+ layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
239 if o.paletteActive && !state.floating.visible {242 if o.paletteActive && !state.floating.visible {
240- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)243+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
241 }244 }
242 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {245 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
243 if modal.inner.Width == 0 && modal.inner.Height == 0 {246 if modal.inner.Width == 0 && modal.inner.Height == 0 {
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
@@ -95,6 +95,7 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes
9595 require.Equal(t, '│', committed.frame.At(20, 1).Rune)
9696 focusedStyle := committed.frame.At(0, 1).Style
9797 dimmedStyle := committed.frame.At(21, 1).Style
98+ require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, dimmedStyle.ForegroundRGB, "inactive panes should use stronger foreground dimming")
9899
99100 undamaged := initial
100101 undamaged.reset = false
@@ -95,6 +95,7 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes
95 require.Equal(t, '│', committed.frame.At(20, 1).Rune)95 require.Equal(t, '│', committed.frame.At(20, 1).Rune)
96 focusedStyle := committed.frame.At(0, 1).Style96 focusedStyle := committed.frame.At(0, 1).Style
97 dimmedStyle := committed.frame.At(21, 1).Style97 dimmedStyle := committed.frame.At(21, 1).Style
98+ require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, dimmedStyle.ForegroundRGB, "inactive panes should use stronger foreground dimming")
98 99
99 undamaged := initial100 undamaged := initial
100 undamaged.reset = false101 undamaged.reset = false
M internal/usecase/daemon/render_test.go +3 -3
@@ -46,7 +46,7 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
4646 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
4747 dimmed := client.Frame.At(0, 1)
4848 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
49- require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame")
49+ require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
5050 require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")
5151 paletteVisible := false
5252 for y := range client.Frame.Height {
@@ -110,7 +110,7 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110110 d.enterPalette(sess, ac)
111111 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
112112 require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
113- require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
113+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
114114 }
115115
116116 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
@@ -131,7 +131,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
131131 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
132132 dimmed := client.Frame.At(0, 1)
133133 require.Equal(t, 'X', dimmed.Rune)
134- require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
134+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
135135 require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
136136 require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
137137
@@ -46,7 +46,7 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
46 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))46 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
47 dimmed := client.Frame.At(0, 1)47 dimmed := client.Frame.At(0, 1)
48 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")48 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
49- require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame")49+ require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
50 require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")50 require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")
51 paletteVisible := false51 paletteVisible := false
52 for y := range client.Frame.Height {52 for y := range client.Frame.Height {
@@ -110,7 +110,7 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110 d.enterPalette(sess, ac)110 d.enterPalette(sess, ac)
111 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))111 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
112 require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")112 require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
113- require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")113+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
114 }114 }
115 115
116 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {116 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
@@ -131,7 +131,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
131 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))131 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
132 dimmed := client.Frame.At(0, 1)132 dimmed := client.Frame.At(0, 1)
133 require.Equal(t, 'X', dimmed.Rune)133 require.Equal(t, 'X', dimmed.Rune)
134- require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")134+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
135 require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")135 require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
136 require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")136 require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
137 137
M internal/usecase/theme/theme.go +117 -14
@@ -373,28 +373,131 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
373373 return out
374374 }
375375
376-func DimStyle(style renderer.Style, t Theme) renderer.Style {
377- if !usable(t) {
376+const defaultDimmingPercent = 35
377+
378+// Dimmer transforms resolved terminal colors for subdued UI states. Construct
379+// it once and reuse it while rendering cells with the same theme and policy.
380+type Dimmer struct {
381+ theme Theme
382+ backgroundPercent int
383+ foregroundPercent int
384+}
385+
386+type dimmingTarget uint8
387+
388+const (
389+ dimBackground dimmingTarget = iota
390+ dimForeground
391+)
392+
393+// DimmerOption customizes a Dimmer without allocating a closure.
394+type DimmerOption struct {
395+ target dimmingTarget
396+ percent int
397+}
398+
399+// WithBackgroundDimming overrides the default 35 percent background fade.
400+func WithBackgroundDimming(percent int) DimmerOption {
401+ return DimmerOption{target: dimBackground, percent: percent}
402+}
403+
404+// WithForegroundDimming overrides the default 35 percent foreground fade.
405+func WithForegroundDimming(percent int) DimmerOption {
406+ return DimmerOption{target: dimForeground, percent: percent}
407+}
408+
409+// NewDimmer returns a reusable dimmer with optional per-channel overrides.
410+func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
411+ d := Dimmer{
412+ theme: t,
413+ backgroundPercent: defaultDimmingPercent,
414+ foregroundPercent: defaultDimmingPercent,
415+ }
416+ for _, opt := range opts {
417+ switch opt.target {
418+ case dimBackground:
419+ d.backgroundPercent = opt.percent
420+ case dimForeground:
421+ d.foregroundPercent = opt.percent
422+ }
423+ }
424+ d.backgroundPercent = clampPercent(d.backgroundPercent)
425+ d.foregroundPercent = clampPercent(d.foregroundPercent)
426+ return d
427+}
428+
429+// Dim fades a style's resolved background toward the terminal background,
430+// then fades its foreground and custom underline toward that dimmed background.
431+func (d Dimmer) Dim(style renderer.Style) renderer.Style {
432+ if !usable(d.theme) {
378433 return style
379434 }
435+
436+ foreground := resolveForeground(style, d.theme)
437+ background := resolveBackground(style, d.theme)
438+ if style.Inverse {
439+ foreground, background = background, foreground
440+ }
441+ background = blendPercent(background, d.theme.Background, d.backgroundPercent)
442+
380443 out := style
444+ out.Inverse = false
381445 out.HasForegroundRGB = true
446+ out.ForegroundRGB = blendPercent(foreground, background, d.foregroundPercent)
447+ out.HasBackgroundRGB = true
448+ out.BackgroundRGB = background
449+ if style.HasUnderlineColorRGB || style.HasUnderlineColor {
450+ underline := style.UnderlineColorRGB
451+ if !style.HasUnderlineColorRGB {
452+ underline = xterm256Color(style.UnderlineColor)
453+ }
454+ out.HasUnderlineColor = false
455+ out.HasUnderlineColorRGB = true
456+ out.UnderlineColorRGB = blendPercent(underline, background, d.foregroundPercent)
457+ }
458+ return out
459+}
460+
461+func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
382462 if style.HasForegroundRGB {
383- out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)
384- } else if style.Foreground >= 0 {
385- out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
386- } else {
387- out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
463+ return style.ForegroundRGB
388464 }
389- out.HasBackgroundRGB = true
465+ if style.Foreground >= 0 {
466+ return xterm256Color(style.Foreground)
467+ }
468+ return t.Foreground
469+}
470+
471+func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
390472 if style.HasBackgroundRGB {
391- out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)
392- } else if style.Background >= 0 {
393- out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
394- } else {
395- out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
473+ return style.BackgroundRGB
396474 }
397- return out
475+ if style.Background >= 0 {
476+ return xterm256Color(style.Background)
477+ }
478+ return t.Background
479+}
480+
481+func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
482+ return renderer.RGB{
483+ R: blendBytePercent(a.R, b.R, percent),
484+ G: blendBytePercent(a.G, b.G, percent),
485+ B: blendBytePercent(a.B, b.B, percent),
486+ }
487+}
488+
489+func blendBytePercent(a, b uint8, percent int) uint8 {
490+ return uint8((int(a)*(100-percent) + int(b)*percent + 50) / 100)
491+}
492+
493+func clampPercent(percent int) int {
494+ if percent < 0 {
495+ return 0
496+ }
497+ if percent > 100 {
498+ return 100
499+ }
500+ return percent
398501 }
399502
400503 var (
@@ -373,28 +373,131 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
373 return out373 return out
374 }374 }
375 375
376-func DimStyle(style renderer.Style, t Theme) renderer.Style {376+const defaultDimmingPercent = 35
377- if !usable(t) {377+
378+// Dimmer transforms resolved terminal colors for subdued UI states. Construct
379+// it once and reuse it while rendering cells with the same theme and policy.
380+type Dimmer struct {
381+ theme Theme
382+ backgroundPercent int
383+ foregroundPercent int
384+}
385+
386+type dimmingTarget uint8
387+
388+const (
389+ dimBackground dimmingTarget = iota
390+ dimForeground
391+)
392+
393+// DimmerOption customizes a Dimmer without allocating a closure.
394+type DimmerOption struct {
395+ target dimmingTarget
396+ percent int
397+}
398+
399+// WithBackgroundDimming overrides the default 35 percent background fade.
400+func WithBackgroundDimming(percent int) DimmerOption {
401+ return DimmerOption{target: dimBackground, percent: percent}
402+}
403+
404+// WithForegroundDimming overrides the default 35 percent foreground fade.
405+func WithForegroundDimming(percent int) DimmerOption {
406+ return DimmerOption{target: dimForeground, percent: percent}
407+}
408+
409+// NewDimmer returns a reusable dimmer with optional per-channel overrides.
410+func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
411+ d := Dimmer{
412+ theme: t,
413+ backgroundPercent: defaultDimmingPercent,
414+ foregroundPercent: defaultDimmingPercent,
415+ }
416+ for _, opt := range opts {
417+ switch opt.target {
418+ case dimBackground:
419+ d.backgroundPercent = opt.percent
420+ case dimForeground:
421+ d.foregroundPercent = opt.percent
422+ }
423+ }
424+ d.backgroundPercent = clampPercent(d.backgroundPercent)
425+ d.foregroundPercent = clampPercent(d.foregroundPercent)
426+ return d
427+}
428+
429+// Dim fades a style's resolved background toward the terminal background,
430+// then fades its foreground and custom underline toward that dimmed background.
431+func (d Dimmer) Dim(style renderer.Style) renderer.Style {
432+ if !usable(d.theme) {
378 return style433 return style
379 }434 }
435+
436+ foreground := resolveForeground(style, d.theme)
437+ background := resolveBackground(style, d.theme)
438+ if style.Inverse {
439+ foreground, background = background, foreground
440+ }
441+ background = blendPercent(background, d.theme.Background, d.backgroundPercent)
442+
380 out := style443 out := style
444+ out.Inverse = false
381 out.HasForegroundRGB = true445 out.HasForegroundRGB = true
446+ out.ForegroundRGB = blendPercent(foreground, background, d.foregroundPercent)
447+ out.HasBackgroundRGB = true
448+ out.BackgroundRGB = background
449+ if style.HasUnderlineColorRGB || style.HasUnderlineColor {
450+ underline := style.UnderlineColorRGB
451+ if !style.HasUnderlineColorRGB {
452+ underline = xterm256Color(style.UnderlineColor)
453+ }
454+ out.HasUnderlineColor = false
455+ out.HasUnderlineColorRGB = true
456+ out.UnderlineColorRGB = blendPercent(underline, background, d.foregroundPercent)
457+ }
458+ return out
459+}
460+
461+func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
382 if style.HasForegroundRGB {462 if style.HasForegroundRGB {
383- out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)463+ return style.ForegroundRGB
384- } else if style.Foreground >= 0 {
385- out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
386- } else {
387- out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
388 }464 }
389- out.HasBackgroundRGB = true465+ if style.Foreground >= 0 {
466+ return xterm256Color(style.Foreground)
467+ }
468+ return t.Foreground
469+}
470+
471+func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
390 if style.HasBackgroundRGB {472 if style.HasBackgroundRGB {
391- out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)473+ return style.BackgroundRGB
392- } else if style.Background >= 0 {
393- out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
394- } else {
395- out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
396 }474 }
397- return out475+ if style.Background >= 0 {
476+ return xterm256Color(style.Background)
477+ }
478+ return t.Background
479+}
480+
481+func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
482+ return renderer.RGB{
483+ R: blendBytePercent(a.R, b.R, percent),
484+ G: blendBytePercent(a.G, b.G, percent),
485+ B: blendBytePercent(a.B, b.B, percent),
486+ }
487+}
488+
489+func blendBytePercent(a, b uint8, percent int) uint8 {
490+ return uint8((int(a)*(100-percent) + int(b)*percent + 50) / 100)
491+}
492+
493+func clampPercent(percent int) int {
494+ if percent < 0 {
495+ return 0
496+ }
497+ if percent > 100 {
498+ return 100
499+ }
500+ return percent
398 }501 }
399 502
400 var (503 var (
M internal/usecase/theme/theme_test.go +84 -7
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
910 )
1011
1112 func TestParseXColor(t *testing.T) {
@@ -261,7 +262,83 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
261262 }
262263 }
263264
264-func TestDimStyle(t *testing.T) {
265+func TestDimmer(t *testing.T) {
266+ theme := Theme{
267+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
268+ Background: renderer.RGB{R: 10, G: 20, B: 30},
269+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
270+ }
271+ dimmer := NewDimmer(theme, WithForegroundDimming(55))
272+
273+ t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
274+ style := renderer.Style{
275+ Foreground: -1, Background: -1,
276+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
277+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
278+ }
279+
280+ got := dimmer.Dim(style)
281+
282+ require.Equal(t, renderer.RGB{R: 36, G: 105, B: 173}, got.BackgroundRGB)
283+ require.Equal(t, renderer.RGB{R: 132, G: 103, B: 118}, got.ForegroundRGB)
284+ })
285+
286+ t.Run("resolves inverse and custom underline colors before fading", func(t *testing.T) {
287+ style := renderer.Style{
288+ Bold: true, Italic: true, Inverse: true, Attrs: renderer.AttrUnderline,
289+ Foreground: -1, Background: -1,
290+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
291+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
292+ HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 200, G: 10, B: 100},
293+ }
294+
295+ got := dimmer.Dim(style)
296+
297+ require.False(t, got.Inverse)
298+ require.Equal(t, renderer.RGB{R: 166, G: 72, B: 43}, got.BackgroundRGB)
299+ require.Equal(t, renderer.RGB{R: 114, G: 107, B: 136}, got.ForegroundRGB)
300+ require.Equal(t, renderer.RGB{R: 181, G: 44, B: 69}, got.UnderlineColorRGB)
301+ require.True(t, got.Bold)
302+ require.True(t, got.Italic)
303+ require.Equal(t, renderer.AttrUnderline, got.Attrs)
304+ })
305+
306+ t.Run("resolves indexed and default colors", func(t *testing.T) {
307+ style := renderer.Style{
308+ Foreground: 196,
309+ Background: -1,
310+ HasUnderlineColor: true,
311+ UnderlineColor: 46,
312+ }
313+
314+ got := dimmer.Dim(style)
315+
316+ require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, got.BackgroundRGB)
317+ require.Equal(t, renderer.RGB{R: 120, G: 11, B: 17}, got.ForegroundRGB)
318+ require.True(t, got.HasUnderlineColorRGB)
319+ require.Equal(t, renderer.RGB{R: 6, G: 126, B: 17}, got.UnderlineColorRGB)
320+ })
321+
322+ t.Run("clamps percentages", func(t *testing.T) {
323+ style := renderer.Style{
324+ Foreground: -1, Background: -1,
325+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
326+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
327+ }
328+
329+ got := NewDimmer(theme, WithBackgroundDimming(-1), WithForegroundDimming(101)).Dim(style)
330+
331+ require.Equal(t, style.BackgroundRGB, got.BackgroundRGB)
332+ require.Equal(t, style.BackgroundRGB, got.ForegroundRGB)
333+ })
334+
335+ t.Run("leaves style unchanged without a usable truecolor theme", func(t *testing.T) {
336+ style := renderer.Style{Foreground: 196, Background: 21, Inverse: true}
337+ require.Equal(t, style, NewDimmer(Theme{}, WithForegroundDimming(55)).Dim(style))
338+ })
339+}
340+
341+func TestDimmerDefaults(t *testing.T) {
265342 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
266343 tests := []struct {
267344 name string
@@ -311,9 +388,9 @@ func TestDimStyle(t *testing.T) {
311388 Foreground: -1,
312389 Background: 21,
313390 HasForegroundRGB: true,
314- ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
391+ ForegroundRGB: renderer.RGB{R: 131, G: 132, B: 192},
315392 HasBackgroundRGB: true,
316- BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),
393+ BackgroundRGB: renderer.RGB{R: 4, G: 7, B: 176},
317394 },
318395 },
319396 {
@@ -330,18 +407,18 @@ func TestDimStyle(t *testing.T) {
330407 Foreground: -1,
331408 Background: -1,
332409 HasForegroundRGB: true,
333- ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),
410+ ForegroundRGB: renderer.RGB{R: 71, G: 58, B: 61},
334411 HasBackgroundRGB: true,
335- BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),
412+ BackgroundRGB: renderer.RGB{R: 17, G: 72, B: 128},
336413 },
337414 },
338415 }
339416
340417 for _, tt := range tests {
341418 t.Run(tt.name, func(t *testing.T) {
342- got := DimStyle(tt.in, theme)
419+ got := NewDimmer(theme).Dim(tt.in)
343420 if !got.Equal(tt.want) {
344- t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)
421+ t.Fatalf("Dimmer.Dim()=%+v want %+v", got, tt.want)
345422 }
346423 })
347424 }
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
9 )10 )
10 11
11 func TestParseXColor(t *testing.T) {12 func TestParseXColor(t *testing.T) {
@@ -261,7 +262,83 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
261 }262 }
262 }263 }
263 264
264-func TestDimStyle(t *testing.T) {265+func TestDimmer(t *testing.T) {
266+ theme := Theme{
267+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
268+ Background: renderer.RGB{R: 10, G: 20, B: 30},
269+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
270+ }
271+ dimmer := NewDimmer(theme, WithForegroundDimming(55))
272+
273+ t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
274+ style := renderer.Style{
275+ Foreground: -1, Background: -1,
276+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
277+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
278+ }
279+
280+ got := dimmer.Dim(style)
281+
282+ require.Equal(t, renderer.RGB{R: 36, G: 105, B: 173}, got.BackgroundRGB)
283+ require.Equal(t, renderer.RGB{R: 132, G: 103, B: 118}, got.ForegroundRGB)
284+ })
285+
286+ t.Run("resolves inverse and custom underline colors before fading", func(t *testing.T) {
287+ style := renderer.Style{
288+ Bold: true, Italic: true, Inverse: true, Attrs: renderer.AttrUnderline,
289+ Foreground: -1, Background: -1,
290+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
291+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
292+ HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 200, G: 10, B: 100},
293+ }
294+
295+ got := dimmer.Dim(style)
296+
297+ require.False(t, got.Inverse)
298+ require.Equal(t, renderer.RGB{R: 166, G: 72, B: 43}, got.BackgroundRGB)
299+ require.Equal(t, renderer.RGB{R: 114, G: 107, B: 136}, got.ForegroundRGB)
300+ require.Equal(t, renderer.RGB{R: 181, G: 44, B: 69}, got.UnderlineColorRGB)
301+ require.True(t, got.Bold)
302+ require.True(t, got.Italic)
303+ require.Equal(t, renderer.AttrUnderline, got.Attrs)
304+ })
305+
306+ t.Run("resolves indexed and default colors", func(t *testing.T) {
307+ style := renderer.Style{
308+ Foreground: 196,
309+ Background: -1,
310+ HasUnderlineColor: true,
311+ UnderlineColor: 46,
312+ }
313+
314+ got := dimmer.Dim(style)
315+
316+ require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, got.BackgroundRGB)
317+ require.Equal(t, renderer.RGB{R: 120, G: 11, B: 17}, got.ForegroundRGB)
318+ require.True(t, got.HasUnderlineColorRGB)
319+ require.Equal(t, renderer.RGB{R: 6, G: 126, B: 17}, got.UnderlineColorRGB)
320+ })
321+
322+ t.Run("clamps percentages", func(t *testing.T) {
323+ style := renderer.Style{
324+ Foreground: -1, Background: -1,
325+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
326+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
327+ }
328+
329+ got := NewDimmer(theme, WithBackgroundDimming(-1), WithForegroundDimming(101)).Dim(style)
330+
331+ require.Equal(t, style.BackgroundRGB, got.BackgroundRGB)
332+ require.Equal(t, style.BackgroundRGB, got.ForegroundRGB)
333+ })
334+
335+ t.Run("leaves style unchanged without a usable truecolor theme", func(t *testing.T) {
336+ style := renderer.Style{Foreground: 196, Background: 21, Inverse: true}
337+ require.Equal(t, style, NewDimmer(Theme{}, WithForegroundDimming(55)).Dim(style))
338+ })
339+}
340+
341+func TestDimmerDefaults(t *testing.T) {
265 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}342 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
266 tests := []struct {343 tests := []struct {
267 name string344 name string
@@ -311,9 +388,9 @@ func TestDimStyle(t *testing.T) {
311 Foreground: -1,388 Foreground: -1,
312 Background: 21,389 Background: 21,
313 HasForegroundRGB: true,390 HasForegroundRGB: true,
314- ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),391+ ForegroundRGB: renderer.RGB{R: 131, G: 132, B: 192},
315 HasBackgroundRGB: true,392 HasBackgroundRGB: true,
316- BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),393+ BackgroundRGB: renderer.RGB{R: 4, G: 7, B: 176},
317 },394 },
318 },395 },
319 {396 {
@@ -330,18 +407,18 @@ func TestDimStyle(t *testing.T) {
330 Foreground: -1,407 Foreground: -1,
331 Background: -1,408 Background: -1,
332 HasForegroundRGB: true,409 HasForegroundRGB: true,
333- ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),410+ ForegroundRGB: renderer.RGB{R: 71, G: 58, B: 61},
334 HasBackgroundRGB: true,411 HasBackgroundRGB: true,
335- BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),412+ BackgroundRGB: renderer.RGB{R: 17, G: 72, B: 128},
336 },413 },
337 },414 },
338 }415 }
339 416
340 for _, tt := range tests {417 for _, tt := range tests {
341 t.Run(tt.name, func(t *testing.T) {418 t.Run(tt.name, func(t *testing.T) {
342- got := DimStyle(tt.in, theme)419+ got := NewDimmer(theme).Dim(tt.in)
343 if !got.Equal(tt.want) {420 if !got.Equal(tt.want) {
344- t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)421+ t.Fatalf("Dimmer.Dim()=%+v want %+v", got, tt.want)
345 }422 }
346 })423 })
347 }424 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close