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}