aboutsummaryrefslogtreecommitdiff
path: root/gui
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
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')
-rw-r--r--gui/internal/model/plan.go37
-rw-r--r--gui/internal/model/plan_test.go47
-rw-r--r--gui/internal/model/prefs.go6
-rw-r--r--gui/internal/model/preview.go5
-rw-r--r--gui/internal/ui/plan.go222
-rw-r--r--gui/internal/ui/settings.go16
-rw-r--r--gui/internal/ui/window.go55
7 files changed, 306 insertions, 82 deletions
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 6d3e857..51a86d8 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -9,16 +9,20 @@ import (
"context"
"fmt"
"sort"
+ "time"
"krino/internal/engine"
"krino/internal/lock"
"krino/internal/plan"
+ "krino/internal/scan"
)
// Row is one line of the Plan tab: a file krino would act on, or one it
// could not decide about.
type Row struct {
Rel string // the file, relative to the directory's root
+ Size int64
+ ModTime time.Time
Steps []plan.Step
Rule string // the rule that matched first, for the Rule column
Warnings []string
@@ -104,8 +108,10 @@ func (t *PlanTab) fill() {
r := t.dp.Result
t.Warnings = append([]string(nil), r.Warnings...)
warnings := map[string][]string{}
+ files := map[string]scan.File{}
for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} {
for _, fm := range fms {
+ files[fm.File.Rel] = fm.File
if len(fm.Warnings) > 0 {
warnings[fm.File.Rel] = fm.Warnings
}
@@ -119,7 +125,8 @@ func (t *PlanTab) fill() {
}
t.Rows = nil
for _, c := range t.dp.Chains {
- row := Row{Rel: c.File.Rel, Steps: c.Steps, Warnings: warnings[c.File.Rel]}
+ row := Row{Rel: c.File.Rel, Size: c.File.Size, ModTime: c.File.ModTime,
+ Steps: c.Steps, Warnings: warnings[c.File.Rel]}
for _, s := range c.Steps {
if s.Skip == "" {
row.Actable = true
@@ -142,7 +149,9 @@ func (t *PlanTab) fill() {
}
sort.Strings(rest)
for _, rel := range rest {
- t.Rows = append(t.Rows, Row{Rel: rel, Warnings: warnings[rel]})
+ f := files[rel]
+ t.Rows = append(t.Rows, Row{Rel: rel, Size: f.Size, ModTime: f.ModTime,
+ Warnings: warnings[rel]})
}
t.Counts = Counts{
Scanned: len(r.Matched) + len(r.Unmatched) + len(r.Skipped),
@@ -280,3 +289,27 @@ func (t *PlanTab) record(res *engine.ApplyResult) {
}
}
}
+
+// AgeText is how long ago a file was last written, in the units krino's own
+// (age ...) test uses: minutes, hours, days and weeks, and years past that,
+// so a plan can be read at a glance (his request, 2026-09-17).
+func AgeText(mod time.Time, now time.Time) string {
+ if mod.IsZero() {
+ return ""
+ }
+ d := now.Sub(mod)
+ if d < 0 {
+ return "0m" // a file dated in the future is not aged
+ }
+ switch {
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh", int(d.Hours()))
+ case d < 7*24*time.Hour:
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
+ case d < 52*7*24*time.Hour:
+ return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
+ }
+ return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
+}
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index ecbb81d..a54768c 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -361,3 +361,50 @@ func TestReplaceSelected(t *testing.T) {
t.Errorf("the unchecked file did not stay put: %v", err)
}
}
+
+// TestRowsCarrySizeAndAge: a plan's rows know how big each file is and when
+// it was last written, for the columns that show them.
+func TestRowsCarrySizeAndAge(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "three bytes and more"})
+ tab := planTab(t, e)
+ if len(tab.Rows) != 1 {
+ t.Fatalf("rows = %+v", tab.Rows)
+ }
+ fi, err := os.Stat(filepath.Join(h, "dl", "a.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tab.Rows[0].Size != fi.Size() {
+ t.Errorf("size = %d, want %d", tab.Rows[0].Size, fi.Size())
+ }
+ if !tab.Rows[0].ModTime.Equal(fi.ModTime()) {
+ t.Errorf("mtime = %v, want %v", tab.Rows[0].ModTime, fi.ModTime())
+ }
+}
+
+// TestAgeText: the units krino's own (age ...) test uses, and nothing for a
+// file whose time is unknown.
+func TestAgeText(t *testing.T) {
+ now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
+ for _, c := range []struct {
+ ago time.Duration
+ want string
+ }{
+ {30 * time.Minute, "30m"},
+ {5 * time.Hour, "5h"},
+ {3 * 24 * time.Hour, "3d"},
+ {3 * 7 * 24 * time.Hour, "3w"},
+ {3 * 365 * 24 * time.Hour, "3y"},
+ } {
+ if got := AgeText(now.Add(-c.ago), now); got != c.want {
+ t.Errorf("%v ago = %q, want %q", c.ago, got, c.want)
+ }
+ }
+ if got := AgeText(time.Time{}, now); got != "" {
+ t.Errorf("an unknown time = %q, want nothing", got)
+ }
+ if got := AgeText(now.Add(time.Hour), now); got != "0m" {
+ t.Errorf("a file from the future = %q, want 0m", got)
+ }
+}
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index 77aa784..6b3a6cb 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -31,6 +31,11 @@ type Prefs struct {
// moves is kept (his request, 2026-09-17).
ListWidth int `json:"list_width"`
ListHeight int `json:"list_height"`
+ // ShowSize, ShowAge and ShowRule are the columns that can be turned off
+ // when the window is narrow or they are not wanted.
+ ShowSize bool `json:"show_size"`
+ ShowAge bool `json:"show_age"`
+ ShowRule bool `json:"show_rule"`
// Layout is how the Plan tab is arranged: "side" puts the file list
// beside the explanation, "stacked" puts it above, with the preview
// beside the explanation underneath.
@@ -55,6 +60,7 @@ const (
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
return Prefs{Colours: true, Preview: true, SelectAll: true,
+ ShowSize: true, ShowAge: true, ShowRule: true,
PreviewHeight: DefaultPreviewHeight, PreviewWidth: DefaultPreviewWidth,
ListWidth: DefaultListWidth, ListHeight: DefaultListHeight,
Layout: LayoutSide}
diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go
index 4ec0569..a180dff 100644
--- a/gui/internal/model/preview.go
+++ b/gui/internal/model/preview.go
@@ -192,7 +192,10 @@ func isImageExt(ext string) bool {
}
// size is a file's size in the units krino's own settings use.
-func size(n int64) string {
+func size(n int64) string { return SizeText(n) }
+
+// SizeText is a file's size in the units krino's settings are written in.
+func SizeText(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1fG", float64(n)/(1<<30))
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 {
diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go
index 10f4bab..d1b5752 100644
--- a/gui/internal/ui/settings.go
+++ b/gui/internal/ui/settings.go
@@ -96,7 +96,20 @@ func (w *Window) showSettings() {
previewHeight := gtk.NewSpinButtonWithRange(80, 2000, 20)
previewHeight.SetValue(float64(prefs.PreviewHeight))
previewHeight.SetTooltipText("how tall the preview under an explanation is, in pixels; dragging the divider above it does the same")
+ showSize := gtk.NewCheckButtonWithLabel("size")
+ showSize.SetActive(prefs.ShowSize)
+ showAge := gtk.NewCheckButtonWithLabel("age")
+ showAge.SetActive(prefs.ShowAge)
+ showRule := gtk.NewCheckButtonWithLabel("rule")
+ showRule.SetActive(prefs.ShowRule)
+ columns := gtk.NewBox(gtk.OrientationHorizontal, 12)
+ columns.Append(showSize)
+ columns.Append(showAge)
+ columns.Append(showRule)
+ columns.SetTooltipText("which of the optional columns the plan shows; file, action and where it would go are always there")
+
box.Append(field("layout", layout))
+ box.Append(field("columns", columns))
box.Append(field("preview height", previewHeight))
box.Append(field("after a scan", selectAll))
box.Append(colours)
@@ -108,6 +121,9 @@ func (w *Window) showSettings() {
which = model.LayoutStacked
}
p := model.Prefs{
+ ShowSize: showSize.Active(),
+ ShowAge: showAge.Active(),
+ ShowRule: showRule.Active(),
Colours: colours.Active(),
Preview: preview.Active(),
SelectAll: selectAll.Selected() == 0,
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index 06bce05..12e94a3 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -254,12 +254,26 @@ var actionColours = map[plan.Kind]string{
plan.DeletePermanent: "#c01c28",
}
-// themeColours takes what it can from the GTK theme: the accent for the
-// actions that file a document, the theme's own warning and error colours
-// for the two that take it away. A theme that names none of them leaves the
-// fallbacks above.
+// actionClasses name the CSS class each action's cell carries. The colour
+// is applied by a style sheet rather than by painting the text, so that a
+// selected row - which draws its own background - can take the colour back
+// and stay readable: his green accent on his green selection was not (his
+// report, 2026-09-17).
+var actionClasses = map[plan.Kind]string{
+ plan.Copy: "krino-copy",
+ plan.Move: "krino-move",
+ plan.Rename: "krino-rename",
+ plan.Trash: "krino-trash",
+ plan.DeletePermanent: "krino-delete",
+}
+
+// themeColours takes what it can from the GTK theme - the accent for a
+// move, the selection blue for a rename, the theme's own success, warning
+// and error for the rest - and installs the style sheet that paints the
+// action cells. A theme that names none of them leaves the fallbacks above.
func themeColours(w gtk.Widgetter) {
- ctx := gtk.BaseWidget(w).StyleContext()
+ widget := gtk.BaseWidget(w)
+ ctx := widget.StyleContext()
pick := func(names ...string) string {
for _, name := range names {
if rgba, ok := ctx.LookupColor(name); ok {
@@ -274,12 +288,28 @@ func themeColours(w gtk.Widgetter) {
actionColours[kind] = colour
}
}
- accent := pick("accent_color", "theme_selected_bg_color", "accent_bg_color")
- set(plan.Move, accent)
+ set(plan.Move, pick("accent_color", "theme_selected_bg_color", "accent_bg_color"))
set(plan.Copy, pick("success_color", "success_bg_color"))
- set(plan.Rename, accent)
+ set(plan.Rename, pick("theme_selected_bg_color", "accent_bg_color"))
set(plan.Trash, pick("warning_color", "warning_bg_color"))
set(plan.DeletePermanent, pick("error_color", "destructive_color", "error_bg_color"))
+
+ var css strings.Builder
+ css.WriteString(".krino-action { font-weight: bold; }\n")
+ for kind, class := range actionClasses {
+ fmt.Fprintf(&css, ".%s { color: %s; }\n", class, actionColours[kind])
+ }
+ // A selected row paints its own background; the action takes that row's
+ // foreground so it never sits on a colour of its own.
+ fmt.Fprintf(&css, ".krino-warn { color: %s; }\n", actionColours[plan.Trash])
+ css.WriteString(".krino-title { font-weight: bold; font-size: 115%; }\n")
+ css.WriteString("row:selected .krino-action { color: @theme_selected_fg_color; }\n")
+ css.WriteString("row:selected .krino-action { color: @accent_fg_color; }\n")
+ provider := gtk.NewCSSProvider()
+ provider.LoadFromData(css.String())
+ if display := widget.Display(); display != nil {
+ gtk.StyleContextAddProviderForDisplay(display, provider, 700)
+ }
}
// actionRank decides which action gives a row its colour when a file gets
@@ -289,8 +319,9 @@ var actionRank = map[plan.Kind]int{
}
// rowAction is a row's actions in capitals - "MOVE", "RENAME+MOVE" - and
-// the colour they are shown in. A row that would do nothing has neither.
-func rowAction(r model.Row) (text, colour string) {
+// the CSS class they are painted with. A row that would do nothing has
+// neither.
+func rowAction(r model.Row) (text, class string) {
if len(r.Steps) == 0 {
return "", ""
}
@@ -309,9 +340,9 @@ func rowAction(r model.Row) (text, colour string) {
}
}
if len(parts) == 0 {
- return "SKIPPED", dimColour
+ return "SKIPPED", ""
}
- return strings.Join(parts, "+"), actionColours[worst]
+ return strings.Join(parts, "+"), actionClasses[worst]
}
// rowWhere is where a row's file would end up - the last place its steps