ui.go

  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}