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