summaryrefslogtreecommitdiff
path: root/gui/internal/ui/plan.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 09:40:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 09:40:24 +0200
commit0e067e693609589d46f5aa6e161b95cc905987cc (patch)
tree913f5f0c63923863ab0cc90fd70568ed41a2c322 /gui/internal/ui/plan.go
parent4d6386960c981678a8e8123a4db5463df7ee60bb (diff)
downloadkrino-0e067e693609589d46f5aa6e161b95cc905987cc.tar.gz
krino-0e067e693609589d46f5aa6e161b95cc905987cc.zip
gui: size and age columns, column toggles, readable colours, a laid-out explanation
Diffstat (limited to 'gui/internal/ui/plan.go')
-rw-r--r--gui/internal/ui/plan.go222
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 {