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