aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/filter.go107
-rw-r--r--gui/internal/model/filter_test.go99
-rw-r--r--gui/internal/model/plan.go19
-rw-r--r--gui/internal/model/plan_test.go48
-rw-r--r--gui/internal/model/prefs.go4
-rw-r--r--gui/internal/model/prefs_test.go2
6 files changed, 275 insertions, 4 deletions
diff --git a/gui/internal/model/filter.go b/gui/internal/model/filter.go
new file mode 100644
index 0000000..af617c3
--- /dev/null
+++ b/gui/internal/model/filter.go
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "strings"
+
+ "krino/internal/norm"
+)
+
+// FuzzyMatch reports whether every character of pattern appears in text in
+// order - the way fzf matches - and how good the match is. Capitals and
+// accents are ignored, using krino's own folding, so "zazolc" finds
+// "zażółć" exactly as a rule with (fold yes) would (his request,
+// 2026-09-17).
+//
+// The score rewards characters that follow one another and those at the
+// start of a word, so "lec" ranks "lectio-2026.pdf" above
+// "old-latex-certificate.pdf".
+func FuzzyMatch(pattern, text string) (score int, ok bool) {
+ p := []rune(norm.Fold(strings.ToLower(pattern)))
+ t := []rune(norm.Fold(strings.ToLower(text)))
+ if len(p) == 0 {
+ return 0, true
+ }
+ if len(p) > len(t) {
+ return 0, false
+ }
+ pi := 0
+ run := 0
+ for ti := 0; ti < len(t) && pi < len(p); ti++ {
+ if t[ti] != p[pi] {
+ run = 0
+ continue
+ }
+ score += 1 + run // a run of matching characters is worth more
+ if ti == 0 || isWordBreak(t[ti-1]) {
+ score += 4 // the start of a word counts for more still
+ }
+ run++
+ pi++
+ }
+ if pi < len(p) {
+ return 0, false
+ }
+ // A short name matching is a better match than a long one.
+ if len(t) > 0 {
+ score += 20 * len(p) / len(t)
+ }
+ return score, true
+}
+
+// isWordBreak reports whether a character separates words in a file name.
+func isWordBreak(r rune) bool {
+ switch r {
+ case ' ', '.', '-', '_', '/', ',', '(', ')', '[', ']':
+ return true
+ }
+ return false
+}
+
+// Matching is the rows a filter leaves, in the plan's own order - which is
+// what a list of files is read in - not in score order. An empty pattern
+// matches everything.
+func (t *PlanTab) Matching(pattern string) []int {
+ var out []int
+ for i, r := range t.Rows {
+ if _, ok := FuzzyMatch(pattern, r.Rel); ok {
+ out = append(out, i)
+ continue
+ }
+ // The rule a file took is worth searching too: "to-sort" finds
+ // everything that rule claimed.
+ if _, ok := FuzzyMatch(pattern, r.Rule); ok {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+// SelectOnly checks exactly the rows given - a filtered "Select all" - and
+// clears the rest, so what is applied is what was on screen.
+func (t *PlanTab) SelectOnly(rows []int) {
+ want := map[int]bool{}
+ for _, i := range rows {
+ want[i] = true
+ }
+ for i := range t.Rows {
+ t.Rows[i].Selected = want[i] && t.Rows[i].Actable
+ }
+}
+
+// HiddenSelected is how many checked files a filter is hiding, so the
+// window can say so rather than apply something out of sight.
+func (t *PlanTab) HiddenSelected(shown []int) int {
+ visible := map[int]bool{}
+ for _, i := range shown {
+ visible[i] = true
+ }
+ n := 0
+ for i, r := range t.Rows {
+ if r.Selected && !visible[i] {
+ n++
+ }
+ }
+ return n
+}
diff --git a/gui/internal/model/filter_test.go b/gui/internal/model/filter_test.go
new file mode 100644
index 0000000..47e4dc7
--- /dev/null
+++ b/gui/internal/model/filter_test.go
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "testing"
+)
+
+// TestFuzzyMatch: characters in order match, out of order do not, and
+// capitals and accents are ignored the way a rule with (fold yes) is.
+func TestFuzzyMatch(t *testing.T) {
+ yes := []struct{ pattern, text string }{
+ {"", "anything.pdf"},
+ {"lec", "lectio-2026-07-24.pdf"},
+ {"l24", "lectio-2026-07-24.pdf"},
+ {"LEC", "lectio-2026-07-24.pdf"},
+ {"zazolc", "zażółć gęślą jaźń.txt"},
+ {"faktura", "acme-ltd_faktura FV_12_2026.pdf"},
+ }
+ for _, c := range yes {
+ if _, ok := FuzzyMatch(c.pattern, c.text); !ok {
+ t.Errorf("%q does not match %q", c.pattern, c.text)
+ }
+ }
+ no := []struct{ pattern, text string }{
+ {"zzz", "lectio-2026-07-24.pdf"},
+ {"oitcel", "lectio.pdf"},
+ {"lectiox", "lectio.pdf"},
+ }
+ for _, c := range no {
+ if _, ok := FuzzyMatch(c.pattern, c.text); ok {
+ t.Errorf("%q matches %q and should not", c.pattern, c.text)
+ }
+ }
+}
+
+// TestFuzzyScorePrefersTheObviousMatch: a name that starts with the pattern
+// scores above one where the characters are scattered.
+func TestFuzzyScorePrefersTheObviousMatch(t *testing.T) {
+ close, ok1 := FuzzyMatch("lec", "lectio.pdf")
+ far, ok2 := FuzzyMatch("lec", "old-latex-certificate.pdf")
+ if !ok1 || !ok2 {
+ t.Fatalf("both should match: %v %v", ok1, ok2)
+ }
+ if close <= far {
+ t.Errorf("lectio.pdf scored %d, no better than the scattered match at %d", close, far)
+ }
+}
+
+// TestPlanFilter: the filter leaves the rows that match, in the plan's
+// order; checking "all" then applies to those only, and the tab can say how
+// many checked files the filter is hiding.
+func TestPlanFilter(t *testing.T) {
+ conf := "(path \"~/dl\")\n" +
+ "(rule \"pdfs\" (when (type pdf)) (move \"Docs\") (stop))\n" +
+ "(rule \"rest\" (move \"Other\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{
+ "lectio-one.pdf": "a", "lectio-two.pdf": "b", "notes.txt": "c",
+ })
+ tab := planTab(t, e)
+
+ all := tab.Matching("")
+ if len(all) != 3 {
+ t.Fatalf("an empty filter shows %d rows, want every one", len(all))
+ }
+ shown := tab.Matching("lectio")
+ if len(shown) != 2 {
+ t.Fatalf("filter shows %+v, want the two lectio files", shown)
+ }
+ for i := 1; i < len(shown); i++ {
+ if shown[i] <= shown[i-1] {
+ t.Error("the filter reordered the plan")
+ }
+ }
+ // A rule's name finds its files too.
+ if rows := tab.Matching("pdfs"); len(rows) != 2 {
+ t.Errorf("filtering by rule shows %+v, want the two pdfs", rows)
+ }
+
+ tab.SelectOnly(shown)
+ if tab.SelectedCount() != 2 {
+ t.Errorf("checked %d, want the two shown", tab.SelectedCount())
+ }
+ for _, r := range tab.Rows {
+ if r.Rel == "notes.txt" && r.Selected {
+ t.Error("a row the filter hid was checked")
+ }
+ }
+ // Now narrow the filter: one checked file is out of sight, and the tab
+ // says so.
+ narrow := tab.Matching("lectio-one")
+ if n := tab.HiddenSelected(narrow); n != 1 {
+ t.Errorf("HiddenSelected = %d, want 1", n)
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 86acab7..6d3e857 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -219,6 +219,25 @@ func (t *PlanTab) Replace(i int, kind plan.Kind) error {
return fmt.Errorf("model: %s is not in this plan", rel)
}
+// ReplaceSelected swaps the steps of every checked file for the one action
+// chosen - "Trash the checked files", "Delete them permanently" - and
+// reports how many were changed. Nothing happens on disk: like every other
+// review decision, it changes the plan, and Apply carries it out (his
+// request, 2026-09-17).
+func (t *PlanTab) ReplaceSelected(kind plan.Kind) (int, error) {
+ n := 0
+ for i, r := range t.Rows {
+ if !r.Selected {
+ continue
+ }
+ if err := t.Replace(i, kind); err != nil {
+ return n, err
+ }
+ n++
+ }
+ return n, nil
+}
+
// Apply acts on the selected files and logs the rest as declined, exactly
// as choosing per file in the terminal does. Each row then carries its
// outcome.
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index 746ddfc..ecbb81d 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -313,3 +313,51 @@ func equal(a, b []string) bool {
}
return true
}
+
+// TestReplaceSelected: one choice for every checked file at once - trash
+// them, or delete them - changing the plan and nothing on disk until Apply
+// (his request, 2026-09-17).
+func TestReplaceSelected(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two", "c.pdf": "three"})
+ tab := planTab(t, e)
+ tab.SelectNone()
+ for i, r := range tab.Rows {
+ if r.Rel != "c.pdf" {
+ tab.Toggle(i)
+ }
+ }
+ n, err := tab.ReplaceSelected(plan.Trash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 2 {
+ t.Errorf("changed %d files, want the 2 checked", n)
+ }
+ for _, r := range tab.Rows {
+ want := plan.Trash
+ if r.Rel == "c.pdf" {
+ want = plan.Move
+ }
+ if len(r.Steps) == 0 || r.Steps[0].Kind != want {
+ t.Errorf("%s: steps = %+v, want %s", r.Rel, r.Steps, want)
+ }
+ }
+ // Still nothing on disk.
+ for _, name := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(h, "dl", name)); err != nil {
+ t.Errorf("%s was touched before Apply: %v", name, err)
+ }
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 2 {
+ t.Errorf("the Trash holds %d files, want 2", len(entries))
+ }
+ // The unchecked file was declined, so it is where it was, and its plan
+ // still says move - the choice was made for the checked files only.
+ if _, err := os.Stat(filepath.Join(h, "dl", "c.pdf")); err != nil {
+ t.Errorf("the unchecked file did not stay put: %v", err)
+ }
+}
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index 52ed012..1a91194 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -14,8 +14,6 @@ import (
// files, which belongs in the configuration. It lives beside krino.conf as
// gui.json, a file krino itself never reads (his request, 2026-09-16).
type Prefs struct {
- // FontSize is the editor's font in points; 0 keeps the theme's.
- FontSize int `json:"font_size"`
// Colours paints the configuration in the Text tab.
Colours bool `json:"colours"`
// Preview shows the file behind the selected row in the Plan tab.
@@ -33,7 +31,7 @@ const DefaultPreviewHeight = 280
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
- return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true,
+ return Prefs{Colours: true, Preview: true, SelectAll: true,
PreviewHeight: DefaultPreviewHeight}
}
diff --git a/gui/internal/model/prefs_test.go b/gui/internal/model/prefs_test.go
index 6c01dee..328ac31 100644
--- a/gui/internal/model/prefs_test.go
+++ b/gui/internal/model/prefs_test.go
@@ -25,7 +25,7 @@ func sandboxHome(t *testing.T) string {
func TestPrefsRoundTrip(t *testing.T) {
h := sandboxHome(t)
p := DefaultPrefs()
- p.FontSize = 13
+ p.PreviewHeight = 420
p.Colours = false
p.SelectAll = false
if err := p.Save(); err != nil {