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