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