Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,17 +448,20 @@ func (m model) View() tea.View {
s = lipgloss.JoinVertical(lipgloss.Center, banner(), "", body)
}

vAlign := lipgloss.Center
if lipgloss.Height(s) > m.height {
// Content taller than the terminal: centering vertically would clip
// it top and bottom with no way to scroll back to what's lost.
// Anchor to the top instead so everything stays reachable.
vAlign = lipgloss.Top
// Anchored to the top, not centered: a centered screen re-centers
// vertically every time its height changes, e.g. a toast, a progress
// panel, or a search prompt appearing. Anchoring to the top means only
// content below the change moves, not the whole screen.
const topMargin = 1
margin := topMargin
if lipgloss.Height(s)+margin > m.height {
margin = 0
}
s = strings.Repeat("\n", margin) + s
// Overlays go on last, over the finished screen: they must not be part of
// what Place centers. The modal sits under the toast, so a toast raised
// while the picker is open is still readable.
return m.newView(m.renderToast(m.renderModal(lipgloss.Place(m.width, m.height, lipgloss.Center, vAlign, s))))
return m.newView(m.renderToast(m.renderModal(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Top, s))))
}

// newView wraps a rendered frame in a tea.View with the alt-screen flag set,
Expand Down
23 changes: 9 additions & 14 deletions internal/tui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,9 @@ func (m model) viewDetail() string {
v := m.detail.vm

if v.Name == "" {
parts := []string{pane("", dimStyle.Render("no vm selected"), m.width), ""}
if m.status != "" {
parts = append(parts, warnStyle.Render(m.status))
}
parts := []string{pane("", dimStyle.Render("no vm selected"), m.width), "", warnStyle.Render(m.status)}
parts = append(parts, dimStyle.Render("esc back"))
return lipgloss.JoinVertical(lipgloss.Center, parts...)
return column(appContentWidth, parts...)
}

if m.detail.pager != nil {
Expand All @@ -333,7 +330,7 @@ func (m model) viewDetail() string {
state = upStyle.Render("running")
}

var facts fields
facts := fields{width: appContentWidth}
facts.row("", "", dimStyle.Render(modeLabel(v.Mode))+dimStyle.Render(glyphSep)+state)
facts.hint(modeHint(v.Mode))
facts.gap()
Expand Down Expand Up @@ -462,13 +459,13 @@ func (m model) viewDetail() string {
}
}

factsBox := pane(v.Name, facts.String(), m.width)
factsBox := paneAt(v.Name, facts.String(), appContentWidth, m.width)

parts := []string{factsBox}
if m.detail.log != "" {
// lipgloss re-applies the style on every line of a multi-line string,
// so the log needs no per-line loop of its own.
parts = append(parts, "", pane("last provision", dimStyle.Render(m.detail.log), m.width))
parts = append(parts, "", paneAt("last provision", dimStyle.Render(m.detail.log), appContentWidth, m.width))
}

if panel := progressPanel(m); panel != "" {
Expand All @@ -479,14 +476,12 @@ func (m model) viewDetail() string {
for _, l := range provLinesExcept(m, v.Name) {
parts = append(parts, l)
}
if m.status != "" {
parts = append(parts, warnStyle.Render(m.status))
}
// An always-present slot: an appearing status line replaces blank
// space instead of pushing the footer down.
parts = append(parts, warnStyle.Render(m.status))
parts = append(parts, renderFooter(detailHelp{
sshAvailable: v.State == core.StateRunning,
consolePassword: consolePasswordAvailable(v),
}, m.width, m.showHelp))
// Center for the same reason as the list: the footer is wider than the
// pane, and a left join would pin the pane to the footer's left edge.
return lipgloss.JoinVertical(lipgloss.Center, parts...)
return column(appContentWidth, parts...)
}
13 changes: 11 additions & 2 deletions internal/tui/detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ import (
"github.com/novusedge/stoat/internal/qemu"
)

// containsWrapped reports whether s contains needle once the pane's border
// and whitespace are stripped. The facts pane holds every value to a fixed
// column width now, so a path longer than that column wraps mid-token
// across two rendered lines instead of pushing the pane wider.
func containsWrapped(s, needle string) bool {
flat := strings.NewReplacer("\n", "", " ", "", "│", "").Replace(s)
return strings.Contains(flat, needle)
}

// TestTickGenerationOnlyReArmsCurrentChain proves the fix for the ticker
// chain leak. A tickMsg carrying a stale generation must not re-arm; its
// chain dies. One carrying the current generation must re-arm. Without the
Expand Down Expand Up @@ -240,7 +249,7 @@ func TestDetailSurfacesVNCForAHeadlessVM(t *testing.T) {
if !strings.Contains(out, "vnc") {
t.Fatalf("headless VM's detail screen must show a vnc row:\n%s", out)
}
if !strings.Contains(out, sock) {
if !containsWrapped(out, sock) {
t.Fatalf("vnc row must show the actual socket path %q:\n%s", sock, out)
}
if strings.Contains(out, "qemu window only") {
Expand Down Expand Up @@ -278,7 +287,7 @@ func TestDetailExplainsTheVNCFallbackOnAHeadlessHost(t *testing.T) {
m.detail = newDetail(v)
out := ansi.Strip(m.viewDetail())

if !strings.Contains(out, sock) {
if !containsWrapped(out, sock) {
t.Errorf("the install console is on the socket now; the detail screen must show it:\n%s", out)
}
if !strings.Contains(out, "no usable graphical session on this host") {
Expand Down
11 changes: 5 additions & 6 deletions internal/tui/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (

"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

"github.com/novusedge/stoat/internal/config"
"github.com/novusedge/stoat/internal/core"
Expand Down Expand Up @@ -522,12 +521,12 @@ func (m model) viewEdit() string {

box := paneAt("edit "+e.vm.Name, body, editContentWidth, m.width)

parts := []string{box, ""}
if m.status != "" {
parts = append(parts, warnStyle.Render(m.status))
}
// The status slot is always present, blank when there is nothing to
// show, so an appearing message replaces blank space instead of
// pushing the footer down.
parts := []string{box, "", warnStyle.Render(m.status)}
parts = append(parts, renderFooter(editHelp{}, m.width, m.showHelp))
return lipgloss.JoinVertical(lipgloss.Center, parts...)
return column(appContentWidth, parts...)
}

func editRecipesLabel(e editModel) string {
Expand Down
19 changes: 9 additions & 10 deletions internal/tui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (

"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/cloudinit"
Expand Down Expand Up @@ -223,7 +222,10 @@ func byoOSNames() []string {
//
// 72 plus the pane frame is 78 cells, so the box fits an 80-column terminal
// without paneAt having to clamp it.
const formContentWidth = 72
//
// Equal to appContentWidth: the form and the list share one left edge and
// one width, so switching between screens does not shift the column.
const formContentWidth = appContentWidth

type formModel struct {
inputs []textinput.Model // name, ram, cpus, disk, share
Expand Down Expand Up @@ -930,15 +932,12 @@ func (m model) viewForm() string {

box := paneAt("new vm", body, formContentWidth, m.width)

// Center rather than Left: the footer is far wider than the box, so a
// left join pins the box to the footer's left edge and the pane reads as
// off-center once the whole rectangle is placed.
parts := []string{box, ""}
if m.status != "" {
parts = append(parts, warnStyle.Render(m.status))
}
// The status slot is always present, blank when there is nothing to
// show, so an appearing message replaces blank space instead of
// pushing the footer down.
parts := []string{box, "", warnStyle.Render(m.status)}
parts = append(parts, renderFooter(formHelp{}, m.width, m.showHelp))
return lipgloss.JoinVertical(lipgloss.Center, parts...)
return column(appContentWidth, parts...)
}

// recipesLabel renders the recipes row's checkbox list, highlighting the
Expand Down
32 changes: 32 additions & 0 deletions internal/tui/geometry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"fmt"
"strings"
"testing"
"time"

"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/core"
)
Expand Down Expand Up @@ -74,6 +77,35 @@ func TestListWidthFitsARunningRow(t *testing.T) {
}
}

// TestColumnHoldsEveryLineWithinWidth pins column's left-align contract: a
// caller stacking narrower pieces gets back a block padded to width, not one
// that drifts wider or narrower line by line.
func TestColumnHoldsEveryLineWithinWidth(t *testing.T) {
out := column(appContentWidth, "short", "a bit longer than short", "")
for _, l := range strings.Split(out, "\n") {
if w := lipgloss.Width(l); w != appContentWidth {
t.Errorf("line %q is %d cells wide, want %d", l, w, appContentWidth)
}
}
}

// TestListRowWidthInvariantForLongNames covers the bug hand-padded
// fmt.Sprintf rows had: a name longer than its column pushed every column
// after it to the right instead of being cut off. vmDelegate.Render must
// truncate the name instead, so the row's rendered width stays fixed
// regardless of how long a VM's name is.
func TestListRowWidthInvariantForLongNames(t *testing.T) {
v := core.VM{
Name: strings.Repeat("n", 200), Mode: "cloud", State: core.StateRunning,
RAM: 8192, CPUs: 8, SSHPort: 65535, StartedAt: time.Now().Add(-999 * time.Hour),
}
l := list.New([]list.Item{vmItem{vm: v}}, vmDelegate{}, listWidth, 2)
if got := lipgloss.Width(ansi.Strip(l.View())); got > listWidth {
t.Errorf("row for a %d-char name rendered %d cells wide, want <= listWidth (%d)",
len(v.Name), got, listWidth)
}
}

// TestFooterNeverOverflows covers help.Model giving up on truncation. Once
// its running total passes the width, it can no longer fit an ellipsis, so
// it appends every remaining binding and returns a line WIDER than the
Expand Down
35 changes: 11 additions & 24 deletions internal/tui/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"strings"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"

"github.com/novusedge/stoat/internal/core"
Expand Down Expand Up @@ -289,36 +288,24 @@ func (m model) viewList() string {
}
box = joinAccess(box, accessBox(cur, ci, m.ciProg, m.width), m.width)

// lipgloss.Place centers (or left-aligns) each LINE of a string
// independently, sized against that string's widest line. Handing it
// box+status+footer concatenated as-is would make every shorter line
// drift toward center on its own: the "justified" look this used to
// have. JoinVertical instead pads every line of every piece out to the
// widest piece's width first, so the result is one rectangle that moves
// as a whole once centered in the terminal.
//
// Center, not Left, join: the footer is much wider than the box, so a
// left join pins the box to the footer's left edge, leaving it visibly
// off-center under the centered banner.
parts := []string{box, ""}
// The search line sits between the pane and the status: while the input
// is open it IS the input, and once a filter is applied it reports what
// is being hidden. Without it a filtered list just looks like VMs went
// missing.
if search := listStatusLine(m.list); search != "" {
parts = append(parts, search, "")
}
// column holds every piece to appContentWidth and stacks them left-
// aligned. Without it, the box, the status line, and the footer would
// each center on their own width, drifting sideways from one another
// as content came and went.
// The search line and the status line are always-present slots, blank
// when there is nothing to show. A conditionally-appended line pushes
// everything below it down the moment it appears; a slot that is
// always there just fills in.
parts := []string{box, "", listStatusLine(m.list)}
// In-flight provision runs sit above the status line: they are ongoing
// state, not a one-off message, and a run started from here keeps going
// while the user moves around the list.
for _, l := range provLines(m) {
parts = append(parts, l)
}
if m.status != "" {
parts = append(parts, warnStyle.Render(m.status))
}
parts = append(parts, warnStyle.Render(m.status))
v := m.current()
sshAvailable := v != nil && v.State == core.StateRunning
parts = append(parts, renderFooter(listHelp{sshAvailable: sshAvailable}, m.width, m.showHelp))
return lipgloss.JoinVertical(lipgloss.Center, parts...)
return column(appContentWidth, parts...)
}
5 changes: 2 additions & 3 deletions internal/tui/progressbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,17 @@ func renderProgressPanel(phase string, p Progress, hasProg bool, elapsed time.Du
var f fields
f.row("", "phase", dimStyle.Render(phase))
if hasProg {
f.row("", "", bar(p.Frac, detailBarWidth))
num := fmt.Sprintf("%3.0f%%", p.Frac*100)
if p.Total > 0 {
num = fmt.Sprintf("%d/%d", p.Done, p.Total)
}
f.row("", "", dimStyle.Render(num))
f.row("", "", bar(p.Frac, detailBarWidth)+" "+dimStyle.Render(num))
if p.Label != "" {
f.row("", "package", p.Label)
}
} else {
f.row("", "", dimStyle.Render("waiting for output…"))
}
f.row("", "elapsed", provElapsed(elapsed))
return pane("progress", f.String(), width)
return paneAt("progress", f.String(), appContentWidth, width)
}
6 changes: 5 additions & 1 deletion internal/tui/provstep.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ func provLine(spin spinner.Model, name string, st provState, now time.Time) stri
case st.last != "":
out += dimStyle.Render(" · " + ansi.Truncate(st.last, provMaxLast, "…"))
}
return out + dimStyle.Render(" · "+provElapsed(now.Sub(st.start)))
out += dimStyle.Render(" · " + provElapsed(now.Sub(st.start)))
// A long VM name can still push the line past the pane and wrap what
// should be one row. Every field above already has its own cap; this
// is the backstop for the sum of them.
return ansi.Truncate(out, appContentWidth, "…")
}

// installLine renders one disk VM mid unattended install the same way
Expand Down
24 changes: 24 additions & 0 deletions internal/tui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ func radio(label string, on bool) string {
// one place.
const rowGap = "\n\n"

// appContentWidth is the left edge every screen's stacked panes share. Before
// this, each pane centered on its own width, so switching screens, or a pane
// changing size as content appeared, shifted the whole block sideways.
const appContentWidth = 72

// column stacks parts left-aligned inside one block, at least width cells
// wide. Every part then starts at the same left edge, instead of each one
// centering on its own width.
//
// The block widens for a part wider than width rather than wrapping it: a
// lipgloss Style.Width() narrower than its content word-wraps that content,
// which mangles a bordered pane's box-drawing runs instead of just leaving
// it be. The list pane plus its side-by-side access box is routinely wider
// than appContentWidth, so this has to hold.
func column(width int, parts ...string) string {
for _, p := range parts {
if w := lipgloss.Width(p); w > width {
width = w
}
}
return lipgloss.NewStyle().Width(width).Align(lipgloss.Left).
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
}

// paneAt draws a pane whose content is held at a fixed width, for screens
// whose rows come and go, e.g. a download block, an error line, a
// conditional disk row. pane() hugs its content, so without this the box
Expand Down
Loading
Loading