feat(daemon): add notification history overlay and command
Adds a `notifications` command (NTC) that opens a scrollable, newest-first modal listing the daemon's recorded notice history, mirroring the session picker's overlay pattern: a notices.Model list state, an overlayRuntime slot guarded by the existing noticeMu, and j/k/arrow navigation with q/ESC/Ctrl-C close. Yank (Task 6) is left unbuilt.
- commit
- 1a97012936852ed38749096d0dd632a987d4073a
- author
- brice · · 2026-07-20 13:29 UTC
- parent
- af22256
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +748 -6
M internal/usecase/command/command.go +1 -0
| @@ -51,6 +51,7 @@ type Context interface { | |||
| 51 | 51 | RenameSession() error | |
| 52 | 52 | RenameTab() error | |
| 53 | 53 | OpenSessionPicker() error | |
| 54 | + | OpenNotifications() error | |
| 54 | 55 | JumpRecentSession(rank int) error | |
| 55 | 56 | } | |
| 56 | 57 | ||
| @@ -51,6 +51,7 @@ type Context interface { | |||||
| 51 | RenameSession() error | 51 | RenameSession() error | ||
| 52 | RenameTab() error | 52 | RenameTab() error | ||
| 53 | OpenSessionPicker() error | 53 | OpenSessionPicker() error | ||
| 54 | + | OpenNotifications() error | |||
| 54 | JumpRecentSession(rank int) error | 55 | JumpRecentSession(rank int) error | ||
| 55 | } | 56 | } | ||
| 56 | 57 | ||||
M internal/usecase/command/context_mock_test.go +44 -0
| @@ -614,6 +614,50 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||
| 614 | 614 | return _c | |
| 615 | 615 | } | |
| 616 | 616 | ||
| 617 | + | // OpenNotifications provides a mock function for the type MockContext | |
| 618 | + | func (_mock *MockContext) OpenNotifications() error { | |
| 619 | + | ret := _mock.Called() | |
| 620 | + | ||
| 621 | + | if len(ret) == 0 { | |
| 622 | + | panic("no return value specified for OpenNotifications") | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | var r0 error | |
| 626 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 627 | + | r0 = returnFunc() | |
| 628 | + | } else { | |
| 629 | + | r0 = ret.Error(0) | |
| 630 | + | } | |
| 631 | + | return r0 | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | // MockContext_OpenNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenNotifications' | |
| 635 | + | type MockContext_OpenNotifications_Call struct { | |
| 636 | + | *mock.Call | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // OpenNotifications is a helper method to define mock.On call | |
| 640 | + | func (_e *MockContext_Expecter) OpenNotifications() *MockContext_OpenNotifications_Call { | |
| 641 | + | return &MockContext_OpenNotifications_Call{Call: _e.mock.On("OpenNotifications")} | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func (_c *MockContext_OpenNotifications_Call) Run(run func()) *MockContext_OpenNotifications_Call { | |
| 645 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 646 | + | run() | |
| 647 | + | }) | |
| 648 | + | return _c | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func (_c *MockContext_OpenNotifications_Call) Return(err error) *MockContext_OpenNotifications_Call { | |
| 652 | + | _c.Call.Return(err) | |
| 653 | + | return _c | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func (_c *MockContext_OpenNotifications_Call) RunAndReturn(run func() error) *MockContext_OpenNotifications_Call { | |
| 657 | + | _c.Call.Return(run) | |
| 658 | + | return _c | |
| 659 | + | } | |
| 660 | + | ||
| 617 | 661 | // OpenSessionPicker provides a mock function for the type MockContext | |
| 618 | 662 | func (_mock *MockContext) OpenSessionPicker() error { | |
| 619 | 663 | ret := _mock.Called() | |
| @@ -614,6 +614,50 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||||
| 614 | return _c | 614 | return _c | ||
| 615 | } | 615 | } | ||
| 616 | 616 | ||||
| 617 | + | // OpenNotifications provides a mock function for the type MockContext | |||
| 618 | + | func (_mock *MockContext) OpenNotifications() error { | |||
| 619 | + | ret := _mock.Called() | |||
| 620 | + | ||||
| 621 | + | if len(ret) == 0 { | |||
| 622 | + | panic("no return value specified for OpenNotifications") | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | var r0 error | |||
| 626 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 627 | + | r0 = returnFunc() | |||
| 628 | + | } else { | |||
| 629 | + | r0 = ret.Error(0) | |||
| 630 | + | } | |||
| 631 | + | return r0 | |||
| 632 | + | } | |||
| 633 | + | ||||
| 634 | + | // MockContext_OpenNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenNotifications' | |||
| 635 | + | type MockContext_OpenNotifications_Call struct { | |||
| 636 | + | *mock.Call | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | // OpenNotifications is a helper method to define mock.On call | |||
| 640 | + | func (_e *MockContext_Expecter) OpenNotifications() *MockContext_OpenNotifications_Call { | |||
| 641 | + | return &MockContext_OpenNotifications_Call{Call: _e.mock.On("OpenNotifications")} | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func (_c *MockContext_OpenNotifications_Call) Run(run func()) *MockContext_OpenNotifications_Call { | |||
| 645 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 646 | + | run() | |||
| 647 | + | }) | |||
| 648 | + | return _c | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | func (_c *MockContext_OpenNotifications_Call) Return(err error) *MockContext_OpenNotifications_Call { | |||
| 652 | + | _c.Call.Return(err) | |||
| 653 | + | return _c | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | func (_c *MockContext_OpenNotifications_Call) RunAndReturn(run func() error) *MockContext_OpenNotifications_Call { | |||
| 657 | + | _c.Call.Return(run) | |||
| 658 | + | return _c | |||
| 659 | + | } | |||
| 660 | + | ||||
| 617 | // OpenSessionPicker provides a mock function for the type MockContext | 661 | // OpenSessionPicker provides a mock function for the type MockContext | ||
| 618 | func (_mock *MockContext) OpenSessionPicker() error { | 662 | func (_mock *MockContext) OpenSessionPicker() error { | ||
| 619 | ret := _mock.Called() | 663 | ret := _mock.Called() | ||
M internal/usecase/command/registry.go +1 -0
| @@ -31,6 +31,7 @@ func Registry() []Command { | |||
| 31 | 31 | return ctx.JumpRecentSession(rank) | |
| 32 | 32 | }}, | |
| 33 | 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | |
| 34 | + | {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }}, | |
| 34 | 35 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | |
| 35 | 36 | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | |
| 36 | 37 | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | |
| @@ -31,6 +31,7 @@ func Registry() []Command { | |||||
| 31 | return ctx.JumpRecentSession(rank) | 31 | return ctx.JumpRecentSession(rank) | ||
| 32 | }}, | 32 | }}, | ||
| 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | ||
| 34 | + | {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }}, | |||
| 34 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | 35 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | ||
| 35 | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | 36 | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | ||
| 36 | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | 37 | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | ||
M internal/usecase/command/registry_test.go +12 -1
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 9 | commands := Registry() | |
| 10 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 10 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "VIS", "RNS", "RNT", "DET"} | |
| 11 | 11 | ||
| 12 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -47,6 +47,16 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |||
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | func TestRegistryIncludesNotifications(t *testing.T) { | |
| 51 | + | cmd, ok := ByCode("NTC") | |
| 52 | + | if !ok { | |
| 53 | + | t.Fatal("ByCode(\"NTC\") ok = false, want true") | |
| 54 | + | } | |
| 55 | + | if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" { | |
| 56 | + | t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 50 | 60 | func TestByCodeIsCaseInsensitive(t *testing.T) { | |
| 51 | 61 | for _, code := range []string{"CNT", "cnt", "CnT"} { | |
| 52 | 62 | cmd, ok := ByCode(code) | |
| @@ -88,6 +98,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 88 | 98 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | |
| 89 | 99 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | |
| 90 | 100 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 101 | + | {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }}, | |
| 91 | 102 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 92 | 103 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | |
| 93 | 104 | {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }}, | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 9 | commands := Registry() | 9 | commands := Registry() | ||
| 10 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"} | 10 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "VIS", "RNS", "RNT", "DET"} |
| 11 | 11 | ||||
| 12 | if len(commands) != len(wantCodes) { | 12 | if len(commands) != len(wantCodes) { | ||
| 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -47,6 +47,16 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestRegistryIncludesNotifications(t *testing.T) { | |||
| 51 | + | cmd, ok := ByCode("NTC") | |||
| 52 | + | if !ok { | |||
| 53 | + | t.Fatal("ByCode(\"NTC\") ok = false, want true") | |||
| 54 | + | } | |||
| 55 | + | if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" { | |||
| 56 | + | t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 50 | func TestByCodeIsCaseInsensitive(t *testing.T) { | 60 | func TestByCodeIsCaseInsensitive(t *testing.T) { | ||
| 51 | for _, code := range []string{"CNT", "cnt", "CnT"} { | 61 | for _, code := range []string{"CNT", "cnt", "CnT"} { | ||
| 52 | cmd, ok := ByCode(code) | 62 | cmd, ok := ByCode(code) | ||
| @@ -88,6 +98,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 88 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | 98 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | ||
| 89 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | 99 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | ||
| 90 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | 100 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | ||
| 101 | + | {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }}, | |||
| 91 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | 102 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | ||
| 92 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | 103 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | ||
| 93 | {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }}, | 104 | {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }}, | ||
A internal/usecase/daemon/notices_overlay.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/notices" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter} | |
| 12 | + | ||
| 13 | + | // enterNotices opens the notification history overlay from the daemon's full | |
| 14 | + | // notice center history, newest first. | |
| 15 | + | func (d *Daemon) enterNotices(sess *session, ac *attachedClient) { | |
| 16 | + | history := d.notices.history() | |
| 17 | + | now := d.clock.Now() | |
| 18 | + | ac.overlays.noticeMu.Lock() | |
| 19 | + | ac.overlays.noticesOverlay = notices.New(history, now) | |
| 20 | + | ac.overlays.noticesPending = nil | |
| 21 | + | ac.overlays.noticeMu.Unlock() | |
| 22 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |
| 26 | + | sess := ac.currentSession() | |
| 27 | + | if sess == nil { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | ac.overlays.noticeMu.Lock() | |
| 31 | + | if ac.overlays.noticesOverlay == nil { | |
| 32 | + | ac.overlays.noticesPending = nil | |
| 33 | + | d.stopNoticesPendingTimerLocked(ac) | |
| 34 | + | ac.overlays.noticeMu.Unlock() | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | if len(ac.overlays.noticesPending) > 0 { | |
| 38 | + | d.stopNoticesPendingTimerLocked(ac) | |
| 39 | + | combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data)) | |
| 40 | + | combined = append(combined, ac.overlays.noticesPending...) | |
| 41 | + | combined = append(combined, data...) | |
| 42 | + | data = combined | |
| 43 | + | ac.overlays.noticesPending = nil | |
| 44 | + | } | |
| 45 | + | changed := false | |
| 46 | + | exit := false | |
| 47 | + | for i := 0; i < len(data); i++ { | |
| 48 | + | switch data[i] { | |
| 49 | + | case 'j': | |
| 50 | + | ac.overlays.noticesOverlay.Down() | |
| 51 | + | changed = true | |
| 52 | + | case 'k': | |
| 53 | + | ac.overlays.noticesOverlay.Up() | |
| 54 | + | changed = true | |
| 55 | + | case 'q', 0x03, 0x1b: | |
| 56 | + | if data[i] == 0x1b { | |
| 57 | + | tail := data[i:] | |
| 58 | + | consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail) | |
| 59 | + | if ok { | |
| 60 | + | i += consumed - 1 | |
| 61 | + | changed = true | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | if len(tail) == 1 { | |
| 65 | + | d.retainNoticesESCLocked(ac) | |
| 66 | + | break | |
| 67 | + | } | |
| 68 | + | if isPickerEscapePrefix(tail) { | |
| 69 | + | ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...) | |
| 70 | + | break | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | exit = true | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ac.overlays.noticeMu.Unlock() | |
| 77 | + | ||
| 78 | + | if exit { | |
| 79 | + | d.closeNotices(ac) | |
| 80 | + | } | |
| 81 | + | if exit || changed { | |
| 82 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) { | |
| 87 | + | ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC) | |
| 88 | + | ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 89 | + | ac.overlays.noticeMu.Lock() | |
| 90 | + | if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil { | |
| 91 | + | ac.overlays.noticeMu.Unlock() | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ac.overlays.noticesPending = nil | |
| 95 | + | ac.overlays.noticesESC.timer = nil | |
| 96 | + | ac.overlays.noticesESC.done = nil | |
| 97 | + | ac.overlays.noticesOverlay = nil | |
| 98 | + | ac.overlays.noticeMu.Unlock() | |
| 99 | + | ||
| 100 | + | if sess := ac.currentSession(); sess != nil { | |
| 101 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |
| 102 | + | } | |
| 103 | + | }) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) { | |
| 107 | + | ac.overlays.noticesESC.stop() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (d *Daemon) closeNotices(ac *attachedClient) { | |
| 111 | + | ac.overlays.noticeMu.Lock() | |
| 112 | + | ac.overlays.noticesOverlay = nil | |
| 113 | + | ac.overlays.noticesPending = nil | |
| 114 | + | d.stopNoticesPendingTimerLocked(ac) | |
| 115 | + | ac.overlays.noticeMu.Unlock() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) { | |
| 119 | + | if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') { | |
| 120 | + | switch data[2] { | |
| 121 | + | case 'A': | |
| 122 | + | m.Up() | |
| 123 | + | return 3, true | |
| 124 | + | case 'B': | |
| 125 | + | m.Down() | |
| 126 | + | return 3, true | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return 0, false | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter} | |||
| 12 | + | ||||
| 13 | + | // enterNotices opens the notification history overlay from the daemon's full | |||
| 14 | + | // notice center history, newest first. | |||
| 15 | + | func (d *Daemon) enterNotices(sess *session, ac *attachedClient) { | |||
| 16 | + | history := d.notices.history() | |||
| 17 | + | now := d.clock.Now() | |||
| 18 | + | ac.overlays.noticeMu.Lock() | |||
| 19 | + | ac.overlays.noticesOverlay = notices.New(history, now) | |||
| 20 | + | ac.overlays.noticesPending = nil | |||
| 21 | + | ac.overlays.noticeMu.Unlock() | |||
| 22 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 26 | + | sess := ac.currentSession() | |||
| 27 | + | if sess == nil { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | ac.overlays.noticeMu.Lock() | |||
| 31 | + | if ac.overlays.noticesOverlay == nil { | |||
| 32 | + | ac.overlays.noticesPending = nil | |||
| 33 | + | d.stopNoticesPendingTimerLocked(ac) | |||
| 34 | + | ac.overlays.noticeMu.Unlock() | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | if len(ac.overlays.noticesPending) > 0 { | |||
| 38 | + | d.stopNoticesPendingTimerLocked(ac) | |||
| 39 | + | combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data)) | |||
| 40 | + | combined = append(combined, ac.overlays.noticesPending...) | |||
| 41 | + | combined = append(combined, data...) | |||
| 42 | + | data = combined | |||
| 43 | + | ac.overlays.noticesPending = nil | |||
| 44 | + | } | |||
| 45 | + | changed := false | |||
| 46 | + | exit := false | |||
| 47 | + | for i := 0; i < len(data); i++ { | |||
| 48 | + | switch data[i] { | |||
| 49 | + | case 'j': | |||
| 50 | + | ac.overlays.noticesOverlay.Down() | |||
| 51 | + | changed = true | |||
| 52 | + | case 'k': | |||
| 53 | + | ac.overlays.noticesOverlay.Up() | |||
| 54 | + | changed = true | |||
| 55 | + | case 'q', 0x03, 0x1b: | |||
| 56 | + | if data[i] == 0x1b { | |||
| 57 | + | tail := data[i:] | |||
| 58 | + | consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail) | |||
| 59 | + | if ok { | |||
| 60 | + | i += consumed - 1 | |||
| 61 | + | changed = true | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | if len(tail) == 1 { | |||
| 65 | + | d.retainNoticesESCLocked(ac) | |||
| 66 | + | break | |||
| 67 | + | } | |||
| 68 | + | if isPickerEscapePrefix(tail) { | |||
| 69 | + | ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...) | |||
| 70 | + | break | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | exit = true | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ac.overlays.noticeMu.Unlock() | |||
| 77 | + | ||||
| 78 | + | if exit { | |||
| 79 | + | d.closeNotices(ac) | |||
| 80 | + | } | |||
| 81 | + | if exit || changed { | |||
| 82 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) { | |||
| 87 | + | ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC) | |||
| 88 | + | ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 89 | + | ac.overlays.noticeMu.Lock() | |||
| 90 | + | if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil { | |||
| 91 | + | ac.overlays.noticeMu.Unlock() | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | ac.overlays.noticesPending = nil | |||
| 95 | + | ac.overlays.noticesESC.timer = nil | |||
| 96 | + | ac.overlays.noticesESC.done = nil | |||
| 97 | + | ac.overlays.noticesOverlay = nil | |||
| 98 | + | ac.overlays.noticeMu.Unlock() | |||
| 99 | + | ||||
| 100 | + | if sess := ac.currentSession(); sess != nil { | |||
| 101 | + | d.invalidateRender(sess, ac, true, "notices_overlay.go") | |||
| 102 | + | } | |||
| 103 | + | }) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) { | |||
| 107 | + | ac.overlays.noticesESC.stop() | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (d *Daemon) closeNotices(ac *attachedClient) { | |||
| 111 | + | ac.overlays.noticeMu.Lock() | |||
| 112 | + | ac.overlays.noticesOverlay = nil | |||
| 113 | + | ac.overlays.noticesPending = nil | |||
| 114 | + | d.stopNoticesPendingTimerLocked(ac) | |||
| 115 | + | ac.overlays.noticeMu.Unlock() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) { | |||
| 119 | + | if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') { | |||
| 120 | + | switch data[2] { | |||
| 121 | + | case 'A': | |||
| 122 | + | m.Up() | |||
| 123 | + | return 3, true | |||
| 124 | + | case 'B': | |||
| 125 | + | m.Down() | |||
| 126 | + | return 3, true | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | return 0, false | |||
| 130 | + | } | |||
A internal/usecase/daemon/notices_overlay_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) { | |
| 14 | + | p, release := newBlockingPTY(t) | |
| 15 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 16 | + | defer release() | |
| 17 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)}) | |
| 18 | + | ||
| 19 | + | d.enterPalette(sess, ac) | |
| 20 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 21 | + | d.handlePaletteInput(ac, []byte("NTC\r")) | |
| 22 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 23 | + | ||
| 24 | + | require.False(t, ac.overlays.paletteActive()) | |
| 25 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |
| 26 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.Contains(t, string(msg.Data), "Notifications") | |
| 29 | + | require.Contains(t, string(msg.Data), "pane-spawn") | |
| 30 | + | require.Contains(t, string(msg.Data), "couldn't open pane") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestNoticesJKNavigatesSelection(t *testing.T) { | |
| 34 | + | p, release := newBlockingPTY(t) | |
| 35 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 36 | + | defer release() | |
| 37 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)}) | |
| 38 | + | d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)}) | |
| 39 | + | d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)}) | |
| 40 | + | ||
| 41 | + | d.enterNotices(sess, ac) | |
| 42 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 43 | + | selected, ok := ac.overlays.noticesOverlay.Selected() | |
| 44 | + | require.True(t, ok) | |
| 45 | + | require.Equal(t, "third", selected.Message) | |
| 46 | + | ||
| 47 | + | d.handleInput(sess, ac, []byte("j")) | |
| 48 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 49 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |
| 50 | + | require.True(t, ok) | |
| 51 | + | require.Equal(t, "second", selected.Message) | |
| 52 | + | ||
| 53 | + | d.handleInput(sess, ac, []byte("j")) | |
| 54 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 55 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |
| 56 | + | require.True(t, ok) | |
| 57 | + | require.Equal(t, "first", selected.Message) | |
| 58 | + | ||
| 59 | + | // One more 'j' at the bottom must clamp rather than wrap. | |
| 60 | + | d.handleInput(sess, ac, []byte("j")) | |
| 61 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 62 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |
| 63 | + | require.True(t, ok) | |
| 64 | + | require.Equal(t, "first", selected.Message) | |
| 65 | + | ||
| 66 | + | d.handleInput(sess, ac, []byte("k")) | |
| 67 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 68 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |
| 69 | + | require.True(t, ok) | |
| 70 | + | require.Equal(t, "second", selected.Message) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) { | |
| 74 | + | for _, key := range []byte{'q', 0x03} { | |
| 75 | + | p, release := newBlockingPTY(t) | |
| 76 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 77 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |
| 78 | + | ||
| 79 | + | d.enterNotices(sess, ac) | |
| 80 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 81 | + | require.True(t, ac.overlays.noticesActive()) | |
| 82 | + | ||
| 83 | + | d.handleInput(sess, ac, []byte{key}) | |
| 84 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 85 | + | ||
| 86 | + | require.False(t, ac.overlays.noticesActive()) | |
| 87 | + | require.Nil(t, ac.overlays.noticesOverlay) | |
| 88 | + | release() | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) { | |
| 93 | + | p, release := newBlockingPTY(t) | |
| 94 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 95 | + | defer release() | |
| 96 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |
| 97 | + | ||
| 98 | + | d.enterNotices(sess, ac) | |
| 99 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 100 | + | ||
| 101 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 102 | + | d.clock = clk | |
| 103 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 104 | + | timer := <-clk.timers | |
| 105 | + | require.True(t, ac.overlays.noticesActive()) | |
| 106 | + | ||
| 107 | + | timer.ch <- time.Now() | |
| 108 | + | require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond) | |
| 109 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |
| 113 | + | p, release := newBlockingPTY(t) | |
| 114 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 115 | + | defer release() | |
| 116 | + | ||
| 117 | + | require.NotPanics(t, func() { d.enterNotices(sess, ac) }) | |
| 118 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 119 | + | ||
| 120 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |
| 121 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | require.Contains(t, string(msg.Data), "Notifications") | |
| 124 | + | require.Contains(t, string(msg.Data), "No notifications") | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) { | |||
| 14 | + | p, release := newBlockingPTY(t) | |||
| 15 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 16 | + | defer release() | |||
| 17 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)}) | |||
| 18 | + | ||||
| 19 | + | d.enterPalette(sess, ac) | |||
| 20 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 21 | + | d.handlePaletteInput(ac, []byte("NTC\r")) | |||
| 22 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 23 | + | ||||
| 24 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 25 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |||
| 26 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | require.Contains(t, string(msg.Data), "Notifications") | |||
| 29 | + | require.Contains(t, string(msg.Data), "pane-spawn") | |||
| 30 | + | require.Contains(t, string(msg.Data), "couldn't open pane") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestNoticesJKNavigatesSelection(t *testing.T) { | |||
| 34 | + | p, release := newBlockingPTY(t) | |||
| 35 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 36 | + | defer release() | |||
| 37 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)}) | |||
| 38 | + | d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)}) | |||
| 39 | + | d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)}) | |||
| 40 | + | ||||
| 41 | + | d.enterNotices(sess, ac) | |||
| 42 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 43 | + | selected, ok := ac.overlays.noticesOverlay.Selected() | |||
| 44 | + | require.True(t, ok) | |||
| 45 | + | require.Equal(t, "third", selected.Message) | |||
| 46 | + | ||||
| 47 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 48 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 49 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 50 | + | require.True(t, ok) | |||
| 51 | + | require.Equal(t, "second", selected.Message) | |||
| 52 | + | ||||
| 53 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 54 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 55 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 56 | + | require.True(t, ok) | |||
| 57 | + | require.Equal(t, "first", selected.Message) | |||
| 58 | + | ||||
| 59 | + | // One more 'j' at the bottom must clamp rather than wrap. | |||
| 60 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 61 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 62 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 63 | + | require.True(t, ok) | |||
| 64 | + | require.Equal(t, "first", selected.Message) | |||
| 65 | + | ||||
| 66 | + | d.handleInput(sess, ac, []byte("k")) | |||
| 67 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 68 | + | selected, ok = ac.overlays.noticesOverlay.Selected() | |||
| 69 | + | require.True(t, ok) | |||
| 70 | + | require.Equal(t, "second", selected.Message) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) { | |||
| 74 | + | for _, key := range []byte{'q', 0x03} { | |||
| 75 | + | p, release := newBlockingPTY(t) | |||
| 76 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 77 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 78 | + | ||||
| 79 | + | d.enterNotices(sess, ac) | |||
| 80 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 81 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 82 | + | ||||
| 83 | + | d.handleInput(sess, ac, []byte{key}) | |||
| 84 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 85 | + | ||||
| 86 | + | require.False(t, ac.overlays.noticesActive()) | |||
| 87 | + | require.Nil(t, ac.overlays.noticesOverlay) | |||
| 88 | + | release() | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) { | |||
| 93 | + | p, release := newBlockingPTY(t) | |||
| 94 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 95 | + | defer release() | |||
| 96 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 97 | + | ||||
| 98 | + | d.enterNotices(sess, ac) | |||
| 99 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 100 | + | ||||
| 101 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 102 | + | d.clock = clk | |||
| 103 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 104 | + | timer := <-clk.timers | |||
| 105 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 106 | + | ||||
| 107 | + | timer.ch <- time.Now() | |||
| 108 | + | require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond) | |||
| 109 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |||
| 113 | + | p, release := newBlockingPTY(t) | |||
| 114 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 115 | + | defer release() | |||
| 116 | + | ||||
| 117 | + | require.NotPanics(t, func() { d.enterNotices(sess, ac) }) | |||
| 118 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 119 | + | ||||
| 120 | + | require.NotNil(t, ac.overlays.noticesOverlay) | |||
| 121 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | require.Contains(t, string(msg.Data), "Notifications") | |||
| 124 | + | require.Contains(t, string(msg.Data), "No notifications") | |||
| 125 | + | } | |||
M internal/usecase/daemon/overlay_runtime.go +29 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/notices" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/palette" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | 12 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| @@ -53,12 +54,21 @@ type overlayRuntime struct { | |||
| 53 | 54 | // noticeMu is the innermost overlay lock: it guards the toast fields below | |
| 54 | 55 | // and nothing is ever locked, sent, or rendered while it is held. The only | |
| 55 | 56 | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | |
| 57 | + | // The notification history overlay's fields share this lock rather than | |
| 58 | + | // adding one of their own: both concern notice presentation for this | |
| 59 | + | // client and neither is ever held across a render call. | |
| 56 | 60 | noticeMu sync.Mutex | |
| 57 | 61 | noticeToasts []noticeToast | |
| 58 | 62 | noticeOverflow int | |
| 59 | 63 | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | |
| 60 | 64 | // dismiss the refreshed entry that replaced the one it belonged to. | |
| 61 | 65 | noticeSeq uint64 | |
| 66 | + | ||
| 67 | + | // noticesOverlay is the `notifications` command's history modal. nil when | |
| 68 | + | // closed. | |
| 69 | + | noticesOverlay *notices.Model | |
| 70 | + | noticesPending []byte | |
| 71 | + | noticesESC pendingByteTimer | |
| 62 | 72 | } | |
| 63 | 73 | ||
| 64 | 74 | type copyPointerState struct { | |
| @@ -88,7 +98,7 @@ func (rt *overlayRuntime) Active() bool { | |||
| 88 | 98 | if rt == nil || rt.ac == nil { | |
| 89 | 99 | return false | |
| 90 | 100 | } | |
| 91 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive() | |
| 101 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive() | |
| 92 | 102 | } | |
| 93 | 103 | ||
| 94 | 104 | func (rt *overlayRuntime) promptActive() bool { | |
| @@ -118,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool { | |||
| 118 | 128 | return rt.picker != nil | |
| 119 | 129 | } | |
| 120 | 130 | ||
| 131 | + | func (rt *overlayRuntime) noticesActive() bool { | |
| 132 | + | if rt == nil { | |
| 133 | + | return false | |
| 134 | + | } | |
| 135 | + | rt.noticeMu.Lock() | |
| 136 | + | defer rt.noticeMu.Unlock() | |
| 137 | + | return rt.noticesOverlay != nil | |
| 138 | + | } | |
| 139 | + | ||
| 121 | 140 | func (rt *overlayRuntime) copyActive() bool { | |
| 122 | 141 | if rt == nil { | |
| 123 | 142 | return false | |
| @@ -212,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||
| 212 | 231 | d.handlePickerInput(ac, data) | |
| 213 | 232 | return true | |
| 214 | 233 | } | |
| 234 | + | if rt.noticesActive() { | |
| 235 | + | d.handleNoticesInput(ac, data) | |
| 236 | + | return true | |
| 237 | + | } | |
| 215 | 238 | if rt.copyActive() { | |
| 216 | 239 | d.handleCopyInput(ac, data) | |
| 217 | 240 | return true | |
| @@ -232,6 +255,9 @@ type overlayRenderSnapshot struct { | |||
| 232 | 255 | pickerModel *picker.Model | |
| 233 | 256 | previewTab *tab | |
| 234 | 257 | ||
| 258 | + | noticesOverlayActive bool | |
| 259 | + | noticesOverlayModel *notices.Model | |
| 260 | + | ||
| 235 | 261 | paletteActive bool | |
| 236 | 262 | paletteModel *palette.Model | |
| 237 | 263 | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| @@ -271,6 +297,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 271 | 297 | } | |
| 272 | 298 | } | |
| 273 | 299 | snap.noticeOverflow = rt.noticeOverflow | |
| 300 | + | snap.noticesOverlayActive = rt.noticesOverlay != nil | |
| 301 | + | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | |
| 274 | 302 | rt.noticeMu.Unlock() | |
| 275 | 303 | ||
| 276 | 304 | rt.copyMu.Lock() | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 9 | "github.com/bnema/vev/internal/usecase/palette" | 10 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 10 | "github.com/bnema/vev/internal/usecase/picker" | 11 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 11 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 12 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| @@ -53,12 +54,21 @@ type overlayRuntime struct { | |||||
| 53 | // noticeMu is the innermost overlay lock: it guards the toast fields below | 54 | // noticeMu is the innermost overlay lock: it guards the toast fields below | ||
| 54 | // and nothing is ever locked, sent, or rendered while it is held. The only | 55 | // and nothing is ever locked, sent, or rendered while it is held. The only | ||
| 55 | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | 56 | // permitted nesting is sendMu -> noticeMu (rendering reads the toasts). | ||
| 57 | + | // The notification history overlay's fields share this lock rather than | |||
| 58 | + | // adding one of their own: both concern notice presentation for this | |||
| 59 | + | // client and neither is ever held across a render call. | |||
| 56 | noticeMu sync.Mutex | 60 | noticeMu sync.Mutex | ||
| 57 | noticeToasts []noticeToast | 61 | noticeToasts []noticeToast | ||
| 58 | noticeOverflow int | 62 | noticeOverflow int | ||
| 59 | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | 63 | // noticeSeq numbers toast entries so an already-fired expiry timer cannot | ||
| 60 | // dismiss the refreshed entry that replaced the one it belonged to. | 64 | // dismiss the refreshed entry that replaced the one it belonged to. | ||
| 61 | noticeSeq uint64 | 65 | noticeSeq uint64 | ||
| 66 | + | ||||
| 67 | + | // noticesOverlay is the `notifications` command's history modal. nil when | |||
| 68 | + | // closed. | |||
| 69 | + | noticesOverlay *notices.Model | |||
| 70 | + | noticesPending []byte | |||
| 71 | + | noticesESC pendingByteTimer | |||
| 62 | } | 72 | } | ||
| 63 | 73 | ||||
| 64 | type copyPointerState struct { | 74 | type copyPointerState struct { | ||
| @@ -88,7 +98,7 @@ func (rt *overlayRuntime) Active() bool { | |||||
| 88 | if rt == nil || rt.ac == nil { | 98 | if rt == nil || rt.ac == nil { | ||
| 89 | return false | 99 | return false | ||
| 90 | } | 100 | } | ||
| 91 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive() | 101 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive() |
| 92 | } | 102 | } | ||
| 93 | 103 | ||||
| 94 | func (rt *overlayRuntime) promptActive() bool { | 104 | func (rt *overlayRuntime) promptActive() bool { | ||
| @@ -118,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool { | |||||
| 118 | return rt.picker != nil | 128 | return rt.picker != nil | ||
| 119 | } | 129 | } | ||
| 120 | 130 | ||||
| 131 | + | func (rt *overlayRuntime) noticesActive() bool { | |||
| 132 | + | if rt == nil { | |||
| 133 | + | return false | |||
| 134 | + | } | |||
| 135 | + | rt.noticeMu.Lock() | |||
| 136 | + | defer rt.noticeMu.Unlock() | |||
| 137 | + | return rt.noticesOverlay != nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 121 | func (rt *overlayRuntime) copyActive() bool { | 140 | func (rt *overlayRuntime) copyActive() bool { | ||
| 122 | if rt == nil { | 141 | if rt == nil { | ||
| 123 | return false | 142 | return false | ||
| @@ -212,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||||
| 212 | d.handlePickerInput(ac, data) | 231 | d.handlePickerInput(ac, data) | ||
| 213 | return true | 232 | return true | ||
| 214 | } | 233 | } | ||
| 234 | + | if rt.noticesActive() { | |||
| 235 | + | d.handleNoticesInput(ac, data) | |||
| 236 | + | return true | |||
| 237 | + | } | |||
| 215 | if rt.copyActive() { | 238 | if rt.copyActive() { | ||
| 216 | d.handleCopyInput(ac, data) | 239 | d.handleCopyInput(ac, data) | ||
| 217 | return true | 240 | return true | ||
| @@ -232,6 +255,9 @@ type overlayRenderSnapshot struct { | |||||
| 232 | pickerModel *picker.Model | 255 | pickerModel *picker.Model | ||
| 233 | previewTab *tab | 256 | previewTab *tab | ||
| 234 | 257 | ||||
| 258 | + | noticesOverlayActive bool | |||
| 259 | + | noticesOverlayModel *notices.Model | |||
| 260 | + | ||||
| 235 | paletteActive bool | 261 | paletteActive bool | ||
| 236 | paletteModel *palette.Model | 262 | paletteModel *palette.Model | ||
| 237 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 263 | // paletteHints is a copy captured under paletteMu. Rendering must use this | ||
| @@ -271,6 +297,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 271 | } | 297 | } | ||
| 272 | } | 298 | } | ||
| 273 | snap.noticeOverflow = rt.noticeOverflow | 299 | snap.noticeOverflow = rt.noticeOverflow | ||
| 300 | + | snap.noticesOverlayActive = rt.noticesOverlay != nil | |||
| 301 | + | snap.noticesOverlayModel = rt.noticesOverlay.Clone() | |||
| 274 | rt.noticeMu.Unlock() | 302 | rt.noticeMu.Unlock() | ||
| 275 | 303 | ||||
| 276 | rt.copyMu.Lock() | 304 | rt.copyMu.Lock() | ||
M internal/usecase/daemon/palette.go +5 -0
| @@ -480,6 +480,11 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 480 | 480 | return nil | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | + | func (e paletteExec) OpenNotifications() error { | |
| 484 | + | e.d.enterNotices(e.sess, e.ac) | |
| 485 | + | return nil | |
| 486 | + | } | |
| 487 | + | ||
| 483 | 488 | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 484 | 489 | if rank < 1 || rank > len(e.recent) { | |
| 485 | 490 | return command.ErrInvalidArguments | |
| @@ -480,6 +480,11 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 480 | return nil | 480 | return nil | ||
| 481 | } | 481 | } | ||
| 482 | 482 | ||||
| 483 | + | func (e paletteExec) OpenNotifications() error { | |||
| 484 | + | e.d.enterNotices(e.sess, e.ac) | |||
| 485 | + | return nil | |||
| 486 | + | } | |||
| 487 | + | ||||
| 483 | func (e paletteExec) JumpRecentSession(rank int) error { | 488 | func (e paletteExec) JumpRecentSession(rank int) error { | ||
| 484 | if rank < 1 || rank > len(e.recent) { | 489 | if rank < 1 || rank > len(e.recent) { | ||
| 485 | return command.ErrInvalidArguments | 490 | return command.ErrInvalidArguments | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -563,7 +563,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 563 | 563 | for i, cmd := range commands { | |
| 564 | 564 | codes[i] = cmd.Code | |
| 565 | 565 | } | |
| 566 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 566 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 567 | 567 | } | |
| 568 | 568 | ||
| 569 | 569 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| @@ -563,7 +563,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 563 | for i, cmd := range commands { | 563 | for i, cmd := range commands { | ||
| 564 | codes[i] = cmd.Code | 564 | codes[i] = cmd.Code | ||
| 565 | } | 565 | } | ||
| 566 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes) | 566 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "VIS", "RNS", "RNT", "DET"}, codes) |
| 567 | } | 567 | } | ||
| 568 | 568 | ||||
| 569 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 569 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
M internal/usecase/daemon/render_capture.go +3 -2
| @@ -86,11 +86,12 @@ type capturedFloatingRenderState struct { | |||
| 86 | 86 | ||
| 87 | 87 | type capturedOverlayRenderState struct { | |
| 88 | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 89 | + | noticesOverlayActive bool | |
| 89 | 90 | copyMode *scopy.Mode | |
| 90 | 91 | copyPaneID layout.PaneID | |
| 91 | 92 | copyFeedback string | |
| 92 | 93 | paletteGuidance string | |
| 93 | - | copySearch, picker, palette, prompt capturedModal | |
| 94 | + | copySearch, picker, palette, prompt, noticesOverlay capturedModal | |
| 94 | 95 | notices []domain.Notification | |
| 95 | 96 | noticeOverflow int | |
| 96 | 97 | } | |
| @@ -102,7 +103,7 @@ type capturedModal struct { | |||
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | 105 | func (o capturedOverlayRenderState) active() bool { | |
| 105 | - | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive | |
| 106 | + | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive | |
| 106 | 107 | } | |
| 107 | 108 | ||
| 108 | 109 | type capturedCursorInputs struct { | |
| @@ -86,11 +86,12 @@ type capturedFloatingRenderState struct { | |||||
| 86 | 86 | ||||
| 87 | type capturedOverlayRenderState struct { | 87 | type capturedOverlayRenderState struct { | ||
| 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | ||
| 89 | + | noticesOverlayActive bool | |||
| 89 | copyMode *scopy.Mode | 90 | copyMode *scopy.Mode | ||
| 90 | copyPaneID layout.PaneID | 91 | copyPaneID layout.PaneID | ||
| 91 | copyFeedback string | 92 | copyFeedback string | ||
| 92 | paletteGuidance string | 93 | paletteGuidance string | ||
| 93 | - | copySearch, picker, palette, prompt capturedModal | 94 | + | copySearch, picker, palette, prompt, noticesOverlay capturedModal |
| 94 | notices []domain.Notification | 95 | notices []domain.Notification | ||
| 95 | noticeOverflow int | 96 | noticeOverflow int | ||
| 96 | } | 97 | } | ||
| @@ -102,7 +103,7 @@ type capturedModal struct { | |||||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 104 | func (o capturedOverlayRenderState) active() bool { | 105 | func (o capturedOverlayRenderState) active() bool { | ||
| 105 | - | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive | 106 | + | return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive |
| 106 | } | 107 | } | ||
| 107 | 108 | ||||
| 108 | type capturedCursorInputs struct { | 109 | type capturedCursorInputs struct { | ||
M internal/usecase/daemon/render_pipeline.go +9 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/notices" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/prompt" | |
| @@ -201,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 201 | 202 | } | |
| 202 | 203 | o := &state.overlays | |
| 203 | 204 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 205 | + | o.noticesOverlayActive = snap.noticesOverlayActive | |
| 204 | 206 | o.copyMode = snap.copyMode | |
| 205 | 207 | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | |
| 206 | 208 | if snap.copyPane != nil { | |
| @@ -222,6 +224,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 222 | 224 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | |
| 223 | 225 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 224 | 226 | } | |
| 227 | + | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| 228 | + | o.noticesOverlay.modal = noticesModal | |
| 229 | + | o.noticesOverlay.focused = true | |
| 230 | + | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | |
| 231 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | |
| 232 | + | } | |
| 225 | 233 | if snap.paletteActive && snap.paletteModel != nil { | |
| 226 | 234 | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 227 | 235 | o.palette.focused = true | |
| @@ -266,7 +274,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 266 | 274 | if o.paletteActive && !state.floating.visible { | |
| 267 | 275 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | |
| 268 | 276 | } | |
| 269 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} { | |
| 277 | + | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { | |
| 270 | 278 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 271 | 279 | continue | |
| 272 | 280 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 10 | "github.com/bnema/vev/internal/usecase/palette" | 11 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 11 | "github.com/bnema/vev/internal/usecase/picker" | 12 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 12 | "github.com/bnema/vev/internal/usecase/prompt" | 13 | "github.com/bnema/vev/internal/usecase/prompt" | ||
| @@ -201,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 201 | } | 202 | } | ||
| 202 | o := &state.overlays | 203 | o := &state.overlays | ||
| 203 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | 204 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | ||
| 205 | + | o.noticesOverlayActive = snap.noticesOverlayActive | |||
| 204 | o.copyMode = snap.copyMode | 206 | o.copyMode = snap.copyMode | ||
| 205 | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | 207 | o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow | ||
| 206 | if snap.copyPane != nil { | 208 | if snap.copyPane != nil { | ||
| @@ -222,6 +224,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 222 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | 224 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | ||
| 223 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 225 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | ||
| 224 | } | 226 | } | ||
| 227 | + | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |||
| 228 | + | o.noticesOverlay.modal = noticesModal | |||
| 229 | + | o.noticesOverlay.focused = true | |||
| 230 | + | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | |||
| 231 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | |||
| 232 | + | } | |||
| 225 | if snap.paletteActive && snap.paletteModel != nil { | 233 | if snap.paletteActive && snap.paletteModel != nil { | ||
| 226 | o.palette.modal = paletteModalFor(size, paletteCfg) | 234 | o.palette.modal = paletteModalFor(size, paletteCfg) | ||
| 227 | o.palette.focused = true | 235 | o.palette.focused = true | ||
| @@ -266,7 +274,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 266 | if o.paletteActive && !state.floating.visible { | 274 | if o.paletteActive && !state.floating.visible { | ||
| 267 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | 275 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | ||
| 268 | } | 276 | } | ||
| 269 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} { | 277 | + | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { |
| 270 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | 278 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | ||
| 271 | continue | 279 | continue | ||
| 272 | } | 280 | } | ||
A internal/usecase/notices/model.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | // Package notices renders the notification history overlay: a scrollable, | |
| 2 | + | // newest-first list of the daemon's recorded notifications. | |
| 3 | + | package notices | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // RenderStyles are the styles Render uses to draw list rows. The zero value | |
| 15 | + | // is never used directly by daemon composition, which always supplies a | |
| 16 | + | // theme-derived RenderStyles the way picker.RenderStyles is supplied. | |
| 17 | + | type RenderStyles struct { | |
| 18 | + | Background renderer.Style // fills unused interior rows | |
| 19 | + | Base renderer.Style // non-selected row fill | |
| 20 | + | Selection renderer.Style // selected row fill | |
| 21 | + | Text renderer.Style // non-selected message segment | |
| 22 | + | SelectionText renderer.Style // selected row message segment | |
| 23 | + | Muted renderer.Style // non-selected severity/time/slug segment | |
| 24 | + | SelectionMuted renderer.Style // selected row severity/time/slug segment | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Model is the history overlay's list state: a snapshot of notifications, | |
| 28 | + | // newest first, with a navigable selection. now anchors the relative | |
| 29 | + | // timestamps rendered next to each row and is fixed at construction so the | |
| 30 | + | // overlay's times don't drift while it stays open. | |
| 31 | + | type Model struct { | |
| 32 | + | rows []domain.Notification | |
| 33 | + | selected int | |
| 34 | + | now time.Time | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // New builds a Model from entries, expected newest-first as returned by the | |
| 38 | + | // notice center's history(). now is the instant relative row times render | |
| 39 | + | // against. | |
| 40 | + | func New(entries []domain.Notification, now time.Time) *Model { | |
| 41 | + | m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1} | |
| 42 | + | if len(m.rows) > 0 { | |
| 43 | + | m.selected = 0 | |
| 44 | + | } | |
| 45 | + | return m | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (m *Model) Up() { | |
| 49 | + | m.move(-1) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (m *Model) Down() { | |
| 53 | + | m.move(1) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (m *Model) move(delta int) { | |
| 57 | + | if m == nil || len(m.rows) == 0 { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | next := m.selected + delta | |
| 61 | + | if next < 0 { | |
| 62 | + | next = 0 | |
| 63 | + | } | |
| 64 | + | if next >= len(m.rows) { | |
| 65 | + | next = len(m.rows) - 1 | |
| 66 | + | } | |
| 67 | + | m.selected = next | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Selected returns the highlighted notification, or false when history is | |
| 71 | + | // empty. | |
| 72 | + | func (m *Model) Selected() (domain.Notification, bool) { | |
| 73 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |
| 74 | + | return domain.Notification{}, false | |
| 75 | + | } | |
| 76 | + | return m.rows[m.selected], true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Clone returns an independent copy safe to render outside the lock that | |
| 80 | + | // guards the live model. | |
| 81 | + | func (m *Model) Clone() *Model { | |
| 82 | + | if m == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | clone := *m | |
| 86 | + | clone.rows = append([]domain.Notification(nil), m.rows...) | |
| 87 | + | return &clone | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Render draws the history list into inner, scrolling to keep the selection | |
| 91 | + | // visible. Degenerate dimensions draw nothing; an empty history draws a | |
| 92 | + | // centered placeholder instead of a blank box. | |
| 93 | + | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |
| 94 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 95 | + | if frame.Width <= 0 || frame.Height <= 0 { | |
| 96 | + | return frame | |
| 97 | + | } | |
| 98 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background}) | |
| 99 | + | if m == nil || len(m.rows) == 0 { | |
| 100 | + | text := "No notifications" | |
| 101 | + | x := max(0, (frame.Width-textWidth(text))/2) | |
| 102 | + | ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text) | |
| 103 | + | return frame | |
| 104 | + | } | |
| 105 | + | offset := m.scrollOffset(frame.Height) | |
| 106 | + | for y := 0; y < frame.Height; y++ { | |
| 107 | + | idx := offset + y | |
| 108 | + | if idx >= len(m.rows) { | |
| 109 | + | break | |
| 110 | + | } | |
| 111 | + | n := m.rows[idx] | |
| 112 | + | base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text | |
| 113 | + | if idx == m.selected { | |
| 114 | + | base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText | |
| 115 | + | } | |
| 116 | + | ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 117 | + | meta := fmt.Sprintf("[%c] %s %s ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String()) | |
| 118 | + | x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle) | |
| 119 | + | ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle) | |
| 120 | + | } | |
| 121 | + | return frame | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // rowMessage appends a "×N" coalesce count suffix, matching the toast | |
| 125 | + | // overlay's own count formatting. | |
| 126 | + | func rowMessage(n domain.Notification) string { | |
| 127 | + | if n.Count > 1 { | |
| 128 | + | return fmt.Sprintf("%s ×%d", n.Message, n.Count) | |
| 129 | + | } | |
| 130 | + | return n.Message | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (m *Model) scrollOffset(visible int) int { | |
| 134 | + | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |
| 135 | + | return 0 | |
| 136 | + | } | |
| 137 | + | if m.selected < visible { | |
| 138 | + | return 0 | |
| 139 | + | } | |
| 140 | + | offset := m.selected - visible + 1 | |
| 141 | + | return min(offset, len(m.rows)-visible) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func severityLetter(sev domain.NoticeSeverity) byte { | |
| 145 | + | switch sev { | |
| 146 | + | case domain.NoticeWarn: | |
| 147 | + | return 'W' | |
| 148 | + | case domain.NoticeError: | |
| 149 | + | return 'E' | |
| 150 | + | default: | |
| 151 | + | return 'I' | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // relativeTime renders how long ago t was, relative to now, in the coarsest | |
| 156 | + | // unit that keeps the row compact. A non-positive delta (including clock | |
| 157 | + | // skew that puts t in the future) reads as "just now" rather than negative. | |
| 158 | + | func relativeTime(now, t time.Time) string { | |
| 159 | + | d := now.Sub(t) | |
| 160 | + | switch { | |
| 161 | + | case d < time.Minute: | |
| 162 | + | return "just now" | |
| 163 | + | case d < time.Hour: | |
| 164 | + | return fmt.Sprintf("%dm ago", int(d/time.Minute)) | |
| 165 | + | case d < 24*time.Hour: | |
| 166 | + | return fmt.Sprintf("%dh ago", int(d/time.Hour)) | |
| 167 | + | default: | |
| 168 | + | return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func textWidth(text string) int { | |
| 173 | + | width := 0 | |
| 174 | + | for _, r := range text { | |
| 175 | + | width += renderer.RuneWidth(r) | |
| 176 | + | } | |
| 177 | + | return width | |
| 178 | + | } | |
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | // Package notices renders the notification history overlay: a scrollable, | |||
| 2 | + | // newest-first list of the daemon's recorded notifications. | |||
| 3 | + | package notices | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // RenderStyles are the styles Render uses to draw list rows. The zero value | |||
| 15 | + | // is never used directly by daemon composition, which always supplies a | |||
| 16 | + | // theme-derived RenderStyles the way picker.RenderStyles is supplied. | |||
| 17 | + | type RenderStyles struct { | |||
| 18 | + | Background renderer.Style // fills unused interior rows | |||
| 19 | + | Base renderer.Style // non-selected row fill | |||
| 20 | + | Selection renderer.Style // selected row fill | |||
| 21 | + | Text renderer.Style // non-selected message segment | |||
| 22 | + | SelectionText renderer.Style // selected row message segment | |||
| 23 | + | Muted renderer.Style // non-selected severity/time/slug segment | |||
| 24 | + | SelectionMuted renderer.Style // selected row severity/time/slug segment | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Model is the history overlay's list state: a snapshot of notifications, | |||
| 28 | + | // newest first, with a navigable selection. now anchors the relative | |||
| 29 | + | // timestamps rendered next to each row and is fixed at construction so the | |||
| 30 | + | // overlay's times don't drift while it stays open. | |||
| 31 | + | type Model struct { | |||
| 32 | + | rows []domain.Notification | |||
| 33 | + | selected int | |||
| 34 | + | now time.Time | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // New builds a Model from entries, expected newest-first as returned by the | |||
| 38 | + | // notice center's history(). now is the instant relative row times render | |||
| 39 | + | // against. | |||
| 40 | + | func New(entries []domain.Notification, now time.Time) *Model { | |||
| 41 | + | m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1} | |||
| 42 | + | if len(m.rows) > 0 { | |||
| 43 | + | m.selected = 0 | |||
| 44 | + | } | |||
| 45 | + | return m | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (m *Model) Up() { | |||
| 49 | + | m.move(-1) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (m *Model) Down() { | |||
| 53 | + | m.move(1) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (m *Model) move(delta int) { | |||
| 57 | + | if m == nil || len(m.rows) == 0 { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | next := m.selected + delta | |||
| 61 | + | if next < 0 { | |||
| 62 | + | next = 0 | |||
| 63 | + | } | |||
| 64 | + | if next >= len(m.rows) { | |||
| 65 | + | next = len(m.rows) - 1 | |||
| 66 | + | } | |||
| 67 | + | m.selected = next | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Selected returns the highlighted notification, or false when history is | |||
| 71 | + | // empty. | |||
| 72 | + | func (m *Model) Selected() (domain.Notification, bool) { | |||
| 73 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |||
| 74 | + | return domain.Notification{}, false | |||
| 75 | + | } | |||
| 76 | + | return m.rows[m.selected], true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Clone returns an independent copy safe to render outside the lock that | |||
| 80 | + | // guards the live model. | |||
| 81 | + | func (m *Model) Clone() *Model { | |||
| 82 | + | if m == nil { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | clone := *m | |||
| 86 | + | clone.rows = append([]domain.Notification(nil), m.rows...) | |||
| 87 | + | return &clone | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Render draws the history list into inner, scrolling to keep the selection | |||
| 91 | + | // visible. Degenerate dimensions draw nothing; an empty history draws a | |||
| 92 | + | // centered placeholder instead of a blank box. | |||
| 93 | + | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 94 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |||
| 95 | + | if frame.Width <= 0 || frame.Height <= 0 { | |||
| 96 | + | return frame | |||
| 97 | + | } | |||
| 98 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background}) | |||
| 99 | + | if m == nil || len(m.rows) == 0 { | |||
| 100 | + | text := "No notifications" | |||
| 101 | + | x := max(0, (frame.Width-textWidth(text))/2) | |||
| 102 | + | ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text) | |||
| 103 | + | return frame | |||
| 104 | + | } | |||
| 105 | + | offset := m.scrollOffset(frame.Height) | |||
| 106 | + | for y := 0; y < frame.Height; y++ { | |||
| 107 | + | idx := offset + y | |||
| 108 | + | if idx >= len(m.rows) { | |||
| 109 | + | break | |||
| 110 | + | } | |||
| 111 | + | n := m.rows[idx] | |||
| 112 | + | base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text | |||
| 113 | + | if idx == m.selected { | |||
| 114 | + | base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText | |||
| 115 | + | } | |||
| 116 | + | ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |||
| 117 | + | meta := fmt.Sprintf("[%c] %s %s ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String()) | |||
| 118 | + | x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle) | |||
| 119 | + | ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle) | |||
| 120 | + | } | |||
| 121 | + | return frame | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // rowMessage appends a "×N" coalesce count suffix, matching the toast | |||
| 125 | + | // overlay's own count formatting. | |||
| 126 | + | func rowMessage(n domain.Notification) string { | |||
| 127 | + | if n.Count > 1 { | |||
| 128 | + | return fmt.Sprintf("%s ×%d", n.Message, n.Count) | |||
| 129 | + | } | |||
| 130 | + | return n.Message | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (m *Model) scrollOffset(visible int) int { | |||
| 134 | + | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |||
| 135 | + | return 0 | |||
| 136 | + | } | |||
| 137 | + | if m.selected < visible { | |||
| 138 | + | return 0 | |||
| 139 | + | } | |||
| 140 | + | offset := m.selected - visible + 1 | |||
| 141 | + | return min(offset, len(m.rows)-visible) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func severityLetter(sev domain.NoticeSeverity) byte { | |||
| 145 | + | switch sev { | |||
| 146 | + | case domain.NoticeWarn: | |||
| 147 | + | return 'W' | |||
| 148 | + | case domain.NoticeError: | |||
| 149 | + | return 'E' | |||
| 150 | + | default: | |||
| 151 | + | return 'I' | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // relativeTime renders how long ago t was, relative to now, in the coarsest | |||
| 156 | + | // unit that keeps the row compact. A non-positive delta (including clock | |||
| 157 | + | // skew that puts t in the future) reads as "just now" rather than negative. | |||
| 158 | + | func relativeTime(now, t time.Time) string { | |||
| 159 | + | d := now.Sub(t) | |||
| 160 | + | switch { | |||
| 161 | + | case d < time.Minute: | |||
| 162 | + | return "just now" | |||
| 163 | + | case d < time.Hour: | |||
| 164 | + | return fmt.Sprintf("%dm ago", int(d/time.Minute)) | |||
| 165 | + | case d < 24*time.Hour: | |||
| 166 | + | return fmt.Sprintf("%dh ago", int(d/time.Hour)) | |||
| 167 | + | default: | |||
| 168 | + | return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func textWidth(text string) int { | |||
| 173 | + | width := 0 | |||
| 174 | + | for _, r := range text { | |||
| 175 | + | width += renderer.RuneWidth(r) | |||
| 176 | + | } | |||
| 177 | + | return width | |||
| 178 | + | } | |||
A internal/usecase/notices/model_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package notices | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func testStyles() RenderStyles { | |
| 14 | + | selection := renderer.DefaultStyle() | |
| 15 | + | selection.Inverse = true | |
| 16 | + | base := renderer.DefaultStyle() | |
| 17 | + | return RenderStyles{Background: base, Base: base, Selection: selection, Text: base, SelectionText: selection, Muted: base, SelectionMuted: selection} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // rowText flattens one rendered row back to text, skipping wide-rune | |
| 21 | + | // continuation cells, so assertions can check substrings. | |
| 22 | + | func rowText(f renderer.Frame, y int) string { | |
| 23 | + | var b strings.Builder | |
| 24 | + | for _, c := range f.Row(y) { | |
| 25 | + | if c.Continuation { | |
| 26 | + | continue | |
| 27 | + | } | |
| 28 | + | if c.Rune == 0 { | |
| 29 | + | b.WriteRune(' ') | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | b.WriteRune(c.Rune) | |
| 33 | + | } | |
| 34 | + | return b.String() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestNewSelectsNewestFirstEntry(t *testing.T) { | |
| 38 | + | now := time.Unix(1000, 0) | |
| 39 | + | entries := []domain.Notification{ | |
| 40 | + | {Code: domain.NoticePaneSpawn, Message: "newest", Time: now}, | |
| 41 | + | {Code: domain.NoticeTabSpawn, Message: "oldest", Time: now.Add(-time.Hour)}, | |
| 42 | + | } | |
| 43 | + | m := New(entries, now) | |
| 44 | + | got, ok := m.Selected() | |
| 45 | + | if !ok || got.Message != "newest" { | |
| 46 | + | t.Fatalf("Selected() = %+v, %v, want newest, true", got, ok) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestNavigationClampsAtBothEnds(t *testing.T) { | |
| 51 | + | tests := []struct { | |
| 52 | + | name string | |
| 53 | + | moves func(m *Model) | |
| 54 | + | want string | |
| 55 | + | }{ | |
| 56 | + | {"up from top stays at top", func(m *Model) { m.Up(); m.Up() }, "a"}, | |
| 57 | + | {"down past bottom stays at bottom", func(m *Model) { m.Down(); m.Down(); m.Down() }, "c"}, | |
| 58 | + | {"down then up returns to top", func(m *Model) { m.Down(); m.Down(); m.Up(); m.Up() }, "a"}, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | now := time.Unix(1000, 0) | |
| 63 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}, {Message: "c", Time: now}}, now) | |
| 64 | + | tt.moves(m) | |
| 65 | + | got, ok := m.Selected() | |
| 66 | + | if !ok || got.Message != tt.want { | |
| 67 | + | t.Fatalf("Selected() = %q, %v, want %q, true", got.Message, ok, tt.want) | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestSelectedAndNavigationOnEmptyHistory(t *testing.T) { | |
| 74 | + | m := New(nil, time.Unix(1000, 0)) | |
| 75 | + | if _, ok := m.Selected(); ok { | |
| 76 | + | t.Fatal("Selected() ok = true on empty history, want false") | |
| 77 | + | } | |
| 78 | + | m.Up() | |
| 79 | + | m.Down() | |
| 80 | + | if _, ok := m.Selected(); ok { | |
| 81 | + | t.Fatal("Selected() ok = true after navigating empty history, want false") | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRelativeTime(t *testing.T) { | |
| 86 | + | now := time.Unix(10000, 0) | |
| 87 | + | tests := []struct { | |
| 88 | + | name string | |
| 89 | + | at time.Time | |
| 90 | + | want string | |
| 91 | + | }{ | |
| 92 | + | {"seconds ago", now.Add(-30 * time.Second), "just now"}, | |
| 93 | + | {"two minutes ago", now.Add(-2 * time.Minute), "2m ago"}, | |
| 94 | + | {"fifty nine minutes ago", now.Add(-59 * time.Minute), "59m ago"}, | |
| 95 | + | {"two hours ago", now.Add(-2 * time.Hour), "2h ago"}, | |
| 96 | + | {"two days ago", now.Add(-48 * time.Hour), "2d ago"}, | |
| 97 | + | {"future clock skew", now.Add(time.Minute), "just now"}, | |
| 98 | + | } | |
| 99 | + | for _, tt := range tests { | |
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |
| 101 | + | if got := relativeTime(now, tt.at); got != tt.want { | |
| 102 | + | t.Errorf("relativeTime(now, %v) = %q, want %q", tt.at, got, tt.want) | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestRenderRowContainsSeverityCodeSlugAndCount(t *testing.T) { | |
| 109 | + | now := time.Unix(10000, 0) | |
| 110 | + | entries := []domain.Notification{ | |
| 111 | + | {Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Count: 3, Time: now.Add(-2 * time.Minute)}, | |
| 112 | + | } | |
| 113 | + | m := New(entries, now) | |
| 114 | + | frame := m.Render(domain.Size{Cols: 60, Rows: 5}, testStyles()) | |
| 115 | + | row := rowText(frame, 0) | |
| 116 | + | for _, want := range []string{"[E]", "2m ago", "pane-spawn", "couldn't open pane", "×3"} { | |
| 117 | + | if !strings.Contains(row, want) { | |
| 118 | + | t.Errorf("row %q does not contain %q", row, want) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestRenderSeverityLetterPerSeverity(t *testing.T) { | |
| 124 | + | now := time.Unix(10000, 0) | |
| 125 | + | tests := []struct { | |
| 126 | + | sev domain.NoticeSeverity | |
| 127 | + | want string | |
| 128 | + | }{ | |
| 129 | + | {domain.NoticeInfo, "[I]"}, | |
| 130 | + | {domain.NoticeWarn, "[W]"}, | |
| 131 | + | {domain.NoticeError, "[E]"}, | |
| 132 | + | } | |
| 133 | + | for _, tt := range tests { | |
| 134 | + | m := New([]domain.Notification{{Severity: tt.sev, Message: "m", Time: now}}, now) | |
| 135 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, testStyles()) | |
| 136 | + | row := rowText(frame, 0) | |
| 137 | + | if !strings.Contains(row, tt.want) { | |
| 138 | + | t.Errorf("severity %v: row %q does not contain %q", tt.sev, row, tt.want) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestRenderEmptyHistoryShowsPlaceholderNotBlank(t *testing.T) { | |
| 144 | + | m := New(nil, time.Unix(0, 0)) | |
| 145 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |
| 146 | + | blank := true | |
| 147 | + | for y := 0; y < frame.Height; y++ { | |
| 148 | + | if strings.TrimSpace(rowText(frame, y)) != "" { | |
| 149 | + | blank = false | |
| 150 | + | break | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | if blank { | |
| 154 | + | t.Fatal("Render() on empty history drew nothing, want a placeholder") | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestRenderDegenerateDimensionsDoesNotPanic(t *testing.T) { | |
| 159 | + | m := New([]domain.Notification{{Message: "x", Time: time.Unix(0, 0)}}, time.Unix(0, 0)) | |
| 160 | + | sizes := []domain.Size{{Cols: 0, Rows: 0}, {Cols: -1, Rows: 5}, {Cols: 5, Rows: -1}, {Cols: 0, Rows: 5}, {Cols: 1, Rows: 1}} | |
| 161 | + | for _, size := range sizes { | |
| 162 | + | frame := m.Render(size, testStyles()) | |
| 163 | + | if frame.Width < 0 || frame.Height < 0 { | |
| 164 | + | t.Fatalf("Render(%+v) produced negative frame dims", size) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestRenderScrollsToKeepSelectionVisible(t *testing.T) { | |
| 170 | + | now := time.Unix(10000, 0) | |
| 171 | + | entries := make([]domain.Notification, 200) | |
| 172 | + | for i := range entries { | |
| 173 | + | entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now} | |
| 174 | + | } | |
| 175 | + | m := New(entries, now) | |
| 176 | + | for range 150 { | |
| 177 | + | m.Down() | |
| 178 | + | } | |
| 179 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |
| 180 | + | found := false | |
| 181 | + | for y := 0; y < frame.Height; y++ { | |
| 182 | + | if strings.Contains(rowText(frame, y), "n150") { | |
| 183 | + | found = true | |
| 184 | + | break | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | if !found { | |
| 188 | + | t.Fatal("Render() scrolled the selected row out of view") | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestCloneIsIndependentOfOriginal(t *testing.T) { | |
| 193 | + | now := time.Unix(10000, 0) | |
| 194 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now) | |
| 195 | + | clone := m.Clone() | |
| 196 | + | m.Down() | |
| 197 | + | if got, _ := clone.Selected(); got.Message != "a" { | |
| 198 | + | t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message) | |
| 199 | + | } | |
| 200 | + | if got, _ := m.Selected(); got.Message != "b" { | |
| 201 | + | t.Fatalf("m.Selected() = %q, want b", got.Message) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestCloneOfNilIsNil(t *testing.T) { | |
| 206 | + | var m *Model | |
| 207 | + | if clone := m.Clone(); clone != nil { | |
| 208 | + | t.Fatalf("Clone() of nil = %v, want nil", clone) | |
| 209 | + | } | |
| 210 | + | } | |
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | package notices | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func testStyles() RenderStyles { | |||
| 14 | + | selection := renderer.DefaultStyle() | |||
| 15 | + | selection.Inverse = true | |||
| 16 | + | base := renderer.DefaultStyle() | |||
| 17 | + | return RenderStyles{Background: base, Base: base, Selection: selection, Text: base, SelectionText: selection, Muted: base, SelectionMuted: selection} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // rowText flattens one rendered row back to text, skipping wide-rune | |||
| 21 | + | // continuation cells, so assertions can check substrings. | |||
| 22 | + | func rowText(f renderer.Frame, y int) string { | |||
| 23 | + | var b strings.Builder | |||
| 24 | + | for _, c := range f.Row(y) { | |||
| 25 | + | if c.Continuation { | |||
| 26 | + | continue | |||
| 27 | + | } | |||
| 28 | + | if c.Rune == 0 { | |||
| 29 | + | b.WriteRune(' ') | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | b.WriteRune(c.Rune) | |||
| 33 | + | } | |||
| 34 | + | return b.String() | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewSelectsNewestFirstEntry(t *testing.T) { | |||
| 38 | + | now := time.Unix(1000, 0) | |||
| 39 | + | entries := []domain.Notification{ | |||
| 40 | + | {Code: domain.NoticePaneSpawn, Message: "newest", Time: now}, | |||
| 41 | + | {Code: domain.NoticeTabSpawn, Message: "oldest", Time: now.Add(-time.Hour)}, | |||
| 42 | + | } | |||
| 43 | + | m := New(entries, now) | |||
| 44 | + | got, ok := m.Selected() | |||
| 45 | + | if !ok || got.Message != "newest" { | |||
| 46 | + | t.Fatalf("Selected() = %+v, %v, want newest, true", got, ok) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestNavigationClampsAtBothEnds(t *testing.T) { | |||
| 51 | + | tests := []struct { | |||
| 52 | + | name string | |||
| 53 | + | moves func(m *Model) | |||
| 54 | + | want string | |||
| 55 | + | }{ | |||
| 56 | + | {"up from top stays at top", func(m *Model) { m.Up(); m.Up() }, "a"}, | |||
| 57 | + | {"down past bottom stays at bottom", func(m *Model) { m.Down(); m.Down(); m.Down() }, "c"}, | |||
| 58 | + | {"down then up returns to top", func(m *Model) { m.Down(); m.Down(); m.Up(); m.Up() }, "a"}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | now := time.Unix(1000, 0) | |||
| 63 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}, {Message: "c", Time: now}}, now) | |||
| 64 | + | tt.moves(m) | |||
| 65 | + | got, ok := m.Selected() | |||
| 66 | + | if !ok || got.Message != tt.want { | |||
| 67 | + | t.Fatalf("Selected() = %q, %v, want %q, true", got.Message, ok, tt.want) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestSelectedAndNavigationOnEmptyHistory(t *testing.T) { | |||
| 74 | + | m := New(nil, time.Unix(1000, 0)) | |||
| 75 | + | if _, ok := m.Selected(); ok { | |||
| 76 | + | t.Fatal("Selected() ok = true on empty history, want false") | |||
| 77 | + | } | |||
| 78 | + | m.Up() | |||
| 79 | + | m.Down() | |||
| 80 | + | if _, ok := m.Selected(); ok { | |||
| 81 | + | t.Fatal("Selected() ok = true after navigating empty history, want false") | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestRelativeTime(t *testing.T) { | |||
| 86 | + | now := time.Unix(10000, 0) | |||
| 87 | + | tests := []struct { | |||
| 88 | + | name string | |||
| 89 | + | at time.Time | |||
| 90 | + | want string | |||
| 91 | + | }{ | |||
| 92 | + | {"seconds ago", now.Add(-30 * time.Second), "just now"}, | |||
| 93 | + | {"two minutes ago", now.Add(-2 * time.Minute), "2m ago"}, | |||
| 94 | + | {"fifty nine minutes ago", now.Add(-59 * time.Minute), "59m ago"}, | |||
| 95 | + | {"two hours ago", now.Add(-2 * time.Hour), "2h ago"}, | |||
| 96 | + | {"two days ago", now.Add(-48 * time.Hour), "2d ago"}, | |||
| 97 | + | {"future clock skew", now.Add(time.Minute), "just now"}, | |||
| 98 | + | } | |||
| 99 | + | for _, tt := range tests { | |||
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | + | if got := relativeTime(now, tt.at); got != tt.want { | |||
| 102 | + | t.Errorf("relativeTime(now, %v) = %q, want %q", tt.at, got, tt.want) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRenderRowContainsSeverityCodeSlugAndCount(t *testing.T) { | |||
| 109 | + | now := time.Unix(10000, 0) | |||
| 110 | + | entries := []domain.Notification{ | |||
| 111 | + | {Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Count: 3, Time: now.Add(-2 * time.Minute)}, | |||
| 112 | + | } | |||
| 113 | + | m := New(entries, now) | |||
| 114 | + | frame := m.Render(domain.Size{Cols: 60, Rows: 5}, testStyles()) | |||
| 115 | + | row := rowText(frame, 0) | |||
| 116 | + | for _, want := range []string{"[E]", "2m ago", "pane-spawn", "couldn't open pane", "×3"} { | |||
| 117 | + | if !strings.Contains(row, want) { | |||
| 118 | + | t.Errorf("row %q does not contain %q", row, want) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRenderSeverityLetterPerSeverity(t *testing.T) { | |||
| 124 | + | now := time.Unix(10000, 0) | |||
| 125 | + | tests := []struct { | |||
| 126 | + | sev domain.NoticeSeverity | |||
| 127 | + | want string | |||
| 128 | + | }{ | |||
| 129 | + | {domain.NoticeInfo, "[I]"}, | |||
| 130 | + | {domain.NoticeWarn, "[W]"}, | |||
| 131 | + | {domain.NoticeError, "[E]"}, | |||
| 132 | + | } | |||
| 133 | + | for _, tt := range tests { | |||
| 134 | + | m := New([]domain.Notification{{Severity: tt.sev, Message: "m", Time: now}}, now) | |||
| 135 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, testStyles()) | |||
| 136 | + | row := rowText(frame, 0) | |||
| 137 | + | if !strings.Contains(row, tt.want) { | |||
| 138 | + | t.Errorf("severity %v: row %q does not contain %q", tt.sev, row, tt.want) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestRenderEmptyHistoryShowsPlaceholderNotBlank(t *testing.T) { | |||
| 144 | + | m := New(nil, time.Unix(0, 0)) | |||
| 145 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |||
| 146 | + | blank := true | |||
| 147 | + | for y := 0; y < frame.Height; y++ { | |||
| 148 | + | if strings.TrimSpace(rowText(frame, y)) != "" { | |||
| 149 | + | blank = false | |||
| 150 | + | break | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | if blank { | |||
| 154 | + | t.Fatal("Render() on empty history drew nothing, want a placeholder") | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestRenderDegenerateDimensionsDoesNotPanic(t *testing.T) { | |||
| 159 | + | m := New([]domain.Notification{{Message: "x", Time: time.Unix(0, 0)}}, time.Unix(0, 0)) | |||
| 160 | + | sizes := []domain.Size{{Cols: 0, Rows: 0}, {Cols: -1, Rows: 5}, {Cols: 5, Rows: -1}, {Cols: 0, Rows: 5}, {Cols: 1, Rows: 1}} | |||
| 161 | + | for _, size := range sizes { | |||
| 162 | + | frame := m.Render(size, testStyles()) | |||
| 163 | + | if frame.Width < 0 || frame.Height < 0 { | |||
| 164 | + | t.Fatalf("Render(%+v) produced negative frame dims", size) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestRenderScrollsToKeepSelectionVisible(t *testing.T) { | |||
| 170 | + | now := time.Unix(10000, 0) | |||
| 171 | + | entries := make([]domain.Notification, 200) | |||
| 172 | + | for i := range entries { | |||
| 173 | + | entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now} | |||
| 174 | + | } | |||
| 175 | + | m := New(entries, now) | |||
| 176 | + | for range 150 { | |||
| 177 | + | m.Down() | |||
| 178 | + | } | |||
| 179 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles()) | |||
| 180 | + | found := false | |||
| 181 | + | for y := 0; y < frame.Height; y++ { | |||
| 182 | + | if strings.Contains(rowText(frame, y), "n150") { | |||
| 183 | + | found = true | |||
| 184 | + | break | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | if !found { | |||
| 188 | + | t.Fatal("Render() scrolled the selected row out of view") | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestCloneIsIndependentOfOriginal(t *testing.T) { | |||
| 193 | + | now := time.Unix(10000, 0) | |||
| 194 | + | m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now) | |||
| 195 | + | clone := m.Clone() | |||
| 196 | + | m.Down() | |||
| 197 | + | if got, _ := clone.Selected(); got.Message != "a" { | |||
| 198 | + | t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message) | |||
| 199 | + | } | |||
| 200 | + | if got, _ := m.Selected(); got.Message != "b" { | |||
| 201 | + | t.Fatalf("m.Selected() = %q, want b", got.Message) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestCloneOfNilIsNil(t *testing.T) { | |||
| 206 | + | var m *Model | |||
| 207 | + | if clone := m.Clone(); clone != nil { | |||
| 208 | + | t.Fatalf("Clone() of nil = %v, want nil", clone) | |||
| 209 | + | } | |||
| 210 | + | } | |||