1package layout
2
3import (
4 "log/slog"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8
9 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/crush/internal/tui/util"
11 "github.com/charmbracelet/lipgloss/v2"
12)
13
14type LayoutPanel string
15
16const (
17 LeftPanel LayoutPanel = "left"
18 RightPanel LayoutPanel = "right"
19 BottomPanel LayoutPanel = "bottom"
20)
21
22type SplitPaneLayout interface {
23 util.Model
24 Sizeable
25 Help
26 SetLeftPanel(panel Container) tea.Cmd
27 SetRightPanel(panel Container) tea.Cmd
28 SetBottomPanel(panel Container) tea.Cmd
29
30 ClearLeftPanel() tea.Cmd
31 ClearRightPanel() tea.Cmd
32 ClearBottomPanel() tea.Cmd
33
34 FocusPanel(panel LayoutPanel) tea.Cmd
35 SetOffset(x, y int)
36}
37
38type splitPaneLayout struct {
39 width int
40 height int
41 xOffset int
42 yOffset int
43
44 ratio float64
45 verticalRatio float64
46
47 rightPanel Container
48 leftPanel Container
49 bottomPanel Container
50
51 fixedBottomHeight int // Fixed height for the bottom panel, if any
52 fixedRightWidth int // Fixed width for the right panel, if any
53}
54
55type SplitPaneOption func(*splitPaneLayout)
56
57func (s *splitPaneLayout) Init() tea.Cmd {
58 var cmds []tea.Cmd
59
60 if s.leftPanel != nil {
61 cmds = append(cmds, s.leftPanel.Init())
62 }
63
64 if s.rightPanel != nil {
65 cmds = append(cmds, s.rightPanel.Init())
66 }
67
68 if s.bottomPanel != nil {
69 cmds = append(cmds, s.bottomPanel.Init())
70 }
71
72 return tea.Batch(cmds...)
73}
74
75func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76 var cmds []tea.Cmd
77 switch msg := msg.(type) {
78 case tea.WindowSizeMsg:
79 return s, s.SetSize(msg.Width, msg.Height)
80 }
81
82 if s.rightPanel != nil {
83 u, cmd := s.rightPanel.Update(msg)
84 s.rightPanel = u.(Container)
85 if cmd != nil {
86 cmds = append(cmds, cmd)
87 }
88 }
89
90 if s.leftPanel != nil {
91 u, cmd := s.leftPanel.Update(msg)
92 s.leftPanel = u.(Container)
93 if cmd != nil {
94 cmds = append(cmds, cmd)
95 }
96 }
97
98 if s.bottomPanel != nil {
99 u, cmd := s.bottomPanel.Update(msg)
100 s.bottomPanel = u.(Container)
101 if cmd != nil {
102 cmds = append(cmds, cmd)
103 }
104 }
105
106 return s, tea.Batch(cmds...)
107}
108
109func (s *splitPaneLayout) View() tea.View {
110 var topSection string
111
112 if s.leftPanel != nil && s.rightPanel != nil {
113 leftView := s.leftPanel.View()
114 rightView := s.rightPanel.View()
115 topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
116 } else if s.leftPanel != nil {
117 topSection = s.leftPanel.View().String()
118 } else if s.rightPanel != nil {
119 topSection = s.rightPanel.View().String()
120 } else {
121 topSection = ""
122 }
123
124 var finalView string
125
126 if s.bottomPanel != nil && topSection != "" {
127 bottomView := s.bottomPanel.View()
128 finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
129 } else if s.bottomPanel != nil {
130 finalView = s.bottomPanel.View().String()
131 } else {
132 finalView = topSection
133 }
134
135 // TODO: think of a better way to handle multiple cursors
136 var cursor *tea.Cursor
137 if s.bottomPanel != nil {
138 cursor = s.bottomPanel.View().Cursor()
139 } else if s.rightPanel != nil {
140 cursor = s.rightPanel.View().Cursor()
141 } else if s.leftPanel != nil {
142 cursor = s.leftPanel.View().Cursor()
143 }
144
145 t := styles.CurrentTheme()
146
147 style := t.S().Base.
148 Width(s.width).
149 Height(s.height)
150
151 view := tea.NewView(style.Render(finalView))
152 view.SetCursor(cursor)
153 return view
154}
155
156func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
157 s.width = width
158 s.height = height
159 slog.Info("Setting split pane size", "width", width, "height", height)
160
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}