summaryrefslogtreecommitdiff
path: root/gui/internal/model/filter.go
blob: af617c3555a4950fddccbf99eae365fbebeec78d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
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
}