feat(theme): apply semantic floating borders
- commit
- adf123ac76ea200769c6259025a9806cbe7f95ab
- author
- brice · · 2026-07-18 09:01 UTC
- parent
- e7fd3eb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, [ | |||
| 117 | 117 | for _, d := range floating.pane.damage { | |
| 118 | 118 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 119 | 119 | } | |
| 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 | |
| 121 | 121 | 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 | 127 | if full || popupChanged { | |
| 124 | 128 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 125 | 129 | } | |
| @@ -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 != geometry | 120 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused |
| 121 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | 121 | 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 | |||
| 178 | 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 | 250 | func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |
| 182 | 251 | initial := floatingGeometry{ | |
| 183 | 252 | 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 { | |||
| 76 | 76 | ||
| 77 | 77 | type capturedFloatingRenderState struct { | |
| 78 | 78 | visible bool | |
| 79 | + | focused bool | |
| 79 | 80 | pane capturedPaneRenderState | |
| 80 | 81 | geometry floatingGeometry | |
| 81 | 82 | title string | |
| @@ -285,7 +286,9 @@ func capturePrimaryRenderState( | |||
| 285 | 286 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 286 | 287 | } | |
| 287 | 288 | 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 | 292 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 290 | 293 | p.mu.Unlock() | |
| 291 | 294 | } | |
| @@ -76,6 +76,7 @@ type capturedPaneRenderState struct { | |||||
| 76 | 76 | ||||
| 77 | type capturedFloatingRenderState struct { | 77 | type capturedFloatingRenderState struct { | ||
| 78 | visible bool | 78 | visible bool | ||
| 79 | + | focused bool | |||
| 79 | pane capturedPaneRenderState | 80 | pane capturedPaneRenderState | ||
| 80 | geometry floatingGeometry | 81 | geometry floatingGeometry | ||
| 81 | title string | 82 | 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] = captured | 288 | 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 { | |||
| 38 | 38 | titleGenerations map[layout.PaneID]uint64 | |
| 39 | 39 | damage []renderer.Damage | |
| 40 | 40 | floatingVisible bool | |
| 41 | + | floatingFocused bool | |
| 41 | 42 | floatingGeneration uint64 | |
| 42 | 43 | floatingGeometry floatingGeometry | |
| 43 | 44 | floatingTitleGeneration uint64 | |
| @@ -162,7 +163,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 162 | 163 | cursorInputs := state.cursor | |
| 163 | 164 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 164 | 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 | 167 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 167 | 168 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 168 | 169 | } | |
| @@ -38,6 +38,7 @@ type composeCacheInput struct { | |||||
| 38 | titleGenerations map[layout.PaneID]uint64 | 38 | titleGenerations map[layout.PaneID]uint64 | ||
| 39 | damage []renderer.Damage | 39 | damage []renderer.Damage | ||
| 40 | floatingVisible bool | 40 | floatingVisible bool | ||
| 41 | + | floatingFocused bool | |||
| 41 | floatingGeneration uint64 | 42 | floatingGeneration uint64 | ||
| 42 | floatingGeometry floatingGeometry | 43 | floatingGeometry floatingGeometry | ||
| 43 | floatingTitleGeneration uint64 | 44 | floatingTitleGeneration uint64 | ||
| @@ -162,7 +163,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 162 | cursorInputs := state.cursor | 163 | 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 | } | ||