perf(daemon): preserve incremental damage with notice toasts
- commit
- dfadb212ef92b5bb859f870f57ecfcc1f6b1539d
- author
- brice · · 2026-07-21 10:42 UTC
- parent
- 6cda4a5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +217 -33
M internal/usecase/daemon/notify.go +4 -1
| @@ -374,5 +374,8 @@ func (d *Daemon) repaintForNotice(ac *attachedClient) { | |||
| 374 | 374 | // invalidateRender cannot paint without a session. | |
| 375 | 375 | return | |
| 376 | 376 | } | |
| 377 | - | d.invalidateRender(sess, ac, true, "notify.go") | |
| 377 | + | // Toast footprints carry their own old/new damage, so a normal notice | |
| 378 | + | // transition can remain incremental. First paint and transport recovery | |
| 379 | + | // still provide their independent reset invariants. | |
| 380 | + | d.invalidateRender(sess, ac, false, "notify.go") | |
| 378 | 381 | } | |
| @@ -374,5 +374,8 @@ func (d *Daemon) repaintForNotice(ac *attachedClient) { | |||||
| 374 | // invalidateRender cannot paint without a session. | 374 | // invalidateRender cannot paint without a session. | ||
| 375 | return | 375 | return | ||
| 376 | } | 376 | } | ||
| 377 | - | d.invalidateRender(sess, ac, true, "notify.go") | 377 | + | // Toast footprints carry their own old/new damage, so a normal notice |
| 378 | + | // transition can remain incremental. First paint and transport recovery | |||
| 379 | + | // still provide their independent reset invariants. | |||
| 380 | + | d.invalidateRender(sess, ac, false, "notify.go") | |||
| 378 | } | 381 | } | ||
M internal/usecase/daemon/notify_test.go +14 -16
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | "github.com/bnema/vev/pkg/vt" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | |
| @@ -299,16 +300,6 @@ func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notifi | |||
| 299 | 300 | return got | |
| 300 | 301 | } | |
| 301 | 302 | ||
| 302 | - | func drainFrames(sends chan ports.Frame) { | |
| 303 | - | for { | |
| 304 | - | select { | |
| 305 | - | case <-sends: | |
| 306 | - | default: | |
| 307 | - | return | |
| 308 | - | } | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | ||
| 312 | 303 | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | |
| 313 | 304 | tests := []struct { | |
| 314 | 305 | name string | |
| @@ -746,7 +737,11 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||
| 746 | 737 | ||
| 747 | 738 | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 748 | 739 | awaitToastCount(t, ac, 1) | |
| 749 | - | drainFrames(sends) | |
| 740 | + | shown := awaitFrame(t, sends, ports.MsgOutput) | |
| 741 | + | shownOutput, err := ports.UnmarshalOutput(shown.Payload) | |
| 742 | + | require.NoError(t, err) | |
| 743 | + | terminal := vt.NewScreen(80, 25) | |
| 744 | + | terminal.Write(shownOutput.Data) | |
| 750 | 745 | ||
| 751 | 746 | // Short of the 8s error TTL nothing expires. | |
| 752 | 747 | clk.advance(7 * time.Second) | |
| @@ -758,11 +753,14 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||
| 758 | 753 | _, overflow := visibleToasts(ac) | |
| 759 | 754 | require.Zero(t, overflow, "overflow resets once no toast is visible") | |
| 760 | 755 | ||
| 761 | - | select { | |
| 762 | - | case <-sends: | |
| 763 | - | case <-time.After(2 * time.Second): | |
| 764 | - | t.Fatal("expiry did not invalidate the render") | |
| 765 | - | } | |
| 756 | + | expired := awaitFrame(t, sends, ports.MsgOutput) | |
| 757 | + | expiredOutput, err := ports.UnmarshalOutput(expired.Payload) | |
| 758 | + | require.NoError(t, err) | |
| 759 | + | terminal.Write(expiredOutput.Data) | |
| 760 | + | ac.sendMu.Lock() | |
| 761 | + | base := ac.pipelineCache.frame.Clone() | |
| 762 | + | ac.sendMu.Unlock() | |
| 763 | + | require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell") | |
| 766 | 764 | } | |
| 767 | 765 | ||
| 768 | 766 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | + | "github.com/bnema/vev/pkg/vt" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | 21 | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | ||
| @@ -299,16 +300,6 @@ func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notifi | |||||
| 299 | return got | 300 | return got | ||
| 300 | } | 301 | } | ||
| 301 | 302 | ||||
| 302 | - | func drainFrames(sends chan ports.Frame) { | |||
| 303 | - | for { | |||
| 304 | - | select { | |||
| 305 | - | case <-sends: | |||
| 306 | - | default: | |||
| 307 | - | return | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | 303 | func TestReportErrorUserErrorBecomesNotification(t *testing.T) { | ||
| 313 | tests := []struct { | 304 | tests := []struct { | ||
| 314 | name string | 305 | name string | ||
| @@ -746,7 +737,11 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||||
| 746 | 737 | ||||
| 747 | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | 738 | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | ||
| 748 | awaitToastCount(t, ac, 1) | 739 | awaitToastCount(t, ac, 1) | ||
| 749 | - | drainFrames(sends) | 740 | + | shown := awaitFrame(t, sends, ports.MsgOutput) |
| 741 | + | shownOutput, err := ports.UnmarshalOutput(shown.Payload) | |||
| 742 | + | require.NoError(t, err) | |||
| 743 | + | terminal := vt.NewScreen(80, 25) | |||
| 744 | + | terminal.Write(shownOutput.Data) | |||
| 750 | 745 | ||||
| 751 | // Short of the 8s error TTL nothing expires. | 746 | // Short of the 8s error TTL nothing expires. | ||
| 752 | clk.advance(7 * time.Second) | 747 | clk.advance(7 * time.Second) | ||
| @@ -758,11 +753,14 @@ func TestToastExpiresOnFakeClock(t *testing.T) { | |||||
| 758 | _, overflow := visibleToasts(ac) | 753 | _, overflow := visibleToasts(ac) | ||
| 759 | require.Zero(t, overflow, "overflow resets once no toast is visible") | 754 | require.Zero(t, overflow, "overflow resets once no toast is visible") | ||
| 760 | 755 | ||||
| 761 | - | select { | 756 | + | expired := awaitFrame(t, sends, ports.MsgOutput) |
| 762 | - | case <-sends: | 757 | + | expiredOutput, err := ports.UnmarshalOutput(expired.Payload) |
| 763 | - | case <-time.After(2 * time.Second): | 758 | + | require.NoError(t, err) |
| 764 | - | t.Fatal("expiry did not invalidate the render") | 759 | + | terminal.Write(expiredOutput.Data) |
| 765 | - | } | 760 | + | ac.sendMu.Lock() |
| 761 | + | base := ac.pipelineCache.frame.Clone() | |||
| 762 | + | ac.sendMu.Unlock() | |||
| 763 | + | require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell") | |||
| 766 | } | 764 | } | ||
| 767 | 765 | ||||
| 768 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | 766 | // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a | ||
M internal/usecase/daemon/render_pipeline.go +34 -13
| @@ -44,6 +44,7 @@ type composeCacheInput struct { | |||
| 44 | 44 | floatingGeometry floatingGeometry | |
| 45 | 45 | floatingTitleGeneration uint64 | |
| 46 | 46 | bars barCache | |
| 47 | + | toastFootprints []domain.Rect | |
| 47 | 48 | theme themeui.Theme | |
| 48 | 49 | styleGeneration uint64 | |
| 49 | 50 | } | |
| @@ -152,10 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 152 | 153 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 153 | 154 | } | |
| 154 | 155 | } | |
| 155 | - | // Toasts render even with no modal overlay active, so this gate also | |
| 156 | - | // checks for pending notices rather than only state.overlays.active(). | |
| 157 | - | overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0 | |
| 158 | - | if overlaysOrToasts { | |
| 156 | + | // The cache stays toast-free. The terminal shadow, in contrast, includes | |
| 157 | + | // toasts, so every render damages both the last and current toast coverage. | |
| 158 | + | // This restores cells exposed by dismissal and redraws a stable toast over | |
| 159 | + | // any underlying pane update without promoting either case to a full frame. | |
| 160 | + | overlaysActive := state.overlays.active() | |
| 161 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 162 | + | if overlaysActive || toastsVisible { | |
| 159 | 163 | // Without a floating frame, overlay composition would otherwise mutate | |
| 160 | 164 | // the base cache in place. Floating composition already owns a clone. | |
| 161 | 165 | if !state.floating.visible { | |
| @@ -163,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 163 | 167 | } | |
| 164 | 168 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 165 | 169 | } | |
| 166 | - | if full || overlaysOrToasts { | |
| 170 | + | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) | |
| 171 | + | if full || overlaysActive { | |
| 167 | 172 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 173 | + | } else { | |
| 174 | + | damage = appendToastDamage(damage, in.toastFootprints) | |
| 175 | + | damage = appendToastDamage(damage, toastFootprints) | |
| 168 | 176 | } | |
| 169 | 177 | cursorInputs := state.cursor | |
| 170 | - | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 178 | + | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 171 | 179 | cursor := desiredCapturedCursor(cursorInputs) | |
| 172 | - | 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} | |
| 180 | + | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), 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} | |
| 173 | 181 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 174 | 182 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 175 | 183 | } | |
| @@ -296,14 +304,27 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 296 | 304 | } | |
| 297 | 305 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 298 | 306 | } | |
| 299 | - | if len(o.notices) > 0 || o.noticeOverflow > 0 { | |
| 300 | - | views := make([]ui.NoticeView, len(o.notices)) | |
| 301 | - | for i, n := range o.notices { | |
| 302 | - | views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count} | |
| 307 | + | return frame, damage | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | |
| 311 | + | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |
| 312 | + | return nil | |
| 313 | + | } | |
| 314 | + | views := make([]ui.NoticeView, len(overlays.notices)) | |
| 315 | + | for i, n := range overlays.notices { | |
| 316 | + | views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count} | |
| 317 | + | } | |
| 318 | + | return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles)) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage { | |
| 322 | + | for _, footprint := range footprints { | |
| 323 | + | if footprint.Width > 0 && footprint.Height > 0 { | |
| 324 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |
| 303 | 325 | } | |
| 304 | - | ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles)) | |
| 305 | 326 | } | |
| 306 | - | return frame, damage | |
| 327 | + | return damage | |
| 307 | 328 | } | |
| 308 | 329 | ||
| 309 | 330 | // noticeStylesFrom picks the toast box color per severity from the theme's | |
| @@ -44,6 +44,7 @@ type composeCacheInput struct { | |||||
| 44 | floatingGeometry floatingGeometry | 44 | floatingGeometry floatingGeometry | ||
| 45 | floatingTitleGeneration uint64 | 45 | floatingTitleGeneration uint64 | ||
| 46 | bars barCache | 46 | bars barCache | ||
| 47 | + | toastFootprints []domain.Rect | |||
| 47 | theme themeui.Theme | 48 | theme themeui.Theme | ||
| 48 | styleGeneration uint64 | 49 | styleGeneration uint64 | ||
| 49 | } | 50 | } | ||
| @@ -152,10 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 152 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 153 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 153 | } | 154 | } | ||
| 154 | } | 155 | } | ||
| 155 | - | // Toasts render even with no modal overlay active, so this gate also | 156 | + | // The cache stays toast-free. The terminal shadow, in contrast, includes |
| 156 | - | // checks for pending notices rather than only state.overlays.active(). | 157 | + | // toasts, so every render damages both the last and current toast coverage. |
| 157 | - | overlaysOrToasts := state.overlays.active() || len(state.overlays.notices) > 0 | 158 | + | // This restores cells exposed by dismissal and redraws a stable toast over |
| 158 | - | if overlaysOrToasts { | 159 | + | // any underlying pane update without promoting either case to a full frame. |
| 160 | + | overlaysActive := state.overlays.active() | |||
| 161 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 162 | + | if overlaysActive || toastsVisible { | |||
| 159 | // Without a floating frame, overlay composition would otherwise mutate | 163 | // Without a floating frame, overlay composition would otherwise mutate | ||
| 160 | // the base cache in place. Floating composition already owns a clone. | 164 | // the base cache in place. Floating composition already owns a clone. | ||
| 161 | if !state.floating.visible { | 165 | if !state.floating.visible { | ||
| @@ -163,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 163 | } | 167 | } | ||
| 164 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | 168 | frame, damage = composeCapturedOverlays(state, frame, damage, content) | ||
| 165 | } | 169 | } | ||
| 166 | - | if full || overlaysOrToasts { | 170 | + | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) |
| 171 | + | if full || overlaysActive { | |||
| 167 | damage = []renderer.Damage{renderer.FullRedraw()} | 172 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 173 | + | } else { | |||
| 174 | + | damage = appendToastDamage(damage, in.toastFootprints) | |||
| 175 | + | damage = appendToastDamage(damage, toastFootprints) | |||
| 168 | } | 176 | } | ||
| 169 | cursorInputs := state.cursor | 177 | cursorInputs := state.cursor | ||
| 170 | - | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | 178 | + | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive |
| 171 | cursor := desiredCapturedCursor(cursorInputs) | 179 | cursor := desiredCapturedCursor(cursorInputs) | ||
| 172 | - | 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} | 180 | + | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), 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} |
| 173 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 181 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | ||
| 174 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 182 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 175 | } | 183 | } | ||
| @@ -296,14 +304,27 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 296 | } | 304 | } | ||
| 297 | damage = []renderer.Damage{renderer.FullRedraw()} | 305 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 298 | } | 306 | } | ||
| 299 | - | if len(o.notices) > 0 || o.noticeOverflow > 0 { | 307 | + | return frame, damage |
| 300 | - | views := make([]ui.NoticeView, len(o.notices)) | 308 | + | } |
| 301 | - | for i, n := range o.notices { | 309 | + | |
| 302 | - | views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count} | 310 | + | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { |
| 311 | + | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |||
| 312 | + | return nil | |||
| 313 | + | } | |||
| 314 | + | views := make([]ui.NoticeView, len(overlays.notices)) | |||
| 315 | + | for i, n := range overlays.notices { | |||
| 316 | + | views[i] = ui.NoticeView{Severity: uint8(n.Severity), Title: n.Code.String(), Message: n.Message, Count: n.Count} | |||
| 317 | + | } | |||
| 318 | + | return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles)) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage { | |||
| 322 | + | for _, footprint := range footprints { | |||
| 323 | + | if footprint.Width > 0 && footprint.Height > 0 { | |||
| 324 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |||
| 303 | } | 325 | } | ||
| 304 | - | ui.ComposeNotices(frame, views, o.noticeOverflow, noticeStylesFrom(state.styles)) | |||
| 305 | } | 326 | } | ||
| 306 | - | return frame, damage | 327 | + | return damage |
| 307 | } | 328 | } | ||
| 308 | 329 | ||||
| 309 | // noticeStylesFrom picks the toast box color per severity from the theme's | 330 | // noticeStylesFrom picks the toast box color per severity from the theme's | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
| @@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||
| 300 | 300 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | |
| 301 | 301 | maps.Copy(out.titleGenerations, in.titleGenerations) | |
| 302 | 302 | out.damage = append([]renderer.Damage(nil), in.damage...) | |
| 303 | + | out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...) | |
| 303 | 304 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | |
| 304 | 305 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | |
| 305 | 306 | return out | |
| @@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput { | |||||
| 300 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | 300 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | ||
| 301 | maps.Copy(out.titleGenerations, in.titleGenerations) | 301 | maps.Copy(out.titleGenerations, in.titleGenerations) | ||
| 302 | out.damage = append([]renderer.Damage(nil), in.damage...) | 302 | out.damage = append([]renderer.Damage(nil), in.damage...) | ||
| 303 | + | out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...) | |||
| 303 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | 304 | out.bars.top = append([]renderer.Cell(nil), in.bars.top...) | ||
| 304 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | 305 | out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...) | ||
| 305 | return out | 306 | return out | ||
A internal/usecase/daemon/toast_damage_test.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) { | |
| 14 | + | baseState := toastDamageState(nil, nil, true) | |
| 15 | + | base := composeFrame(baseState, composeCacheInput{}) | |
| 16 | + | stream := newOutputStateStream() | |
| 17 | + | terminal := vt.NewScreen(base.frame.Width, base.frame.Height) | |
| 18 | + | replayToastFrame(t, stream, terminal, base) | |
| 19 | + | ||
| 20 | + | one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |
| 21 | + | shownState := toastDamageState([]domain.Notification{one}, nil, false) | |
| 22 | + | shown := composeFrame(shownState, base.cache) | |
| 23 | + | requireNoFullRedraw(t, shown.damage) | |
| 24 | + | requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints) | |
| 25 | + | replayToastFrame(t, stream, terminal, shown) | |
| 26 | + | ||
| 27 | + | // A one-cell PTY update while the toast is stable redraws only the pane cell | |
| 28 | + | // and toast footprint, never the whole terminal. | |
| 29 | + | updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |
| 30 | + | updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |
| 31 | + | updated := composeFrame(updatedState, shown.cache) | |
| 32 | + | requireNoFullRedraw(t, updated.damage) | |
| 33 | + | require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1}) | |
| 34 | + | requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints) | |
| 35 | + | requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints) | |
| 36 | + | incremental, err := stream.render(updated.frame, updated.damage, updated.reset) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | fullRenderer := renderer.New(renderer.Capabilities{}) | |
| 39 | + | full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()}) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | |
| 42 | + | terminal.Write(incremental) | |
| 43 | + | require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame)) | |
| 44 | + | ||
| 45 | + | // Stacking and overflow stay visually identical while their complete old and | |
| 46 | + | // new coverage remains incremental damage. | |
| 47 | + | two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1} | |
| 48 | + | three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1} | |
| 49 | + | stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false) | |
| 50 | + | stackedState.overlays.noticeOverflow = 2 | |
| 51 | + | stacked := composeFrame(stackedState, updated.cache) | |
| 52 | + | requireNoFullRedraw(t, stacked.damage) | |
| 53 | + | requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints) | |
| 54 | + | requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints) | |
| 55 | + | replayToastFrame(t, stream, terminal, stacked) | |
| 56 | + | ||
| 57 | + | // Coalescing changes the title count in place. Both old and new coverage is | |
| 58 | + | // damaged even where their geometry happens to be identical. | |
| 59 | + | coalescedTwo := two | |
| 60 | + | coalescedTwo.Count = 12 | |
| 61 | + | coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false) | |
| 62 | + | coalescedState.overlays.noticeOverflow = 2 | |
| 63 | + | coalesced := composeFrame(coalescedState, stacked.cache) | |
| 64 | + | requireNoFullRedraw(t, coalesced.damage) | |
| 65 | + | requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints) | |
| 66 | + | requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints) | |
| 67 | + | replayToastFrame(t, stream, terminal, coalesced) | |
| 68 | + | ||
| 69 | + | // Dismissal and expiry have the same rendering transition: no current toast | |
| 70 | + | // exists, so the previous footprint alone must restore every exposed cell. | |
| 71 | + | for _, tt := range []struct { | |
| 72 | + | name string | |
| 73 | + | from composedRenderFrame | |
| 74 | + | }{ | |
| 75 | + | {name: "dismiss", from: coalesced}, | |
| 76 | + | {name: "expiry", from: shown}, | |
| 77 | + | } { | |
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |
| 79 | + | cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache) | |
| 80 | + | requireNoFullRedraw(t, cleared.damage) | |
| 81 | + | requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints) | |
| 82 | + | require.Empty(t, cleared.cache.toastFootprints) | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache) | |
| 87 | + | replayToastFrame(t, stream, terminal, cleared) | |
| 88 | + | require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) { | |
| 92 | + | base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{}) | |
| 93 | + | notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |
| 94 | + | shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{}) | |
| 95 | + | cache, scratch := shown.cache, base.cache | |
| 96 | + | state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |
| 97 | + | ||
| 98 | + | b.ReportAllocs() | |
| 99 | + | for i := 0; b.Loop(); i++ { | |
| 100 | + | cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()} | |
| 101 | + | state.panes[0].frame.Set(0, 0, cell) | |
| 102 | + | out := composeFrame(state, cache, scratch) | |
| 103 | + | if hasFullRedraw(out.damage) { | |
| 104 | + | b.Fatal("stable toast forced a full redraw") | |
| 105 | + | } | |
| 106 | + | scratch, cache = cache, out.cache | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState { | |
| 111 | + | paneFrame := renderer.NewFrame(80, 20) | |
| 112 | + | for y := 0; y < paneFrame.Height; y++ { | |
| 113 | + | for x := 0; x < paneFrame.Width; x++ { | |
| 114 | + | paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()}) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}} | |
| 118 | + | return capturedRenderState{ | |
| 119 | + | reset: reset, | |
| 120 | + | layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true}, | |
| 121 | + | panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}}, | |
| 122 | + | overlays: capturedOverlayRenderState{notices: notices}, | |
| 123 | + | styles: resolveStyles(nil), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) { | |
| 128 | + | t.Helper() | |
| 129 | + | data, err := stream.render(composed.frame, composed.damage, composed.reset) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | terminal.Write(data) | |
| 132 | + | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | |
| 136 | + | t.Helper() | |
| 137 | + | for _, d := range damage { | |
| 138 | + | require.NotEqual(t, renderer.DamageFullRedraw, d.Kind) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) { | |
| 143 | + | t.Helper() | |
| 144 | + | for _, footprint := range footprints { | |
| 145 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func hasFullRedraw(damage []renderer.Damage) bool { | |
| 150 | + | for _, d := range damage { | |
| 151 | + | if d.Kind == renderer.DamageFullRedraw { | |
| 152 | + | return true | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return false | |
| 156 | + | } | |
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) { | |||
| 14 | + | baseState := toastDamageState(nil, nil, true) | |||
| 15 | + | base := composeFrame(baseState, composeCacheInput{}) | |||
| 16 | + | stream := newOutputStateStream() | |||
| 17 | + | terminal := vt.NewScreen(base.frame.Width, base.frame.Height) | |||
| 18 | + | replayToastFrame(t, stream, terminal, base) | |||
| 19 | + | ||||
| 20 | + | one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |||
| 21 | + | shownState := toastDamageState([]domain.Notification{one}, nil, false) | |||
| 22 | + | shown := composeFrame(shownState, base.cache) | |||
| 23 | + | requireNoFullRedraw(t, shown.damage) | |||
| 24 | + | requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints) | |||
| 25 | + | replayToastFrame(t, stream, terminal, shown) | |||
| 26 | + | ||||
| 27 | + | // A one-cell PTY update while the toast is stable redraws only the pane cell | |||
| 28 | + | // and toast footprint, never the whole terminal. | |||
| 29 | + | updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 30 | + | updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 31 | + | updated := composeFrame(updatedState, shown.cache) | |||
| 32 | + | requireNoFullRedraw(t, updated.damage) | |||
| 33 | + | require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1}) | |||
| 34 | + | requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints) | |||
| 35 | + | requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints) | |||
| 36 | + | incremental, err := stream.render(updated.frame, updated.damage, updated.reset) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | fullRenderer := renderer.New(renderer.Capabilities{}) | |||
| 39 | + | full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()}) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output") | |||
| 42 | + | terminal.Write(incremental) | |||
| 43 | + | require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame)) | |||
| 44 | + | ||||
| 45 | + | // Stacking and overflow stay visually identical while their complete old and | |||
| 46 | + | // new coverage remains incremental damage. | |||
| 47 | + | two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1} | |||
| 48 | + | three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1} | |||
| 49 | + | stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false) | |||
| 50 | + | stackedState.overlays.noticeOverflow = 2 | |||
| 51 | + | stacked := composeFrame(stackedState, updated.cache) | |||
| 52 | + | requireNoFullRedraw(t, stacked.damage) | |||
| 53 | + | requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints) | |||
| 54 | + | requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints) | |||
| 55 | + | replayToastFrame(t, stream, terminal, stacked) | |||
| 56 | + | ||||
| 57 | + | // Coalescing changes the title count in place. Both old and new coverage is | |||
| 58 | + | // damaged even where their geometry happens to be identical. | |||
| 59 | + | coalescedTwo := two | |||
| 60 | + | coalescedTwo.Count = 12 | |||
| 61 | + | coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false) | |||
| 62 | + | coalescedState.overlays.noticeOverflow = 2 | |||
| 63 | + | coalesced := composeFrame(coalescedState, stacked.cache) | |||
| 64 | + | requireNoFullRedraw(t, coalesced.damage) | |||
| 65 | + | requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints) | |||
| 66 | + | requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints) | |||
| 67 | + | replayToastFrame(t, stream, terminal, coalesced) | |||
| 68 | + | ||||
| 69 | + | // Dismissal and expiry have the same rendering transition: no current toast | |||
| 70 | + | // exists, so the previous footprint alone must restore every exposed cell. | |||
| 71 | + | for _, tt := range []struct { | |||
| 72 | + | name string | |||
| 73 | + | from composedRenderFrame | |||
| 74 | + | }{ | |||
| 75 | + | {name: "dismiss", from: coalesced}, | |||
| 76 | + | {name: "expiry", from: shown}, | |||
| 77 | + | } { | |||
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 79 | + | cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache) | |||
| 80 | + | requireNoFullRedraw(t, cleared.damage) | |||
| 81 | + | requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints) | |||
| 82 | + | require.Empty(t, cleared.cache.toastFootprints) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache) | |||
| 87 | + | replayToastFrame(t, stream, terminal, cleared) | |||
| 88 | + | require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) { | |||
| 92 | + | base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{}) | |||
| 93 | + | notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1} | |||
| 94 | + | shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{}) | |||
| 95 | + | cache, scratch := shown.cache, base.cache | |||
| 96 | + | state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 97 | + | ||||
| 98 | + | b.ReportAllocs() | |||
| 99 | + | for i := 0; b.Loop(); i++ { | |||
| 100 | + | cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()} | |||
| 101 | + | state.panes[0].frame.Set(0, 0, cell) | |||
| 102 | + | out := composeFrame(state, cache, scratch) | |||
| 103 | + | if hasFullRedraw(out.damage) { | |||
| 104 | + | b.Fatal("stable toast forced a full redraw") | |||
| 105 | + | } | |||
| 106 | + | scratch, cache = cache, out.cache | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState { | |||
| 111 | + | paneFrame := renderer.NewFrame(80, 20) | |||
| 112 | + | for y := 0; y < paneFrame.Height; y++ { | |||
| 113 | + | for x := 0; x < paneFrame.Width; x++ { | |||
| 114 | + | paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()}) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}} | |||
| 118 | + | return capturedRenderState{ | |||
| 119 | + | reset: reset, | |||
| 120 | + | layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true}, | |||
| 121 | + | panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}}, | |||
| 122 | + | overlays: capturedOverlayRenderState{notices: notices}, | |||
| 123 | + | styles: resolveStyles(nil), | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) { | |||
| 128 | + | t.Helper() | |||
| 129 | + | data, err := stream.render(composed.frame, composed.damage, composed.reset) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | terminal.Write(data) | |||
| 132 | + | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | for _, d := range damage { | |||
| 138 | + | require.NotEqual(t, renderer.DamageFullRedraw, d.Kind) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) { | |||
| 143 | + | t.Helper() | |||
| 144 | + | for _, footprint := range footprints { | |||
| 145 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func hasFullRedraw(damage []renderer.Damage) bool { | |||
| 150 | + | for _, d := range damage { | |||
| 151 | + | if d.Kind == renderer.DamageFullRedraw { | |||
| 152 | + | return true | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | return false | |||
| 156 | + | } | |||
M internal/usecase/ui/notice.go +8 -3
| @@ -43,11 +43,13 @@ type NoticeStyles struct { | |||
| 43 | 43 | // lines. Boxes stack downward with one blank row between them; once the | |
| 44 | 44 | // count behind overflow is nonzero, a right-aligned "+N more" line follows | |
| 45 | 45 | // the last box. | |
| 46 | - | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) { | |
| 46 | + | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect { | |
| 47 | 47 | if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) { | |
| 48 | - | return | |
| 48 | + | return nil | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | footprints := make([]domain.Rect, 0, len(notices)+1) | |
| 52 | + | ||
| 51 | 53 | width := frame.Width * 2 / 5 | |
| 52 | 54 | if width > noticeMaxWidth { | |
| 53 | 55 | width = noticeMaxWidth | |
| @@ -59,7 +61,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||
| 59 | 61 | width = frame.Width | |
| 60 | 62 | } | |
| 61 | 63 | if width <= 0 { | |
| 62 | - | return | |
| 64 | + | return nil | |
| 63 | 65 | } | |
| 64 | 66 | x := frame.Width - noticeMargin - width | |
| 65 | 67 | y := noticeMargin | |
| @@ -75,6 +77,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||
| 75 | 77 | } | |
| 76 | 78 | height := len(lines) + 2 | |
| 77 | 79 | bounds := domain.Rect{X: x, Y: y, Width: width, Height: height} | |
| 80 | + | footprints = append(footprints, bounds) | |
| 78 | 81 | box := noticeBoxStyle(styles, n.Severity) | |
| 79 | 82 | DrawBox(frame, bounds, box) | |
| 80 | 83 | ||
| @@ -112,7 +115,9 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||
| 112 | 115 | start = x | |
| 113 | 116 | } | |
| 114 | 117 | DrawText(frame, start, y, x+width, text, styles.Text) | |
| 118 | + | footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1}) | |
| 115 | 119 | } | |
| 120 | + | return footprints | |
| 116 | 121 | } | |
| 117 | 122 | ||
| 118 | 123 | func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style { | |
| @@ -43,11 +43,13 @@ type NoticeStyles struct { | |||||
| 43 | // lines. Boxes stack downward with one blank row between them; once the | 43 | // lines. Boxes stack downward with one blank row between them; once the | ||
| 44 | // count behind overflow is nonzero, a right-aligned "+N more" line follows | 44 | // count behind overflow is nonzero, a right-aligned "+N more" line follows | ||
| 45 | // the last box. | 45 | // the last box. | ||
| 46 | - | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) { | 46 | + | func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect { |
| 47 | if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) { | 47 | if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) { | ||
| 48 | - | return | 48 | + | return nil |
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | footprints := make([]domain.Rect, 0, len(notices)+1) | |||
| 52 | + | ||||
| 51 | width := frame.Width * 2 / 5 | 53 | width := frame.Width * 2 / 5 | ||
| 52 | if width > noticeMaxWidth { | 54 | if width > noticeMaxWidth { | ||
| 53 | width = noticeMaxWidth | 55 | width = noticeMaxWidth | ||
| @@ -59,7 +61,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||||
| 59 | width = frame.Width | 61 | width = frame.Width | ||
| 60 | } | 62 | } | ||
| 61 | if width <= 0 { | 63 | if width <= 0 { | ||
| 62 | - | return | 64 | + | return nil |
| 63 | } | 65 | } | ||
| 64 | x := frame.Width - noticeMargin - width | 66 | x := frame.Width - noticeMargin - width | ||
| 65 | y := noticeMargin | 67 | y := noticeMargin | ||
| @@ -75,6 +77,7 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||||
| 75 | } | 77 | } | ||
| 76 | height := len(lines) + 2 | 78 | height := len(lines) + 2 | ||
| 77 | bounds := domain.Rect{X: x, Y: y, Width: width, Height: height} | 79 | bounds := domain.Rect{X: x, Y: y, Width: width, Height: height} | ||
| 80 | + | footprints = append(footprints, bounds) | |||
| 78 | box := noticeBoxStyle(styles, n.Severity) | 81 | box := noticeBoxStyle(styles, n.Severity) | ||
| 79 | DrawBox(frame, bounds, box) | 82 | DrawBox(frame, bounds, box) | ||
| 80 | 83 | ||||
| @@ -112,7 +115,9 @@ func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, st | |||||
| 112 | start = x | 115 | start = x | ||
| 113 | } | 116 | } | ||
| 114 | DrawText(frame, start, y, x+width, text, styles.Text) | 117 | DrawText(frame, start, y, x+width, text, styles.Text) | ||
| 118 | + | footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1}) | |||
| 115 | } | 119 | } | ||
| 120 | + | return footprints | |||
| 116 | } | 121 | } | ||
| 117 | 122 | ||||
| 118 | func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style { | 123 | func noticeBoxStyle(styles NoticeStyles, severity uint8) renderer.Style { | ||