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}