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