feat: add HandleClick, SpinnerChar and UI interaction methods for claude-pm overlay integration
This commit is contained in:
+408
-131
@@ -839,8 +839,19 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
x, y := msg.X, msg.Y
|
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 {
|
if y == 0 {
|
||||||
|
// Top border = sort by column header
|
||||||
col := a.headerColumnAt(x)
|
col := a.headerColumnAt(x)
|
||||||
if col >= 0 {
|
if col >= 0 {
|
||||||
a.toggleSort(SortColumn(col))
|
a.toggleSort(SortColumn(col))
|
||||||
@@ -848,16 +859,8 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container area
|
// Content rows: y=1..containerH-2
|
||||||
containerH := a.containerAreaHeight()
|
row := y - 1 + a.ScrollOffset
|
||||||
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
|
|
||||||
if row >= 0 && row < len(a.filtered()) {
|
if row >= 0 && row < len(a.filtered()) {
|
||||||
a.SelectedIdx = row
|
a.SelectedIdx = row
|
||||||
a.CmdSelectedIdx = 0
|
a.CmdSelectedIdx = 0
|
||||||
@@ -868,12 +871,36 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
// Click in commands panel
|
// Click in commands panel
|
||||||
a.ActivePanel = PanelCommands
|
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
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs area
|
// Logs area
|
||||||
logsStart := 1 + containerH
|
logsStart := containerH
|
||||||
logsEnd := logsStart + a.logsAreaHeight()
|
logsEnd := logsStart + a.logsAreaHeight()
|
||||||
if y >= logsStart && y < logsEnd {
|
if y >= logsStart && y < logsEnd {
|
||||||
a.ActivePanel = PanelLogs
|
a.ActivePanel = PanelLogs
|
||||||
@@ -899,6 +926,75 @@ func (a *App) headerColumnAt(x int) int {
|
|||||||
return -1
|
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
|
// NAVIGATION & SCROLL
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -2045,14 +2141,13 @@ func (a *App) viewNormal() string {
|
|||||||
chartsH := a.chartsAreaHeight()
|
chartsH := a.chartsAreaHeight()
|
||||||
|
|
||||||
// Clamp
|
// Clamp
|
||||||
total := 1 + containerH + logsH + chartsH
|
total := containerH + logsH + chartsH
|
||||||
if total > a.Height {
|
if total > a.Height {
|
||||||
logsH -= total - a.Height
|
logsH -= total - a.Height
|
||||||
if logsH < 3 { logsH = 3 }
|
if logsH < 3 { logsH = 3 }
|
||||||
}
|
}
|
||||||
|
|
||||||
var sections []string
|
var sections []string
|
||||||
sections = append(sections, a.viewHeader())
|
|
||||||
sections = append(sections, a.viewContainerSection(containerH))
|
sections = append(sections, a.viewContainerSection(containerH))
|
||||||
if a.ShowLogs {
|
if a.ShowLogs {
|
||||||
sections = append(sections, a.viewLogs(logsH))
|
sections = append(sections, a.viewLogs(logsH))
|
||||||
@@ -2061,12 +2156,22 @@ func (a *App) viewNormal() string {
|
|||||||
|
|
||||||
result := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
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")
|
lines := strings.Split(result, "\n")
|
||||||
if len(lines) > a.Height {
|
if len(lines) > a.Height {
|
||||||
lines = 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
|
// Overlays
|
||||||
if a.ShowHelp {
|
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))
|
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 {
|
func (a *App) sortIcon(col SortColumn) 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 {
|
|
||||||
if a.SortCol == col && a.SortOrd != SortNone {
|
if a.SortCol == col && a.SortOrd != SortNone {
|
||||||
if a.SortOrd == SortAsc { return " ▲" }
|
if a.SortOrd == SortAsc {
|
||||||
return " ▼"
|
return "▲"
|
||||||
|
}
|
||||||
|
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 }
|
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 {
|
func (a *App) colWidths() colW {
|
||||||
cmdW := a.Width * 10 / 100
|
cmdW := a.Width * 10 / 100
|
||||||
if cmdW < 12 { cmdW = 12 }
|
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 }
|
if inner < 60 { inner = 60 }
|
||||||
|
|
||||||
// Fixed-width columns (content has predictable size)
|
// Fixed-width columns (content has predictable size)
|
||||||
const (
|
const (
|
||||||
cpuW = 8 // "99.99%"
|
cpuW = 8 // "99.99%"
|
||||||
|
stateW = 3 // icon only (✓/✗/‖)
|
||||||
idW = 10 // 8 chars + pad
|
idW = 10 // 8 chars + pad
|
||||||
rxW = 9 // "999.9 MB"
|
rxW = 9 // "999.9 MB"
|
||||||
txW = 9
|
txW = 9
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compute max widths from data for variable columns (use rune count for unicode)
|
// 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() {
|
for _, c := range a.filtered() {
|
||||||
if n := len([]rune(c.Name)); n > nameMax { nameMax = n }
|
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 }
|
if n := len([]rune(c.Status)); n > statusMax { statusMax = n }
|
||||||
memStr := fmtBytes(c.MemUsage) + " / " + fmtBytes(c.MemLimit)
|
memStr := fmtBytes(c.MemUsage) + " / " + fmtBytes(c.MemLimit)
|
||||||
if n := len([]rune(memStr)); n > memMax { memMax = n }
|
if n := len([]rune(memStr)); n > memMax { memMax = n }
|
||||||
if n := len([]rune(c.Image)); n > imageMax { imageMax = 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
|
avail := inner - fixed
|
||||||
if avail < 30 { avail = 30 }
|
if avail < 30 { avail = 30 }
|
||||||
|
|
||||||
// Proportional distribution of variable columns
|
// Proportional distribution of variable columns
|
||||||
total := nameMax + stateMax + statusMax + memMax + imageMax
|
total := nameMax + statusMax + memMax + imageMax
|
||||||
if total == 0 { total = 1 }
|
if total == 0 { total = 1 }
|
||||||
|
|
||||||
nameW := avail * nameMax / total
|
nameW := avail * nameMax / total
|
||||||
stateW := avail * stateMax / total
|
|
||||||
statusW := avail * statusMax / total
|
statusW := avail * statusMax / total
|
||||||
memW := avail * memMax / 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
|
// Apply minimums
|
||||||
if nameW < 6 { nameW = 6 }
|
if nameW < 6 { nameW = 6 }
|
||||||
if stateW < 7 { stateW = 7 }
|
|
||||||
if statusW < 8 { statusW = 8 }
|
if statusW < 8 { statusW = 8 }
|
||||||
if memW < 8 { memW = 8 }
|
if memW < 8 { memW = 8 }
|
||||||
if imageW < 7 { imageW = 7 }
|
if imageW < 7 { imageW = 7 }
|
||||||
@@ -2196,7 +2269,7 @@ func (a *App) colWidths() colW {
|
|||||||
func (a *App) viewContainerSection(h int) string {
|
func (a *App) viewContainerSection(h int) string {
|
||||||
cmdW := a.Width * 10 / 100
|
cmdW := a.Width * 10 / 100
|
||||||
if cmdW < 12 { cmdW = 12 }
|
if cmdW < 12 { cmdW = 12 }
|
||||||
tableW := a.Width - cmdW - 4
|
tableW := a.Width - cmdW
|
||||||
table := a.viewContainerTable(tableW, h)
|
table := a.viewContainerTable(tableW, h)
|
||||||
cmds := a.viewCommands(cmdW, h)
|
cmds := a.viewCommands(cmdW, h)
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, table, cmds)
|
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 {
|
func (a *App) viewContainerTable(w, h int) string {
|
||||||
bc := a.borderUnselected()
|
bc := a.borderUnselected()
|
||||||
if a.ActivePanel == PanelContainers { bc = a.borderSelected() }
|
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()
|
cs := a.filtered()
|
||||||
total := len(cs)
|
innerW := w - 2
|
||||||
sel := a.SelectedIdx + 1
|
contentW := innerW - 1 // 1 char right padding inside border
|
||||||
if total == 0 { sel = 0 }
|
|
||||||
|
|
||||||
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
|
var top strings.Builder
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
|
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 {
|
if a.FilterMode {
|
||||||
filterLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render(fmt.Sprintf("Filter(%s): ", a.FilterBy))
|
filterLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render(fmt.Sprintf("Filter(%s): ", a.FilterBy))
|
||||||
filterInput := lipgloss.NewStyle().Foreground(cText).Render(a.FilterText + "_")
|
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 }
|
if vis < 1 { vis = 1 }
|
||||||
end := a.ScrollOffset + vis
|
end := a.ScrollOffset + vis
|
||||||
if end > len(cs) { end = len(cs) }
|
if end > len(cs) { end = len(cs) }
|
||||||
cw := a.colWidths()
|
|
||||||
|
|
||||||
if total == 0 {
|
rowsWritten := 0
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found"))
|
if len(cs) == 0 {
|
||||||
return style.Render(sb.String())
|
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++ {
|
for idx := a.ScrollOffset; idx < end; idx++ {
|
||||||
c := cs[idx]
|
c := cs[idx]
|
||||||
selected := idx == a.SelectedIdx
|
selected := idx == a.SelectedIdx
|
||||||
@@ -2250,9 +2376,9 @@ func (a *App) viewContainerTable(w, h int) string {
|
|||||||
txStyle := lipgloss.NewStyle().Foreground(cPeach)
|
txStyle := lipgloss.NewStyle().Foreground(cPeach)
|
||||||
icon, _ := stateStyle(c.State)
|
icon, _ := stateStyle(c.State)
|
||||||
sc := a.stateColorCfg(c.State)
|
sc := a.stateColorCfg(c.State)
|
||||||
|
|
||||||
row.WriteString(textStyle.Render(padR(c.Name, cw.name)))
|
row.WriteString(textStyle.Render(padR(c.Name, cw.name)))
|
||||||
stateText := icon + " " + c.StateStr
|
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(icon, cw.state)))
|
||||||
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(stateText, cw.state)))
|
|
||||||
row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(trunc(c.Status, cw.status-1), cw.status)))
|
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(fmt.Sprintf("%05.2f%%", c.CPUPercent), cw.cpu))
|
||||||
row.WriteString(padR(fmtBytes(c.MemUsage)+" / "+fmtBytes(c.MemLimit), cw.mem))
|
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(rxStyle.Render(padL(fmtBytes(c.RxBytes), cw.rx)))
|
||||||
row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx)))
|
row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx)))
|
||||||
|
|
||||||
|
rowStr := row.String()
|
||||||
if selected {
|
if selected {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row.String()))
|
rowStr = lipgloss.NewStyle().Bold(true).Render(rowStr)
|
||||||
} else {
|
|
||||||
sb.WriteString(row.String())
|
|
||||||
}
|
}
|
||||||
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 {
|
func (a *App) viewCommands(w, h int) string {
|
||||||
bc := a.borderUnselected()
|
bc := a.borderUnselected()
|
||||||
if a.ActivePanel == PanelCommands { bc = a.borderSelected() }
|
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()
|
c := a.selected()
|
||||||
if c == nil { return style.Render("") }
|
|
||||||
|
|
||||||
|
// Build command lines with spacing.
|
||||||
|
var cmdLines []string
|
||||||
|
if c != nil {
|
||||||
cmds := commandsForState(c.State)
|
cmds := commandsForState(c.State)
|
||||||
if a.CmdSelectedIdx >= len(cmds) {
|
if a.CmdSelectedIdx >= len(cmds) {
|
||||||
a.CmdSelectedIdx = 0
|
a.CmdSelectedIdx = 0
|
||||||
}
|
}
|
||||||
arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
|
arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
for i, cmd := range cmds {
|
for i, cmd := range cmds {
|
||||||
clr := a.cmdColorCfg(cmd)
|
clr := a.cmdColorCfg(cmd)
|
||||||
if i == a.CmdSelectedIdx {
|
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 {
|
} 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 ---
|
// --- Logs ---
|
||||||
@@ -2306,7 +2491,13 @@ func (a *App) viewCommands(w, h int) string {
|
|||||||
func (a *App) viewLogs(h int) string {
|
func (a *App) viewLogs(h int) string {
|
||||||
bc := a.borderUnselected()
|
bc := a.borderUnselected()
|
||||||
if a.ActivePanel == PanelLogs { bc = a.borderSelected() }
|
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()
|
c := a.selected()
|
||||||
name := ""
|
name := ""
|
||||||
@@ -2319,37 +2510,46 @@ func (a *App) viewLogs(h int) string {
|
|||||||
|
|
||||||
title := fmt.Sprintf("Logs %d/%d - %s", end, total, name)
|
title := fmt.Sprintf("Logs %d/%d - %s", end, total, name)
|
||||||
|
|
||||||
var sb strings.Builder
|
// Top border with embedded title.
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
|
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 {
|
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 + "_")
|
searchInput := lipgloss.NewStyle().Foreground(cText).Render(a.SearchText + "_")
|
||||||
matchInfo := lipgloss.NewStyle().Foreground(cSubtext0).Render(fmt.Sprintf(" (%d matches)", len(a.SearchMatches)))
|
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 rows < 1 { rows = 1 }
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
if c != nil && c.LogsSince == "" {
|
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 {
|
} else {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
|
contentLines = append(contentLines, lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
start := a.LogScroll
|
start := a.LogScroll
|
||||||
end := start + rows
|
end := start + rows
|
||||||
if end > total { end = total; start = end - rows; if start < 0 { start = 0 } }
|
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 }
|
if maxW < 20 { maxW = 20 }
|
||||||
|
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
line := stripAnsi(a.LogLines[i])
|
line := stripAnsi(a.LogLines[i])
|
||||||
runes := []rune(line)
|
runes := []rune(line)
|
||||||
// Horizontal scroll (rune-based for UTF-8)
|
|
||||||
if a.LogHScroll > 0 && len(runes) > a.LogHScroll {
|
if a.LogHScroll > 0 && len(runes) > a.LogHScroll {
|
||||||
runes = runes[a.LogHScroll:]
|
runes = runes[a.LogHScroll:]
|
||||||
} else if a.LogHScroll > 0 {
|
} else if a.LogHScroll > 0 {
|
||||||
@@ -2363,31 +2563,45 @@ func (a *App) viewLogs(h int) string {
|
|||||||
prefix = lipgloss.NewStyle().Foreground(cBlue).Render("▶ ")
|
prefix = lipgloss.NewStyle().Foreground(cBlue).Render("▶ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight search matches (substring-level)
|
|
||||||
if a.SearchText != "" {
|
if a.SearchText != "" {
|
||||||
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
|
caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
|
||||||
line = highlightMatches(line, a.SearchText, caseSensitive)
|
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 ---
|
// --- Bottom: Charts + Ports ---
|
||||||
|
|
||||||
func (a *App) viewBottom(h int) string {
|
func (a *App) viewBottom(h int) string {
|
||||||
c := a.selected()
|
c := a.selected()
|
||||||
portsW := a.Width * 18 / 100
|
portsW := a.Width * 22 / 100
|
||||||
if portsW < 22 { portsW = 22 }
|
if portsW < 28 { portsW = 28 }
|
||||||
remaining := a.Width - portsW - 8
|
remaining := a.Width - portsW
|
||||||
chartW := remaining / 3
|
chartW := remaining / 3
|
||||||
|
bwW := remaining - 2*chartW // absorb integer-division remainder
|
||||||
|
|
||||||
cpu := a.viewSparkChart("cpu", chartW, h, c)
|
cpu := a.viewSparkChart("cpu", chartW, h, c)
|
||||||
mem := a.viewSparkChart("memory", 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)
|
ports := a.viewPorts(portsW, h, c)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, cpu, mem, bw, ports)
|
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) +
|
// Build title to fit within chart border (w - 4 for corners and min dashes).
|
||||||
" " + lipgloss.NewStyle().Foreground(cPeach).Render("tx: "+txRate)
|
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)
|
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)
|
// 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)
|
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
|
leftDash := (w - 2 - titleW) / 2
|
||||||
if leftDash < 1 { leftDash = 1 }
|
if leftDash < 1 { leftDash = 1 }
|
||||||
rightDash := w - 2 - titleW - leftDash
|
rightDash := w - 2 - titleW - leftDash
|
||||||
if rightDash < 1 { rightDash = 1 }
|
if rightDash < 1 { rightDash = 1 }
|
||||||
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
|
|
||||||
|
|
||||||
// Bottom border
|
// Bottom border
|
||||||
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
|
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")
|
contentLines := strings.Split(sb.String(), "\n")
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
bStyle := lipgloss.NewStyle().Foreground(borderColor)
|
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++ {
|
for i := 0; i < innerH; i++ {
|
||||||
line := ""
|
line := ""
|
||||||
if i < len(contentLines) { line = contentLines[i] }
|
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)
|
titleW := lipgloss.Width(title)
|
||||||
titleRendered := " " + title + " "
|
titleRendered := " " + title + " "
|
||||||
titleRW := titleW + 2
|
titleRW := titleW + 2
|
||||||
topBorder := "╭"
|
|
||||||
leftDash := (w - 2 - titleRW) / 2
|
leftDash := (w - 2 - titleRW) / 2
|
||||||
if leftDash < 1 { leftDash = 1 }
|
if leftDash < 1 { leftDash = 1 }
|
||||||
rightDash := w - 2 - titleRW - leftDash
|
rightDash := w - 2 - titleRW - leftDash
|
||||||
if rightDash < 1 { rightDash = 1 }
|
if rightDash < 1 { rightDash = 1 }
|
||||||
topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
|
|
||||||
|
|
||||||
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
|
bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
|
||||||
|
|
||||||
contentLines := strings.Split(sb.String(), "\n")
|
contentLines := strings.Split(sb.String(), "\n")
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
bStyle := lipgloss.NewStyle().Foreground(borderColor)
|
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++ {
|
for i := 0; i < innerH; i++ {
|
||||||
line := ""
|
line := ""
|
||||||
if i < len(contentLines) { line = contentLines[i] }
|
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 {
|
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"
|
titleStr := "ports"
|
||||||
pad := (w - len(titleStr)) / 2
|
titleRendered := " " + hStyle.Render(titleStr) + " "
|
||||||
if pad < 0 { pad = 0 }
|
titleW := lipgloss.Width(titleRendered)
|
||||||
sb.WriteString(strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(titleStr) + "\n")
|
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 {
|
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 {
|
} 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 {
|
for _, p := range c.Ports {
|
||||||
ip := p.IP
|
ip := p.IP
|
||||||
pubStr := ""
|
pubStr := ""
|
||||||
if p.Public > 0 {
|
if p.Public > 0 {
|
||||||
pubStr = fmt.Sprintf("%d", p.Public)
|
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 ---
|
// --- Overlays ---
|
||||||
@@ -2930,7 +3195,7 @@ func (a *App) logsAreaHeight() int {
|
|||||||
if !a.ShowLogs { return 0 }
|
if !a.ShowLogs { return 0 }
|
||||||
ch := a.containerAreaHeight()
|
ch := a.containerAreaHeight()
|
||||||
bh := a.chartsAreaHeight()
|
bh := a.chartsAreaHeight()
|
||||||
lh := a.Height - ch - bh - 1
|
lh := a.Height - ch - bh
|
||||||
if lh < 5 { lh = 5 }
|
if lh < 5 { lh = 5 }
|
||||||
// Apply log height percentage
|
// Apply log height percentage
|
||||||
maxLogs := a.Height * a.LogHeight / 100
|
maxLogs := a.Height * a.LogHeight / 100
|
||||||
@@ -2945,13 +3210,13 @@ func (a *App) chartsAreaHeight() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) containerVisibleRows() int {
|
func (a *App) containerVisibleRows() int {
|
||||||
h := a.containerAreaHeight() - 4
|
h := a.containerAreaHeight() - 2
|
||||||
if h < 1 { h = 1 }
|
if h < 1 { h = 1 }
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) logsVisibleRows() int {
|
func (a *App) logsVisibleRows() int {
|
||||||
h := a.logsAreaHeight() - 4
|
h := a.logsAreaHeight() - 2 // -2 for top/bottom border
|
||||||
if h < 1 { h = 1 }
|
if h < 1 { h = 1 }
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -3134,6 +3399,18 @@ func fmtRate(b uint64) string {
|
|||||||
return fmt.Sprintf("%.2f Gb/s", gb)
|
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 {
|
func trunc(s string, n int) string {
|
||||||
if n <= 0 { return "" }
|
if n <= 0 { return "" }
|
||||||
r := []rune(s)
|
r := []rune(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user