feat: add HandleClick, SpinnerChar and UI interaction methods for claude-pm overlay integration
This commit is contained in:
+407
-130
@@ -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,96 +2195,64 @@ 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 " ▲" }
|
||||
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 }
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user