Skip to content

Commit 3cc50b7

Browse files
Blazeclaude
andcommitted
Make TUI layout responsive to terminal size
- Add dynamic table column sizing based on terminal width - Form inputs scale to ~80% of available width - Table and viewport heights use available space (no arbitrary caps) - Text truncation adapts to actual column widths - Define layout constants for consistent spacing calculations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 82ff485 commit 3cc50b7

1 file changed

Lines changed: 155 additions & 35 deletions

File tree

internal/tui/app.go

Lines changed: 155 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,63 @@ const (
143143
fieldCount
144144
)
145145

146+
// Layout constants
147+
const (
148+
minWidth = 60
149+
maxTableWidth = 160
150+
headerHeight = 4 // Logo + spacing
151+
footerHeight = 4 // Help + status
152+
minTableHeight = 5
153+
formHeaderHeight = 4
154+
formFooterHeight = 6
155+
outputHeaderHeight = 5
156+
outputFooterHeight = 3
157+
)
158+
159+
// calculateTableColumns returns column definitions sized for the given width
160+
func calculateTableColumns(width int) []table.Column {
161+
// Account for table borders and padding
162+
availableWidth := width - 4
163+
if availableWidth < minWidth {
164+
availableWidth = minWidth
165+
}
166+
if availableWidth > maxTableWidth {
167+
availableWidth = maxTableWidth
168+
}
169+
170+
// Column proportions (percentages): Name 25%, Schedule 20%, Status 12%, Next 20%, Last 20%
171+
// Status is fixed width since it's short text
172+
statusWidth := 10
173+
remaining := availableWidth - statusWidth - 8 // 8 for column separators
174+
175+
nameWidth := remaining * 25 / 85
176+
scheduleWidth := remaining * 20 / 85
177+
nextWidth := remaining * 20 / 85
178+
lastWidth := remaining * 20 / 85
179+
180+
// Ensure minimum widths
181+
if nameWidth < 12 {
182+
nameWidth = 12
183+
}
184+
if scheduleWidth < 15 {
185+
scheduleWidth = 15
186+
}
187+
if nextWidth < 14 {
188+
nextWidth = 14
189+
}
190+
if lastWidth < 14 {
191+
lastWidth = 14
192+
}
193+
194+
return []table.Column{
195+
{Title: "Name", Width: nameWidth},
196+
{Title: "Schedule", Width: scheduleWidth},
197+
{Title: "Status", Width: statusWidth},
198+
{Title: "Next Run", Width: nextWidth},
199+
{Title: "Last Run", Width: lastWidth},
200+
}
201+
}
202+
146203
// NewModel creates a new TUI model
147204
func NewModel(database *db.DB, sched *scheduler.Scheduler) Model {
148205
// Spinner
@@ -155,19 +212,13 @@ func NewModel(database *db.DB, sched *scheduler.Scheduler) Model {
155212
h.Styles.ShortKey = helpKeyStyle
156213
h.Styles.ShortDesc = helpDescStyle
157214

158-
// Table
159-
columns := []table.Column{
160-
{Title: "Name", Width: 20},
161-
{Title: "Schedule", Width: 20},
162-
{Title: "Status", Width: 12},
163-
{Title: "Next Run", Width: 20},
164-
{Title: "Last Run", Width: 20},
165-
}
215+
// Table - start with reasonable default, will resize on WindowSizeMsg
216+
columns := calculateTableColumns(100)
166217

167218
t := table.New(
168219
table.WithColumns(columns),
169220
table.WithFocused(true),
170-
table.WithHeight(7),
221+
table.WithHeight(10),
171222
)
172223

173224
ts := table.DefaultStyles()
@@ -224,35 +275,83 @@ func NewModel(database *db.DB, sched *scheduler.Scheduler) Model {
224275
func (m *Model) initFormInputs() {
225276
m.formInputs = make([]textinput.Model, fieldCount)
226277

278+
// Calculate responsive width (will be updated on WindowSizeMsg)
279+
inputWidth := m.getFormInputWidth()
280+
227281
m.formInputs[fieldName] = textinput.New()
228282
m.formInputs[fieldName].Placeholder = "Daily code review"
229283
m.formInputs[fieldName].CharLimit = 100
230-
m.formInputs[fieldName].Width = 50
284+
m.formInputs[fieldName].Width = inputWidth
231285

232286
// Prompt uses textarea for multi-line input
233287
m.promptInput = textarea.New()
234288
m.promptInput.Placeholder = "Review recent changes and summarize..."
235289
m.promptInput.CharLimit = 2000
236-
m.promptInput.SetWidth(52)
237-
m.promptInput.SetHeight(6)
290+
m.promptInput.SetWidth(inputWidth + 2)
291+
m.promptInput.SetHeight(m.getTextareaHeight())
238292
m.promptInput.ShowLineNumbers = false
239293

240294
m.formInputs[fieldCron] = textinput.New()
241295
m.formInputs[fieldCron].Placeholder = "0 * * * * * (every minute)"
242296
m.formInputs[fieldCron].CharLimit = 50
243-
m.formInputs[fieldCron].Width = 50
297+
m.formInputs[fieldCron].Width = inputWidth
244298

245299
m.formInputs[fieldWorkingDir] = textinput.New()
246300
m.formInputs[fieldWorkingDir].Placeholder = "/path/to/project"
247301
m.formInputs[fieldWorkingDir].CharLimit = 500
248-
m.formInputs[fieldWorkingDir].Width = 50
302+
m.formInputs[fieldWorkingDir].Width = inputWidth
249303
wd, _ := os.Getwd()
250304
m.formInputs[fieldWorkingDir].SetValue(wd)
251305

252306
m.formInputs[fieldDiscordWebhook] = textinput.New()
253307
m.formInputs[fieldDiscordWebhook].Placeholder = "https://discord.com/api/webhooks/..."
254308
m.formInputs[fieldDiscordWebhook].CharLimit = 500
255-
m.formInputs[fieldDiscordWebhook].Width = 50
309+
m.formInputs[fieldDiscordWebhook].Width = inputWidth
310+
}
311+
312+
// getFormInputWidth calculates responsive input width
313+
func (m *Model) getFormInputWidth() int {
314+
if m.width == 0 {
315+
return 50 // default before first WindowSizeMsg
316+
}
317+
// Use ~80% of available width, with min/max bounds
318+
width := (m.width - 8) * 80 / 100
319+
if width < 40 {
320+
width = 40
321+
}
322+
if width > 100 {
323+
width = 100
324+
}
325+
return width
326+
}
327+
328+
// getTextareaHeight calculates responsive textarea height
329+
func (m *Model) getTextareaHeight() int {
330+
if m.height == 0 {
331+
return 6 // default before first WindowSizeMsg
332+
}
333+
// Calculate available height for form
334+
// Each field takes ~3 lines (label + input + spacing)
335+
otherFieldsHeight := 4 * 3 // 4 other fields
336+
availableForTextarea := m.height - formHeaderHeight - formFooterHeight - otherFieldsHeight - 4
337+
if availableForTextarea < 4 {
338+
availableForTextarea = 4
339+
}
340+
if availableForTextarea > 12 {
341+
availableForTextarea = 12
342+
}
343+
return availableForTextarea
344+
}
345+
346+
// updateFormWidths updates all form input widths for new terminal size
347+
func (m *Model) updateFormWidths(width int) {
348+
inputWidth := m.getFormInputWidth()
349+
350+
for i := range m.formInputs {
351+
m.formInputs[i].Width = inputWidth
352+
}
353+
m.promptInput.SetWidth(inputWidth + 2)
354+
m.promptInput.SetHeight(m.getTextareaHeight())
256355
}
257356

258357
func (m *Model) resetForm() {
@@ -284,6 +383,15 @@ func (m *Model) updateTable() {
284383
return
285384
}
286385

386+
// Get current column widths for truncation
387+
columns := m.table.Columns()
388+
nameWidth := 18
389+
scheduleWidth := 18
390+
if len(columns) >= 2 {
391+
nameWidth = columns[0].Width - 2 // leave room for ellipsis
392+
scheduleWidth = columns[1].Width - 2
393+
}
394+
287395
rows := make([]table.Row, len(m.tasks))
288396
for i, task := range m.tasks {
289397
status := "disabled"
@@ -304,22 +412,13 @@ func (m *Model) updateTable() {
304412
}
305413

306414
rows[i] = table.Row{
307-
truncate(task.Name, 18),
308-
truncate(task.CronExpr, 18),
415+
truncate(task.Name, nameWidth),
416+
truncate(task.CronExpr, scheduleWidth),
309417
status,
310418
nextRun,
311419
lastRun,
312420
}
313421
}
314-
// Set height first, then rows
315-
h := len(m.tasks)
316-
if h < 5 {
317-
h = 5
318-
}
319-
if h > 15 {
320-
h = 15
321-
}
322-
m.table.SetHeight(h)
323422
m.table.SetRows(rows)
324423
}
325424

@@ -438,19 +537,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
438537
case tea.WindowSizeMsg:
439538
m.width = msg.Width
440539
m.height = msg.Height
441-
m.table.SetWidth(msg.Width - 4)
442-
// Set reasonable table height
443-
h := msg.Height - 15
444-
if h < 5 {
445-
h = 5
540+
541+
// Update table columns and dimensions
542+
m.table.SetColumns(calculateTableColumns(msg.Width))
543+
tableWidth := msg.Width - 4
544+
if tableWidth > maxTableWidth {
545+
tableWidth = maxTableWidth
546+
}
547+
m.table.SetWidth(tableWidth)
548+
549+
// Calculate table height based on available space
550+
// Account for header, running indicator (2 lines if shown), status, and help
551+
runningIndicatorHeight := 0
552+
if len(m.runningTasks) > 0 {
553+
runningIndicatorHeight = 2
446554
}
447-
if h > 20 {
448-
h = 20
555+
availableHeight := msg.Height - headerHeight - footerHeight - runningIndicatorHeight - 2 // 2 for app padding
556+
if availableHeight < minTableHeight {
557+
availableHeight = minTableHeight
558+
}
559+
m.table.SetHeight(availableHeight)
560+
561+
// Update viewport for output view
562+
viewportHeight := msg.Height - outputHeaderHeight - outputFooterHeight - 2
563+
if viewportHeight < 5 {
564+
viewportHeight = 5
449565
}
450-
m.table.SetHeight(h)
451566
m.viewport.Width = msg.Width - 6
452-
m.viewport.Height = msg.Height - 12
567+
m.viewport.Height = viewportHeight
568+
453569
m.help.Width = msg.Width
570+
571+
// Update form input widths
572+
m.updateFormWidths(msg.Width)
573+
454574
// Update markdown renderer for new width
455575
if renderer, err := glamour.NewTermRenderer(
456576
glamour.WithAutoStyle(),

0 commit comments

Comments
 (0)