diff options
Diffstat (limited to 'cmd/krino/render.go')
| -rw-r--r-- | cmd/krino/render.go | 258 |
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) |
