1package layout
2
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/logging"
7 "github.com/charmbracelet/crush/internal/tui/styles"
8 "github.com/charmbracelet/crush/internal/tui/util"
9 "github.com/charmbracelet/lipgloss/v2"
10)
11
12type LayoutPanel string
13
14const (
15 LeftPanel LayoutPanel = "left"
16 RightPanel LayoutPanel = "right"
17 BottomPanel LayoutPanel = "bottom"
18)
19
20type SplitPaneLayout interface {
21 util.Model
22 Sizeable
23 Help
24 SetLeftPanel(panel Container) tea.Cmd
25 SetRightPanel(panel Container) tea.Cmd
26 SetBottomPanel(panel Container) tea.Cmd
27
28 ClearLeftPanel() tea.Cmd
29 ClearRightPanel() tea.Cmd
30 ClearBottomPanel() tea.Cmd
31
32 FocusPanel(panel LayoutPanel) tea.Cmd
33 SetOffset(x, y int)
34}
35
36type splitPaneLayout struct {
37 width int
38 height int
39 xOffset int
40 yOffset int
41
42 ratio float64
43 verticalRatio float64
44
45 rightPanel Container
46 leftPanel Container
47 bottomPanel Container
48
49 fixedBottomHeight int // Fixed height for the bottom panel, if any
50 fixedRightWidth int // Fixed width for the right panel, if any
51}
52
53type SplitPaneOption func(*splitPaneLayout)
54
55func (s *splitPaneLayout) Init() tea.Cmd {
56 var cmds []tea.Cmd
57
58 if s.leftPanel != nil {
59 cmds = append(cmds, s.leftPanel.Init())
60 }
61
62 if s.rightPanel != nil {
63 cmds = append(cmds, s.rightPanel.Init())
64 }
65
66 if s.bottomPanel != nil {
67 cmds = append(cmds, s.bottomPanel.Init())
68 }
69
70 return tea.Batch(cmds...)
71}
72
73func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74 var cmds []tea.Cmd
75 switch msg := msg.(type) {
76 case tea.WindowSizeMsg:
77 return s, s.SetSize(msg.Width, msg.Height)
78 }
79
80 if s.rightPanel != nil {
81 u, cmd := s.rightPanel.Update(msg)
82 s.rightPanel = u.(Container)
83 if cmd != nil {
84 cmds = append(cmds, cmd)
85 }
86 }
87
88 if s.leftPanel != nil {
89 u, cmd := s.leftPanel.Update(msg)
90 s.leftPanel = u.(Container)
91 if cmd != nil {
92 cmds = append(cmds, cmd)
93 }
94 }
95
96 if s.bottomPanel != nil {
97 u, cmd := s.bottomPanel.Update(msg)
98 s.bottomPanel = u.(Container)
99 if cmd != nil {
100 cmds = append(cmds, cmd)
101 }
102 }
103
104 return s, tea.Batch(cmds...)
105}
106
107func (s *splitPaneLayout) Cursor() *tea.Cursor {
108 if s.bottomPanel != nil {
109 if c, ok := s.bottomPanel.(util.Cursor); ok {
110 return c.Cursor()
111 }
112 } else if s.rightPanel != nil {
113 if c, ok := s.rightPanel.(util.Cursor); ok {
114 return c.Cursor()
115 }
116 } else if s.leftPanel != nil {
117 if c, ok := s.leftPanel.(util.Cursor); ok {
118 return c.Cursor()
119 }
120 }
121 return nil
122}
123
124func (s *splitPaneLayout) View() string {
125 var topSection string
126
127 if s.leftPanel != nil && s.rightPanel != nil {
128 leftView := s.leftPanel.View()
129 rightView := s.rightPanel.View()
130 topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
131 } else if s.leftPanel != nil {
132 topSection = s.leftPanel.View()
133 } else if s.rightPanel != nil {
134 topSection = s.rightPanel.View()
135 } else {
136 topSection = ""
137 }
138
139 var finalView string
140
141 if s.bottomPanel != nil && topSection != "" {
142 bottomView := s.bottomPanel.View()
143 finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
144 } else if s.bottomPanel != nil {
145 finalView = s.bottomPanel.View()
146 } else {
147 finalView = topSection
148 }
149
150 t := styles.CurrentTheme()
151
152 style := t.S().Base.
153 Width(s.width).
154 Height(s.height)
155
156 return style.Render(finalView)
157}
158
159func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
160 s.width = width
161 s.height = height
162 logging.Info("Setting split pane size", "width", width, "height", height)
163
164 var topHeight, bottomHeight int
165 var cmds []tea.Cmd
166 if s.bottomPanel != nil {
167 if s.fixedBottomHeight > 0 {
168 bottomHeight = s.fixedBottomHeight
169 topHeight = height - bottomHeight
170 } else {
171 topHeight = int(float64(height) * s.verticalRatio)
172 bottomHeight = height - topHeight
173 if bottomHeight <= 0 {
174 bottomHeight = 2
175 topHeight = height - bottomHeight
176 }
177 }
178 } else {
179 topHeight = height
180 bottomHeight = 0
181 }
182
183 var leftWidth, rightWidth int
184 if s.leftPanel != nil && s.rightPanel != nil {
185 if s.fixedRightWidth > 0 {
186 rightWidth = s.fixedRightWidth
187 leftWidth = width - rightWidth
188 } else {
189 leftWidth = int(float64(width) * s.ratio)
190 rightWidth = width - leftWidth
191 if rightWidth <= 0 {
192 rightWidth = 2
193 leftWidth = width - rightWidth
194 }
195 }
196 } else if s.leftPanel != nil {
197 leftWidth = width
198 rightWidth = 0
199 } else if s.rightPanel != nil {
200 leftWidth = 0
201 rightWidth = width
202 }
203
204 if s.leftPanel != nil {
205 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
206 cmds = append(cmds, cmd)
207 if positional, ok := s.leftPanel.(Positional); ok {
208 cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
209 }
210 }
211
212 if s.rightPanel != nil {
213 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
214 cmds = append(cmds, cmd)
215 if positional, ok := s.rightPanel.(Positional); ok {
216 cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
217 }
218 }
219
220 if s.bottomPanel != nil {
221 cmd := s.bottomPanel.SetSize(width, bottomHeight)
222 cmds = append(cmds, cmd)
223 if positional, ok := s.bottomPanel.(Positional); ok {
224 cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
225 }
226 }
227 return tea.Batch(cmds...)
228}
229
230func (s *splitPaneLayout) GetSize() (int, int) {
231 return s.width, s.height
232}
233
234func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
235 s.leftPanel = panel
236 if s.width > 0 && s.height > 0 {
237 return s.SetSize(s.width, s.height)
238 }
239 return nil
240}
241
242func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
243 s.rightPanel = panel
244 if s.width > 0 && s.height > 0 {
245 return s.SetSize(s.width, s.height)
246 }
247 return nil
248}
249
250func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
251 s.bottomPanel = panel
252 if s.width > 0 && s.height > 0 {
253 return s.SetSize(s.width, s.height)
254 }
255 return nil
256}
257
258func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
259 s.leftPanel = nil
260 if s.width > 0 && s.height > 0 {
261 return s.SetSize(s.width, s.height)
262 }
263 return nil
264}
265
266func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
267 s.rightPanel = nil
268 if s.width > 0 && s.height > 0 {
269 return s.SetSize(s.width, s.height)
270 }
271 return nil
272}
273
274func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
275 s.bottomPanel = nil
276 if s.width > 0 && s.height > 0 {
277 return s.SetSize(s.width, s.height)
278 }
279 return nil
280}
281
282func (s *splitPaneLayout) Bindings() []key.Binding {
283 if s.leftPanel != nil {
284 if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
285 return b.Bindings()
286 }
287 }
288 if s.rightPanel != nil {
289 if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
290 return b.Bindings()
291 }
292 }
293 if s.bottomPanel != nil {
294 if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
295 return b.Bindings()
296 }
297 }
298 return nil
299}
300
301func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
302 panels := map[LayoutPanel]Container{
303 LeftPanel: s.leftPanel,
304 RightPanel: s.rightPanel,
305 BottomPanel: s.bottomPanel,
306 }
307 var cmds []tea.Cmd
308 for p, container := range panels {
309 if container == nil {
310 continue
311 }
312 if p == panel {
313 cmds = append(cmds, container.Focus())
314 } else {
315 cmds = append(cmds, container.Blur())
316 }
317 }
318 return tea.Batch(cmds...)
319}
320
321// SetOffset implements SplitPaneLayout.
322func (s *splitPaneLayout) SetOffset(x int, y int) {
323 s.xOffset = x
324 s.yOffset = y
325}
326
327func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
328 layout := &splitPaneLayout{
329 ratio: 0.8,
330 verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
331 }
332 for _, option := range options {
333 option(layout)
334 }
335 return layout
336}
337
338func WithLeftPanel(panel Container) SplitPaneOption {
339 return func(s *splitPaneLayout) {
340 s.leftPanel = panel
341 }
342}
343
344func WithRightPanel(panel Container) SplitPaneOption {
345 return func(s *splitPaneLayout) {
346 s.rightPanel = panel
347 }
348}
349
350func WithRatio(ratio float64) SplitPaneOption {
351 return func(s *splitPaneLayout) {
352 s.ratio = ratio
353 }
354}
355
356func WithBottomPanel(panel Container) SplitPaneOption {
357 return func(s *splitPaneLayout) {
358 s.bottomPanel = panel
359 }
360}
361
362func WithVerticalRatio(ratio float64) SplitPaneOption {
363 return func(s *splitPaneLayout) {
364 s.verticalRatio = ratio
365 }
366}
367
368func WithFixedBottomHeight(height int) SplitPaneOption {
369 return func(s *splitPaneLayout) {
370 s.fixedBottomHeight = height
371 }
372}
373
374func WithFixedRightWidth(width int) SplitPaneOption {
375 return func(s *splitPaneLayout) {
376 s.fixedRightWidth = width
377 }
378}