detached adf123a

feat(theme): apply semantic floating borders

commit
adf123ac76ea200769c6259025a9806cbe7f95ab
author
brice · · 2026-07-18 09:01 UTC
parent
e7fd3eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +81 -4
M internal/usecase/daemon/floating_render.go +6 -2
@@ -117,9 +117,13 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117117 for _, d := range floating.pane.damage {
118118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119119 }
120- popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
120+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
121121 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
122- drawFloatingBorder(frame, geometry.Bounds, floating.title, styles.BorderMuted)
122+ border := styles.BorderMuted
123+ if floating.focused {
124+ border = styles.BorderActive
125+ }
126+ drawFloatingBorder(frame, geometry.Bounds, floating.title, border)
123127 if full || popupChanged {
124128 return frame, []renderer.Damage{renderer.FullRedraw()}
125129 }
@@ -117,9 +117,13 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
117 for _, d := range floating.pane.damage {117 for _, d := range floating.pane.damage {
118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119 }119 }
120- popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry120+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
121 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration121 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
122- drawFloatingBorder(frame, geometry.Bounds, floating.title, styles.BorderMuted)122+ border := styles.BorderMuted
123+ if floating.focused {
124+ border = styles.BorderActive
125+ }
126+ drawFloatingBorder(frame, geometry.Bounds, floating.title, border)
123 if full || popupChanged {127 if full || popupChanged {
124 return frame, []renderer.Damage{renderer.FullRedraw()}128 return frame, []renderer.Damage{renderer.FullRedraw()}
125 }129 }
M internal/usecase/daemon/floating_render_test.go +69 -0
@@ -178,6 +178,75 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
178178 require.Equal(t, 1, damage[0].Height)
179179 }
180180
181+func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
182+ base := renderer.NewFrame(20, 9)
183+ content := domain.Rect{Y: 1, Width: 20, Height: 7}
184+ geometry := floatingGeometry{
185+ Bounds: domain.Rect{X: 3, Y: 1, Width: 8, Height: 5},
186+ Inner: domain.Rect{X: 4, Y: 2, Width: 6, Height: 3},
187+ }
188+ active := renderer.Style{Foreground: 2, Background: 3}
189+ muted := renderer.Style{Foreground: 4, Background: 5}
190+ pane := renderer.NewFrame(6, 3)
191+ for y := range pane.Height {
192+ for x := range pane.Width {
193+ pane.Set(x, y, renderer.Cell{Rune: rune('a' + y*pane.Width + x), Style: renderer.Style{Foreground: x + y*10, Background: x + y*10 + 1}})
194+ }
195+ }
196+
197+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
198+ baseFrame: base,
199+ floating: capturedFloatingRenderState{
200+ visible: true, focused: true, pane: capturedPaneRenderState{frame: pane}, geometry: geometry, title: "float", generation: 1,
201+ },
202+ content: content,
203+ styles: themeui.Styles{BorderActive: active, BorderMuted: muted},
204+ full: true,
205+ })
206+ bounds := geometry.translate(content.X, content.Y)
207+ require.Equal(t, active, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style, "focused floating border must use BorderActive")
208+ require.NotEqual(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
209+ for y := range bounds.Inner.Height {
210+ for x := range bounds.Inner.Width {
211+ require.Equal(t, pane.At(x, y), frame.At(bounds.Inner.X+x, bounds.Inner.Y+y), "pane cell (%d,%d) must remain byte-identical", x, y)
212+ }
213+ }
214+}
215+
216+func TestComposeCapturedFloatingFrameUsesMutedBorderWhenUnfocused(t *testing.T) {
217+ base := renderer.NewFrame(12, 7)
218+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
219+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
220+ active, muted := renderer.Style{Foreground: 2}, renderer.Style{Foreground: 4}
221+
222+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
223+ baseFrame: base,
224+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, title: "float", generation: 1},
225+ content: content,
226+ styles: themeui.Styles{BorderActive: active, BorderMuted: muted},
227+ full: true,
228+ })
229+ bounds := geometry.translate(content.X, content.Y)
230+ require.Equal(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
231+}
232+
233+func TestComposeCapturedFloatingFrameFocusChangeInvalidatesCache(t *testing.T) {
234+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
235+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
236+ frame, damage := composeCapturedFloatingFrame(floatingComposeInput{
237+ baseFrame: renderer.NewFrame(12, 7),
238+ floating: capturedFloatingRenderState{
239+ visible: true, focused: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, generation: 1,
240+ },
241+ content: content,
242+ cache: composeCacheInput{
243+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y),
244+ },
245+ })
246+ require.NotNil(t, frame)
247+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage, "a focused border role change must redraw the popup")
248+}
249+
181250 func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
182251 initial := floatingGeometry{
183252 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
@@ -178,6 +178,75 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
178 require.Equal(t, 1, damage[0].Height)178 require.Equal(t, 1, damage[0].Height)
179 }179 }
180 180
181+func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
182+ base := renderer.NewFrame(20, 9)
183+ content := domain.Rect{Y: 1, Width: 20, Height: 7}
184+ geometry := floatingGeometry{
185+ Bounds: domain.Rect{X: 3, Y: 1, Width: 8, Height: 5},
186+ Inner: domain.Rect{X: 4, Y: 2, Width: 6, Height: 3},
187+ }
188+ active := renderer.Style{Foreground: 2, Background: 3}
189+ muted := renderer.Style{Foreground: 4, Background: 5}
190+ pane := renderer.NewFrame(6, 3)
191+ for y := range pane.Height {
192+ for x := range pane.Width {
193+ pane.Set(x, y, renderer.Cell{Rune: rune('a' + y*pane.Width + x), Style: renderer.Style{Foreground: x + y*10, Background: x + y*10 + 1}})
194+ }
195+ }
196+
197+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
198+ baseFrame: base,
199+ floating: capturedFloatingRenderState{
200+ visible: true, focused: true, pane: capturedPaneRenderState{frame: pane}, geometry: geometry, title: "float", generation: 1,
201+ },
202+ content: content,
203+ styles: themeui.Styles{BorderActive: active, BorderMuted: muted},
204+ full: true,
205+ })
206+ bounds := geometry.translate(content.X, content.Y)
207+ require.Equal(t, active, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style, "focused floating border must use BorderActive")
208+ require.NotEqual(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
209+ for y := range bounds.Inner.Height {
210+ for x := range bounds.Inner.Width {
211+ require.Equal(t, pane.At(x, y), frame.At(bounds.Inner.X+x, bounds.Inner.Y+y), "pane cell (%d,%d) must remain byte-identical", x, y)
212+ }
213+ }
214+}
215+
216+func TestComposeCapturedFloatingFrameUsesMutedBorderWhenUnfocused(t *testing.T) {
217+ base := renderer.NewFrame(12, 7)
218+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
219+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
220+ active, muted := renderer.Style{Foreground: 2}, renderer.Style{Foreground: 4}
221+
222+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
223+ baseFrame: base,
224+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, title: "float", generation: 1},
225+ content: content,
226+ styles: themeui.Styles{BorderActive: active, BorderMuted: muted},
227+ full: true,
228+ })
229+ bounds := geometry.translate(content.X, content.Y)
230+ require.Equal(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
231+}
232+
233+func TestComposeCapturedFloatingFrameFocusChangeInvalidatesCache(t *testing.T) {
234+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
235+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
236+ frame, damage := composeCapturedFloatingFrame(floatingComposeInput{
237+ baseFrame: renderer.NewFrame(12, 7),
238+ floating: capturedFloatingRenderState{
239+ visible: true, focused: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, generation: 1,
240+ },
241+ content: content,
242+ cache: composeCacheInput{
243+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y),
244+ },
245+ })
246+ require.NotNil(t, frame)
247+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage, "a focused border role change must redraw the popup")
248+}
249+
181 func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {250 func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
182 initial := floatingGeometry{251 initial := floatingGeometry{
183 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},252 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
M internal/usecase/daemon/render_capture.go +4 -1
@@ -76,6 +76,7 @@ type capturedPaneRenderState struct {
7676
7777 type capturedFloatingRenderState struct {
7878 visible bool
79+ focused bool
7980 pane capturedPaneRenderState
8081 geometry floatingGeometry
8182 title string
@@ -285,7 +286,9 @@ func capturePrimaryRenderState(
285286 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
286287 }
287288 ac.captureFrames[p] = captured
288- state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
289+ // A visible floating pane is the terminal input target, so its structural
290+ // border carries the focused semantic role independently of its content.
291+ state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
289292 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
290293 p.mu.Unlock()
291294 }
@@ -76,6 +76,7 @@ type capturedPaneRenderState struct {
76 76
77 type capturedFloatingRenderState struct {77 type capturedFloatingRenderState struct {
78 visible bool78 visible bool
79+ focused bool
79 pane capturedPaneRenderState80 pane capturedPaneRenderState
80 geometry floatingGeometry81 geometry floatingGeometry
81 title string82 title string
@@ -285,7 +286,9 @@ func capturePrimaryRenderState(
285 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})286 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
286 }287 }
287 ac.captureFrames[p] = captured288 ac.captureFrames[p] = captured
288- state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}289+ // A visible floating pane is the terminal input target, so its structural
290+ // border carries the focused semantic role independently of its content.
291+ state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
289 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)292 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
290 p.mu.Unlock()293 p.mu.Unlock()
291 }294 }
M internal/usecase/daemon/render_pipeline.go +2 -1
@@ -38,6 +38,7 @@ type composeCacheInput struct {
3838 titleGenerations map[layout.PaneID]uint64
3939 damage []renderer.Damage
4040 floatingVisible bool
41+ floatingFocused bool
4142 floatingGeneration uint64
4243 floatingGeometry floatingGeometry
4344 floatingTitleGeneration uint64
@@ -162,7 +163,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
162163 cursorInputs := state.cursor
163164 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
164165 cursor := desiredCapturedCursor(cursorInputs)
165- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
166+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
166167 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
167168 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
168169 }
@@ -38,6 +38,7 @@ type composeCacheInput struct {
38 titleGenerations map[layout.PaneID]uint6438 titleGenerations map[layout.PaneID]uint64
39 damage []renderer.Damage39 damage []renderer.Damage
40 floatingVisible bool40 floatingVisible bool
41+ floatingFocused bool
41 floatingGeneration uint6442 floatingGeneration uint64
42 floatingGeometry floatingGeometry43 floatingGeometry floatingGeometry
43 floatingTitleGeneration uint6444 floatingTitleGeneration uint64
@@ -162,7 +163,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
162 cursorInputs := state.cursor163 cursorInputs := state.cursor
163 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()164 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
164 cursor := desiredCapturedCursor(cursorInputs)165 cursor := desiredCapturedCursor(cursorInputs)
165- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}166+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
166 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))167 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
167 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}168 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
168 }169 }

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