diff options
Diffstat (limited to 'gui/internal/ui/plan.go')
| -rw-r--r-- | gui/internal/ui/plan.go | 222 |
1 files changed, 155 insertions, 67 deletions
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index 4fa9c39..b861b14 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/pango" @@ -36,13 +37,13 @@ type planView struct { selNone *gtk.Button checked *gtk.MenuButton - groups [6]*gtk.SizeGroup + groups [8]*gtk.SizeGroup listScroll *gtk.ScrolledWindow filter *gtk.SearchEntry shown []int list *gtk.ListBox headerBox *gtk.Box - details *gtk.TextView + details *gtk.Box previewNote *gtk.Label picture *gtk.Picture @@ -137,19 +138,21 @@ func newPlanView(w *Window) *planView { listSide.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) listSide.Append(listScroll) - p.details = gtk.NewTextView() - p.details.Buffer().SetText("Select a file to see what would happen to it, and why.") - p.details.SetEditable(false) - p.details.SetMonospace(true) - p.details.SetWrapMode(gtk.WrapWord) - p.details.SetLeftMargin(8) - p.details.SetRightMargin(8) - p.details.SetTopMargin(6) - p.details.SetBottomMargin(6) + // The explanation is laid out rather than printed: the file's name, then + // a line per step with the action in its own colour, centred in the + // pane so the eye lands on it (his request, 2026-09-17). + p.details = gtk.NewBox(gtk.OrientationVertical, 6) + p.details.SetHAlign(gtk.AlignCenter) + p.details.SetVAlign(gtk.AlignStart) + p.details.SetMarginTop(16) + p.details.SetMarginBottom(12) + p.details.SetMarginStart(12) + p.details.SetMarginEnd(12) + p.detailsHint("Select a file to see what would happen to it, and why.") detailScroll := gtk.NewScrolledWindow() detailScroll.SetChild(p.details) detailScroll.SetVExpand(true) - detailScroll.SetSizeRequest(-1, 160) + detailScroll.SetSizeRequest(-1, 200) // Under the explanation, a look at the file itself: a picture for an // image, the first page for a PDF, the first lines for anything that is @@ -581,20 +584,23 @@ func (p *planView) fillList() { // longest value it holds, within limits, so a rule name or an outcome is // shown whole rather than cut to an ellipsis. The list scrolls sideways // when the total does not fit (his report, 2026-09-16). -func (p *planView) widths() [5]int { - w := [5]int{16, 6, 16, 8, 6} +func (p *planView) widths() [7]int { + w := [7]int{16, 4, 3, 6, 16, 8, 6} root := p.dirRoot() + now := time.Now() for _, r := range p.tab.Rows { action, _ := rowAction(r) - w[0] = max(w[0], len([]rune(r.Rel))) - w[1] = max(w[1], len([]rune(action))) - w[2] = max(w[2], len([]rune(rowWhere(r, root)))) - w[3] = max(w[3], len([]rune(r.Rule))) - w[4] = max(w[4], len([]rune(r.Outcome))) + w[colFile] = max(w[colFile], len([]rune(r.Rel))) + w[colSize] = max(w[colSize], len([]rune(model.SizeText(r.Size)))) + w[colAge] = max(w[colAge], len([]rune(model.AgeText(r.ModTime, now)))) + w[colAction] = max(w[colAction], len([]rune(action))) + w[colWhere] = max(w[colWhere], len([]rune(rowWhere(r, root)))) + w[colRule] = max(w[colRule], len([]rune(r.Rule))) + w[colOutcome] = max(w[colOutcome], len([]rune(r.Outcome))) } // Past these a single long value would push every other column off the // window; the details pane holds the whole text either way. - for i, cap := range [5]int{40, 16, 52, 34, 24} { + for i, cap := range [7]int{40, 7, 5, 16, 52, 34, 24} { w[i] = min(w[i], cap) } // A column is never narrower than its own heading, or the heading is @@ -605,6 +611,33 @@ func (p *planView) widths() [5]int { return w } +// The columns of the plan, in the order they are shown. +const ( + colFile = iota + colSize + colAge + colAction + colWhere + colRule + colOutcome +) + +// showColumn reports whether a column is shown: three of them are settings, +// and the outcome appears only once a plan has been applied. +func (p *planView) showColumn(i int) bool { + switch i { + case colSize: + return p.w.prefs.ShowSize + case colAge: + return p.w.prefs.ShowAge + case colRule: + return p.w.prefs.ShowRule + case colOutcome: + return p.showOutcome() + } + return true +} + // showOutcome reports whether there is anything to put in the last column. func (p *planView) showOutcome() bool { return p.tab != nil && p.tab.Applied @@ -612,13 +645,13 @@ func (p *planView) showOutcome() bool { // headerFloors are how narrow each heading may become, matching the cells // under it. -var headerFloors = [5]int{12, 6, 16, 10, 8} +var headerFloors = [7]int{12, 4, 3, 6, 16, 10, 8} // columnTitles name the columns of the plan. -var columnTitles = [5]string{"file", "action", "where it would go", "rule", "outcome"} +var columnTitles = [7]string{"file", "size", "age", "action", "where it would go", "rule", "outcome"} // header is the line above the list saying what each column is. -func (p *planView) header(w [5]int) { +func (p *planView) header(w [7]int) { if child := p.headerBox.FirstChild(); child != nil { for child != nil { next := gtk.BaseWidget(child).NextSibling() @@ -634,10 +667,10 @@ func (p *planView) header(w [5]int) { p.groups[0].AddWidget(spacer) p.headerBox.Append(spacer) for i, title := range columnTitles { - if i == 4 && !p.showOutcome() { + if !p.showColumn(i) { continue } - l := columnMin(title, w[i], headerFloors[i], i == 0 || i == 2) + l := columnMin(title, w[i], headerFloors[i], i == colFile || i == colWhere) l.AddCSSClass("heading") l.SetTooltipText("") p.groups[i+1].AddWidget(l) @@ -646,7 +679,7 @@ func (p *planView) header(w [5]int) { } // rowWidget is one line of the list. -func (p *planView) rowWidget(i int, r model.Row, w [5]int) *gtk.ListBoxRow { +func (p *planView) rowWidget(i int, r model.Row, w [7]int) *gtk.ListBoxRow { box := gtk.NewBox(gtk.OrientationHorizontal, 8) box.SetMarginStart(6) box.SetMarginEnd(6) @@ -673,18 +706,19 @@ func (p *planView) rowWidget(i int, r model.Row, w [5]int) *gtk.ListBoxRow { // Every column can shrink: a pane narrower than their natural widths // used to push the whole row out of view to the left (his report, // 2026-09-17). - cells := []gtk.Widgetter{ - columnMin(escape(r.Rel), w[0], 12, true), - colouredColumn(action, w[1], colour), - columnMin(escape(rowWhere(r, p.dirRoot())), w[2], 16, true), - columnMin(escape(r.Rule), w[3], 10, false), - } - // The outcome column appears once a plan has been applied: before that - // it would be an empty column with a heading over it. - if p.showOutcome() { - cells = append(cells, columnMin(escape(r.Outcome), w[4], 8, false)) + cells := [7]gtk.Widgetter{ + colFile: columnMin(escape(r.Rel), w[colFile], 12, true), + colSize: columnMin(model.SizeText(r.Size), w[colSize], 4, false), + colAge: columnMin(model.AgeText(r.ModTime, time.Now()), w[colAge], 3, false), + colAction: colouredColumn(action, w[colAction], colour), + colWhere: columnMin(escape(rowWhere(r, p.dirRoot())), w[colWhere], 16, true), + colRule: columnMin(escape(r.Rule), w[colRule], 10, false), + colOutcome: columnMin(escape(r.Outcome), w[colOutcome], 8, false), } for i, cell := range cells { + if !p.showColumn(i) { + continue + } p.groups[i+1].AddWidget(cell) box.Append(cell) } @@ -729,68 +763,119 @@ func columnMin(text string, chars, floor int, expand bool) *gtk.Label { return l } -// colouredColumn is a fixed column painted in a colour - the actions, so -// that a MOVE and a DELETE do not read alike. An empty colour leaves the -// theme's own. -func colouredColumn(text string, chars int, colour string) *gtk.Label { +// colouredColumn is a fixed column carrying the CSS classes that colour an +// action, so that a MOVE and a DELETE do not read alike - and so that a +// selected row can take the colour back for readability. +func colouredColumn(text string, chars int, class string) *gtk.Label { l := column(escape(text), chars, false) - if colour == "" || text == "" { + if class == "" || text == "" { return l } - r, g, b := rgb16(colour) - attrs := pango.NewAttrList() - fg := pango.NewAttrForeground(r, g, b) - attrs.Insert(fg) - attrs.Insert(pango.NewAttrWeight(pango.WeightBold)) - l.SetAttributes(attrs) + l.AddCSSClass("krino-action") + l.AddCSSClass(class) return l } -// rgb16 reads "#rrggbb" as the 16-bit channels pango wants. -func rgb16(hex string) (r, g, b uint16) { - var rr, gg, bb int - fmt.Sscanf(hex, "#%02x%02x%02x", &rr, &gg, &bb) - scale := func(v int) uint16 { return uint16(v)<<8 | uint16(v) } - return scale(rr), scale(gg), scale(bb) -} - // yieldChars is how narrow an expanding column may become. const yieldChars = 14 -// showDetails writes the selected file's steps and warnings into the pane. +// detailsHint empties the explanation and leaves one dim line in it. +func (p *planView) detailsHint(text string) { + p.clearDetails() + l := gtk.NewLabel(text) + l.SetWrap(true) + l.SetJustify(gtk.JustifyCenter) + l.AddCSSClass("dim-label") + p.details.Append(l) +} + +// clearDetails takes the last explanation out of the pane. +func (p *planView) clearDetails() { + for child := p.details.FirstChild(); child != nil; child = p.details.FirstChild() { + p.details.Remove(child) + } +} + +// detailLine is one centred line of the explanation. +func detailLine(text string, classes ...string) *gtk.Label { + l := gtk.NewLabel(text) + l.SetWrap(true) + l.SetJustify(gtk.JustifyCenter) + l.SetMaxWidthChars(60) + l.SetSelectable(true) + for _, c := range classes { + l.AddCSSClass(c) + } + return l +} + +// showDetails lays out the selected file's steps and warnings. func (p *planView) showDetails(i int) { if p.tab == nil || i < 0 || i >= len(p.tab.Rows) { return } r := p.tab.Rows[i] - var b strings.Builder - b.WriteString(escape(r.Rel) + "\n\n") + p.clearDetails() + + title := detailLine(escape(r.Rel), "krino-title") + p.details.Append(title) + if what := describeFile(r); what != "" { + p.details.Append(detailLine(what, "dim-label")) + } + for _, s := range r.Steps { + row := gtk.NewBox(gtk.OrientationHorizontal, 8) + row.SetHAlign(gtk.AlignCenter) + row.SetMarginTop(6) + action := gtk.NewLabel(strings.ToUpper(s.Kind.String())) + action.AddCSSClass("krino-action") + if class, ok := actionClasses[s.Kind]; ok { + action.AddCSSClass(class) + } + row.Append(action) switch { case s.Skip != "": - fmt.Fprintf(&b, "%s skipped: %s\n", s.Kind, escape(s.Skip)) - case s.Kind == plan.Trash || s.Kind == plan.DeletePermanent: - fmt.Fprintf(&b, "%s\n", s.Kind) + row.Append(detailLine("skipped: "+escape(s.Skip), "dim-label")) + case s.Kind == plan.Trash: + row.Append(detailLine("to the Trash")) + case s.Kind == plan.DeletePermanent: + row.Append(detailLine("gone for good", "krino-warn")) default: - fmt.Fprintf(&b, "%s -> %s\n", s.Kind, escape(shorten(s.Dst, p.dirRoot()))) + row.Append(detailLine("→ " + escape(shorten(s.Dst, p.dirRoot())))) } + p.details.Append(row) if s.Rule != "" { - fmt.Fprintf(&b, " rule %s\n", escape(s.Rule)) + p.details.Append(detailLine("rule "+escape(s.Rule), "dim-label")) } if s.Reason != "" { - fmt.Fprintf(&b, " because %s\n", escape(s.Reason)) + p.details.Append(detailLine("because "+escape(s.Reason), "dim-label")) } } for _, warn := range r.Warnings { - b.WriteString("\n" + escape(warn) + "\n") + p.details.Append(detailLine(escape(warn), "krino-warn")) } if r.Outcome != "" { - b.WriteString("\n" + escape(r.Outcome) + "\n") + p.details.Append(detailLine(escape(r.Outcome), "krino-title")) + } + if adj := p.detailScroll.VAdjustment(); adj != nil { + adj.SetValue(0) } - p.details.Buffer().SetText(b.String()) p.showPreview(r.Rel) } +// describeFile is the line under the name: how big the file is and how long +// it has been sitting there. +func describeFile(r model.Row) string { + var parts []string + if r.Size > 0 { + parts = append(parts, model.SizeText(r.Size)) + } + if age := model.AgeText(r.ModTime, time.Now()); age != "" { + parts = append(parts, "last written "+age+" ago") + } + return strings.Join(parts, ", ") +} + // showPreview looks at the file behind row rel, off the main loop: reading // it, and rendering a PDF page, takes long enough to stutter the window. func (p *planView) showPreview(rel string) { @@ -978,6 +1063,9 @@ func (p *planView) applyPrefs(prefs model.Prefs) { p.previewOff = !prefs.Preview p.startSelected = prefs.SelectAll p.setLayout(prefs.Layout) + if p.tab != nil { + p.fillList() + } if prefs.Layout == model.LayoutStacked { p.setPreviewHeight(orDefault(prefs.PreviewWidth, model.DefaultPreviewWidth)) } else { |
