From fdb53fbcf27026f0fe51f002734ff69b3d4018c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1nos=20Mik=C3=B3?= Date: Mon, 27 Jul 2026 15:57:50 +0200 Subject: [PATCH 1/2] fix(taints): keep the effect field visible in the add-row Typing a long taint key pushed the effect selector off the right edge, where it rendered as "eff~". The effect is a cycling control with no scroll of its own, so a truncated one leaves the user unable to see which effect they are about to stage -- on a change that reshapes scheduling for the whole node. Two causes. The overlay box was sized by OverlayListWidth over the taint LIST rows only, so the add-row never influenced the box width. The add-row was then joined into one line and truncated as a unit, and the effect sits last. Size the box against the add-row as well while an input is focused, and stack the fields onto separate lines when they cannot share one instead of truncating the row as a unit. Each input's value is fitted to its own budget, so the field label survives; the focused value truncates from the front so the caret end -- what the user is typing right now -- stays visible. Adds two ui primitives: OverlayContentWidth, extracted from OverlayListWidth so any overlay can size itself against content the list does not include, and TruncateStart, the mirror of Truncate for text whose end matters more than its start. --- internal/app/taint_editor_addrow_test.go | 83 +++++++++++++++++ internal/app/view_overlay_taint_editor.go | 105 ++++++++++++++++------ internal/ui/explorer_format.go | 18 ++++ internal/ui/explorer_test.go | 28 ++++++ internal/ui/overlay_list.go | 9 ++ 5 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 internal/app/taint_editor_addrow_test.go diff --git a/internal/app/taint_editor_addrow_test.go b/internal/app/taint_editor_addrow_test.go new file mode 100644 index 00000000..8126c2c3 --- /dev/null +++ b/internal/app/taint_editor_addrow_test.go @@ -0,0 +1,83 @@ +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 — everything +// from the "add:" label on. Assertions must target this, not the whole +// overlay: the taint list above it also contains effect names. +func addRowBlock(t *testing.T, m Model) (block string, width int) { + t.Helper() + content, w, _ := m.renderOverlayTaintEditor() + plain := stripANSI(content) + _, after, found := strings.Cut(plain, "add:") + require.True(t, found, "add-row must render while an input is focused") + return after, 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") +} diff --git a/internal/app/view_overlay_taint_editor.go b/internal/app/view_overlay_taint_editor.go index 21c79281..475014cf 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,82 @@ 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 ui.OverlayDimStyle.Render(text) + 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 + } + + fields := taintAddRowFields(p, -1) + sep := taintAddRowSep + if taintAddRowNaturalWidth(p) > innerW { + fields = taintAddRowFields(p, innerW) + sep = "\n" } - effect := "effect <" + model.ValidTaintEffects[p.addEff] + ">" - if p.focus == taintFocusEffect { - effect = ui.OverlayFilterStyle.Render(effect) - } else { - effect = ui.OverlayDimStyle.Render(effect) + for i, f := range fields { + fields[i] = style(i).Render(ui.Truncate(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 From dc7e8639696a3c5dba03349a27044f1839f03d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1nos=20Mik=C3=B3?= Date: Mon, 27 Jul 2026 16:16:33 +0200 Subject: [PATCH 2/2] fix(taints): keep the caret in the add-row's safety-net truncation The per-field budget floors at one column, so on a terminal narrow enough that a field's label alone outgrows the line, taintAddRowFields returns a field wider than innerW and the final truncation runs. That cut was unconditional and right-side, which strips exactly the caret the front-truncation had just preserved -- undoing the fix at narrow widths. Pick the truncation direction by focus there too. The test helper anchored on the "add:" label to find the block; at these widths the label is itself truncated away, so it now anchors on the blank line the renderer joins the block with. --- internal/app/taint_editor_addrow_test.go | 30 ++++++++++++++++++----- internal/app/view_overlay_taint_editor.go | 10 +++++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/internal/app/taint_editor_addrow_test.go b/internal/app/taint_editor_addrow_test.go index 8126c2c3..e2fff24a 100644 --- a/internal/app/taint_editor_addrow_test.go +++ b/internal/app/taint_editor_addrow_test.go @@ -8,16 +8,18 @@ import ( "github.com/stretchr/testify/require" ) -// addRowBlock returns the add-row portion of the rendered editor — everything -// from the "add:" label on. Assertions must target this, not the whole -// overlay: the taint list above it also contains effect names. +// 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) - _, after, found := strings.Cut(plain, "add:") - require.True(t, found, "add-row must render while an input is focused") - return after, w + 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 @@ -81,3 +83,19 @@ func TestRenderOverlayTaintEditor_BoxWidensForAddRow(t *testing.T) { 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 475014cf..68dba424 100644 --- a/internal/app/view_overlay_taint_editor.go +++ b/internal/app/view_overlay_taint_editor.go @@ -165,7 +165,15 @@ func (m Model) renderTaintEditorAddRow(innerW int) string { sep = "\n" } for i, f := range fields { - fields[i] = style(i).Render(ui.Truncate(f, innerW)) + // 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)) } return strings.Join(fields, sep) }