1package model
2
3import (
4 "image"
5 "math/rand"
6
7 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/dialog"
13 "github.com/charmbracelet/lipgloss/v2"
14 uv "github.com/charmbracelet/ultraviolet"
15)
16
17type uiState uint8
18
19const (
20 uiChat uiState = iota
21 uiEdit
22)
23
24type UI struct {
25 app *app.App
26 com *common.Common
27
28 state uiState
29 showFullHelp bool
30
31 keyMap KeyMap
32
33 chat *ChatModel
34 editor *EditorModel
35 dialog *dialog.Overlay
36 help help.Model
37
38 layout layout
39}
40
41func New(com *common.Common, app *app.App) *UI {
42 return &UI{
43 app: app,
44 com: com,
45 dialog: dialog.NewOverlay(),
46 keyMap: DefaultKeyMap(),
47 editor: NewEditorModel(com, app),
48 help: help.New(),
49 }
50}
51
52func (m *UI) Init() tea.Cmd {
53 return nil
54}
55
56func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 var cmds []tea.Cmd
58 switch msg := msg.(type) {
59 case tea.WindowSizeMsg:
60 m.updateLayout(msg.Width, msg.Height)
61 m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
62 m.help.Width = m.layout.help.Dx()
63 case tea.KeyPressMsg:
64 if m.dialog.HasDialogs() {
65 m.updateDialogs(msg, &cmds)
66 } else {
67 switch {
68 case key.Matches(msg, m.keyMap.Tab):
69 if m.state == uiChat {
70 m.state = uiEdit
71 cmds = append(cmds, m.editor.Focus())
72 } else {
73 m.state = uiChat
74 cmds = append(cmds, m.editor.Blur())
75 }
76 case key.Matches(msg, m.keyMap.Help):
77 m.showFullHelp = !m.showFullHelp
78 m.help.ShowAll = m.showFullHelp
79 m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy())
80 case key.Matches(msg, m.keyMap.Quit):
81 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
82 m.dialog.AddDialog(dialog.NewQuit(m.com))
83 return m, nil
84 }
85 default:
86 m.updateFocused(msg, &cmds)
87 }
88 }
89 }
90
91 return m, tea.Batch(cmds...)
92}
93
94func (m *UI) View() tea.View {
95 var v tea.View
96 v.AltScreen = true
97
98 layers := []*lipgloss.Layer{}
99
100 // Determine the help key map based on focus
101 helpKeyMap := m.focusedKeyMap()
102
103 // The screen areas we're working with
104 area := m.layout.area
105 chatRect := m.layout.chat
106 sideRect := m.layout.sidebar
107 editRect := m.layout.editor
108 helpRect := m.layout.help
109
110 if m.dialog.HasDialogs() {
111 if dialogView := m.dialog.View(); dialogView != "" {
112 // If the dialog has its own help, use that instead
113 if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 {
114 helpKeyMap = m.dialog
115 }
116
117 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
118 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
119 layers = append(layers,
120 lipgloss.NewLayer(dialogView).
121 X(dialogArea.Min.X).
122 Y(dialogArea.Min.Y).
123 Z(99),
124 )
125 }
126 }
127
128 if m.state == uiEdit && m.editor.Focused() {
129 cur := m.editor.Cursor()
130 cur.X++ // Adjust for app margins
131 cur.Y += editRect.Min.Y
132 v.Cursor = cur
133 }
134
135 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
136 Width(area.Dx()).Height(area.Dy()).
137 AddLayers(
138 lipgloss.NewLayer(
139 lipgloss.NewStyle().Width(chatRect.Dx()).
140 Height(chatRect.Dy()).
141 Background(lipgloss.ANSIColor(rand.Intn(256))).
142 Render(" Main View "),
143 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
144 lipgloss.NewLayer(
145 lipgloss.NewStyle().Width(sideRect.Dx()).
146 Height(sideRect.Dy()).
147 Background(lipgloss.ANSIColor(rand.Intn(256))).
148 Render(" Side View "),
149 ).X(sideRect.Min.X).Y(sideRect.Min.Y),
150 lipgloss.NewLayer(m.editor.View()).
151 X(editRect.Min.X).Y(editRect.Min.Y),
152 lipgloss.NewLayer(m.help.View(helpKeyMap)).
153 X(helpRect.Min.X).Y(helpRect.Min.Y),
154 )
155
156 layers = append(layers, mainLayer)
157
158 v.Layer = lipgloss.NewCanvas(layers...)
159
160 return v
161}
162
163func (m *UI) focusedKeyMap() help.KeyMap {
164 if m.state == uiChat {
165 return m.chat
166 }
167 return m.editor
168}
169
170// updateDialogs updates the dialog overlay with the given message and appends
171// any resulting commands to the cmds slice.
172func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
173 updatedDialog, cmd := m.dialog.Update(msg)
174 m.dialog = updatedDialog
175 if cmd != nil {
176 *cmds = append(*cmds, cmd)
177 }
178}
179
180// updateFocused updates the focused model (chat or editor) with the given message
181// and appends any resulting commands to the cmds slice.
182func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
183 switch m.state {
184 case uiChat:
185 m.updateChat(msg, cmds)
186 case uiEdit:
187 m.updateEditor(msg, cmds)
188 }
189}
190
191// updateChat updates the chat model with the given message and appends any
192// resulting commands to the cmds slice.
193func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
194 updatedChat, cmd := m.chat.Update(msg)
195 m.chat = updatedChat
196 if cmd != nil {
197 *cmds = append(*cmds, cmd)
198 }
199}
200
201// updateEditor updates the editor model with the given message and appends any
202// resulting commands to the cmds slice.
203func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
204 updatedEditor, cmd := m.editor.Update(msg)
205 m.editor = updatedEditor
206 if cmd != nil {
207 *cmds = append(*cmds, cmd)
208 }
209}
210
211// updateLayout updates the layout based on the given terminal width and
212// height given in cells.
213func (m *UI) updateLayout(w, h int) {
214 // The screen area we're working with
215 area := image.Rect(0, 0, w, h)
216 helpKeyMap := m.focusedKeyMap()
217 helpHeight := 1
218 if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
219 helpKeyMap = m.dialog
220 }
221 if m.showFullHelp {
222 for _, row := range helpKeyMap.FullHelp() {
223 helpHeight = max(helpHeight, len(row))
224 }
225 }
226
227 // Add app margins
228 mainRect := area
229 mainRect.Min.X += 1
230 mainRect.Min.Y += 1
231 mainRect.Max.X -= 1
232 mainRect.Max.Y -= 1
233
234 mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
235 chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
236 chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
237
238 // Add 1 line margin bottom of chatRect
239 chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
240 // Add 1 line margin bottom of editRect
241 editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
242
243 m.layout = layout{
244 area: area,
245 main: mainRect,
246 chat: chatRect,
247 editor: editRect,
248 sidebar: sideRect,
249 help: helpRect,
250 }
251}
252
253// layout defines the positioning of UI elements.
254type layout struct {
255 // area is the overall available area.
256 area uv.Rectangle
257
258 // main is the main area excluding help.
259 main uv.Rectangle
260
261 // chat is the area for the chat pane.
262 chat uv.Rectangle
263
264 // editor is the area for the editor pane.
265 editor uv.Rectangle
266
267 // sidebar is the area for the sidebar.
268 sidebar uv.Rectangle
269
270 // help is the area for the help view.
271 help uv.Rectangle
272}