fix(ui): hide active border when only one visible pane area
- commit
- 8141b0b1f7690b5c96fdd88072db3191c690c10e
- author
- bnema · · 2026-01-06 19:01 UTC
- committer
- bnema · · 2026-01-06 19:04 UTC
- parent
- eac150d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +180 -2
M internal/domain/entity/pane.go +34 -0
| @@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int { | |||
| 156 | 156 | }) | |
| 157 | 157 | return count | |
| 158 | 158 | } | |
| 159 | + | ||
| 160 | + | // VisibleAreaCount returns the number of visible pane areas in the tree. | |
| 161 | + | // A stacked node counts as 1 visible area (only one pane visible at a time). | |
| 162 | + | // A leaf node counts as 1 visible area. | |
| 163 | + | // A split node's visible areas are the sum of its children's visible areas. | |
| 164 | + | func (n *PaneNode) VisibleAreaCount() int { | |
| 165 | + | switch { | |
| 166 | + | case n.IsLeaf(): | |
| 167 | + | return 1 | |
| 168 | + | case n.IsStacked: | |
| 169 | + | // Stacked panes: only one is visible at a time | |
| 170 | + | return 1 | |
| 171 | + | case n.IsSplit(): | |
| 172 | + | // Split: sum of both children's visible areas | |
| 173 | + | count := 0 | |
| 174 | + | if left := n.Left(); left != nil { | |
| 175 | + | count += left.VisibleAreaCount() | |
| 176 | + | } | |
| 177 | + | if right := n.Right(); right != nil { | |
| 178 | + | count += right.VisibleAreaCount() | |
| 179 | + | } | |
| 180 | + | return count | |
| 181 | + | default: | |
| 182 | + | // Fallback for other container types | |
| 183 | + | if len(n.Children) == 0 { | |
| 184 | + | return 1 | |
| 185 | + | } | |
| 186 | + | count := 0 | |
| 187 | + | for _, child := range n.Children { | |
| 188 | + | count += child.VisibleAreaCount() | |
| 189 | + | } | |
| 190 | + | return count | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int { | |||||
| 156 | }) | 156 | }) | ||
| 157 | return count | 157 | return count | ||
| 158 | } | 158 | } | ||
| 159 | + | ||||
| 160 | + | // VisibleAreaCount returns the number of visible pane areas in the tree. | |||
| 161 | + | // A stacked node counts as 1 visible area (only one pane visible at a time). | |||
| 162 | + | // A leaf node counts as 1 visible area. | |||
| 163 | + | // A split node's visible areas are the sum of its children's visible areas. | |||
| 164 | + | func (n *PaneNode) VisibleAreaCount() int { | |||
| 165 | + | switch { | |||
| 166 | + | case n.IsLeaf(): | |||
| 167 | + | return 1 | |||
| 168 | + | case n.IsStacked: | |||
| 169 | + | // Stacked panes: only one is visible at a time | |||
| 170 | + | return 1 | |||
| 171 | + | case n.IsSplit(): | |||
| 172 | + | // Split: sum of both children's visible areas | |||
| 173 | + | count := 0 | |||
| 174 | + | if left := n.Left(); left != nil { | |||
| 175 | + | count += left.VisibleAreaCount() | |||
| 176 | + | } | |||
| 177 | + | if right := n.Right(); right != nil { | |||
| 178 | + | count += right.VisibleAreaCount() | |||
| 179 | + | } | |||
| 180 | + | return count | |||
| 181 | + | default: | |||
| 182 | + | // Fallback for other container types | |||
| 183 | + | if len(n.Children) == 0 { | |||
| 184 | + | return 1 | |||
| 185 | + | } | |||
| 186 | + | count := 0 | |||
| 187 | + | for _, child := range n.Children { | |||
| 188 | + | count += child.VisibleAreaCount() | |||
| 189 | + | } | |||
| 190 | + | return count | |||
| 191 | + | } | |||
| 192 | + | } | |||
A internal/domain/entity/pane_test.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestPaneNode_VisibleAreaCount(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | node *PaneNode | |
| 9 | + | expected int | |
| 10 | + | }{ | |
| 11 | + | { | |
| 12 | + | name: "single leaf pane", | |
| 13 | + | node: &PaneNode{ | |
| 14 | + | ID: "pane1", | |
| 15 | + | Pane: NewPane("pane1"), | |
| 16 | + | }, | |
| 17 | + | expected: 1, | |
| 18 | + | }, | |
| 19 | + | { | |
| 20 | + | name: "horizontal split with two leaves", | |
| 21 | + | node: &PaneNode{ | |
| 22 | + | ID: "split1", | |
| 23 | + | SplitDir: SplitHorizontal, | |
| 24 | + | Children: []*PaneNode{ | |
| 25 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |
| 26 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | expected: 2, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "stacked panes count as one", | |
| 33 | + | node: &PaneNode{ | |
| 34 | + | ID: "stack1", | |
| 35 | + | IsStacked: true, | |
| 36 | + | ActiveStackIndex: 0, | |
| 37 | + | Children: []*PaneNode{ | |
| 38 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |
| 39 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |
| 40 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |
| 41 | + | }, | |
| 42 | + | }, | |
| 43 | + | expected: 1, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "split with one stacked side", | |
| 47 | + | node: &PaneNode{ | |
| 48 | + | ID: "split1", | |
| 49 | + | SplitDir: SplitHorizontal, | |
| 50 | + | Children: []*PaneNode{ | |
| 51 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |
| 52 | + | { | |
| 53 | + | ID: "stack1", | |
| 54 | + | IsStacked: true, | |
| 55 | + | ActiveStackIndex: 0, | |
| 56 | + | Children: []*PaneNode{ | |
| 57 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |
| 58 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |
| 59 | + | }, | |
| 60 | + | }, | |
| 61 | + | }, | |
| 62 | + | }, | |
| 63 | + | expected: 2, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "nested splits", | |
| 67 | + | node: &PaneNode{ | |
| 68 | + | ID: "split1", | |
| 69 | + | SplitDir: SplitHorizontal, | |
| 70 | + | Children: []*PaneNode{ | |
| 71 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |
| 72 | + | { | |
| 73 | + | ID: "split2", | |
| 74 | + | SplitDir: SplitVertical, | |
| 75 | + | Children: []*PaneNode{ | |
| 76 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |
| 77 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |
| 78 | + | }, | |
| 79 | + | }, | |
| 80 | + | }, | |
| 81 | + | }, | |
| 82 | + | expected: 3, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "complex tree with stacks and splits", | |
| 86 | + | node: &PaneNode{ | |
| 87 | + | ID: "split1", | |
| 88 | + | SplitDir: SplitHorizontal, | |
| 89 | + | Children: []*PaneNode{ | |
| 90 | + | { | |
| 91 | + | ID: "stack1", | |
| 92 | + | IsStacked: true, | |
| 93 | + | ActiveStackIndex: 0, | |
| 94 | + | Children: []*PaneNode{ | |
| 95 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |
| 96 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |
| 97 | + | }, | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | ID: "split2", | |
| 101 | + | SplitDir: SplitVertical, | |
| 102 | + | Children: []*PaneNode{ | |
| 103 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |
| 104 | + | { | |
| 105 | + | ID: "stack2", | |
| 106 | + | IsStacked: true, | |
| 107 | + | ActiveStackIndex: 1, | |
| 108 | + | Children: []*PaneNode{ | |
| 109 | + | {ID: "pane4", Pane: NewPane("pane4")}, | |
| 110 | + | {ID: "pane5", Pane: NewPane("pane5")}, | |
| 111 | + | }, | |
| 112 | + | }, | |
| 113 | + | }, | |
| 114 | + | }, | |
| 115 | + | }, | |
| 116 | + | }, | |
| 117 | + | expected: 3, // stack1(1) + pane3(1) + stack2(1) | |
| 118 | + | }, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | for _, tt := range tests { | |
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |
| 123 | + | got := tt.node.VisibleAreaCount() | |
| 124 | + | if got != tt.expected { | |
| 125 | + | t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected) | |
| 126 | + | } | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestPaneNode_VisibleAreaCount(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | node *PaneNode | |||
| 9 | + | expected int | |||
| 10 | + | }{ | |||
| 11 | + | { | |||
| 12 | + | name: "single leaf pane", | |||
| 13 | + | node: &PaneNode{ | |||
| 14 | + | ID: "pane1", | |||
| 15 | + | Pane: NewPane("pane1"), | |||
| 16 | + | }, | |||
| 17 | + | expected: 1, | |||
| 18 | + | }, | |||
| 19 | + | { | |||
| 20 | + | name: "horizontal split with two leaves", | |||
| 21 | + | node: &PaneNode{ | |||
| 22 | + | ID: "split1", | |||
| 23 | + | SplitDir: SplitHorizontal, | |||
| 24 | + | Children: []*PaneNode{ | |||
| 25 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |||
| 26 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | expected: 2, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "stacked panes count as one", | |||
| 33 | + | node: &PaneNode{ | |||
| 34 | + | ID: "stack1", | |||
| 35 | + | IsStacked: true, | |||
| 36 | + | ActiveStackIndex: 0, | |||
| 37 | + | Children: []*PaneNode{ | |||
| 38 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |||
| 39 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |||
| 40 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |||
| 41 | + | }, | |||
| 42 | + | }, | |||
| 43 | + | expected: 1, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | name: "split with one stacked side", | |||
| 47 | + | node: &PaneNode{ | |||
| 48 | + | ID: "split1", | |||
| 49 | + | SplitDir: SplitHorizontal, | |||
| 50 | + | Children: []*PaneNode{ | |||
| 51 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |||
| 52 | + | { | |||
| 53 | + | ID: "stack1", | |||
| 54 | + | IsStacked: true, | |||
| 55 | + | ActiveStackIndex: 0, | |||
| 56 | + | Children: []*PaneNode{ | |||
| 57 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |||
| 58 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |||
| 59 | + | }, | |||
| 60 | + | }, | |||
| 61 | + | }, | |||
| 62 | + | }, | |||
| 63 | + | expected: 2, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "nested splits", | |||
| 67 | + | node: &PaneNode{ | |||
| 68 | + | ID: "split1", | |||
| 69 | + | SplitDir: SplitHorizontal, | |||
| 70 | + | Children: []*PaneNode{ | |||
| 71 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |||
| 72 | + | { | |||
| 73 | + | ID: "split2", | |||
| 74 | + | SplitDir: SplitVertical, | |||
| 75 | + | Children: []*PaneNode{ | |||
| 76 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |||
| 77 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |||
| 78 | + | }, | |||
| 79 | + | }, | |||
| 80 | + | }, | |||
| 81 | + | }, | |||
| 82 | + | expected: 3, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "complex tree with stacks and splits", | |||
| 86 | + | node: &PaneNode{ | |||
| 87 | + | ID: "split1", | |||
| 88 | + | SplitDir: SplitHorizontal, | |||
| 89 | + | Children: []*PaneNode{ | |||
| 90 | + | { | |||
| 91 | + | ID: "stack1", | |||
| 92 | + | IsStacked: true, | |||
| 93 | + | ActiveStackIndex: 0, | |||
| 94 | + | Children: []*PaneNode{ | |||
| 95 | + | {ID: "pane1", Pane: NewPane("pane1")}, | |||
| 96 | + | {ID: "pane2", Pane: NewPane("pane2")}, | |||
| 97 | + | }, | |||
| 98 | + | }, | |||
| 99 | + | { | |||
| 100 | + | ID: "split2", | |||
| 101 | + | SplitDir: SplitVertical, | |||
| 102 | + | Children: []*PaneNode{ | |||
| 103 | + | {ID: "pane3", Pane: NewPane("pane3")}, | |||
| 104 | + | { | |||
| 105 | + | ID: "stack2", | |||
| 106 | + | IsStacked: true, | |||
| 107 | + | ActiveStackIndex: 1, | |||
| 108 | + | Children: []*PaneNode{ | |||
| 109 | + | {ID: "pane4", Pane: NewPane("pane4")}, | |||
| 110 | + | {ID: "pane5", Pane: NewPane("pane5")}, | |||
| 111 | + | }, | |||
| 112 | + | }, | |||
| 113 | + | }, | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | expected: 3, // stack1(1) + pane3(1) + stack2(1) | |||
| 118 | + | }, | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | for _, tt := range tests { | |||
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 123 | + | got := tt.node.VisibleAreaCount() | |||
| 124 | + | if got != tt.expected { | |||
| 125 | + | t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected) | |||
| 126 | + | } | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | } | |||
M internal/domain/entity/workspace.go +9 -0
| @@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane { | |||
| 64 | 64 | }) | |
| 65 | 65 | return panes | |
| 66 | 66 | } | |
| 67 | + | ||
| 68 | + | // VisibleAreaCount returns the number of visible pane areas. | |
| 69 | + | // Stacked panes count as 1 (only one visible at a time). | |
| 70 | + | func (w *Workspace) VisibleAreaCount() int { | |
| 71 | + | if w.Root == nil { | |
| 72 | + | return 0 | |
| 73 | + | } | |
| 74 | + | return w.Root.VisibleAreaCount() | |
| 75 | + | } | |
| @@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane { | |||||
| 64 | }) | 64 | }) | ||
| 65 | return panes | 65 | return panes | ||
| 66 | } | 66 | } | ||
| 67 | + | ||||
| 68 | + | // VisibleAreaCount returns the number of visible pane areas. | |||
| 69 | + | // Stacked panes count as 1 (only one visible at a time). | |||
| 70 | + | func (w *Workspace) VisibleAreaCount() int { | |||
| 71 | + | if w.Root == nil { | |||
| 72 | + | return 0 | |||
| 73 | + | } | |||
| 74 | + | return w.Root.VisibleAreaCount() | |||
| 75 | + | } | |||
M internal/ui/component/workspace_view.go +8 -2
| @@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int { | |||
| 297 | 297 | return len(wv.paneViews) | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | - | // singlePaneClass is used to hide the active pane border when only one pane exists. | |
| 300 | + | // singlePaneClass is used to hide the active pane border when only one visible area exists. | |
| 301 | 301 | const singlePaneClass = "single-pane" | |
| 302 | 302 | ||
| 303 | 303 | // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container. | |
| 304 | + | // Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area. | |
| 304 | 305 | // Must be called with the lock held. | |
| 305 | 306 | func (wv *WorkspaceView) updateSinglePaneModeInternal() { | |
| 306 | 307 | if wv.container == nil { | |
| 307 | 308 | return | |
| 308 | 309 | } | |
| 309 | 310 | ||
| 310 | - | if len(wv.paneViews) <= 1 { | |
| 311 | + | visibleAreas := 0 | |
| 312 | + | if wv.workspace != nil { | |
| 313 | + | visibleAreas = wv.workspace.VisibleAreaCount() | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | if visibleAreas <= 1 { | |
| 311 | 317 | wv.container.AddCssClass(singlePaneClass) | |
| 312 | 318 | } else { | |
| 313 | 319 | wv.container.RemoveCssClass(singlePaneClass) | |
| @@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int { | |||||
| 297 | return len(wv.paneViews) | 297 | return len(wv.paneViews) | ||
| 298 | } | 298 | } | ||
| 299 | 299 | ||||
| 300 | - | // singlePaneClass is used to hide the active pane border when only one pane exists. | 300 | + | // singlePaneClass is used to hide the active pane border when only one visible area exists. |
| 301 | const singlePaneClass = "single-pane" | 301 | const singlePaneClass = "single-pane" | ||
| 302 | 302 | ||||
| 303 | // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container. | 303 | // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container. | ||
| 304 | + | // Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area. | |||
| 304 | // Must be called with the lock held. | 305 | // Must be called with the lock held. | ||
| 305 | func (wv *WorkspaceView) updateSinglePaneModeInternal() { | 306 | func (wv *WorkspaceView) updateSinglePaneModeInternal() { | ||
| 306 | if wv.container == nil { | 307 | if wv.container == nil { | ||
| 307 | return | 308 | return | ||
| 308 | } | 309 | } | ||
| 309 | 310 | ||||
| 310 | - | if len(wv.paneViews) <= 1 { | 311 | + | visibleAreas := 0 |
| 312 | + | if wv.workspace != nil { | |||
| 313 | + | visibleAreas = wv.workspace.VisibleAreaCount() | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | if visibleAreas <= 1 { | |||
| 311 | wv.container.AddCssClass(singlePaneClass) | 317 | wv.container.AddCssClass(singlePaneClass) | ||
| 312 | } else { | 318 | } else { | ||
| 313 | wv.container.RemoveCssClass(singlePaneClass) | 319 | wv.container.RemoveCssClass(singlePaneClass) | ||