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