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, paneID, 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 pane ID, title, and favicon from pane if available
189 paneID := ""
190 title := "Untitled"
191 favicon := ""
192 if child.Pane != nil {
193 paneID = string(child.Pane.ID)
194 if child.Pane.Title != "" {
195 title = child.Pane.Title
196 }
197 favicon = child.Pane.FaviconURL
198 }
199
200 stackedView.AddPane(ctx, paneID, title, favicon, childWidget)
201
202 // Override paneToStack to point to THIS stacked view, not the child's wrapper.
203 // This ensures geometric navigation and stack sync work correctly.
204 if child.Pane != nil {
205 tr.paneToStack[paneID] = stackedView
206 }
207 }
208
209 // Set active index
210 if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() {
211 _ = stackedView.SetActive(ctx, node.ActiveStackIndex)
212 }
213
214 return stackedView.Widget(), nil
215}
216
217// Lookup finds the widget associated with a node ID.
218// Returns nil if the node was not found or hasn't been rendered.
219func (tr *TreeRenderer) Lookup(nodeID string) Widget {
220 tr.mu.RLock()
221 defer tr.mu.RUnlock()
222
223 return tr.nodeToWidget[nodeID]
224}
225
226// LookupNode finds both the widget and verifies the node ID exists.
227// Returns the widget and true if found, nil and false otherwise.
228func (tr *TreeRenderer) LookupNode(nodeID string) (Widget, bool) {
229 tr.mu.RLock()
230 defer tr.mu.RUnlock()
231
232 widget, ok := tr.nodeToWidget[nodeID]
233 return widget, ok
234}
235
236// NodeCount returns the number of nodes currently tracked.
237func (tr *TreeRenderer) NodeCount() int {
238 tr.mu.RLock()
239 defer tr.mu.RUnlock()
240
241 return len(tr.nodeToWidget)
242}
243
244// Clear removes all node-to-widget mappings.
245func (tr *TreeRenderer) Clear() {
246 tr.mu.Lock()
247 defer tr.mu.Unlock()
248
249 tr.nodeToWidget = make(map[string]Widget)
250}
251
252// RegisterWidget adds or updates a node-to-widget mapping.
253// Use this for incremental updates when you don't want to rebuild the entire tree.
254func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
255 tr.mu.Lock()
256 defer tr.mu.Unlock()
257
258 tr.nodeToWidget[nodeID] = widget
259}
260
261// UnregisterWidget removes a node-to-widget mapping.
262// Use this when a node is removed or replaced during incremental operations.
263func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
264 tr.mu.Lock()
265 defer tr.mu.Unlock()
266
267 delete(tr.nodeToWidget, nodeID)
268}
269
270// UpdateSplitRatio updates the ratio of a split view by node ID.
271// Returns an error if the node is not found or is not a split.
272func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273 tr.mu.RLock()
274 widget, ok := tr.nodeToWidget[nodeID]
275 tr.mu.RUnlock()
276
277 if !ok {
278 return ErrNodeNotFound
279 }
280
281 // The widget should be a PanedWidget from a SplitView
282 // We can't directly update the ratio since we only store the Widget interface
283 // The caller would need to track SplitView instances separately or
284 // use the underlying PanedWidget's SetPosition method
285
286 // For now, we can at least verify the widget exists
287 _ = widget
288 return nil
289}
290
291// GetNodeIDs returns all tracked node IDs.
292func (tr *TreeRenderer) GetNodeIDs() []string {
293 tr.mu.RLock()
294 defer tr.mu.RUnlock()
295
296 ids := make([]string, 0, len(tr.nodeToWidget))
297 for id := range tr.nodeToWidget {
298 ids = append(ids, id)
299 }
300 return ids
301}
302
303// GetStackedViewForPane returns the StackedView containing the given pane.
304// Returns nil if the pane is not found.
305func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView {
306 tr.mu.RLock()
307 defer tr.mu.RUnlock()
308
309 return tr.paneToStack[paneID]
310}
311
312// RegisterPaneInStack adds a pane to the paneToStack mapping.
313// Use this when adding a pane to an existing stack without rebuild.
314func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) {
315 tr.mu.Lock()
316 defer tr.mu.Unlock()
317
318 tr.paneToStack[paneID] = stackedView
319}
320
321// UnregisterPane removes a pane from the paneToStack mapping.
322func (tr *TreeRenderer) UnregisterPane(paneID string) {
323 tr.mu.Lock()
324 defer tr.mu.Unlock()
325
326 delete(tr.paneToStack, paneID)
327}