diff --git a/internal/app/taint_editor_addrow_test.go b/internal/app/taint_editor_addrow_test.go new file mode 100644 index 00000000..e2fff24a --- /dev/null +++ b/internal/app/taint_editor_addrow_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// addRowBlock returns the add-row portion of the rendered editor. Assertions +// must target this, not the whole overlay: the taint list above it also +// contains effect names. Anchored on the blank line the renderer joins the +// block with, not on the "add:" label — at narrow widths the label is itself +// truncated away. +func addRowBlock(t *testing.T, m Model) (block string, width int) { + t.Helper() + content, w, _ := m.renderOverlayTaintEditor() + plain := stripANSI(content) + sep := strings.LastIndex(plain, "\n\n") + require.Positive(t, sep, "add-row must render while an input is focused") + return plain[sep+2:], w +} + +// The effect selector is a cycling control with no scroll of its own, so it +// must render in full no matter how long the key is. Before the fix the +// add-row was joined onto one line and truncated to a box sized from the taint +// LIST alone, so a realistic key pushed the effect off the edge as "eff~" — +// leaving the user unable to see which effect they were about to stage. +func TestRenderTaintEditorAddRow_LongKeyKeepsEffectVisible(t *testing.T) { + m := openTaintEditorLoaded(t, taintEditorTestModel()) + m = taintKey(t, m, "a") + m = taintKey(t, m, "node.kubernetes.io/very-long-taint", "tab", "true") + + block, _ := addRowBlock(t, m) + + assert.Contains(t, block, "effect ", "effect must render in full") + assert.NotContains(t, block, "eff~", "effect must never be the truncated field") +} + +// A key too long for even a widened box truncates inside its own field, and +// the effect still renders — the row wraps rather than dropping fields. +func TestRenderTaintEditorAddRow_ExtremeKeyStillShowsEffect(t *testing.T) { + m := openTaintEditorLoaded(t, taintEditorTestModel()) + m = taintKey(t, m, "a") + m = taintKey(t, m, "example.com/"+strings.Repeat("x", 200)) + + block, w := addRowBlock(t, m) + + assert.Contains(t, block, "effect ", "effect must survive an unrenderable key") + assert.LessOrEqual(t, w, m.width, "box must stay on screen") + for line := range strings.SplitSeq(block, "\n") { + assert.LessOrEqual(t, len([]rune(line)), w, "no line may overflow the box: %q", line) + } +} + +// The typing caret sits at the end of the focused field, so an over-long value +// truncates from the front — the user must see what they are typing. +func TestRenderTaintEditorAddRow_FocusedFieldKeepsCaretVisible(t *testing.T) { + m := openTaintEditorLoaded(t, taintEditorTestModel()) + m = taintKey(t, m, "a") + m = taintKey(t, m, "k", "tab", "prefix-"+strings.Repeat("y", 120)+"-tail") + + block, _ := addRowBlock(t, m) + + assert.Contains(t, block, "-tail", "focused field must keep its tail (the caret end) visible") + assert.Contains(t, block, "effect ") +} + +// The box grows to fit the add-row instead of sizing itself from the taint +// list alone, so a moderately long key needs no wrapping at all. +func TestRenderOverlayTaintEditor_BoxWidensForAddRow(t *testing.T) { + m := openTaintEditorLoaded(t, taintEditorTestModel()) + _, listOnlyW, _ := m.renderOverlayTaintEditor() + + m = taintKey(t, m, "a") + m = taintKey(t, m, "node-role.kubernetes.io/control-plane") + block, addRowW := addRowBlock(t, m) + + require.Greater(t, addRowW, listOnlyW, "box must widen to fit the add-row") + assert.LessOrEqual(t, addRowW, m.width, "box must stay on screen") + assert.Contains(t, block, "node-role.kubernetes.io/control-plane", "key fits without truncation") + assert.Equal(t, 1, strings.Count(strings.TrimSpace(block), "\n")+1, + "a fitting add-row stays on one line") +} + +// At a terminal narrow enough that a field's label alone exceeds the line +// budget, taintAddRowFields returns a field wider than innerW and the final +// safety-net truncation runs. That last cut must respect focus too: cutting the +// focused field from the right strips exactly the caret the per-field +// front-truncation just preserved. +func TestRenderTaintEditorAddRow_NarrowOverlayKeepsCaret(t *testing.T) { + m := openTaintEditorLoaded(t, taintEditorTestModel()) + m.width = 20 // innerW lands well below the "add: key [" label width + m = taintKey(t, m, "a") + m = taintKey(t, m, "node.kubernetes.io/some-taint") + + block, _ := addRowBlock(t, m) + + assert.Contains(t, block, "█", "the caret must survive the safety-net truncation") +} diff --git a/internal/app/view_overlay_taint_editor.go b/internal/app/view_overlay_taint_editor.go index 21c79281..68dba424 100644 --- a/internal/app/view_overlay_taint_editor.go +++ b/internal/app/view_overlay_taint_editor.go @@ -51,8 +51,14 @@ func (m Model) renderOverlayTaintEditor() (string, int, int) { } // Fit the box to the widest row (70-col floor, screen-capped), then - // truncate names against the width that actually won. - overlayW := ui.OverlayListWidth(items, cfg, max(m.width-10, 1)) + // truncate names against the width that actually won. While the add-row + // is up it joins the measurement: sizing the box from the list alone + // pushed a long key's effect selector off the right edge. + capW := max(m.width-10, 1) + overlayW := ui.OverlayListWidth(items, cfg, capW) + if p.focus != taintFocusList { + overlayW = max(overlayW, ui.OverlayContentWidth(taintAddRowNaturalWidth(p), capW)) + } innerW := max(overlayW-4, 1) for i := range items { items[i].Name = ui.Truncate(items[i].Name, innerW) @@ -86,35 +92,90 @@ func taintEditorEmptyMessage(p taintEditorState) string { return "No taints on this node" } -// renderTaintEditorAddRow paints the staged-input line: the three -// fields with the focused one highlighted and a cursor marker on the -// text inputs. Rendered only while an input field is focused. -func (m Model) renderTaintEditorAddRow(innerW int) string { - p := m.taintEditor - field := func(label, val string, focused bool) string { - text := label + " [" + val +// taintAddRowSep separates the add-row's fields when they share a line. +const taintAddRowSep = " " + +// taintAddRowFocus maps each add-row field to the focus value that owns it. +var taintAddRowFocus = []taintEditorFocus{taintFocusKey, taintFocusValue, taintFocusEffect} + +// taintAddRowFields returns the add-row's three field texts, unstyled. Each +// input's value is fitted to valW visual columns (the focused one carries a +// block cursor, matching the kv-editor / secret-editor inputs); pass a +// negative valW for the untruncated natural text used to size the box. +// +// The focused value truncates from the front so the caret end — what the user +// is typing right now — stays visible, while an unfocused one keeps its +// recognizable head. +func taintAddRowFields(p taintEditorState, valW int) []string { + input := func(label, val string, focused bool) string { + suffix := "]" if focused { - // Block cursor, matching the kv-editor / secret-editor inputs. - text += "█" + suffix = "\u2588]" } - text += "]" - if focused { - return ui.OverlayFilterStyle.Render(text) + prefix := label + " [" + if valW >= 0 { + budget := max(valW-lipgloss.Width(prefix)-lipgloss.Width(suffix), 1) + if focused { + val = ui.TruncateStart(val, budget) + } else { + val = ui.Truncate(val, budget) + } + } + return prefix + val + suffix + } + return []string{ + input("add: key", p.addKey, p.focus == taintFocusKey), + input("value", p.addVal, p.focus == taintFocusValue), + "effect <" + model.ValidTaintEffects[p.addEff] + ">", + } +} + +// taintAddRowNaturalWidth is the width the add-row wants on one line, used to +// size the overlay box before anything is truncated. +func taintAddRowNaturalWidth(p taintEditorState) int { + fields := taintAddRowFields(p, -1) + w := len(taintAddRowSep) * (len(fields) - 1) + for _, f := range fields { + w += lipgloss.Width(f) + } + return w +} + +// renderTaintEditorAddRow paints the staged-input line: the three fields with +// the focused one highlighted and the rest dimmed. Rendered only while an +// input field is focused. +// +// The fields stack onto separate lines when they cannot share one, rather than +// the row truncating as a unit — that dropped the effect selector off the right +// edge on a long key, hiding a choice that reshapes scheduling for the whole +// node. +func (m Model) renderTaintEditorAddRow(innerW int) string { + p := m.taintEditor + style := func(i int) lipgloss.Style { + if p.focus == taintAddRowFocus[i] { + return ui.OverlayFilterStyle } - return ui.OverlayDimStyle.Render(text) + return ui.OverlayDimStyle } - effect := "effect <" + model.ValidTaintEffects[p.addEff] + ">" - if p.focus == taintFocusEffect { - effect = ui.OverlayFilterStyle.Render(effect) - } else { - effect = ui.OverlayDimStyle.Render(effect) + + fields := taintAddRowFields(p, -1) + sep := taintAddRowSep + if taintAddRowNaturalWidth(p) > innerW { + fields = taintAddRowFields(p, innerW) + sep = "\n" + } + for i, f := range fields { + // Safety net for a field whose label alone outgrows innerW (the + // per-field budget floors at one column). It has to respect focus for + // the same reason the budget does: cutting the focused field from the + // right strips the caret the front-truncation just preserved. + trunc := ui.Truncate + if p.focus == taintAddRowFocus[i] { + trunc = ui.TruncateStart + } + fields[i] = style(i).Render(trunc(f, innerW)) } - row := strings.Join([]string{ - field("add: key", p.addKey, p.focus == taintFocusKey), - field("value", p.addVal, p.focus == taintFocusValue), - effect, - }, " ") - return ui.Truncate(row, innerW) + return strings.Join(fields, sep) } // taintDisplayString renders a taint for the overlay row with control diff --git a/internal/ui/explorer_format.go b/internal/ui/explorer_format.go index ce82b4ef..ab9c6c1d 100644 --- a/internal/ui/explorer_format.go +++ b/internal/ui/explorer_format.go @@ -560,6 +560,24 @@ func Truncate(s string, maxW int) string { return ansi.Truncate(s, maxW-1, "") + "~" } +// TruncateStart truncates a string from the left to maxW visual columns, +// prefixing "~" when it was cut. The mirror of Truncate, for text whose end +// matters more than its start: a text input's caret sits after the last +// character, so the tail is the part the user needs to see while typing. +func TruncateStart(s string, maxW int) string { + if maxW <= 0 { + return "" + } + w := lipgloss.Width(s) + if w <= maxW { + return s + } + if maxW <= 1 { + return "~" + } + return ansi.TruncateLeft(s, w-maxW+1, "~") +} + // TruncateWithSuffix truncates body so that body + suffix fits within maxW // visual columns, then right-pads with spaces so the suffix lands flush // against the right edge. Empty suffix degrades to plain Truncate. diff --git a/internal/ui/explorer_test.go b/internal/ui/explorer_test.go index 9e485bd6..35e5a53a 100644 --- a/internal/ui/explorer_test.go +++ b/internal/ui/explorer_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" "github.com/janosmiko/lfk/internal/model" @@ -243,6 +244,33 @@ func TestTruncate(t *testing.T) { } } +// TruncateStart is Truncate's mirror: it keeps the tail, which is where a +// text input's caret sits, so the user can see what they are typing. +func TestTruncateStart(t *testing.T) { + tests := []struct { + name string + s string + maxW int + expected string + }{ + {"zero maxW", "hello", 0, ""}, + {"negative maxW", "hello", -1, ""}, + {"fits exactly", "hello", 5, "hello"}, + {"fits with room", "hi", 5, "hi"}, + {"needs truncation", "hello world", 6, "~world"}, + {"maxW 1", "hello", 1, "~"}, + {"empty string", "", 5, ""}, + {"unicode", "héllo", 4, "~llo"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateStart(tt.s, tt.maxW) + assert.Equal(t, tt.expected, got) + assert.LessOrEqual(t, lipgloss.Width(got), max(tt.maxW, 0), "never exceeds maxW") + }) + } +} + // --- FormatCPU --- func TestFormatCPU(t *testing.T) { diff --git a/internal/ui/overlay_list.go b/internal/ui/overlay_list.go index a1914daf..7af3e157 100644 --- a/internal/ui/overlay_list.go +++ b/internal/ui/overlay_list.go @@ -117,6 +117,15 @@ func OverlayListWidth(items []OverlayListItem, cfg OverlayListConfig, maxWidth i contentW = w } } + return OverlayContentWidth(contentW, maxWidth) +} + +// OverlayContentWidth returns the overlay box width that shows contentW +// columns of content, applying the same chrome, floor and cap as +// OverlayListWidth. Overlays that render their own content below the list +// (the taint editor's add-row) size themselves against this so the box grows +// to fit them instead of truncating them against a list-derived width. +func OverlayContentWidth(contentW, maxWidth int) int { w := max(contentW+overlayListChrome, OverlayListFloor) if maxWidth > 0 && w > maxWidth { w = maxWidth