Skip to content

Commit c546cbb

Browse files
committed
feat(tui): major visual and information polish of the dashboard
- Consistent centered boxes with better spacing and visual hierarchy - Status glyphs (●/◐/○/✓/✗) for instant scannability - Cleaner typography and graceful handling of missing data - Fixed terminal width overflow and header centering issues - Added small `renderBox` helper for maintainability Single-view polish pass only. No behavior or data changes.
1 parent efecaef commit c546cbb

2 files changed

Lines changed: 138 additions & 32 deletions

File tree

internal/tui/dashboard.go

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,34 @@ var (
3939

4040
keyStyle = lipgloss.NewStyle().
4141
Foreground(lipgloss.Color("#7D56F4")).Bold(true)
42+
43+
// fieldStyle for subtle labels inside boxes (calm, readable).
44+
fieldStyle = lipgloss.NewStyle().
45+
Foreground(lipgloss.Color("#AAAAAA"))
46+
47+
// mutedStyle for secondary / placeholder text.
48+
mutedStyle = lipgloss.NewStyle().
49+
Foreground(lipgloss.Color("#666666"))
4250
)
4351

52+
// renderBox renders the given inner content inside a copy of the provided
53+
// box style (which may have Width set for consistent sizing). When termWidth
54+
// > 0 the resulting block is horizontally centered using PlaceHorizontal.
55+
// Three lightweight strategies exist in View:
56+
// - header: capped Width + Align, then optional Place for full-term centering
57+
// - boxes: adaptive safe boxW (accounting for border+pad) + Place via this helper
58+
// - footer: raw Place
59+
//
60+
// This keeps the helper tiny while avoiding overflow on narrow terminals and
61+
// m.width==0 (pre-WindowSizeMsg) initial renders.
62+
func renderBox(inner string, termWidth int, st lipgloss.Style) string {
63+
s := st.Render(inner)
64+
if termWidth > 0 {
65+
s = lipgloss.PlaceHorizontal(termWidth, lipgloss.Center, s)
66+
}
67+
return s
68+
}
69+
4470
// tickMsg is sent every second to refresh the dashboard.
4571
type tickMsg time.Time
4672

@@ -123,73 +149,141 @@ func (m Model) View() string {
123149

124150
var b strings.Builder
125151

126-
// Title
127-
b.WriteString(titleStyle.Render(" pastelocal dashboard "))
152+
// Full-width colored header banner (professional anchor, uses captured width).
153+
// Capped at 100 for very wide terminals; always PlaceHorizontal-centered when
154+
// m.width > 0 so it matches the geometry of the content cards and footer.
155+
title := "pastelocal dashboard"
156+
titleSt := titleStyle.Copy()
157+
termW := m.width
158+
if termW > 0 {
159+
if termW > 100 {
160+
termW = 100
161+
}
162+
titleSt = titleSt.Width(termW).Align(lipgloss.Center)
163+
}
164+
titleRendered := titleSt.Render(title)
165+
if m.width > 0 {
166+
titleRendered = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, titleRendered)
167+
}
168+
b.WriteString(titleRendered)
128169
b.WriteString("\n\n")
129170

130-
// Daemon status
131-
statusStr := "stopped"
132-
statusStyle := statusErrStyle
171+
// Consistent box width + centering for visual weight and breathing room.
172+
// targetContentWidth (72) chosen for comfortable reading on typical terminals
173+
// while degrading gracefully. The calculation ensures final rendered outer
174+
// width (content + 4 pad + 2 border) never exceeds m.width, preventing
175+
// overflow/clipping on narrow terminals or the initial m.width==0 render.
176+
boxW := 60 // safe default when m.width==0 (before first WindowSizeMsg)
177+
if m.width > 0 {
178+
// lipgloss bordered+padded box outer width ≈ boxW + 6
179+
maxOuter := m.width
180+
desired := 72
181+
boxW = desired
182+
if boxW > maxOuter-6 {
183+
boxW = maxOuter - 6
184+
}
185+
if boxW < 20 {
186+
boxW = 20
187+
}
188+
}
189+
boxSt := boxStyle.Copy().Width(boxW)
190+
191+
// Daemon status box (status icon + color for instant scannability).
192+
var statusStyled string
133193
if m.running {
134194
if m.healthy {
135-
statusStr = "running"
136-
statusStyle = statusOkStyle
195+
statusStyled = statusOkStyle.Render("● running")
137196
} else {
138-
statusStr = "degraded"
139-
statusStyle = statusWarnStyle
197+
statusStyled = statusWarnStyle.Render("◐ degraded")
140198
}
199+
} else {
200+
statusStyled = statusErrStyle.Render("○ stopped")
141201
}
142202

143-
daemonBox := fmt.Sprintf(" Daemon: %s\n Port: %d (loopback)\n PID: %d\n Uptime: %s",
144-
statusStyle.Render(statusStr), m.port, m.pid, m.uptime)
145-
b.WriteString(boxStyle.Render(daemonBox))
203+
daemonLines := []string{
204+
fmt.Sprintf("%s %s", fieldStyle.Render("Status:"), statusStyled),
205+
fmt.Sprintf("%s %d (loopback)", fieldStyle.Render("Port:"), m.port),
206+
}
207+
if m.pid > 0 {
208+
daemonLines = append(daemonLines, fmt.Sprintf("%s %d", fieldStyle.Render("PID:"), m.pid))
209+
}
210+
if m.uptime != "" {
211+
daemonLines = append(daemonLines, fmt.Sprintf("%s %s", fieldStyle.Render("Uptime:"), m.uptime))
212+
}
213+
daemonInner := strings.Join(daemonLines, "\n")
214+
b.WriteString(renderBox(daemonInner, m.width, boxSt))
146215
b.WriteString("\n")
147216

148-
// Last read
217+
// Last Read box (graceful placeholders for currently unpopulated fields).
149218
lastReadStr := "(never)"
150219
if m.lastRead != "" {
151220
lastReadStr = m.lastRead
152221
}
153-
lastReadBox := fmt.Sprintf(" Last Read: %s\n Format: %s",
154-
lastReadStr, m.lastFmt)
155-
b.WriteString(boxStyle.Render(lastReadBox))
222+
lastReadVal := lastReadStr
223+
if lastReadStr == "(never)" {
224+
lastReadVal = mutedStyle.Render("(never)")
225+
}
226+
fmtVal := m.lastFmt
227+
if fmtVal == "" {
228+
fmtVal = mutedStyle.Render("—")
229+
}
230+
lastReadInner := fmt.Sprintf("%s %s\n%s %s",
231+
fieldStyle.Render("Last Read:"), lastReadVal,
232+
fieldStyle.Render("Format:"), fmtVal)
233+
b.WriteString(renderBox(lastReadInner, m.width, boxSt))
156234
b.WriteString("\n")
157235

158-
// Clipboard Watch status (critical for visibility success criterion)
159-
watchStr := "disabled (opt-in via [watch] enabled = true in config)"
236+
// Clipboard Watch box (two-line when change timestamp present for density;
237+
// icons + colors make enabled/disabled state pop at a glance).
238+
var watchInner string
239+
watchLabel := fieldStyle.Render("Clipboard Watch:")
160240
if m.watchEnabled {
161-
watchStr = "enabled (detecting OS changes)"
162241
if m.lastClipboardChange != "" {
163-
watchStr = "enabled (last change: " + m.lastClipboardChange + ")"
242+
watchInner = fmt.Sprintf("%s %s\n%s %s",
243+
watchLabel, statusOkStyle.Render("enabled"), fieldStyle.Render("Last change:"), m.lastClipboardChange)
244+
} else {
245+
watchInner = fmt.Sprintf("%s %s (detecting OS clipboard changes)",
246+
watchLabel, statusOkStyle.Render("enabled"))
164247
}
248+
} else {
249+
hint := mutedStyle.Render("Hint: (set [watch] enabled = true in config)")
250+
watchInner = fmt.Sprintf("%s %s\n%s",
251+
watchLabel, statusWarnStyle.Render("disabled"), hint)
165252
}
166-
b.WriteString(boxStyle.Render(" Clipboard Watch: " + watchStr))
253+
b.WriteString(renderBox(watchInner, m.width, boxSt))
167254
b.WriteString("\n")
168255

169-
// Hosts
256+
// Hosts box (symbols for quick ok/unreachable scan; termius noted subtly).
170257
if len(m.hosts) > 0 {
171258
var hostLines []string
172-
hostLines = append(hostLines, " Hosts:")
259+
hostLines = append(hostLines, fieldStyle.Render("Hosts:"))
173260
for _, h := range m.hosts {
174-
status := statusOkStyle.Render("ok")
261+
statusDisp := statusOkStyle.Render("ok")
175262
if h.Status != "ok" {
176-
status = statusErrStyle.Render(h.Status)
263+
statusDisp = statusErrStyle.Render("✗ " + h.Status)
177264
}
178265
suffix := ""
179266
if h.Termius {
180-
suffix = " (termius)"
267+
suffix = mutedStyle.Render(" (termius)")
181268
}
182-
hostLines = append(hostLines, fmt.Sprintf(" %s %s%s", h.Alias, status, suffix))
269+
hostLines = append(hostLines, fmt.Sprintf(" %s %s%s", h.Alias, statusDisp, suffix))
183270
}
184-
b.WriteString(boxStyle.Render(strings.Join(hostLines, "\n")))
271+
b.WriteString(renderBox(strings.Join(hostLines, "\n"), m.width, boxSt))
185272
b.WriteString("\n")
186273
} else {
187-
b.WriteString(boxStyle.Render(" Hosts: (none configured)"))
274+
hostsInner := fmt.Sprintf("%s %s",
275+
fieldStyle.Render("Hosts:"), mutedStyle.Render("(none configured)"))
276+
b.WriteString(renderBox(hostsInner, m.width, boxSt))
188277
b.WriteString("\n")
189278
}
190279

191-
// Keyboard shortcuts
192-
b.WriteString(dimStyle.Render(" [q] quit "))
280+
// Centered footer with highlighted key (calm, scannable).
281+
footer := dimStyle.Render("[") + keyStyle.Render("q") + dimStyle.Render("] quit")
282+
if m.width > 0 {
283+
footer = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, footer)
284+
}
285+
b.WriteString("\n")
286+
b.WriteString(footer)
193287
b.WriteString("\n")
194288

195289
return b.String()
@@ -206,6 +300,17 @@ func (m *Model) refresh() {
206300
m.healthy = false
207301
m.watchEnabled = false
208302
m.lastClipboardChange = ""
303+
// Rebuild hosts from config so the polished Hosts box shows trustworthy
304+
// "✗ unreachable" instead of stale prior "ok" entries (pre-existing gap
305+
// now visible due to always-rendered substantial cards).
306+
m.hosts = m.hosts[:0]
307+
for alias, h := range m.cfg.Hosts {
308+
m.hosts = append(m.hosts, hostStatus{
309+
Alias: alias,
310+
Status: "unreachable",
311+
Termius: h.Termius,
312+
})
313+
}
209314
return
210315
}
211316
defer resp.Body.Close()
@@ -245,5 +350,6 @@ func (m *Model) refresh() {
245350
}
246351

247352
// Try to read the last clipboard state from the daemon.
248-
// We could add a /stats endpoint for this, but for now we leave it as-is.
353+
// LastRead data is intentionally left unpopulated (no /stats endpoint yet);
354+
// View renders graceful placeholders per polish requirements and non-goals.
249355
}

pastelocal

10.8 MB
Binary file not shown.

0 commit comments

Comments
 (0)