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