diff options
Diffstat (limited to 'gui/internal/model')
| -rw-r--r-- | gui/internal/model/filter.go | 107 | ||||
| -rw-r--r-- | gui/internal/model/filter_test.go | 99 | ||||
| -rw-r--r-- | gui/internal/model/plan.go | 19 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 48 | ||||
| -rw-r--r-- | gui/internal/model/prefs.go | 4 | ||||
| -rw-r--r-- | gui/internal/model/prefs_test.go | 2 |
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 { |
