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