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) Cursor() *tea.Cursor {
110 if s.bottomPanel != nil {
111 if c, ok := s.bottomPanel.(util.Cursor); ok {
112 return c.Cursor()
113 }
114 } else if s.rightPanel != nil {
115 if c, ok := s.rightPanel.(util.Cursor); ok {
116 return c.Cursor()
117 }
118 } else if s.leftPanel != nil {
119 if c, ok := s.leftPanel.(util.Cursor); ok {
120 return c.Cursor()
121 }
122 }
123 return nil
124}
125
126func (s *splitPaneLayout) View() string {
127 var topSection string
128
129 if s.leftPanel != nil && s.rightPanel != nil {
130 leftView := s.leftPanel.View()
131 rightView := s.rightPanel.View()
132 topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
133 } else if s.leftPanel != nil {
134 topSection = s.leftPanel.View()
135 } else if s.rightPanel != nil {
136 topSection = s.rightPanel.View()
137 } else {
138 topSection = ""
139 }
140
141 var finalView string
142
143 if s.bottomPanel != nil && topSection != "" {
144 bottomView := s.bottomPanel.View()
145 finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
146 } else if s.bottomPanel != nil {
147 finalView = s.bottomPanel.View()
148 } else {
149 finalView = topSection
150 }
151
152 t := styles.CurrentTheme()
153
154 style := t.S().Base.
155 Width(s.width).
156 Height(s.height)
157
158 return style.Render(finalView)
159}
160
161func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
162 s.width = width
163 s.height = height
164 slog.Info("Setting split pane size", "width", width, "height", height)
165
166 var topHeight, bottomHeight int
167 var cmds []tea.Cmd
168 if s.bottomPanel != nil {
169 if s.fixedBottomHeight > 0 {
170 bottomHeight = s.fixedBottomHeight
171 topHeight = height - bottomHeight
172 } else {
173 topHeight = int(float64(height) * s.verticalRatio)
174 bottomHeight = height - topHeight
175 if bottomHeight <= 0 {
176 bottomHeight = 2
177 topHeight = height - bottomHeight
178 }
179 }
180 } else {
181 topHeight = height
182 bottomHeight = 0
183 }
184
185 var leftWidth, rightWidth int
186 if s.leftPanel != nil && s.rightPanel != nil {
187 if s.fixedRightWidth > 0 {
188 rightWidth = s.fixedRightWidth
189 leftWidth = width - rightWidth
190 } else {
191 leftWidth = int(float64(width) * s.ratio)
192 rightWidth = width - leftWidth
193 if rightWidth <= 0 {
194 rightWidth = 2
195 leftWidth = width - rightWidth
196 }
197 }
198 } else if s.leftPanel != nil {
199 leftWidth = width
200 rightWidth = 0
201 } else if s.rightPanel != nil {
202 leftWidth = 0
203 rightWidth = width
204 }
205
206 if s.leftPanel != nil {
207 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
208 cmds = append(cmds, cmd)
209 if positional, ok := s.leftPanel.(Positional); ok {
210 cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
211 }
212 }
213
214 if s.rightPanel != nil {
215 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
216 cmds = append(cmds, cmd)
217 if positional, ok := s.rightPanel.(Positional); ok {
218 cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
219 }
220 }
221
222 if s.bottomPanel != nil {
223 cmd := s.bottomPanel.SetSize(width, bottomHeight)
224 cmds = append(cmds, cmd)
225 if positional, ok := s.bottomPanel.(Positional); ok {
226 cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
227 }
228 }
229 return tea.Batch(cmds...)
230}
231
232func (s *splitPaneLayout) GetSize() (int, int) {
233 return s.width, s.height
234}
235
236func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
237 s.leftPanel = panel
238 if s.width > 0 && s.height > 0 {
239 return s.SetSize(s.width, s.height)
240 }
241 return nil
242}
243
244func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
245 s.rightPanel = panel
246 if s.width > 0 && s.height > 0 {
247 return s.SetSize(s.width, s.height)
248 }
249 return nil
250}
251
252func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
253 s.bottomPanel = panel
254 if s.width > 0 && s.height > 0 {
255 return s.SetSize(s.width, s.height)
256 }
257 return nil
258}
259
260func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
261 s.leftPanel = nil
262 if s.width > 0 && s.height > 0 {
263 return s.SetSize(s.width, s.height)
264 }
265 return nil
266}
267
268func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
269 s.rightPanel = nil
270 if s.width > 0 && s.height > 0 {
271 return s.SetSize(s.width, s.height)
272 }
273 return nil
274}
275
276func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
277 s.bottomPanel = nil
278 if s.width > 0 && s.height > 0 {
279 return s.SetSize(s.width, s.height)
280 }
281 return nil
282}
283
284func (s *splitPaneLayout) Bindings() []key.Binding {
285 if s.leftPanel != nil {
286 if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
287 return b.Bindings()
288 }
289 }
290 if s.rightPanel != nil {
291 if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
292 return b.Bindings()
293 }
294 }
295 if s.bottomPanel != nil {
296 if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
297 return b.Bindings()
298 }
299 }
300 return nil
301}
302
303func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
304 panels := map[LayoutPanel]Container{
305 LeftPanel: s.leftPanel,
306 RightPanel: s.rightPanel,
307 BottomPanel: s.bottomPanel,
308 }
309 var cmds []tea.Cmd
310 for p, container := range panels {
311 if container == nil {
312 continue
313 }
314 if p == panel {
315 cmds = append(cmds, container.Focus())
316 } else {
317 cmds = append(cmds, container.Blur())
318 }
319 }
320 return tea.Batch(cmds...)
321}
322
323// SetOffset implements SplitPaneLayout.
324func (s *splitPaneLayout) SetOffset(x int, y int) {
325 s.xOffset = x
326 s.yOffset = y
327}
328
329func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
330 layout := &splitPaneLayout{
331 ratio: 0.8,
332 verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
333 }
334 for _, option := range options {
335 option(layout)
336 }
337 return layout
338}
339
340func WithLeftPanel(panel Container) SplitPaneOption {
341 return func(s *splitPaneLayout) {
342 s.leftPanel = panel
343 }
344}
345
346func WithRightPanel(panel Container) SplitPaneOption {
347 return func(s *splitPaneLayout) {
348 s.rightPanel = panel
349 }
350}
351
352func WithRatio(ratio float64) SplitPaneOption {
353 return func(s *splitPaneLayout) {
354 s.ratio = ratio
355 }
356}
357
358func WithBottomPanel(panel Container) SplitPaneOption {
359 return func(s *splitPaneLayout) {
360 s.bottomPanel = panel
361 }
362}
363
364func WithVerticalRatio(ratio float64) SplitPaneOption {
365 return func(s *splitPaneLayout) {
366 s.verticalRatio = ratio
367 }
368}
369
370func WithFixedBottomHeight(height int) SplitPaneOption {
371 return func(s *splitPaneLayout) {
372 s.fixedBottomHeight = height
373 }
374}
375
376func WithFixedRightWidth(width int) SplitPaneOption {
377 return func(s *splitPaneLayout) {
378 s.fixedRightWidth = width
379 }
380}