termdialog.go

  1// Package termdialog provides a reusable dialog component for embedding
  2// terminal applications in the TUI.
  3package termdialog
  4
  5import (
  6	tea "charm.land/bubbletea/v2"
  7	"charm.land/lipgloss/v2"
  8
  9	"github.com/charmbracelet/crush/internal/terminal"
 10	"github.com/charmbracelet/crush/internal/tui/components/core"
 11	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 12	"github.com/charmbracelet/crush/internal/tui/styles"
 13	"github.com/charmbracelet/crush/internal/tui/util"
 14)
 15
 16const (
 17	// headerHeight is the height of the dialog header (title + padding).
 18	headerHeight = 2
 19	// fullscreenWidthBreakpoint is the width below which the dialog goes
 20	// fullscreen. Matches CompactModeWidthBreakpoint in chat.go.
 21	fullscreenWidthBreakpoint = 120
 22)
 23
 24// Config holds configuration for a terminal dialog.
 25type Config struct {
 26	// ID is the unique identifier for this dialog.
 27	ID dialogs.DialogID
 28	// Title is displayed in the dialog header.
 29	Title string
 30	// LoadingMsg is shown while the terminal is starting.
 31	LoadingMsg string
 32	// Term is the terminal to embed.
 33	Term *terminal.Terminal
 34	// OnClose is called when the dialog is closed (optional).
 35	OnClose func()
 36}
 37
 38// Dialog is a dialog that embeds a terminal application.
 39type Dialog struct {
 40	id         dialogs.DialogID
 41	title      string
 42	loadingMsg string
 43	term       *terminal.Terminal
 44	onClose    func()
 45
 46	wWidth     int
 47	wHeight    int
 48	width      int
 49	height     int
 50	fullscreen bool
 51}
 52
 53// New creates a new terminal dialog with the given configuration.
 54func New(cfg Config) *Dialog {
 55	loadingMsg := cfg.LoadingMsg
 56	if loadingMsg == "" {
 57		loadingMsg = "Starting..."
 58	}
 59
 60	return &Dialog{
 61		id:         cfg.ID,
 62		title:      cfg.Title,
 63		loadingMsg: loadingMsg,
 64		term:       cfg.Term,
 65		onClose:    cfg.OnClose,
 66	}
 67}
 68
 69func (d *Dialog) Init() tea.Cmd {
 70	return nil
 71}
 72
 73func (d *Dialog) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 74	switch msg := msg.(type) {
 75	case tea.WindowSizeMsg:
 76		return d.handleResize(msg)
 77
 78	case terminal.ExitMsg:
 79		return d, util.CmdHandler(dialogs.CloseDialogMsg{})
 80
 81	case terminal.OutputMsg:
 82		if d.term.Closed() {
 83			return d, nil
 84		}
 85		return d, d.term.RefreshCmd()
 86
 87	case tea.KeyPressMsg:
 88		return d.handleKey(msg)
 89
 90	case tea.PasteMsg:
 91		d.term.SendPaste(msg.Content)
 92		return d, nil
 93
 94	case tea.MouseMsg:
 95		return d.handleMouse(msg)
 96	}
 97
 98	return d, nil
 99}
100
101func (d *Dialog) handleResize(msg tea.WindowSizeMsg) (util.Model, tea.Cmd) {
102	d.wWidth = msg.Width
103	d.wHeight = msg.Height
104
105	// Go fullscreen when window is below compact mode breakpoint.
106	d.fullscreen = msg.Width < fullscreenWidthBreakpoint
107
108	var outerWidth, outerHeight int
109	if d.fullscreen {
110		outerWidth = msg.Width
111		outerHeight = msg.Height
112	} else {
113		// Dialog takes up 85% of the screen to show it's embedded.
114		outerWidth = int(float64(msg.Width) * 0.85)
115		outerHeight = int(float64(msg.Height) * 0.85)
116
117		// Cap at reasonable maximums.
118		if outerWidth > msg.Width-6 {
119			outerWidth = msg.Width - 6
120		}
121		if outerHeight > msg.Height-4 {
122			outerHeight = msg.Height - 4
123		}
124	}
125
126	// Inner dimensions = outer - border (1 char each side = 2 total).
127	d.width = max(outerWidth-2, 40)
128	d.height = max(outerHeight-2, 10)
129
130	// Terminal height excludes the header.
131	termHeight := max(d.height-headerHeight, 5)
132
133	// Start the terminal if not started.
134	if !d.term.Started() && d.width > 0 && termHeight > 0 {
135		if err := d.term.Resize(d.width, termHeight); err != nil {
136			return d, util.ReportError(err)
137		}
138		if err := d.term.Start(); err != nil {
139			return d, util.ReportError(err)
140		}
141		return d, tea.Batch(d.term.WaitCmd(), d.term.RefreshCmd())
142	}
143
144	// Resize existing terminal.
145	if err := d.term.Resize(d.width, termHeight); err != nil {
146		return d, util.ReportError(err)
147	}
148	return d, nil
149}
150
151func (d *Dialog) handleKey(msg tea.KeyPressMsg) (util.Model, tea.Cmd) {
152	if msg.Text != "" {
153		d.term.SendText(msg.Text)
154	} else {
155		d.term.SendKey(msg)
156	}
157	return d, nil
158}
159
160func (d *Dialog) handleMouse(msg tea.MouseMsg) (util.Model, tea.Cmd) {
161	row, col := d.Position()
162
163	// Adjust coordinates for dialog position.
164	adjust := func(x, y int) (int, int) {
165		return x - col - 1, y - row - 1 - headerHeight
166	}
167
168	switch ev := msg.(type) {
169	case tea.MouseClickMsg:
170		ev.X, ev.Y = adjust(ev.X, ev.Y)
171		d.term.SendMouse(ev)
172	case tea.MouseReleaseMsg:
173		ev.X, ev.Y = adjust(ev.X, ev.Y)
174		d.term.SendMouse(ev)
175	case tea.MouseWheelMsg:
176		ev.X, ev.Y = adjust(ev.X, ev.Y)
177		d.term.SendMouse(ev)
178	case tea.MouseMotionMsg:
179		ev.X, ev.Y = adjust(ev.X, ev.Y)
180		d.term.SendMouse(ev)
181	}
182	return d, nil
183}
184
185func (d *Dialog) View() string {
186	t := styles.CurrentTheme()
187
188	var termContent string
189	if d.term.Started() {
190		termContent = d.term.Render()
191	} else {
192		termContent = d.loadingMsg
193	}
194
195	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
196	content := lipgloss.JoinVertical(lipgloss.Left, header, termContent)
197
198	dialogStyle := t.S().Base.
199		Border(lipgloss.RoundedBorder()).
200		BorderForeground(t.BorderFocus)
201
202	return dialogStyle.Render(content)
203}
204
205func (d *Dialog) Position() (int, int) {
206	if d.fullscreen {
207		return 0, 0
208	}
209
210	dialogWidth := d.width + 2
211	dialogHeight := d.height + 2
212
213	row := max((d.wHeight-dialogHeight)/2, 0)
214	col := max((d.wWidth-dialogWidth)/2, 0)
215
216	return row, col
217}
218
219func (d *Dialog) ID() dialogs.DialogID {
220	return d.id
221}
222
223// Cursor returns the cursor position adjusted for the dialog's screen position.
224// Returns nil if the terminal cursor is hidden or not available.
225func (d *Dialog) Cursor() *tea.Cursor {
226	x, y := d.term.CursorPosition()
227	if x < 0 || y < 0 {
228		return nil
229	}
230
231	t := styles.CurrentTheme()
232	row, col := d.Position()
233	cursor := tea.NewCursor(x, y)
234	cursor.X += col + 1
235	cursor.Y += row + 1 + headerHeight
236	cursor.Color = t.Secondary
237	cursor.Shape = tea.CursorBlock
238	cursor.Blink = true
239	return cursor
240}
241
242func (d *Dialog) Close() tea.Cmd {
243	_ = d.term.Close()
244
245	if d.onClose != nil {
246		d.onClose()
247	}
248
249	return nil
250}