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