summaryrefslogtreecommitdiff
path: root/cmd/krino/render.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 13:26:51 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 13:26:51 +0200
commit07c24054cab965800983ef40f53a05c2db131ede (patch)
tree741db561d614b35f8f94fee04fb5ce663a629a0b /cmd/krino/render.go
parent70dccf8d573028aaed64185acb8e134e5339afa3 (diff)
downloadkrino-0.0.3.tar.gz
krino-0.0.3.zip
krino: 0.0.3 — the plan as one block per file, wrapped, and -Pv0.0.3
Each file shows its steps, then the rule and the reason it matched, one field per line; on a terminal every line wraps to its width with continuation lines under their own column, and piped output is never wrapped. Choosing per file shows the same block. -P / --no-pager prints the plan without the pager. A duplicate's original is shown with ~.
Diffstat (limited to 'cmd/krino/render.go')
-rw-r--r--cmd/krino/render.go258
1 files changed, 159 insertions, 99 deletions
diff --git a/cmd/krino/render.go b/cmd/krino/render.go
index d8e6e47..8031767 100644
--- a/cmd/krino/render.go
+++ b/cmd/krino/render.go
@@ -15,20 +15,23 @@ import (
"krino/internal/xdg"
)
-// actionKindWidth is the field an action cell's kind word is left-padded
-// to before its arrow: the widest of the three kinds that carry a
-// destination ("copy", "move", "rename"). Trash and DELETE permanently
-// have no destination, hence no arrow to align, so they are not padded.
-const actionKindWidth = len("rename")
+// labelWidth is the column every label in a file's block is padded to, so
+// the values line up: the widest label, "because". A kind word longer than
+// it (DELETE permanently) has no value beside it, so nothing is misaligned.
+const labelWidth = len("because")
+
+// minWrap is the narrowest value column worth wrapping into; below it a
+// value is left whole rather than cut into a column of fragments.
+const minWrap = 10
// printPlan renders one directory's plan the way krino -n shows it, per
// spec §8.2, below the header line cmdSort has already written: a counts
-// line, the numbered action table, the warnings section and the "not
-// acted on" line, each present only when it has something to show. No
-// pager or prompt: those arrive with the review UI in plan 4, which wraps
-// this same function. p styles the table and the warnings (spec §8.2); the
-// zero palette prints plain text.
-func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette) {
+// line, one block per file that has steps, the warnings section and the
+// "not acted on" line, each present only when it has something to show.
+// p styles the blocks and the warnings; the zero palette prints plain
+// text. width wraps every line to that many columns (the terminal's); 0
+// never wraps, which keeps a plan piped to a file one field per line.
+func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width int) {
r := dp.Result
// C1 (plan 2): scanned counts matched, unmatched and skipped alike, not
// just matched plus unmatched - spec §8.2's worked example is "266
@@ -43,22 +46,24 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette) {
// D12: dp.Elapsed spans Match plus Build, unlike r.Elapsed, which stops
// before Build ever runs - the label says "planning", so the number
// must cover all of it.
- fmt.Fprintf(w, "%d scanned · %d to act on · %d warnings · %.2fs\n", scanned, countActing(dp.Chains), warnedCount(lines), dp.Elapsed.Seconds())
-
- if rows := planRows(dp.Chains, dp.Dir.Root); len(rows) > 0 {
- fmt.Fprintln(w)
- printPlanTable(w, rows, p)
+ counts := fmt.Sprintf("%d scanned · %d to act on · %d warnings · %.2fs", scanned, countActing(dp.Chains), warnedCount(lines), dp.Elapsed.Seconds())
+ for _, l := range wrapped("", counts, 2, width, plainText) {
+ fmt.Fprintln(w, l)
}
+ printBlocks(w, dp.Chains, dp.Dir.Root, p, width)
+
if len(lines) > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, p.warn("warnings"))
- printWarnings(w, lines, p)
+ printWarnings(w, lines, p, width)
}
if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
fmt.Fprintln(w)
- fmt.Fprintln(w, line)
+ for _, l := range wrapped("", line, 2, width, plainText) {
+ fmt.Fprintln(w, l)
+ }
}
if verbose {
@@ -110,66 +115,155 @@ func countActing(chains []plan.Chain) int {
return n
}
-// planRow is one line of the action table: a file's first step (num and
-// file set) or a continuation line (both blank). chainIdx (D15) is the
-// index into the DirPlan's own Chains slice this row was built from, kept
-// alongside the display strings so plan 4's per-file approval ("row 2 is
-// chain index 1", spec §8.3) does not have to re-derive the mapping later;
-// it changes no rendering here and is not itself asserted by a test.
-type planRow struct {
- num, file, actions, rule, reason string
- chainIdx int
- step plan.Step // the step this row shows, for styling its cells
-}
-
-// planRows turns the chains that have at least one step into table rows,
-// per spec §8.2: a file's first step shares its numbered row; later steps
-// sit on continuation lines with the number and file columns blank. A
-// chain with no steps at all - an exclusion, or a rule that only stops -
-// has nothing to show and contributes no row and no number. root is the
-// directory being planned, threaded down to destText so a destination
-// inside it renders root-relative rather than home-abbreviated.
-func planRows(chains []plan.Chain, root string) []planRow {
- var rows []planRow
+// printBlocks writes one block per chain that has steps (spec §8.2), a
+// blank line before each: the file's number, right-aligned, and its name,
+// then its steps (stepLines). A chain with no steps at all - an exclusion,
+// or a rule that only stops - has nothing to show and gets no block and no
+// number. The body is indented past the widest number, so labels line up
+// across the whole plan.
+func printBlocks(w io.Writer, chains []plan.Chain, root string, p palette, width int) {
n := 0
- for ci, c := range chains {
+ for _, c := range chains {
+ if len(c.Steps) > 0 {
+ n++
+ }
+ }
+ numW := len(strconv.Itoa(n))
+ indent := 2 + numW + 2
+ i := 0
+ for _, c := range chains {
if len(c.Steps) == 0 {
continue
}
- n++
- for i, s := range c.Steps {
- row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci, step: s}
- if i == 0 {
- row.num = strconv.Itoa(n)
- row.file = c.File.Rel
- }
- rows = append(rows, row)
+ i++
+ fmt.Fprintln(w)
+ head := " " + padLeft(strconv.Itoa(i), numW) + " "
+ for _, l := range wrapped(head, c.File.Rel, indent, width, plainText) {
+ fmt.Fprintln(w, l)
+ }
+ for _, l := range stepLines(c, indent, root, p, width) {
+ fmt.Fprintln(w, l)
+ }
+ }
+}
+
+// stepLines renders a chain's steps as a block body starting at column
+// indent: each step on its own line under its kind word, and after each
+// run of consecutive steps from one rule, that rule's name and - unless
+// the rule has no condition - its reason. Per-file review prints the same
+// lines under its own heading, so both views show the same information.
+func stepLines(c plan.Chain, indent int, root string, p palette, width int) []string {
+ var out []string
+ for i, s := range c.Steps {
+ label, value := s.Kind.String(), stepValue(s, root)
+ styleLabel, styleValue := kindStyle(p, s.Kind), plainText
+ if s.Skip != "" {
+ styleLabel, styleValue = p.faint, p.faint
+ }
+ out = append(out, field(indent, label, value, width, styleLabel, styleValue)...)
+ if i+1 < len(c.Steps) && c.Steps[i+1].Rule == s.Rule {
+ continue
+ }
+ out = append(out, field(indent, "rule", s.Rule, width, plainText, p.rule)...)
+ if s.Reason != "" && s.Reason != "no condition" {
+ out = append(out, field(indent, "because", s.Reason, width, plainText, p.faint)...)
}
}
- return rows
+ return out
}
-// actionCell renders one step's action column. A step with Skip set shows
-// its reason in place of the destination. Trash and DELETE permanently
-// have no destination to show - even when the step came from a duplicate
-// rule, the reason column already says what it is a duplicate of, so the
-// action column is just the kind word. Everything else (copy, move,
-// rename) shows the kind, padded so every arrow in the table lines up,
-// then its destination; Displaces adds a trailing note.
-func actionCell(s plan.Step, root string) string {
- kind := s.Kind.String()
+// stepValue is what a step's line shows after its kind word: the reason a
+// skipped step will not run; nothing for trash and DELETE permanently,
+// which have no destination; otherwise the destination (destText), with a
+// note when the step replaces an existing file.
+func stepValue(s plan.Step, root string) string {
if s.Skip != "" {
- return padCell(kind, actionKindWidth) + " skipped: " + s.Skip
+ return "skipped: " + s.Skip
}
switch s.Kind {
case plan.Trash, plan.DeletePermanent:
- return kind
+ return ""
}
- cell := padCell(kind, actionKindWidth) + " → " + destText(s, root)
+ v := "→ " + destText(s, root)
if s.Displaces != "" {
- cell += " (replaces the existing file)"
+ v += " (replaces the existing file)"
}
- return cell
+ return v
+}
+
+// kindStyle is the style of a step's kind word (spec §8.2): green for copy,
+// move and rename, yellow for trash, bold red for DELETE permanently.
+func kindStyle(p palette, k plan.Kind) func(string) string {
+ switch k {
+ case plan.Trash:
+ return p.warn
+ case plan.DeletePermanent:
+ return p.alarm
+ }
+ return p.good
+}
+
+// plainText is the identity style.
+func plainText(s string) string { return s }
+
+// field lays out one "label value" line at column indent, the label padded
+// to labelWidth and the value wrapped (see wrapped) under its own first
+// column. A label with no value is written alone. Widths are measured on
+// the plain text; styleLabel and styleValue colour each piece afterwards,
+// so escapes never shift a column.
+func field(indent int, label, value string, width int, styleLabel, styleValue func(string) string) []string {
+ lead := strings.Repeat(" ", indent)
+ if value == "" {
+ return []string{lead + styleLabel(label)}
+ }
+ pad := labelWidth - utf8.RuneCountInString(label)
+ if pad < 0 {
+ pad = 0
+ }
+ head := lead + styleLabel(label) + strings.Repeat(" ", pad) + " "
+ valueCol := indent + utf8.RuneCountInString(label) + pad + 1
+ return wrapped(head, value, valueCol, width, styleValue)
+}
+
+// wrapped returns head followed by text, wrapped so no line is wider than
+// width: the first piece of text follows head, and every further piece
+// starts at column col. head is already styled; style colours each piece
+// of text. width 0, or too little room to be worth wrapping into
+// (minWrap), leaves text whole on one line.
+func wrapped(head, text string, col, width int, style func(string) string) []string {
+ room := width - col
+ if width <= 0 || room < minWrap {
+ return []string{head + style(text)}
+ }
+ pieces := wrapText(text, room)
+ out := []string{head + style(pieces[0])}
+ pad := strings.Repeat(" ", col)
+ for _, piece := range pieces[1:] {
+ out = append(out, pad+style(piece))
+ }
+ return out
+}
+
+// wrapText splits s into pieces of at most max runes. Each break falls just
+// after the last space, "/", "_" or "-" in the second half of the piece,
+// or exactly at max when there is none, so a long word is cut rather than
+// overflowing. Every rune of s is in exactly one piece, in order: joining
+// the pieces gives s back.
+func wrapText(s string, max int) []string {
+ r := []rune(s)
+ var out []string
+ for len(r) > max {
+ cut := max
+ for i := max; i > max/2; i-- {
+ if c := r[i-1]; c == ' ' || c == '/' || c == '_' || c == '-' {
+ cut = i
+ break
+ }
+ }
+ out = append(out, string(r[:cut]))
+ r = r[cut:]
+ }
+ return append(out, string(r))
}
// destText renders a copy/move/rename step's destination, per spec §8.2:
@@ -214,43 +308,9 @@ func relToRoot(root, dir string) (rel string, ok bool) {
return filepath.ToSlash(r), true
}
-// printPlanTable prints rows in the table layout of spec §8.2: #, file,
-// actions and rule are padded to what is actually shown in this section,
-// in runes, exactly as relWidth/padCell already do elsewhere; the file
-// column is capped at 40 and the actions column at 46. The reason is the
-// last column and is never padded. The row number is right-aligned
-// (padLeft, not padCell) so the "#" column stays flush as it widens past
-// a single digit - the layout plan 4's review UI inherits unchanged. p
-// styles the action, rule and reason cells; widths are measured on the
-// plain text, so the columns line up with colour on.
-func printPlanTable(w io.Writer, rows []planRow, p palette) {
- nums := make([]string, len(rows))
- files := make([]string, len(rows))
- actions := make([]string, len(rows))
- rules := make([]string, len(rows))
- for i, r := range rows {
- nums[i], files[i], actions[i], rules[i] = r.num, r.file, r.actions, r.rule
- }
- // D13: numW and ruleW are left uncapped, unlike fileW and actionsW. The
- // row number is at most a few digits regardless of how large a
- // directory is, and a rule name is a config author's own identifier,
- // not scan noise from a user's file names - truncating a name someone
- // deliberately chose would only make the row harder to trace back to
- // its rule, so there is nothing here worth capping.
- numW := colWidth(nums, 0)
- fileW := relWidth(files)
- actionsW := colWidth(actions, 46)
- ruleW := colWidth(rules, 0)
-
- fmt.Fprintf(w, " %s %s %s rule\n", padLeft("#", numW), padCell("file", fileW), padCell("actions", actionsW))
- for _, r := range rows {
- fmt.Fprintf(w, " %s %s %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), styleAction(p, r.step, padCell(r.actions, actionsW)), padStyled(r.rule, ruleW, p.rule), p.faint(r.reason))
- }
-}
-
// padLeft pads s to width w (runes, not bytes) with leading spaces,
-// right-aligning it; s already at or beyond w is left unpadded. Used only
-// for the row-number column - every other column reads left-aligned, per
+// right-aligning it; s already at or beyond w is left unpadded. Used for
+// block and table row numbers - every other column reads left-aligned, per
// padCell.
func padLeft(s string, w int) string {
n := utf8.RuneCountInString(s)