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	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}