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 /gui/internal/model | |
| parent | 0e067e693609589d46f5aa6e161b95cc905987cc (diff) | |
| download | krino-47b6776c2cb9b017ad95acb5aae8e34b8776fc7c.tar.gz krino-47b6776c2cb9b017ad95acb5aae8e34b8776fc7c.zip | |
gui: sort the plan by name, size, age, action or rule
Diffstat (limited to 'gui/internal/model')
| -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 |
3 files changed, 186 insertions, 1 deletions
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) + } + } +} |
