diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 09:40:24 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 09:40:24 +0200 |
| commit | 0e067e693609589d46f5aa6e161b95cc905987cc (patch) | |
| tree | 913f5f0c63923863ab0cc90fd70568ed41a2c322 | |
| parent | 4d6386960c981678a8e8123a4db5463df7ee60bb (diff) | |
| download | krino-0e067e693609589d46f5aa6e161b95cc905987cc.tar.gz krino-0e067e693609589d46f5aa6e161b95cc905987cc.zip | |
gui: size and age columns, column toggles, readable colours, a laid-out explanation
| -rw-r--r-- | docs/gui-checklist.md | 6 | ||||
| -rw-r--r-- | gui/internal/model/plan.go | 37 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 47 | ||||
| -rw-r--r-- | gui/internal/model/prefs.go | 6 | ||||
| -rw-r--r-- | gui/internal/model/preview.go | 5 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 222 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 16 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 55 | ||||
| -rw-r--r-- | man/krino-gui.1 | 16 |
9 files changed, 322 insertions, 88 deletions
diff --git a/docs/gui-checklist.md b/docs/gui-checklist.md index 5cc5734..641d208 100644 --- a/docs/gui-checklist.md +++ b/docs/gui-checklist.md @@ -127,3 +127,9 @@ A dialog is its own window: take it by its own id, not the main window's. 40. Both dividers stay where they are dragged, per layout, across a restart, and a list narrower than its columns still starts at the file names rather than scrolled past them. +41. Size and age read in krino's own units, and each of size, age and rule + can be turned off in Settings. +42. A selected row's action stays readable: it takes the row's own + foreground rather than keeping its colour on the selection background. +43. The explanation beside the list is centred: the file's name, its size + and age, then a line per step with the action coloured. 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 diff --git a/man/krino-gui.1 b/man/krino-gui.1 index cfd6ff4..acce43d 100644 --- a/man/krino-gui.1 +++ b/man/krino-gui.1 @@ -53,15 +53,19 @@ checks what is on screen and the status line says how many checked files the filter is hiding. Scanning takes that directory's lock, which is held while the plan is shown, so nothing moves underneath it; a directory another krino is working -in is reported rather than waited for. Each row is a file, the action in capitals and coloured by what it does - -the colours are the running GTK theme's own accent, warning and error - where -the file would go, and the rule that decided; a header names the columns, -and an outcome column appears once a plan has been applied. A file krino could not +in is reported rather than waited for. Each row is a file, its size and age, the action in capitals and coloured by +what it does - the colours are the running GTK theme's own accent, +selection, success, warning and error - where the file would go, and the +rule that decided; a header names the columns, an outcome column appears +once a plan has been applied, and size, age and rule can each be turned off +under +.Cm Settings . +Selecting a row explains it beside the list: the name, how big and how old +the file is, and a line per step with its action, rule and reason. A file krino could not decide about - unreadable content, a failed duplicate check - is listed with the reason and cannot be selected. .Pp -Selecting a row explains it: every step with its rule and reason, and any -warning, and shows the file itself underneath: a picture for an image, the +The explanation also shows the file itself underneath: a picture for an image, the first page for a PDF, the first lines for anything that is text. Nothing else is opened, and the file is only read. Every divider is kept where it is put: the one between the file list and the explanation, and the one above the preview, each remembered for the |
