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}
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) Bindings() []key.Binding {
273 if s.leftPanel != nil {
274 if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
275 return b.Bindings()
276 }
277 }
278 if s.rightPanel != nil {
279 if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
280 return b.Bindings()
281 }
282 }
283 if s.bottomPanel != nil {
284 if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
285 return b.Bindings()
286 }
287 }
288 return nil
289}
290
291func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
292 panels := map[LayoutPanel]Container{
293 LeftPanel: s.leftPanel,
294 RightPanel: s.rightPanel,
295 BottomPanel: s.bottomPanel,
296 }
297 var cmds []tea.Cmd
298 for p, container := range panels {
299 if container == nil {
300 continue
301 }
302 if p == panel {
303 cmds = append(cmds, container.Focus())
304 } else {
305 cmds = append(cmds, container.Blur())
306 }
307 }
308 return tea.Batch(cmds...)
309}
310
311func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
312 layout := &splitPaneLayout{
313 ratio: 0.8,
314 verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
315 }
316 for _, option := range options {
317 option(layout)
318 }
319 return layout
320}
321
322func WithLeftPanel(panel Container) SplitPaneOption {
323 return func(s *splitPaneLayout) {
324 s.leftPanel = panel
325 }
326}
327
328func WithRightPanel(panel Container) SplitPaneOption {
329 return func(s *splitPaneLayout) {
330 s.rightPanel = panel
331 }
332}
333
334func WithRatio(ratio float64) SplitPaneOption {
335 return func(s *splitPaneLayout) {
336 s.ratio = ratio
337 }
338}
339
340func WithBottomPanel(panel Container) SplitPaneOption {
341 return func(s *splitPaneLayout) {
342 s.bottomPanel = panel
343 }
344}
345
346func WithVerticalRatio(ratio float64) SplitPaneOption {
347 return func(s *splitPaneLayout) {
348 s.verticalRatio = ratio
349 }
350}
351
352func WithFixedBottomHeight(height int) SplitPaneOption {
353 return func(s *splitPaneLayout) {
354 s.fixedBottomHeight = height
355 }
356}
357
358func WithFixedRightWidth(width int) SplitPaneOption {
359 return func(s *splitPaneLayout) {
360 s.fixedRightWidth = width
361 }
362}