aboutsummaryrefslogtreecommitdiff
path: root/gui
diff options
context:
space:
mode:
Diffstat (limited to 'gui')
-rw-r--r--gui/internal/model/prefs.go8
-rw-r--r--gui/internal/model/sort.go84
-rw-r--r--gui/internal/model/sort_test.go95
-rw-r--r--gui/internal/ui/plan.go88
-rw-r--r--gui/internal/ui/settings.go14
5 files changed, 269 insertions, 20 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)
+ }
+ }
+}
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(),