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
101 changes: 101 additions & 0 deletions internal/app/taint_editor_addrow_test.go
Original file line number Diff line number Diff line change
@@ -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 <NoSchedule>", "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 <NoSchedule>", "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 <NoSchedule>")
}

// 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")
}
113 changes: 87 additions & 26 deletions internal/app/view_overlay_taint_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// taintDisplayString renders a taint for the overlay row with control
Expand Down
18 changes: 18 additions & 0 deletions internal/ui/explorer_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions internal/ui/explorer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"
"time"

"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"

"github.com/janosmiko/lfk/internal/model"
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/overlay_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down