feat(daemon): add per-tab floating state machine
- commit
- e38876b340e37fe2f953234af5adcd605d8ec143
- author
- brice · · 2026-07-09 23:50 UTC
- committer
- brice · · 2026-07-10 05:40 UTC
- parent
- 1611c3c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +251 -0
A internal/usecase/daemon/floating.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // floatingState describes the lifecycle of a tab's independent floating pane. | |
| 6 | + | // A floating slot is always guarded by its owning tab.mu. | |
| 7 | + | type floatingState uint8 | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | floatingUninitialized floatingState = iota | |
| 11 | + | floatingWarming | |
| 12 | + | floatingHidden | |
| 13 | + | floatingVisible | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // floatingSlot holds the runtime which is deliberately outside tab.tree and | |
| 17 | + | // tab.panes. generation identifies an asynchronous launch and its installed | |
| 18 | + | // pane, preventing stale launch completions and exits from changing new state. | |
| 19 | + | type floatingSlot struct { | |
| 20 | + | state floatingState | |
| 21 | + | pane *pane | |
| 22 | + | desiredVisible bool | |
| 23 | + | generation uint64 | |
| 24 | + | launch domain.FloatingConfig | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // beginFloatingWarmLocked starts a single background launch. The caller must | |
| 28 | + | // hold tb.mu and must only call it for an uninitialized slot. | |
| 29 | + | func (tb *tab) beginFloatingWarmLocked(launch domain.FloatingConfig, desiredVisible bool) uint64 { | |
| 30 | + | if tb == nil || tb.floating.state != floatingUninitialized { | |
| 31 | + | return 0 | |
| 32 | + | } | |
| 33 | + | tb.floating.generation++ | |
| 34 | + | tb.floating.state = floatingWarming | |
| 35 | + | tb.floating.desiredVisible = desiredVisible | |
| 36 | + | tb.floating.launch = launch | |
| 37 | + | return tb.floating.generation | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // toggleFloatingLocked applies the user action. Its result says whether a new | |
| 41 | + | // PTY launch is required and returns the generation associated with that launch | |
| 42 | + | // or current slot. | |
| 43 | + | func (tb *tab) toggleFloatingLocked(launch domain.FloatingConfig) (bool, uint64) { | |
| 44 | + | if tb == nil { | |
| 45 | + | return false, 0 | |
| 46 | + | } | |
| 47 | + | switch tb.floating.state { | |
| 48 | + | case floatingUninitialized: | |
| 49 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |
| 50 | + | return generation != 0, generation | |
| 51 | + | case floatingWarming: | |
| 52 | + | tb.floating.desiredVisible = !tb.floating.desiredVisible | |
| 53 | + | case floatingHidden: | |
| 54 | + | tb.floating.state = floatingVisible | |
| 55 | + | case floatingVisible: | |
| 56 | + | tb.floating.state = floatingHidden | |
| 57 | + | } | |
| 58 | + | return false, tb.floating.generation | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // installFloatingLocked installs a completed launch only when it is still the | |
| 62 | + | // current warming generation. Stale completions must close their PTY elsewhere. | |
| 63 | + | func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool { | |
| 64 | + | if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | tb.floating.pane = p | |
| 68 | + | if tb.floating.desiredVisible { | |
| 69 | + | tb.floating.state = floatingVisible | |
| 70 | + | } else { | |
| 71 | + | tb.floating.state = floatingHidden | |
| 72 | + | } | |
| 73 | + | return true | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // failFloatingLocked clears only the current warming launch. It deliberately | |
| 77 | + | // leaves generation intact; the next launch advances it. | |
| 78 | + | func (tb *tab) failFloatingLocked(generation uint64) bool { | |
| 79 | + | if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | tb.floating.state = floatingUninitialized | |
| 83 | + | tb.floating.pane = nil | |
| 84 | + | tb.floating.desiredVisible = false | |
| 85 | + | tb.floating.launch = domain.FloatingConfig{} | |
| 86 | + | return true | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // clearFloatingLocked clears a matching installed runtime. Matching both pane | |
| 90 | + | // identity and generation prevents an old reader from reaping a newer pane. | |
| 91 | + | func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool { | |
| 92 | + | if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation || | |
| 93 | + | (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | tb.floating.generation++ | |
| 97 | + | tb.floating.state = floatingUninitialized | |
| 98 | + | tb.floating.pane = nil | |
| 99 | + | tb.floating.desiredVisible = false | |
| 100 | + | tb.floating.launch = domain.FloatingConfig{} | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // terminalTargetLocked chooses the visible floating terminal ahead of the | |
| 105 | + | // normal layout target. The caller must hold tb.mu. | |
| 106 | + | func (tb *tab) terminalTargetLocked() *pane { | |
| 107 | + | if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 108 | + | return tb.floating.pane | |
| 109 | + | } | |
| 110 | + | return tb.focusedPane() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // floatingInnerSize returns the terminal area inside a percentage-sized popup. | |
| 114 | + | // For each axis, a popup with at least three cells spends one cell on each | |
| 115 | + | // border; smaller popups omit that axis's border. A valid tab always yields a | |
| 116 | + | // valid PTY size. | |
| 117 | + | func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size { | |
| 118 | + | if !tabSize.Valid() { | |
| 119 | + | return domain.Size{} | |
| 120 | + | } | |
| 121 | + | return domain.Size{ | |
| 122 | + | Cols: floatingInnerAxis(tabSize.Cols, cfg.Width), | |
| 123 | + | Rows: floatingInnerAxis(tabSize.Rows, cfg.Height), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func floatingInnerAxis(available, percent int) int { | |
| 128 | + | percent = min(max(percent, 1), 100) | |
| 129 | + | bounds := min(max(available*percent/100, 1), available) | |
| 130 | + | if bounds >= 3 { | |
| 131 | + | return bounds - 2 | |
| 132 | + | } | |
| 133 | + | return bounds | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // floatingState describes the lifecycle of a tab's independent floating pane. | |||
| 6 | + | // A floating slot is always guarded by its owning tab.mu. | |||
| 7 | + | type floatingState uint8 | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | floatingUninitialized floatingState = iota | |||
| 11 | + | floatingWarming | |||
| 12 | + | floatingHidden | |||
| 13 | + | floatingVisible | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // floatingSlot holds the runtime which is deliberately outside tab.tree and | |||
| 17 | + | // tab.panes. generation identifies an asynchronous launch and its installed | |||
| 18 | + | // pane, preventing stale launch completions and exits from changing new state. | |||
| 19 | + | type floatingSlot struct { | |||
| 20 | + | state floatingState | |||
| 21 | + | pane *pane | |||
| 22 | + | desiredVisible bool | |||
| 23 | + | generation uint64 | |||
| 24 | + | launch domain.FloatingConfig | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // beginFloatingWarmLocked starts a single background launch. The caller must | |||
| 28 | + | // hold tb.mu and must only call it for an uninitialized slot. | |||
| 29 | + | func (tb *tab) beginFloatingWarmLocked(launch domain.FloatingConfig, desiredVisible bool) uint64 { | |||
| 30 | + | if tb == nil || tb.floating.state != floatingUninitialized { | |||
| 31 | + | return 0 | |||
| 32 | + | } | |||
| 33 | + | tb.floating.generation++ | |||
| 34 | + | tb.floating.state = floatingWarming | |||
| 35 | + | tb.floating.desiredVisible = desiredVisible | |||
| 36 | + | tb.floating.launch = launch | |||
| 37 | + | return tb.floating.generation | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // toggleFloatingLocked applies the user action. Its result says whether a new | |||
| 41 | + | // PTY launch is required and returns the generation associated with that launch | |||
| 42 | + | // or current slot. | |||
| 43 | + | func (tb *tab) toggleFloatingLocked(launch domain.FloatingConfig) (bool, uint64) { | |||
| 44 | + | if tb == nil { | |||
| 45 | + | return false, 0 | |||
| 46 | + | } | |||
| 47 | + | switch tb.floating.state { | |||
| 48 | + | case floatingUninitialized: | |||
| 49 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |||
| 50 | + | return generation != 0, generation | |||
| 51 | + | case floatingWarming: | |||
| 52 | + | tb.floating.desiredVisible = !tb.floating.desiredVisible | |||
| 53 | + | case floatingHidden: | |||
| 54 | + | tb.floating.state = floatingVisible | |||
| 55 | + | case floatingVisible: | |||
| 56 | + | tb.floating.state = floatingHidden | |||
| 57 | + | } | |||
| 58 | + | return false, tb.floating.generation | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // installFloatingLocked installs a completed launch only when it is still the | |||
| 62 | + | // current warming generation. Stale completions must close their PTY elsewhere. | |||
| 63 | + | func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool { | |||
| 64 | + | if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | tb.floating.pane = p | |||
| 68 | + | if tb.floating.desiredVisible { | |||
| 69 | + | tb.floating.state = floatingVisible | |||
| 70 | + | } else { | |||
| 71 | + | tb.floating.state = floatingHidden | |||
| 72 | + | } | |||
| 73 | + | return true | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // failFloatingLocked clears only the current warming launch. It deliberately | |||
| 77 | + | // leaves generation intact; the next launch advances it. | |||
| 78 | + | func (tb *tab) failFloatingLocked(generation uint64) bool { | |||
| 79 | + | if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | tb.floating.state = floatingUninitialized | |||
| 83 | + | tb.floating.pane = nil | |||
| 84 | + | tb.floating.desiredVisible = false | |||
| 85 | + | tb.floating.launch = domain.FloatingConfig{} | |||
| 86 | + | return true | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // clearFloatingLocked clears a matching installed runtime. Matching both pane | |||
| 90 | + | // identity and generation prevents an old reader from reaping a newer pane. | |||
| 91 | + | func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool { | |||
| 92 | + | if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation || | |||
| 93 | + | (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) { | |||
| 94 | + | return false | |||
| 95 | + | } | |||
| 96 | + | tb.floating.generation++ | |||
| 97 | + | tb.floating.state = floatingUninitialized | |||
| 98 | + | tb.floating.pane = nil | |||
| 99 | + | tb.floating.desiredVisible = false | |||
| 100 | + | tb.floating.launch = domain.FloatingConfig{} | |||
| 101 | + | return true | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // terminalTargetLocked chooses the visible floating terminal ahead of the | |||
| 105 | + | // normal layout target. The caller must hold tb.mu. | |||
| 106 | + | func (tb *tab) terminalTargetLocked() *pane { | |||
| 107 | + | if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |||
| 108 | + | return tb.floating.pane | |||
| 109 | + | } | |||
| 110 | + | return tb.focusedPane() | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // floatingInnerSize returns the terminal area inside a percentage-sized popup. | |||
| 114 | + | // For each axis, a popup with at least three cells spends one cell on each | |||
| 115 | + | // border; smaller popups omit that axis's border. A valid tab always yields a | |||
| 116 | + | // valid PTY size. | |||
| 117 | + | func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size { | |||
| 118 | + | if !tabSize.Valid() { | |||
| 119 | + | return domain.Size{} | |||
| 120 | + | } | |||
| 121 | + | return domain.Size{ | |||
| 122 | + | Cols: floatingInnerAxis(tabSize.Cols, cfg.Width), | |||
| 123 | + | Rows: floatingInnerAxis(tabSize.Rows, cfg.Height), | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func floatingInnerAxis(available, percent int) int { | |||
| 128 | + | percent = min(max(percent, 1), 100) | |||
| 129 | + | bounds := min(max(available*percent/100, 1), available) | |||
| 130 | + | if bounds >= 3 { | |||
| 131 | + | return bounds - 2 | |||
| 132 | + | } | |||
| 133 | + | return bounds | |||
| 134 | + | } | |||
A internal/usecase/daemon/floating_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFloatingSlotTransitions(t *testing.T) { | |
| 10 | + | first := &pane{} | |
| 11 | + | second := &pane{} | |
| 12 | + | launch := domain.FloatingConfig{Command: "top", Width: 80, Height: 80} | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | run func(t *testing.T, tb *tab) | |
| 17 | + | }{ | |
| 18 | + | { | |
| 19 | + | name: "warming double toggle retains hidden completion", | |
| 20 | + | run: func(t *testing.T, tb *tab) { | |
| 21 | + | tb.mu.Lock() | |
| 22 | + | generation := tb.beginFloatingWarmLocked(launch, false) | |
| 23 | + | start, gotGeneration := tb.toggleFloatingLocked(launch) | |
| 24 | + | if start || gotGeneration != generation || !tb.floating.desiredVisible { | |
| 25 | + | t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |
| 26 | + | } | |
| 27 | + | start, gotGeneration = tb.toggleFloatingLocked(launch) | |
| 28 | + | if start || gotGeneration != generation || tb.floating.desiredVisible { | |
| 29 | + | t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |
| 30 | + | } | |
| 31 | + | if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first { | |
| 32 | + | t.Fatal("warming completion did not retain a hidden pane") | |
| 33 | + | } | |
| 34 | + | tb.mu.Unlock() | |
| 35 | + | }, | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "open and hide retain same pane", | |
| 39 | + | run: func(t *testing.T, tb *tab) { | |
| 40 | + | tb.mu.Lock() | |
| 41 | + | generation := tb.beginFloatingWarmLocked(launch, false) | |
| 42 | + | if !tb.installFloatingLocked(first, generation) { | |
| 43 | + | t.Fatal("install failed") | |
| 44 | + | } | |
| 45 | + | start, _ := tb.toggleFloatingLocked(launch) | |
| 46 | + | if start || tb.floating.state != floatingVisible || tb.floating.pane != first { | |
| 47 | + | t.Fatal("hidden open did not retain pane") | |
| 48 | + | } | |
| 49 | + | tb.toggleFloatingLocked(launch) | |
| 50 | + | if tb.floating.state != floatingHidden || tb.floating.pane != first { | |
| 51 | + | t.Fatal("visible hide did not retain pane") | |
| 52 | + | } | |
| 53 | + | tb.mu.Unlock() | |
| 54 | + | }, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "current launch failure clears desired visibility", | |
| 58 | + | run: func(t *testing.T, tb *tab) { | |
| 59 | + | tb.mu.Lock() | |
| 60 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |
| 61 | + | if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil { | |
| 62 | + | t.Fatal("current launch failure did not clear slot") | |
| 63 | + | } | |
| 64 | + | tb.mu.Unlock() | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | { | |
| 68 | + | name: "current exit clears and stale completion is ignored", | |
| 69 | + | run: func(t *testing.T, tb *tab) { | |
| 70 | + | tb.mu.Lock() | |
| 71 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |
| 72 | + | if !tb.installFloatingLocked(first, generation) { | |
| 73 | + | t.Fatal("install failed") | |
| 74 | + | } | |
| 75 | + | if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil { | |
| 76 | + | t.Fatal("current exit did not clear slot") | |
| 77 | + | } | |
| 78 | + | newGeneration := tb.beginFloatingWarmLocked(launch, true) | |
| 79 | + | if tb.installFloatingLocked(second, generation) { | |
| 80 | + | t.Fatal("stale completion installed") | |
| 81 | + | } | |
| 82 | + | if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil { | |
| 83 | + | t.Fatal("stale completion changed newer warming state") | |
| 84 | + | } | |
| 85 | + | tb.mu.Unlock() | |
| 86 | + | }, | |
| 87 | + | }, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | for _, tt := range tests { | |
| 91 | + | t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) }) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestFloatingInnerSize(t *testing.T) { | |
| 96 | + | tests := []struct { | |
| 97 | + | name string | |
| 98 | + | tab domain.Size | |
| 99 | + | cfg domain.FloatingConfig | |
| 100 | + | want domain.Size | |
| 101 | + | }{ | |
| 102 | + | {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}}, | |
| 103 | + | {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}}, | |
| 104 | + | {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}}, | |
| 105 | + | {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}}, | |
| 106 | + | {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}}, | |
| 107 | + | } | |
| 108 | + | for _, tt := range tests { | |
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |
| 110 | + | if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want { | |
| 111 | + | t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want) | |
| 112 | + | } | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFloatingSlotTransitions(t *testing.T) { | |||
| 10 | + | first := &pane{} | |||
| 11 | + | second := &pane{} | |||
| 12 | + | launch := domain.FloatingConfig{Command: "top", Width: 80, Height: 80} | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | run func(t *testing.T, tb *tab) | |||
| 17 | + | }{ | |||
| 18 | + | { | |||
| 19 | + | name: "warming double toggle retains hidden completion", | |||
| 20 | + | run: func(t *testing.T, tb *tab) { | |||
| 21 | + | tb.mu.Lock() | |||
| 22 | + | generation := tb.beginFloatingWarmLocked(launch, false) | |||
| 23 | + | start, gotGeneration := tb.toggleFloatingLocked(launch) | |||
| 24 | + | if start || gotGeneration != generation || !tb.floating.desiredVisible { | |||
| 25 | + | t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |||
| 26 | + | } | |||
| 27 | + | start, gotGeneration = tb.toggleFloatingLocked(launch) | |||
| 28 | + | if start || gotGeneration != generation || tb.floating.desiredVisible { | |||
| 29 | + | t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |||
| 30 | + | } | |||
| 31 | + | if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first { | |||
| 32 | + | t.Fatal("warming completion did not retain a hidden pane") | |||
| 33 | + | } | |||
| 34 | + | tb.mu.Unlock() | |||
| 35 | + | }, | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "open and hide retain same pane", | |||
| 39 | + | run: func(t *testing.T, tb *tab) { | |||
| 40 | + | tb.mu.Lock() | |||
| 41 | + | generation := tb.beginFloatingWarmLocked(launch, false) | |||
| 42 | + | if !tb.installFloatingLocked(first, generation) { | |||
| 43 | + | t.Fatal("install failed") | |||
| 44 | + | } | |||
| 45 | + | start, _ := tb.toggleFloatingLocked(launch) | |||
| 46 | + | if start || tb.floating.state != floatingVisible || tb.floating.pane != first { | |||
| 47 | + | t.Fatal("hidden open did not retain pane") | |||
| 48 | + | } | |||
| 49 | + | tb.toggleFloatingLocked(launch) | |||
| 50 | + | if tb.floating.state != floatingHidden || tb.floating.pane != first { | |||
| 51 | + | t.Fatal("visible hide did not retain pane") | |||
| 52 | + | } | |||
| 53 | + | tb.mu.Unlock() | |||
| 54 | + | }, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "current launch failure clears desired visibility", | |||
| 58 | + | run: func(t *testing.T, tb *tab) { | |||
| 59 | + | tb.mu.Lock() | |||
| 60 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |||
| 61 | + | if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil { | |||
| 62 | + | t.Fatal("current launch failure did not clear slot") | |||
| 63 | + | } | |||
| 64 | + | tb.mu.Unlock() | |||
| 65 | + | }, | |||
| 66 | + | }, | |||
| 67 | + | { | |||
| 68 | + | name: "current exit clears and stale completion is ignored", | |||
| 69 | + | run: func(t *testing.T, tb *tab) { | |||
| 70 | + | tb.mu.Lock() | |||
| 71 | + | generation := tb.beginFloatingWarmLocked(launch, true) | |||
| 72 | + | if !tb.installFloatingLocked(first, generation) { | |||
| 73 | + | t.Fatal("install failed") | |||
| 74 | + | } | |||
| 75 | + | if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil { | |||
| 76 | + | t.Fatal("current exit did not clear slot") | |||
| 77 | + | } | |||
| 78 | + | newGeneration := tb.beginFloatingWarmLocked(launch, true) | |||
| 79 | + | if tb.installFloatingLocked(second, generation) { | |||
| 80 | + | t.Fatal("stale completion installed") | |||
| 81 | + | } | |||
| 82 | + | if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil { | |||
| 83 | + | t.Fatal("stale completion changed newer warming state") | |||
| 84 | + | } | |||
| 85 | + | tb.mu.Unlock() | |||
| 86 | + | }, | |||
| 87 | + | }, | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | for _, tt := range tests { | |||
| 91 | + | t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestFloatingInnerSize(t *testing.T) { | |||
| 96 | + | tests := []struct { | |||
| 97 | + | name string | |||
| 98 | + | tab domain.Size | |||
| 99 | + | cfg domain.FloatingConfig | |||
| 100 | + | want domain.Size | |||
| 101 | + | }{ | |||
| 102 | + | {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}}, | |||
| 103 | + | {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}}, | |||
| 104 | + | {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}}, | |||
| 105 | + | {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}}, | |||
| 106 | + | {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}}, | |||
| 107 | + | } | |||
| 108 | + | for _, tt := range tests { | |||
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 110 | + | if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want { | |||
| 111 | + | t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want) | |||
| 112 | + | } | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | } | |||
M internal/usecase/daemon/session.go +2 -0
| @@ -84,6 +84,8 @@ type tab struct { | |||
| 84 | 84 | size domain.Size | |
| 85 | 85 | ctx context.Context | |
| 86 | 86 | cancel context.CancelFunc | |
| 87 | + | // floating is independent from the normal layout tree and pane map. | |
| 88 | + | floating floatingSlot | |
| 87 | 89 | ||
| 88 | 90 | // previewClient tracks the one client currently previewing this tab in the picker. | |
| 89 | 91 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | |
| @@ -84,6 +84,8 @@ type tab struct { | |||||
| 84 | size domain.Size | 84 | size domain.Size | ||
| 85 | ctx context.Context | 85 | ctx context.Context | ||
| 86 | cancel context.CancelFunc | 86 | cancel context.CancelFunc | ||
| 87 | + | // floating is independent from the normal layout tree and pane map. | |||
| 88 | + | floating floatingSlot | |||
| 87 | 89 | ||||
| 88 | // previewClient tracks the one client currently previewing this tab in the picker. | 90 | // previewClient tracks the one client currently previewing this tab in the picker. | ||
| 89 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | 91 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | ||