diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 10:05:55 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 10:05:55 +0200 |
| commit | 47b6776c2cb9b017ad95acb5aae8e34b8776fc7c (patch) | |
| tree | 3efb4a2f2c36f4537a3fe1377cfb010fd63072ec | |
| parent | 0e067e693609589d46f5aa6e161b95cc905987cc (diff) | |
| download | krino-47b6776c2cb9b017ad95acb5aae8e34b8776fc7c.tar.gz krino-47b6776c2cb9b017ad95acb5aae8e34b8776fc7c.zip | |
gui: sort the plan by name, size, age, action or rule
| -rw-r--r-- | docs/gui-checklist.md | 3 | ||||
| -rw-r--r-- | gui/internal/model/prefs.go | 8 | ||||
| -rw-r--r-- | gui/internal/model/sort.go | 84 | ||||
| -rw-r--r-- | gui/internal/model/sort_test.go | 95 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 88 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 14 | ||||
| -rw-r--r-- | man/krino-gui.1 | 6 |
7 files changed, 277 insertions, 21 deletions
diff --git a/docs/gui-checklist.md b/docs/gui-checklist.md index 641d208..b7292b9 100644 --- a/docs/gui-checklist.md +++ b/docs/gui-checklist.md @@ -133,3 +133,6 @@ A dialog is its own window: take it by its own id, not the main window's. 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. +44. The sort picker lists the plan as scanned, by name, size, age, action + or rule; Settings holds the one a fresh window starts with, and the + picker keeps what is chosen by hand until the window closes. diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go index 6b3a6cb..2d2dc04 100644 --- a/gui/internal/model/prefs.go +++ b/gui/internal/model/prefs.go @@ -36,6 +36,9 @@ type Prefs struct { ShowSize bool `json:"show_size"` ShowAge bool `json:"show_age"` ShowRule bool `json:"show_rule"` + // Sort is the order a freshly scanned plan is read in; the picker in + // the toolbar changes it for the window in front of you. + Sort string `json:"sort"` // 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. @@ -60,7 +63,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, + ShowSize: true, ShowAge: true, ShowRule: true, Sort: SortDefault, PreviewHeight: DefaultPreviewHeight, PreviewWidth: DefaultPreviewWidth, ListWidth: DefaultListWidth, ListHeight: DefaultListHeight, Layout: LayoutSide} @@ -91,6 +94,9 @@ func LoadPrefs() Prefs { if p.Layout != LayoutSide && p.Layout != LayoutStacked { p.Layout = LayoutSide } + if !IsSortOrder(p.Sort) { + p.Sort = SortDefault + } return p } diff --git a/gui/internal/model/sort.go b/gui/internal/model/sort.go new file mode 100644 index 0000000..9968a08 --- /dev/null +++ b/gui/internal/model/sort.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "sort" + "strings" +) + +// The orders a plan can be read in. "default" is the order krino planned +// the directory in, which is the order the files were scanned; the rest are +// what a column is worth sorting by (his request, 2026-09-17). +const ( + SortDefault = "default" + SortName = "name" + SortSize = "size" + SortAge = "age" + SortAction = "action" + SortRule = "rule" +) + +// SortOrders are the choices, in the order they are offered. +var SortOrders = []string{SortDefault, SortName, SortSize, SortAge, SortAction, SortRule} + +// SortLabels say which way each order runs, for the picker and its help. +var SortLabels = map[string]string{ + SortDefault: "as scanned", + SortName: "name, A to Z", + SortSize: "size, largest first", + SortAge: "age, oldest first", + SortAction: "action, A to Z", + SortRule: "rule, A to Z", +} + +// IsSortOrder reports whether s names one of them. +func IsSortOrder(s string) bool { + for _, o := range SortOrders { + if o == s { + return true + } + } + return false +} + +// Sorted puts the rows named by shown in the order asked for, leaving the +// plan itself alone: only the reading order changes. Ties keep the plan's +// own order, so the list never shuffles between two files that compare the +// same. +func (t *PlanTab) Sorted(shown []int, by string) []int { + out := append([]int(nil), shown...) + if by == SortDefault || by == "" || !IsSortOrder(by) { + return out + } + less := func(a, b int) bool { + x, y := t.Rows[a], t.Rows[b] + switch by { + case SortName: + return strings.ToLower(x.Rel) < strings.ToLower(y.Rel) + case SortSize: + return x.Size > y.Size + case SortAge: + // Oldest first: the files an (age > ...) rule is about. + return x.ModTime.Before(y.ModTime) + case SortAction: + return firstAction(x) < firstAction(y) + case SortRule: + return strings.ToLower(x.Rule) < strings.ToLower(y.Rule) + } + return false + } + sort.SliceStable(out, func(i, j int) bool { return less(out[i], out[j]) }) + return out +} + +// firstAction is what a row does, for sorting; a row that does nothing +// sorts after the ones that do. +func firstAction(r Row) string { + for _, s := range r.Steps { + if s.Skip == "" { + return s.Kind.String() + } + } + return "~" +} diff --git a/gui/internal/model/sort_test.go b/gui/internal/model/sort_test.go new file mode 100644 index 0000000..0b3a01d --- /dev/null +++ b/gui/internal/model/sort_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// names is the rows an order leaves, for the assertions below. +func names(t *PlanTab, shown []int) []string { + var out []string + for _, i := range shown { + out = append(out, t.Rows[i].Rel) + } + return out +} + +func same(a []string, b ...string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestSorted: each order reads the way its label says, and the default +// leaves the plan as krino made it. +func TestSorted(t *testing.T) { + conf := "(path \"~/dl\")\n" + + "(rule \"pdfs\" (when (type pdf)) (move \"Docs\") (stop))\n" + + "(rule \"rest\" (move \"Other\"))\n" + e, h := sandboxDir(t, conf, map[string]string{ + "big.pdf": "0123456789abcdef", "small.txt": "x", "middle.pdf": "0123456789", + }) + // Distinct times, so age has something to order by. + now := time.Now() + for name, ago := range map[string]time.Duration{ + "big.pdf": 3 * time.Hour, "small.txt": 90 * 24 * time.Hour, "middle.pdf": 24 * time.Hour, + } { + p := filepath.Join(h, "dl", name) + when := now.Add(-ago) + if err := os.Chtimes(p, when, when); err != nil { + t.Fatal(err) + } + } + tab := planTab(t, e) + all := tab.Matching("") + + if got := names(tab, tab.Sorted(all, SortDefault)); len(got) != 3 { + t.Fatalf("default = %v", got) + } + if got := names(tab, tab.Sorted(all, SortName)); !same(got, "big.pdf", "middle.pdf", "small.txt") { + t.Errorf("by name = %v", got) + } + if got := names(tab, tab.Sorted(all, SortSize)); !same(got, "big.pdf", "middle.pdf", "small.txt") { + t.Errorf("by size = %v", got) + } + if got := names(tab, tab.Sorted(all, SortAge)); !same(got, "small.txt", "middle.pdf", "big.pdf") { + t.Errorf("by age = %v, want the oldest first", got) + } + if got := names(tab, tab.Sorted(all, SortRule)); !same(got, "big.pdf", "middle.pdf", "small.txt") { + t.Errorf("by rule = %v, want pdfs before rest", got) + } + // An order krino does not know leaves the plan alone rather than + // guessing at one. + if got := names(tab, tab.Sorted(all, "nonsense")); !same(got, names(tab, all)...) { + t.Errorf("an unknown order changed the list: %v", got) + } + // Sorting never changes the plan itself. + if len(tab.Rows) != 3 || tab.Rows[0].Rel != names(tab, all)[0] { + t.Error("the plan's own order changed") + } +} + +// TestSortedIsStable: files that compare the same keep the plan's order, so +// the list does not shuffle under the eye. +func TestSortedIsStable(t *testing.T) { + conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" + e, _ := sandboxDir(t, conf, map[string]string{"a.txt": "x", "b.txt": "x", "c.txt": "x"}) + tab := planTab(t, e) + all := tab.Matching("") + first := names(tab, tab.Sorted(all, SortSize)) + for i := 0; i < 5; i++ { + if got := names(tab, tab.Sorted(all, SortSize)); !same(got, first...) { + t.Fatalf("run %d = %v, want %v", i, got, first) + } + } +} diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index b861b14..8b6a394 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -40,29 +40,31 @@ type planView struct { groups [8]*gtk.SizeGroup listScroll *gtk.ScrolledWindow filter *gtk.SearchEntry + sort *gtk.DropDown shown []int list *gtk.ListBox headerBox *gtk.Box details *gtk.Box - previewNote *gtk.Label - picture *gtk.Picture - previewText *gtk.TextView - previewScroll *gtk.ScrolledWindow - previewTmp string - previewFor string - detailPane *gtk.Paned - arrangement *gtk.Paned - listSide *gtk.Box - detailScroll *gtk.ScrolledWindow - previewBox *gtk.Box - layout string - renderedDir string - previewHeight int - previewOff bool - startSelected bool - menu *gtk.Popover - menuRow int + previewNote *gtk.Label + picture *gtk.Picture + previewText *gtk.TextView + previewScroll *gtk.ScrolledWindow + previewTmp string + previewFor string + detailPane *gtk.Paned + arrangement *gtk.Paned + listSide *gtk.Box + detailScroll *gtk.ScrolledWindow + previewBox *gtk.Box + layout string + renderedDir string + previewHeight int + previewOff bool + sortFollowsPrefs bool + startSelected bool + menu *gtk.Popover + menuRow int tab *model.PlanTab cancelOp context.CancelFunc @@ -113,6 +115,13 @@ func newPlanView(w *Window) *planView { p.filter.SetSizeRequest(200, -1) bar.Append(p.filter) + // The order the plan is read in. It starts as the settings say and can + // be changed for this window alone (his request, 2026-09-17). + p.sort = gtk.NewDropDownFromStrings(sortItems()) + p.sort.SetTooltipText("the order the plan is listed in; Settings has the one a new window starts with") + bar.Append(gtk.NewLabel("sort")) + bar.Append(p.sort) + bar.Append(p.path) bar.Append(p.selAll) bar.Append(p.selNone) @@ -206,6 +215,11 @@ func newPlanView(w *Window) *planView { } }) p.filter.ConnectSearchChanged(func() { p.fillList() }) + p.sortFollowsPrefs = true + p.sort.Connect("notify::selected", func() { + p.sortFollowsPrefs = false + p.fillList() + }) p.selAll.ConnectClicked(func() { p.selectAll(true) }) p.selNone.ConnectClicked(func() { p.selectAll(false) }) p.list.ConnectRowSelected(func(row *gtk.ListBoxRow) { @@ -404,6 +418,10 @@ func (p *planView) confirmDeletePermanent() { func (p *planView) showPath() { if d := p.currentDir(); d != nil { p.path.SetText(escape(xdg.Abbrev(d.Root))) + // The toolbar is crowded, so the path is the first thing to be + // squeezed; the tooltip always has it in full. + p.path.SetTooltipText(escape(d.Root)) + p.dirs.SetTooltipText(escape(d.Root)) return } p.path.SetText("no directory is included; add one with: krino new NAME PATH") @@ -560,7 +578,7 @@ func (p *planView) fillList() { } w := p.widths() p.header(w) - p.shown = p.tab.Matching(p.filter.Text()) + p.shown = p.tab.Sorted(p.tab.Matching(p.filter.Text()), p.sortOrder()) for _, i := range p.shown { p.list.Append(p.rowWidget(i, p.tab.Rows[i], w)) } @@ -622,6 +640,35 @@ const ( colOutcome ) +// sortItems are the orders as the picker shows them. +func sortItems() []string { + out := make([]string, len(model.SortOrders)) + for i, o := range model.SortOrders { + out[i] = model.SortLabels[o] + } + return out +} + +// sortOrder is the order the picker names. +func (p *planView) sortOrder() string { + i := int(p.sort.Selected()) + if i < 0 || i >= len(model.SortOrders) { + return model.SortDefault + } + return model.SortOrders[i] +} + +// setSortOrder puts the picker on an order. +func (p *planView) setSortOrder(order string) { + for i, o := range model.SortOrders { + if o == order { + p.sort.SetSelected(uint(i)) + return + } + } + p.sort.SetSelected(0) +} + // 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 { @@ -1062,6 +1109,9 @@ func (p *planView) setPreviewHeight(height int) { func (p *planView) applyPrefs(prefs model.Prefs) { p.previewOff = !prefs.Preview p.startSelected = prefs.SelectAll + if p.sortFollowsPrefs { + p.setSortOrder(prefs.Sort) + } p.setLayout(prefs.Layout) if p.tab != nil { p.fillList() diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go index d1b5752..e15f60d 100644 --- a/gui/internal/ui/settings.go +++ b/gui/internal/ui/settings.go @@ -108,6 +108,15 @@ func (w *Window) showSettings() { columns.Append(showRule) columns.SetTooltipText("which of the optional columns the plan shows; file, action and where it would go are always there") + sortOrder := gtk.NewDropDownFromStrings(sortItems()) + for i, o := range model.SortOrders { + if o == prefs.Sort { + sortOrder.SetSelected(uint(i)) + } + } + sortOrder.SetTooltipText("the order a freshly scanned plan is listed in; the picker in the toolbar changes it for one window") + + box.Append(field("sort by", sortOrder)) box.Append(field("layout", layout)) box.Append(field("columns", columns)) box.Append(field("preview height", previewHeight)) @@ -120,7 +129,12 @@ func (w *Window) showSettings() { if layout.Selected() == 1 { which = model.LayoutStacked } + order := model.SortDefault + if i := int(sortOrder.Selected()); i >= 0 && i < len(model.SortOrders) { + order = model.SortOrders[i] + } p := model.Prefs{ + Sort: order, ShowSize: showSize.Active(), ShowAge: showAge.Active(), ShowRule: showRule.Active(), diff --git a/man/krino-gui.1 b/man/krino-gui.1 index acce43d..87c9860 100644 --- a/man/krino-gui.1 +++ b/man/krino-gui.1 @@ -43,7 +43,11 @@ Pick one of the directories .Pa krino.conf includes and press .Cm Scan . -The box beside it filters the plan: type a few letters of a name or of a +The picker beside them lists the plan in another order - as scanned, by +name, size, age, action or rule - +.Cm Settings +holding the one a new window starts with. The box beside them filters the +plan: type a few letters of a name or of a rule, in order, and only the files they appear in are listed - capitals and accents ignored, as a rule with .Ic (fold yes) |
