feat: add HandleClick, SpinnerChar and UI interaction methods for claude-pm overlay integration

This commit is contained in:
Niko Syring
2026-03-14 04:39:50 +01:00
parent aac139368f
commit efa294b3e4
+408 -131
View File
@@ -839,8 +839,19 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
}
x, y := msg.X, msg.Y
// Header row (y == 0) → sort by column
containerH := a.containerAreaHeight()
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW
// Container section (y=0..containerH-1)
if y < containerH {
if x < tableW {
// Click in container table area
a.ActivePanel = PanelContainers
if y == 0 {
// Top border = sort by column header
col := a.headerColumnAt(x)
if col >= 0 {
a.toggleSort(SortColumn(col))
@@ -848,16 +859,8 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
return a, nil
}
// Container area
containerH := a.containerAreaHeight()
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW - 4
if y >= 1 && y <= containerH {
if x < tableW+2 {
// Click in container table → select container
row := y - 3 + a.ScrollOffset // 3 = header + border + title
// Content rows: y=1..containerH-2
row := y - 1 + a.ScrollOffset
if row >= 0 && row < len(a.filtered()) {
a.SelectedIdx = row
a.CmdSelectedIdx = 0
@@ -868,12 +871,36 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
} else {
// Click in commands panel
a.ActivePanel = PanelCommands
// Map click Y to command index.
// Commands box: y=0 is top border, y=1..innerH is content.
// Content is vertically centered with blank lines between commands.
c := a.selected()
if c != nil {
cmds := commandsForState(c.State)
innerH := containerH - 2
// visual lines = 2*len(cmds)-1 (cmd + blank between each)
visualCount := 2*len(cmds) - 1
if visualCount < 1 { visualCount = 1 }
topPad := (innerH - visualCount) / 2
if topPad < 0 { topPad = 0 }
contentY := y - 1 // subtract top border
lineIdx := contentY - topPad
// Even indices (0,2,4,6) are commands, odd are blank lines
if lineIdx >= 0 && lineIdx%2 == 0 {
cmdIdx := lineIdx / 2
if cmdIdx >= 0 && cmdIdx < len(cmds) {
a.CmdSelectedIdx = cmdIdx
}
}
}
}
return a, nil
}
// Logs area
logsStart := 1 + containerH
logsStart := containerH
logsEnd := logsStart + a.logsAreaHeight()
if y >= logsStart && y < logsEnd {
a.ActivePanel = PanelLogs
@@ -899,6 +926,75 @@ func (a *App) headerColumnAt(x int) int {
return -1
}
// HandleClick processes a mouse click at the given app-relative coordinates.
// Used by the embedding overlay to forward translated mouse events.
func (a *App) HandleClick(x, y int) tea.Cmd {
if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
return nil
}
if a.InspectMode {
return nil
}
containerH := a.containerAreaHeight()
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW
// Container section (y=0..containerH-1)
if y < containerH {
if x < tableW {
a.ActivePanel = PanelContainers
if y == 0 {
col := a.headerColumnAt(x)
if col >= 0 {
a.toggleSort(SortColumn(col))
}
return nil
}
row := y - 1 + a.ScrollOffset
if row >= 0 && row < len(a.filtered()) {
a.SelectedIdx = row
a.CmdSelectedIdx = 0
a.syncSelectedID()
a.ensureVisible()
return a.loadLogsForSelected()
}
} else {
a.ActivePanel = PanelCommands
sel := a.selected()
if sel == nil {
return nil
}
cmds := commandsForState(sel.State)
innerH := containerH - 2
visualCount := 2*len(cmds) - 1
if visualCount < 1 { visualCount = 1 }
topPad := (innerH - visualCount) / 2
if topPad < 0 { topPad = 0 }
contentY := y - 1
lineIdx := contentY - topPad
if lineIdx >= 0 && lineIdx%2 == 0 {
cmdIdx := lineIdx / 2
if cmdIdx >= 0 && cmdIdx < len(cmds) {
a.CmdSelectedIdx = cmdIdx
}
}
}
return nil
}
// Logs area
logsStart := containerH
logsEnd := logsStart + a.logsAreaHeight()
if y >= logsStart && y < logsEnd {
a.ActivePanel = PanelLogs
return nil
}
return nil
}
// ============================================================
// NAVIGATION & SCROLL
// ============================================================
@@ -2045,14 +2141,13 @@ func (a *App) viewNormal() string {
chartsH := a.chartsAreaHeight()
// Clamp
total := 1 + containerH + logsH + chartsH
total := containerH + logsH + chartsH
if total > a.Height {
logsH -= total - a.Height
if logsH < 3 { logsH = 3 }
}
var sections []string
sections = append(sections, a.viewHeader())
sections = append(sections, a.viewContainerSection(containerH))
if a.ShowLogs {
sections = append(sections, a.viewLogs(logsH))
@@ -2061,12 +2156,22 @@ func (a *App) viewNormal() string {
result := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Clamp to terminal height to prevent overflow causing layout jumping
// Clamp to terminal height to prevent overflow causing layout jumping.
// Also pad every line to full width so the ANSI parser in the overlay
// always produces cells with an explicit BG, preventing the underlying
// pane from bleeding through on the right side.
bgPad := lipgloss.NewStyle().Background(cBase)
lines := strings.Split(result, "\n")
if len(lines) > a.Height {
lines = lines[:a.Height]
result = strings.Join(lines, "\n")
}
for i, line := range lines {
w := lipgloss.Width(line)
if w < a.Width {
lines[i] = line + bgPad.Render(strings.Repeat(" ", a.Width-w))
}
}
result = strings.Join(lines, "\n")
// Overlays
if a.ShowHelp {
@@ -2090,49 +2195,20 @@ func (a *App) centeredMsg(msg string) string {
lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cBlue).Padding(1, 3).Render(msg))
}
// --- Header ---
// SpinnerChar returns the current braille spinner frame character.
func (a *App) SpinnerChar() string {
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
return frames[a.LoadingIdx%len(frames)]
}
func (a *App) viewHeader() string {
bg := cMantle
fg := cText
hdr := lipgloss.NewStyle().Background(bg).Foreground(fg)
cw := a.colWidths()
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
spinner := lipgloss.NewStyle().Background(bg).Foreground(cMauve).Render(spinnerFrames[a.LoadingIdx])
sortIcon := func(col SortColumn) string {
func (a *App) sortIcon(col SortColumn) string {
if a.SortCol == col && a.SortOrd != SortNone {
if a.SortOrd == SortAsc { return " ▲" }
return ""
if a.SortOrd == SortAsc {
return ""
}
return "▼"
}
return ""
}
var b strings.Builder
b.WriteString(hdr.Width(1).Render(spinner))
b.WriteString(hdr.Width(2).Render(" "))
b.WriteString(hdr.Width(cw.name).Render("name" + sortIcon(SortName)))
b.WriteString(hdr.Width(cw.state).Render("state" + sortIcon(SortState)))
b.WriteString(hdr.Width(cw.status).Render("status" + sortIcon(SortStatus)))
b.WriteString(hdr.Width(cw.cpu).Render("cpu" + sortIcon(SortCPU)))
b.WriteString(hdr.Width(cw.mem).Render("memory/limit" + sortIcon(SortMemory)))
b.WriteString(hdr.Width(cw.id).Render("id" + sortIcon(SortID)))
b.WriteString(hdr.Width(cw.image).Render("image" + sortIcon(SortImage)))
b.WriteString(hdr.Width(cw.rx).Align(lipgloss.Right).Render("rx" + sortIcon(SortRX)))
b.WriteString(hdr.Width(cw.tx).Align(lipgloss.Right).Render("tx" + sortIcon(SortTX)))
content := b.String()
contentW := lipgloss.Width(content)
remaining := a.Width - contentW
if remaining > 0 {
helpText := "( h ) show help"
if a.ShowHelp { helpText = "( h ) exit help" }
hint := lipgloss.NewStyle().Background(bg).Foreground(cPeach).Width(remaining).Align(lipgloss.Right).Render(helpText)
content += hint
}
return content
}
type colW struct{ name, state, status, cpu, mem, id, image, rx, tx int }
@@ -2140,46 +2216,43 @@ type colW struct{ name, state, status, cpu, mem, id, image, rx, tx int }
func (a *App) colWidths() colW {
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
inner := a.Width - cmdW - 6 - 3 // -6 for borders, -3 for row selector prefix
inner := a.Width - cmdW - 2 - 1 - 3 // -2 for table borders, -1 for right padding, -3 for row selector prefix
if inner < 60 { inner = 60 }
// Fixed-width columns (content has predictable size)
const (
cpuW = 8 // "99.99%"
stateW = 3 // icon only (✓/✗/‖)
idW = 10 // 8 chars + pad
rxW = 9 // "999.9 MB"
txW = 9
)
// Compute max widths from data for variable columns (use rune count for unicode)
nameMax, stateMax, statusMax, memMax, imageMax := 6, 7, 8, 8, 7 // header minimums
nameMax, statusMax, memMax, imageMax := 6, 8, 8, 7 // header minimums
for _, c := range a.filtered() {
if n := len([]rune(c.Name)); n > nameMax { nameMax = n }
stateLen := len([]rune(c.StateStr)) + 2 // icon + space
if stateLen > stateMax { stateMax = stateLen }
if n := len([]rune(c.Status)); n > statusMax { statusMax = n }
memStr := fmtBytes(c.MemUsage) + " / " + fmtBytes(c.MemLimit)
if n := len([]rune(memStr)); n > memMax { memMax = n }
if n := len([]rune(c.Image)); n > imageMax { imageMax = n }
}
fixed := cpuW + idW + rxW + txW
fixed := cpuW + stateW + idW + rxW + txW
avail := inner - fixed
if avail < 30 { avail = 30 }
// Proportional distribution of variable columns
total := nameMax + stateMax + statusMax + memMax + imageMax
total := nameMax + statusMax + memMax + imageMax
if total == 0 { total = 1 }
nameW := avail * nameMax / total
stateW := avail * stateMax / total
statusW := avail * statusMax / total
memW := avail * memMax / total
imageW := avail - nameW - stateW - statusW - memW // give remainder to image
imageW := avail - nameW - statusW - memW // give remainder to image
// Apply minimums
if nameW < 6 { nameW = 6 }
if stateW < 7 { stateW = 7 }
if statusW < 8 { statusW = 8 }
if memW < 8 { memW = 8 }
if imageW < 7 { imageW = 7 }
@@ -2196,7 +2269,7 @@ func (a *App) colWidths() colW {
func (a *App) viewContainerSection(h int) string {
cmdW := a.Width * 10 / 100
if cmdW < 12 { cmdW = 12 }
tableW := a.Width - cmdW - 4
tableW := a.Width - cmdW
table := a.viewContainerTable(tableW, h)
cmds := a.viewCommands(cmdW, h)
return lipgloss.JoinHorizontal(lipgloss.Top, table, cmds)
@@ -2205,35 +2278,88 @@ func (a *App) viewContainerSection(h int) string {
func (a *App) viewContainerTable(w, h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelContainers { bc = a.borderSelected() }
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
cw := a.colWidths()
cs := a.filtered()
total := len(cs)
sel := a.SelectedIdx + 1
if total == 0 { sel = 0 }
innerW := w - 2
contentW := innerW - 1 // 1 char right padding inside border
title := fmt.Sprintf("Containers %d/%d", sel, total)
// Build top border with embedded column headers.
type hCol struct {
label string
width int
}
cols := []hCol{
{"", 3}, // selection prefix
{"name" + a.sortIcon(SortName), cw.name},
{"", cw.state}, // state: icon only, no header
{"status" + a.sortIcon(SortStatus), cw.status},
{"cpu" + a.sortIcon(SortCPU), cw.cpu},
{"mem" + a.sortIcon(SortMemory), cw.mem},
{"id" + a.sortIcon(SortID), cw.id},
{"image" + a.sortIcon(SortImage), cw.image},
{"rx" + a.sortIcon(SortRX), cw.rx},
{"tx" + a.sortIcon(SortTX), cw.tx},
}
var sb strings.Builder
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
var top strings.Builder
top.WriteString(bStyle.Render("╭"))
used := 0
for _, c := range cols {
if c.label == "" {
top.WriteString(bStyle.Render(strings.Repeat("─", c.width)))
} else {
text := " " + c.label + " "
textW := len([]rune(text))
if textW > c.width {
text = string([]rune(text)[:c.width])
textW = c.width
}
top.WriteString(hStyle.Render(text))
pad := c.width - textW
if pad > 0 {
top.WriteString(bStyle.Render(strings.Repeat("─", pad)))
}
}
used += c.width
}
if rem := innerW - used; rem > 0 {
top.WriteString(bStyle.Render(strings.Repeat("─", rem)))
}
top.WriteString(bStyle.Render("╮"))
var result strings.Builder
result.WriteString(top.String() + "\n")
contentH := h - 2 // minus top + bottom border
if a.FilterMode {
filterLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render(fmt.Sprintf("Filter(%s): ", a.FilterBy))
filterInput := lipgloss.NewStyle().Foreground(cText).Render(a.FilterText + "_")
sb.WriteString(filterLabel + filterInput + "\n")
fl := filterLabel + filterInput
if flW := lipgloss.Width(fl); flW < contentW {
fl += strings.Repeat(" ", contentW-flW)
}
result.WriteString(bStyle.Render("│") + fl + " " + bStyle.Render("│") + "\n")
contentH--
}
vis := h - 4
vis := contentH
if vis < 1 { vis = 1 }
end := a.ScrollOffset + vis
if end > len(cs) { end = len(cs) }
cw := a.colWidths()
if total == 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found"))
return style.Render(sb.String())
rowsWritten := 0
if len(cs) == 0 {
msg := lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found")
if msgW := lipgloss.Width(msg); msgW < contentW {
msg += strings.Repeat(" ", contentW-msgW)
}
result.WriteString(bStyle.Render("│") + msg + " " + bStyle.Render("│") + "\n")
rowsWritten++
} else {
for idx := a.ScrollOffset; idx < end; idx++ {
c := cs[idx]
selected := idx == a.SelectedIdx
@@ -2250,9 +2376,9 @@ func (a *App) viewContainerTable(w, h int) string {
txStyle := lipgloss.NewStyle().Foreground(cPeach)
icon, _ := stateStyle(c.State)
sc := a.stateColorCfg(c.State)
row.WriteString(textStyle.Render(padR(c.Name, cw.name)))
stateText := icon + " " + c.StateStr
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(stateText, cw.state)))
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(icon, cw.state)))
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(trunc(c.Status, cw.status-1), cw.status)))
row.WriteString(padR(fmt.Sprintf("%05.2f%%", c.CPUPercent), cw.cpu))
row.WriteString(padR(fmtBytes(c.MemUsage)+" / "+fmtBytes(c.MemLimit), cw.mem))
@@ -2263,42 +2389,101 @@ func (a *App) viewContainerTable(w, h int) string {
row.WriteString(rxStyle.Render(padL(fmtBytes(c.RxBytes), cw.rx)))
row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx)))
rowStr := row.String()
if selected {
sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row.String()))
} else {
sb.WriteString(row.String())
rowStr = lipgloss.NewStyle().Bold(true).Render(rowStr)
}
sb.WriteString("\n")
if rowW := lipgloss.Width(rowStr); rowW < contentW {
rowStr += strings.Repeat(" ", contentW-rowW)
}
return style.Render(sb.String())
result.WriteString(bStyle.Render("│") + rowStr + " " + bStyle.Render("│") + "\n")
rowsWritten++
}
}
for rowsWritten < vis {
result.WriteString(bStyle.Render("│") + strings.Repeat(" ", contentW) + " " + bStyle.Render("│") + "\n")
rowsWritten++
}
result.WriteString(bStyle.Render("╰" + strings.Repeat("─", innerW) + "╯"))
return result.String()
}
func (a *App) viewCommands(w, h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelCommands { bc = a.borderSelected() }
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
bStyle := lipgloss.NewStyle().Foreground(bc)
innerW := w - 2
innerH := h - 2
if innerW < 4 { innerW = 4 }
if innerH < 1 { innerH = 1 }
c := a.selected()
if c == nil { return style.Render("") }
// Build command lines with spacing.
var cmdLines []string
if c != nil {
cmds := commandsForState(c.State)
if a.CmdSelectedIdx >= len(cmds) {
a.CmdSelectedIdx = 0
}
arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
var sb strings.Builder
for i, cmd := range cmds {
clr := a.cmdColorCfg(cmd)
if i == a.CmdSelectedIdx {
sb.WriteString(arrow + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd) + "\n")
cmdLines = append(cmdLines, arrow+lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd))
} else {
sb.WriteString(" " + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd) + "\n")
cmdLines = append(cmdLines, " "+lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd))
}
}
}
return style.Render(sb.String())
// Build visual lines with blank lines between commands.
var visualLines []string
for i, line := range cmdLines {
visualLines = append(visualLines, line)
if i < len(cmdLines)-1 {
visualLines = append(visualLines, "") // blank line between
}
}
// Vertical centering: compute top padding.
topPad := (innerH - len(visualLines)) / 2
if topPad < 0 { topPad = 0 }
// Horizontal centering: find max visible width of command lines.
maxCmdW := 0
for _, line := range visualLines {
if lw := lipgloss.Width(line); lw > maxCmdW { maxCmdW = lw }
}
leftPad := (innerW - maxCmdW) / 2
if leftPad < 0 { leftPad = 0 }
var result strings.Builder
// Top border.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", innerW)+"╮") + "\n")
for i := 0; i < innerH; i++ {
lineIdx := i - topPad
line := ""
if lineIdx >= 0 && lineIdx < len(visualLines) {
line = strings.Repeat(" ", leftPad) + visualLines[lineIdx]
}
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", innerW)+"╯"))
return result.String()
}
// --- Logs ---
@@ -2306,7 +2491,13 @@ func (a *App) viewCommands(w, h int) string {
func (a *App) viewLogs(h int) string {
bc := a.borderUnselected()
if a.ActivePanel == PanelLogs { bc = a.borderSelected() }
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(a.Width - 2).Height(h - 2)
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
w := a.Width
innerW := w - 2
innerH := h - 2
if innerH < 1 { innerH = 1 }
c := a.selected()
name := ""
@@ -2319,37 +2510,46 @@ func (a *App) viewLogs(h int) string {
title := fmt.Sprintf("Logs %d/%d - %s", end, total, name)
var sb strings.Builder
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
// Top border with embedded title.
titleRendered := " " + hStyle.Render(title) + " "
titleW := lipgloss.Width(titleRendered)
leftDash := 1
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
var result strings.Builder
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
// Build content lines.
var contentLines []string
if a.SearchMode {
searchLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render("Search: ")
searchLabel := lipgloss.NewStyle().Foreground(cYellow).Render("Search: ")
searchInput := lipgloss.NewStyle().Foreground(cText).Render(a.SearchText + "_")
matchInfo := lipgloss.NewStyle().Foreground(cSubtext0).Render(fmt.Sprintf(" (%d matches)", len(a.SearchMatches)))
sb.WriteString(searchLabel + searchInput + matchInfo + "\n")
contentLines = append(contentLines, searchLabel+searchInput+matchInfo)
}
rows := h - 4
rows := innerH - len(contentLines)
if rows < 1 { rows = 1 }
if total == 0 {
if c != nil && c.LogsSince == "" {
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("parsing logs..."))
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("parsing logs..."))
} else {
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
}
} else {
start := a.LogScroll
end := start + rows
if end > total { end = total; start = end - rows; if start < 0 { start = 0 } }
maxW := a.Width - 8
maxW := innerW - 4
if maxW < 20 { maxW = 20 }
for i := start; i < end; i++ {
line := stripAnsi(a.LogLines[i])
runes := []rune(line)
// Horizontal scroll (rune-based for UTF-8)
if a.LogHScroll > 0 && len(runes) > a.LogHScroll {
runes = runes[a.LogHScroll:]
} else if a.LogHScroll > 0 {
@@ -2363,31 +2563,45 @@ func (a *App) viewLogs(h int) string {
prefix = lipgloss.NewStyle().Foreground(cBlue).Render("▶ ")
}
// Highlight search matches (substring-level)
if a.SearchText != "" {
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
line = highlightMatches(line, a.SearchText, caseSensitive)
}
sb.WriteString(prefix + line + "\n")
contentLines = append(contentLines, prefix+line)
}
}
return style.Render(sb.String())
// Side borders around content.
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := innerW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", w-2)+"╯"))
return result.String()
}
// --- Bottom: Charts + Ports ---
func (a *App) viewBottom(h int) string {
c := a.selected()
portsW := a.Width * 18 / 100
if portsW < 22 { portsW = 22 }
remaining := a.Width - portsW - 8
portsW := a.Width * 22 / 100
if portsW < 28 { portsW = 28 }
remaining := a.Width - portsW
chartW := remaining / 3
bwW := remaining - 2*chartW // absorb integer-division remainder
cpu := a.viewSparkChart("cpu", chartW, h, c)
mem := a.viewSparkChart("memory", chartW, h, c)
bw := a.viewBWChart(chartW, h, c)
bw := a.viewBWChart(bwW, h, c)
ports := a.viewPorts(portsW, h, c)
return lipgloss.JoinHorizontal(lipgloss.Top, cpu, mem, bw, ports)
@@ -2453,8 +2667,18 @@ func (a *App) viewBWChart(w, h int, c *Container) string {
}
}
title := lipgloss.NewStyle().Foreground(cTeal).Render("rx: "+rxRate) +
" " + lipgloss.NewStyle().Foreground(cPeach).Render("tx: "+txRate)
// Build title to fit within chart border (w - 4 for corners and min dashes).
maxTitle := w - 4
rxLabel := "rx: " + rxRate
txLabel := "tx: " + txRate
fullLen := len([]rune(rxLabel)) + 1 + len([]rune(txLabel))
if fullLen > maxTitle {
rxRate = fmtRateCompact(rxData)
txRate = fmtRateCompact(txData)
}
valStyle := lipgloss.NewStyle().Foreground(cOverlay0)
title := lipgloss.NewStyle().Foreground(cTeal).Render("rx:") + " " + valStyle.Render(rxRate) +
" " + lipgloss.NewStyle().Foreground(cPeach).Render("tx:") + " " + valStyle.Render(txRate)
return a.renderBrailleBWChart(title, rxData, txData, rxRate, txRate, bc, w, h)
}
@@ -2497,14 +2721,18 @@ func (a *App) renderBrailleChart(title, yLabel string, data []float64, clr, bord
}
// Title in top border (like original Rust)
titleRendered := " " + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(title) + " "
titleRendered := " " + lipgloss.NewStyle().Foreground(cOverlay0).Render(title) + " "
titleW := lipgloss.Width(titleRendered)
topBorder := "╭"
// Truncate title if it won't fit between the corners + 2 dashes.
maxTitleW := w - 4
if titleW > maxTitleW && maxTitleW > 0 {
titleRendered = " " + lipgloss.NewStyle().Foreground(cOverlay0).Render(padR(title, maxTitleW-2)) + " "
titleW = lipgloss.Width(titleRendered)
}
leftDash := (w - 2 - titleW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
// Bottom border
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
@@ -2513,7 +2741,9 @@ func (a *App) renderBrailleChart(title, yLabel string, data []float64, clr, bord
contentLines := strings.Split(sb.String(), "\n")
var result strings.Builder
bStyle := lipgloss.NewStyle().Foreground(borderColor)
result.WriteString(bStyle.Render(topBorder) + "\n")
// Render border segments separately so the title's ANSI reset
// doesn't kill the border color for trailing dashes.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
@@ -2592,19 +2822,19 @@ func (a *App) renderBrailleBWChart(title string, rxData, txData []float64, rxRat
titleW := lipgloss.Width(title)
titleRendered := " " + title + " "
titleRW := titleW + 2
topBorder := "╭"
leftDash := (w - 2 - titleRW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleRW - leftDash
if rightDash < 1 { rightDash = 1 }
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
contentLines := strings.Split(sb.String(), "\n")
var result strings.Builder
bStyle := lipgloss.NewStyle().Foreground(borderColor)
result.WriteString(bStyle.Render(topBorder) + "\n")
// Render border segments separately so the title's ANSI reset
// doesn't kill the border color for trailing dashes.
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
@@ -2648,29 +2878,64 @@ func histDeltas(hist []uint64) []float64 {
}
func (a *App) viewPorts(w, h int, c *Container) string {
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cSurface0).Width(w).Height(h - 2)
bc := a.borderUnselected()
bStyle := lipgloss.NewStyle().Foreground(bc)
hStyle := lipgloss.NewStyle().Foreground(cOverlay0)
var sb strings.Builder
innerW := w - 2
innerH := h - 2
if innerW < 8 { innerW = 8 }
if innerH < 1 { innerH = 1 }
contentW := innerW - 2 // 1 char padding on each side
// Top border with centered title.
titleStr := "ports"
pad := (w - len(titleStr)) / 2
if pad < 0 { pad = 0 }
sb.WriteString(strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(titleStr) + "\n")
titleRendered := " " + hStyle.Render(titleStr) + " "
titleW := lipgloss.Width(titleRendered)
leftDash := (w - 2 - titleW) / 2
if leftDash < 1 { leftDash = 1 }
rightDash := w - 2 - titleW - leftDash
if rightDash < 1 { rightDash = 1 }
var result strings.Builder
result.WriteString(bStyle.Render("╭"+strings.Repeat("─", leftDash)) + titleRendered + bStyle.Render(strings.Repeat("─", rightDash)+"╮") + "\n")
var contentLines []string
if c == nil || len(c.Ports) == 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("no ports"))
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("no ports"))
} else {
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(fmt.Sprintf("%-10s %8s %8s", "ip", "private", "public")) + "\n")
ipW := contentW * 40 / 100
if ipW < 4 { ipW = 4 }
pvtW := (contentW - ipW) / 2
pubW := contentW - ipW - pvtW
hdr := padR("ip", ipW) + padL("private", pvtW) + padL("public", pubW)
contentLines = append(contentLines, hStyle.Render(hdr))
for _, p := range c.Ports {
ip := p.IP
pubStr := ""
if p.Public > 0 {
pubStr = fmt.Sprintf("%d", p.Public)
}
sb.WriteString(fmt.Sprintf("%-10s %8d %8s\n", ip, p.Private, pubStr))
row := padR(ip, ipW) + padL(fmt.Sprintf("%d", p.Private), pvtW) + padL(pubStr, pubW)
contentLines = append(contentLines, row)
}
}
return style.Render(sb.String())
for i := 0; i < innerH; i++ {
line := ""
if i < len(contentLines) { line = contentLines[i] }
lineW := lipgloss.Width(line)
pad := contentW - lineW
if pad < 0 { pad = 0 }
result.WriteString(bStyle.Render("│") + " " + line + strings.Repeat(" ", pad) + " " + bStyle.Render("│"))
if i < innerH-1 {
result.WriteString("\n")
}
}
result.WriteString("\n" + bStyle.Render("╰"+strings.Repeat("─", w-2)+"╯"))
return result.String()
}
// --- Overlays ---
@@ -2930,7 +3195,7 @@ func (a *App) logsAreaHeight() int {
if !a.ShowLogs { return 0 }
ch := a.containerAreaHeight()
bh := a.chartsAreaHeight()
lh := a.Height - ch - bh - 1
lh := a.Height - ch - bh
if lh < 5 { lh = 5 }
// Apply log height percentage
maxLogs := a.Height * a.LogHeight / 100
@@ -2945,13 +3210,13 @@ func (a *App) chartsAreaHeight() int {
}
func (a *App) containerVisibleRows() int {
h := a.containerAreaHeight() - 4
h := a.containerAreaHeight() - 2
if h < 1 { h = 1 }
return h
}
func (a *App) logsVisibleRows() int {
h := a.logsAreaHeight() - 4
h := a.logsAreaHeight() - 2 // -2 for top/bottom border
if h < 1 { h = 1 }
return h
}
@@ -3134,6 +3399,18 @@ func fmtRate(b uint64) string {
return fmt.Sprintf("%.2f Gb/s", gb)
}
// fmtRateCompact returns a short rate string from the latest data point (e.g. "0.0k").
func fmtRateCompact(data []float64) string {
if len(data) == 0 { return "0k" }
v := data[len(data)-1]
if v < 1000 { return fmt.Sprintf("%.0fB", v) }
kb := v / 1000
if kb < 100 { return fmt.Sprintf("%.1fk", kb) }
if kb < 1000 { return fmt.Sprintf("%.0fk", kb) }
mb := kb / 1000
return fmt.Sprintf("%.1fM", mb)
}
func trunc(s string, n int) string {
if n <= 0 { return "" }
r := []rune(s)