detached 694827a

Merge pull request #91 from bnema/feature/truecolor-fader

refactor(theme): add reusable truecolor dimmer
commit
694827ae2d519ea1ba61e21b19acb21b71cc30a4
author
bnema · · 2026-07-15 05:12 UTC
committer
GitHub · · 2026-07-15 05:12 UTC
merge of
201f7b0 + 384a283 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +230 -43
M internal/usecase/daemon/floating_render.go +1 -1
@@ -90,7 +90,7 @@ func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Dam
9090 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
9191 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
9292 geometry := floating.geometry.translate(content.X, content.Y)
93- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
9494 damage := append([]renderer.Damage(nil), baseDamage...)
9595 for _, d := range floating.pane.damage {
9696 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -90,7 +90,7 @@ func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Dam
90 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}90 legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)91 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
92 geometry := floating.geometry.translate(content.X, content.Y)92 geometry := floating.geometry.translate(content.X, content.Y)
93- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
94 damage := append([]renderer.Damage(nil), baseDamage...)94 damage := append([]renderer.Damage(nil), baseDamage...)
95 for _, d := range floating.pane.damage {95 for _, d := range floating.pane.damage {
96 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)96 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
M internal/usecase/daemon/floating_render_legacy_test.go +1 -1
@@ -44,7 +44,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
4444 p.mu.Unlock()
4545 title := captured.title
4646 titleGeneration := captured.titleGeneration
47- blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
47+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, themeui.NewDimmer(theme))
4848 damage := append([]renderer.Damage(nil), baseDamage...)
4949 for _, d := range captured.damage {
5050 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -44,7 +44,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
44 p.mu.Unlock()44 p.mu.Unlock()
45 title := captured.title45 title := captured.title
46 titleGeneration := captured.titleGeneration46 titleGeneration := captured.titleGeneration
47- blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)47+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, themeui.NewDimmer(theme))
48 damage := append([]renderer.Damage(nil), baseDamage...)48 damage := append([]renderer.Damage(nil), baseDamage...)
49 for _, d := range captured.damage {49 for _, d := range captured.damage {
50 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)50 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
@@ -423,14 +423,14 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
423423 return pl
424424 }
425425
426-func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
426+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
427427 rows := min(r.Height, src.Height)
428428 cols := min(r.Width, src.Width)
429429 for y := range rows {
430430 for x := range cols {
431431 cell := src.At(x, y)
432432 if dim {
433- cell.Style = themeui.DimStyle(cell.Style, theme)
433+ cell.Style = dimmer.Dim(cell.Style)
434434 }
435435 dst.Set(r.X+x, r.Y+y, cell)
436436 }
@@ -423,14 +423,14 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
423 return pl423 return pl
424 }424 }
425 425
426-func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {426+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
427 rows := min(r.Height, src.Height)427 rows := min(r.Height, src.Height)
428 cols := min(r.Width, src.Width)428 cols := min(r.Width, src.Width)
429 for y := range rows {429 for y := range rows {
430 for x := range cols {430 for x := range cols {
431 cell := src.At(x, y)431 cell := src.At(x, y)
432 if dim {432 if dim {
433- cell.Style = themeui.DimStyle(cell.Style, theme)433+ cell.Style = dimmer.Dim(cell.Style)
434 }434 }
435 dst.Set(r.X+x, r.Y+y, cell)435 dst.Set(r.X+x, r.Y+y, cell)
436 }436 }
M internal/usecase/daemon/render_legacy_test.go +9 -7
@@ -158,6 +158,9 @@ func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Re
158158 func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159159 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160160 root := tb.tree.Root
161+ styles := newThemeStyles(theme)
162+ defaultDimmer := themeui.NewDimmer(theme)
163+ inactivePaneDimmer := themeui.NewDimmer(theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
161164 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162165 if !ok {
163166 placements, ok = layout.Solve(root, contentArea)
@@ -176,7 +179,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
176179 clear(titleGenerations)
177180 }
178181 if ok && !cacheValid {
179- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
182+ drawDividers(frame, root, area, defaultDimmer.Dim(styles.border))
180183 }
181184 var damage []renderer.Damage
182185 for _, pl := range placements {
@@ -190,7 +193,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
190193 focused := tb.tree.Focus == pl.ID
191194 pl = offsetPlacement(pl, area.X, area.Y)
192195 if pl.TitleBar.Height > 0 {
193- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
196+ generation := drawPaneTitleBar(frame, pl, p, focused, styles, defaultDimmer)
194197 if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195198 titleDamage := pl.TitleBar
196199 titleDamage.X -= area.X
@@ -222,7 +225,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
222225 p.mu.Lock()
223226 paneDamage = p.screen.Damage()
224227 if !cacheValid || len(paneDamage) > 0 {
225- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
228+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, inactivePaneDimmer)
226229 }
227230 for _, d := range paneDamage {
228231 localContent := pl.Content
@@ -234,7 +237,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
234237 continue
235238 }
236239 if !cacheValid || len(paneDamage) > 0 {
237- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
240+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, inactivePaneDimmer)
238241 }
239242 for _, d := range paneDamage {
240243 localContent := pl.Content
@@ -259,13 +262,12 @@ func clearFrameRect(frame renderer.Frame, r domain.Rect) {
259262 }
260263 }
261264
262-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
263- styles := newThemeStyles(theme)
265+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, styles themeStyles, dimmer themeui.Dimmer) uint64 {
264266 style := styles.border
265267 if focused {
266268 style = styles.statusBar
267269 } else {
268- style = themeui.DimStyle(style, theme)
270+ style = dimmer.Dim(style)
269271 }
270272 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271273 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -158,6 +158,9 @@ func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Re
158 func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {158 func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159 contentArea := domain.Rect{Width: area.Width, Height: area.Height}159 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160 root := tb.tree.Root160 root := tb.tree.Root
161+ styles := newThemeStyles(theme)
162+ defaultDimmer := themeui.NewDimmer(theme)
163+ inactivePaneDimmer := themeui.NewDimmer(theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
161 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea164 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162 if !ok {165 if !ok {
163 placements, ok = layout.Solve(root, contentArea)166 placements, ok = layout.Solve(root, contentArea)
@@ -176,7 +179,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
176 clear(titleGenerations)179 clear(titleGenerations)
177 }180 }
178 if ok && !cacheValid {181 if ok && !cacheValid {
179- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))182+ drawDividers(frame, root, area, defaultDimmer.Dim(styles.border))
180 }183 }
181 var damage []renderer.Damage184 var damage []renderer.Damage
182 for _, pl := range placements {185 for _, pl := range placements {
@@ -190,7 +193,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
190 focused := tb.tree.Focus == pl.ID193 focused := tb.tree.Focus == pl.ID
191 pl = offsetPlacement(pl, area.X, area.Y)194 pl = offsetPlacement(pl, area.X, area.Y)
192 if pl.TitleBar.Height > 0 {195 if pl.TitleBar.Height > 0 {
193- generation := drawPaneTitleBar(frame, pl, p, focused, theme)196+ generation := drawPaneTitleBar(frame, pl, p, focused, styles, defaultDimmer)
194 if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {197 if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195 titleDamage := pl.TitleBar198 titleDamage := pl.TitleBar
196 titleDamage.X -= area.X199 titleDamage.X -= area.X
@@ -222,7 +225,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
222 p.mu.Lock()225 p.mu.Lock()
223 paneDamage = p.screen.Damage()226 paneDamage = p.screen.Damage()
224 if !cacheValid || len(paneDamage) > 0 {227 if !cacheValid || len(paneDamage) > 0 {
225- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)228+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, inactivePaneDimmer)
226 }229 }
227 for _, d := range paneDamage {230 for _, d := range paneDamage {
228 localContent := pl.Content231 localContent := pl.Content
@@ -234,7 +237,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
234 continue237 continue
235 }238 }
236 if !cacheValid || len(paneDamage) > 0 {239 if !cacheValid || len(paneDamage) > 0 {
237- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)240+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, inactivePaneDimmer)
238 }241 }
239 for _, d := range paneDamage {242 for _, d := range paneDamage {
240 localContent := pl.Content243 localContent := pl.Content
@@ -259,13 +262,12 @@ func clearFrameRect(frame renderer.Frame, r domain.Rect) {
259 }262 }
260 }263 }
261 264
262-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {265+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, styles themeStyles, dimmer themeui.Dimmer) uint64 {
263- styles := newThemeStyles(theme)
264 style := styles.border266 style := styles.border
265 if focused {267 if focused {
266 style = styles.statusBar268 style = styles.statusBar
267 } else {269 } else {
268- style = themeui.DimStyle(style, theme)270+ style = dimmer.Dim(style)
269 }271 }
270 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {272 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})273 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
M internal/usecase/daemon/render_pipeline.go +9 -6
@@ -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
@@ -90,14 +94,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
9094 for _, pane := range state.panes {
9195 pl := offsetPlacement(pane.placement, 0, 1)
9296 if pl.TitleBar.Height > 0 {
93- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
97+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
9498 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
9599 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96100 }
97101 titles[pane.id] = pane.titleGeneration
98102 }
99103 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
104+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
101105 }
102106 for _, d := range pane.damage {
103107 if d.Kind != renderer.DamageFullRedraw {
@@ -141,13 +145,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
141145 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142146 }
143147
144-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
145- styles := newThemeStyles(theme)
148+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
146149 style := styles.border
147150 if focused {
148151 style = styles.statusBar
149152 } else {
150- style = themeui.DimStyle(style, theme)
153+ style = dimmer.Dim(style)
151154 }
152155 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153156 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -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
@@ -90,14 +94,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
90 for _, pane := range state.panes {94 for _, pane := range state.panes {
91 pl := offsetPlacement(pane.placement, 0, 1)95 pl := offsetPlacement(pane.placement, 0, 1)
92 if pl.TitleBar.Height > 0 {96 if pl.TitleBar.Height > 0 {
93- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)97+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
94 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {98 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
95 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})99 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96 }100 }
97 titles[pane.id] = pane.titleGeneration101 titles[pane.id] = pane.titleGeneration
98 }102 }
99 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {103 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)104+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
101 }105 }
102 for _, d := range pane.damage {106 for _, d := range pane.damage {
103 if d.Kind != renderer.DamageFullRedraw {107 if d.Kind != renderer.DamageFullRedraw {
@@ -141,13 +145,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
141 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}145 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142 }146 }
143 147
144-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {148+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
145- styles := newThemeStyles(theme)
146 style := styles.border149 style := styles.border
147 if focused {150 if focused {
148 style = styles.statusBar151 style = styles.statusBar
149 } else {152 } else {
150- style = themeui.DimStyle(style, theme)153+ style = dimmer.Dim(style)
151 }154 }
152 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {155 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})156 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
M internal/usecase/daemon/render_test.go +4 -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
@@ -825,6 +825,7 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
825825 require.Equal(t, '│', frame.At(20, 0).Rune)
826826 require.Equal(t, 'R', frame.At(21, 0).Rune)
827827 require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
828+ require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, frame.At(0, 0).Style.ForegroundRGB, "inactive panes should use stronger foreground dimming")
828829 require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
829830 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
830831 }
@@ -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
@@ -825,6 +825,7 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
825 require.Equal(t, '│', frame.At(20, 0).Rune)825 require.Equal(t, '│', frame.At(20, 0).Rune)
826 require.Equal(t, 'R', frame.At(21, 0).Rune)826 require.Equal(t, 'R', frame.At(21, 0).Rune)
827 require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")827 require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
828+ require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, frame.At(0, 0).Style.ForegroundRGB, "inactive panes should use stronger foreground dimming")
828 require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")829 require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
829 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})830 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
830 }831 }
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