From 0e067e693609589d46f5aa6e161b95cc905987cc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 09:40:24 +0200 Subject: gui: size and age columns, column toggles, readable colours, a laid-out explanation --- gui/internal/model/plan.go | 37 ++++++- gui/internal/model/plan_test.go | 47 +++++++++ gui/internal/model/prefs.go | 6 ++ gui/internal/model/preview.go | 5 +- gui/internal/ui/plan.go | 222 ++++++++++++++++++++++++++++------------ gui/internal/ui/settings.go | 16 +++ gui/internal/ui/window.go | 55 +++++++--- 7 files changed, 306 insertions(+), 82 deletions(-) (limited to 'gui') 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 -- cgit v1.3