diff --git a/pkg/app/app.go b/pkg/app/app.go index 86c8a7e..d0adcfd 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -839,25 +839,28 @@ func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) { } x, y := msg.X, msg.Y - // Header row (y == 0) → sort by column - if y == 0 { - col := a.headerColumnAt(x) - if col >= 0 { - a.toggleSort(SortColumn(col)) - } - return a, nil - } - - // Container area containerH := a.containerAreaHeight() cmdW := a.Width * 10 / 100 if cmdW < 12 { cmdW = 12 } - tableW := a.Width - cmdW - 4 + tableW := a.Width - cmdW - if y >= 1 && y <= containerH { - if x < tableW+2 { - // Click in container table → select container - row := y - 3 + a.ScrollOffset // 3 = header + border + title + // 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)) + } + return a, nil + } + + // 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 { - if a.SortCol == col && a.SortOrd != SortNone { - if a.SortOrd == SortAsc { return " ▲" } - return " ▼" +func (a *App) sortIcon(col SortColumn) string { + if a.SortCol == col && a.SortOrd != SortNone { + 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 + return "" } 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,100 +2278,212 @@ 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 + + var row strings.Builder + if selected { + row.WriteString(lipgloss.NewStyle().Foreground(cBlue).Render(" > ")) + } else { + row.WriteString(" ") + } + + textStyle := lipgloss.NewStyle().Foreground(cBlue) + rxStyle := lipgloss.NewStyle().Foreground(cTeal) + txStyle := lipgloss.NewStyle().Foreground(cPeach) + icon, _ := stateStyle(c.State) + sc := a.stateColorCfg(c.State) + + row.WriteString(textStyle.Render(padR(c.Name, cw.name))) + 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)) + idStr := c.ID + if len(idStr) > 8 { idStr = idStr[:8] } + row.WriteString(textStyle.Render(padR(idStr, cw.id))) + row.WriteString(textStyle.Render(padR(trunc(c.Image, cw.image-1), cw.image))) + 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 { + rowStr = lipgloss.NewStyle().Bold(true).Render(rowStr) + } + if rowW := lipgloss.Width(rowStr); rowW < contentW { + rowStr += strings.Repeat(" ", contentW-rowW) + } + + result.WriteString(bStyle.Render("│") + rowStr + " " + bStyle.Render("│") + "\n") + rowsWritten++ + } } - for idx := a.ScrollOffset; idx < end; idx++ { - c := cs[idx] - selected := idx == a.SelectedIdx - - var row strings.Builder - if selected { - row.WriteString(lipgloss.NewStyle().Foreground(cBlue).Render(" > ")) - } else { - row.WriteString(" ") - } - - textStyle := lipgloss.NewStyle().Foreground(cBlue) - rxStyle := lipgloss.NewStyle().Foreground(cTeal) - 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(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)) - idStr := c.ID - if len(idStr) > 8 { idStr = idStr[:8] } - row.WriteString(textStyle.Render(padR(idStr, cw.id))) - row.WriteString(textStyle.Render(padR(trunc(c.Image, cw.image-1), cw.image))) - row.WriteString(rxStyle.Render(padL(fmtBytes(c.RxBytes), cw.rx))) - row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx))) - - if selected { - sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row.String())) - } else { - sb.WriteString(row.String()) - } - sb.WriteString("\n") + for rowsWritten < vis { + result.WriteString(bStyle.Render("│") + strings.Repeat(" ", contentW) + " " + bStyle.Render("│") + "\n") + rowsWritten++ } - return style.Render(sb.String()) + 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("") } - 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") - } else { - sb.WriteString(" " + lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd) + "\n") + // 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("▶ ") + for i, cmd := range cmds { + clr := a.cmdColorCfg(cmd) + if i == a.CmdSelectedIdx { + cmdLines = append(cmdLines, arrow+lipgloss.NewStyle().Foreground(clr).Bold(true).Render(cmd)) + } else { + 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)