1package layout
2
3import (
4 "context"
5 "errors"
6 "sync"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/rs/zerolog"
11)
12
13// ErrNilRoot is returned when attempting to build from a nil root node.
14var ErrNilRoot = errors.New("root node is nil")
15
16// ErrNodeNotFound is returned when a node lookup fails.
17var ErrNodeNotFound = errors.New("node not found")
18
19// PaneViewFactory creates pane view widgets from pane nodes.
20// This decouples the TreeRenderer from concrete PaneView implementation.
21type PaneViewFactory interface {
22 // CreatePaneView creates a widget for a leaf pane node.
23 CreatePaneView(node *entity.PaneNode) Widget
24}
25
26// TreeRenderer builds GTK widget trees from domain PaneNode trees.
27// It maps each node to its corresponding GTK widget and maintains
28// a lookup table for finding widgets by node ID.
29type TreeRenderer struct {
30 factory WidgetFactory
31 paneViewFactory PaneViewFactory
32 logger zerolog.Logger
33 nodeToWidget map[string]Widget
34 // paneToStack maps pane IDs to their containing StackedView.
35 // Every leaf pane is wrapped in a StackedView for easy stacking later.
36 paneToStack map[string]*StackedView
37 mu sync.RWMutex
38}
39
40// NewTreeRenderer creates a new tree renderer.
41func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer {
42 log := logging.FromContext(ctx)
43 log.Debug().Msg("creating tree renderer")
44
45 return &TreeRenderer{
46 factory: factory,
47 paneViewFactory: paneViewFactory,
48 logger: log.With().Str("component", "tree-renderer").Logger(),
49 nodeToWidget: make(map[string]Widget),
50 paneToStack: make(map[string]*StackedView),
51 }
52}
53
54// Build constructs the entire widget tree from a root PaneNode.
55// Returns the root widget that can be embedded in a container.
56func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
57 if root == nil {
58 return nil, ErrNilRoot
59 }
60
61 tr.mu.Lock()
62 defer tr.mu.Unlock()
63
64 // Clear previous mappings
65 tr.nodeToWidget = make(map[string]Widget)
66 tr.paneToStack = make(map[string]*StackedView)
67
68 return tr.renderNode(ctx, root)
69}
70
71// renderNode recursively renders a single node and its children.
72// Must be called with lock held.
73func (tr *TreeRenderer) renderNode(ctx context.Context, node *entity.PaneNode) (Widget, error) {
74 if node == nil {
75 return nil, nil
76 }
77
78 var widget Widget
79 var err error
80
81 switch {
82 case node.IsLeaf():
83 widget = tr.renderLeaf(ctx, node)
84 case node.IsSplit():
85 widget, err = tr.renderSplit(ctx, node)
86 case node.IsStacked:
87 widget, err = tr.renderStacked(ctx, node)
88 default:
89 // Unknown node type - treat as leaf if it has a pane
90 if node.Pane != nil {
91 widget = tr.renderLeaf(ctx, node)
92 }
93 }
94
95 if err != nil {
96 return nil, err
97 }
98
99 // Store mapping
100 if widget != nil && node.ID != "" {
101 tr.nodeToWidget[node.ID] = widget
102 }
103
104 return widget, nil
105}
106
107// renderLeaf creates a PaneView widget wrapped in a StackedView.
108// Every pane is wrapped in a StackedView from the start, making stacking trivial.
109// When there's only 1 pane, the StackedView shows no title bar.
110func (tr *TreeRenderer) renderLeaf(ctx context.Context, node *entity.PaneNode) Widget {
111 if tr.paneViewFactory == nil {
112 return nil
113 }
114
115 paneWidget := tr.paneViewFactory.CreatePaneView(node)
116 if paneWidget == nil {
117 return nil
118 }
119
120 // Wrap the pane in a StackedView
121 stackedView := NewStackedView(tr.factory)
122
123 // Get title from pane if available
124 title := "Untitled"
125 paneID := ""
126 if node.Pane != nil {
127 paneID = string(node.Pane.ID)
128 if node.Pane.Title != "" {
129 title = node.Pane.Title
130 }
131 }
132
133 tr.logger.Debug().
134 Str("pane_id", paneID).
135 Str("title", title).
136 Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
137
138 stackedView.AddPane(ctx, title, "", paneWidget)
139
140 // Track this pane's StackedView for later stacking operations
141 if node.Pane != nil {
142 tr.paneToStack[paneID] = stackedView
143 }
144
145 return stackedView.Widget()
146}
147
148// renderSplit creates a SplitView for a split node.
149func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) (Widget, error) {
150 // Render children first
151 leftWidget, err := tr.renderNode(ctx, node.Left())
152 if err != nil {
153 return nil, err
154 }
155
156 rightWidget, err := tr.renderNode(ctx, node.Right())
157 if err != nil {
158 return nil, err
159 }
160
161 // Determine orientation
162 orientation := OrientationHorizontal
163 if node.SplitDir == entity.SplitVertical {
164 orientation = OrientationVertical
165 }
166
167 // Create split view
168 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
169
170 return splitView.Widget(), nil
171}
172
173// renderStacked creates a StackedView for a stacked node.
174func (tr *TreeRenderer) renderStacked(ctx context.Context, node *entity.PaneNode) (Widget, error) {
175 stackedView := NewStackedView(tr.factory)
176
177 tr.logger.Debug().
178 Int("child_count", len(node.Children)).
179 Msg("TreeRenderer.renderStacked: creating stacked view")
180
181 // Add each child pane to the stack
182 for _, child := range node.Children {
183 childWidget, err := tr.renderNode(ctx, child)
184 if err != nil {
185 return nil, err
186 }
187
188 // Get title and favicon from pane if available
189 title := "Untitled"
190 favicon := ""
191 if child.Pane != nil {
192 if child.Pane.Title != "" {
193 title = child.Pane.Title
194 }
195 favicon = child.Pane.FaviconURL
196 }
197
198 stackedView.AddPane(ctx, title, favicon, childWidget)
199
200 // Override paneToStack to point to THIS stacked view, not the child's wrapper.
201 // This ensures geometric navigation and stack sync work correctly.
202 if child.Pane != nil {
203 tr.paneToStack[string(child.Pane.ID)] = stackedView
204 }
205 }
206
207 // Set active index
208 if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
209 _ = stackedView.SetActive(ctx, node.ActiveStackIndex)
210 }
211
212 return stackedView.Widget(), nil
213}
214
215// Lookup finds the widget associated with a node ID.
216// Returns nil if the node was not found or hasn't been rendered.
217func (tr *TreeRenderer) Lookup(nodeID string) Widget {
218 tr.mu.RLock()
219 defer tr.mu.RUnlock()
220
221 return tr.nodeToWidget[nodeID]
222}
223
224// LookupNode finds both the widget and verifies the node ID exists.
225// Returns the widget and true if found, nil and false otherwise.
226func (tr *TreeRenderer) LookupNode(nodeID string) (Widget, bool) {
227 tr.mu.RLock()
228 defer tr.mu.RUnlock()
229
230 widget, ok := tr.nodeToWidget[nodeID]
231 return widget, ok
232}
233
234// NodeCount returns the number of nodes currently tracked.
235func (tr *TreeRenderer) NodeCount() int {
236 tr.mu.RLock()
237 defer tr.mu.RUnlock()
238
239 return len(tr.nodeToWidget)
240}
241
242// Clear removes all node-to-widget mappings.
243func (tr *TreeRenderer) Clear() {
244 tr.mu.Lock()
245 defer tr.mu.Unlock()
246
247 tr.nodeToWidget = make(map[string]Widget)
248}
249
250// RegisterWidget adds or updates a node-to-widget mapping.
251// Use this for incremental updates when you don't want to rebuild the entire tree.
252func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
253 tr.mu.Lock()
254 defer tr.mu.Unlock()
255
256 tr.nodeToWidget[nodeID] = widget
257}
258
259// UnregisterWidget removes a node-to-widget mapping.
260// Use this when a node is removed or replaced during incremental operations.
261func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
262 tr.mu.Lock()
263 defer tr.mu.Unlock()
264
265 delete(tr.nodeToWidget, nodeID)
266}
267
268// UpdateSplitRatio updates the ratio of a split view by node ID.
269// Returns an error if the node is not found or is not a split.
270func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
271 tr.mu.RLock()
272 widget, ok := tr.nodeToWidget[nodeID]
273 tr.mu.RUnlock()
274
275 if !ok {
276 return ErrNodeNotFound
277 }
278
279 // The widget should be a PanedWidget from a SplitView
280 // We can't directly update the ratio since we only store the Widget interface
281 // The caller would need to track SplitView instances separately or
282 // use the underlying PanedWidget's SetPosition method
283
284 // For now, we can at least verify the widget exists
285 _ = widget
286 return nil
287}
288
289// GetNodeIDs returns all tracked node IDs.
290func (tr *TreeRenderer) GetNodeIDs() []string {
291 tr.mu.RLock()
292 defer tr.mu.RUnlock()
293
294 ids := make([]string, 0, len(tr.nodeToWidget))
295 for id := range tr.nodeToWidget {
296 ids = append(ids, id)
297 }
298 return ids
299}
300
301// GetStackedViewForPane returns the StackedView containing the given pane.
302// Returns nil if the pane is not found.
303func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
304 tr.mu.RLock()
305 defer tr.mu.RUnlock()
306
307 return tr.paneToStack[paneID]
308}
309
310// RegisterPaneInStack adds a pane to the paneToStack mapping.
311// Use this when adding a pane to an existing stack without rebuild.
312func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
313 tr.mu.Lock()
314 defer tr.mu.Unlock()
315
316 tr.paneToStack[paneID] = stackedView
317}
318
319// UnregisterPane removes a pane from the paneToStack mapping.
320func (tr *TreeRenderer) UnregisterPane(paneID string) {
321 tr.mu.Lock()
322 defer tr.mu.Unlock()
323
324 delete(tr.paneToStack, paneID)
325}