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