aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui/forms.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 23:31:44 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 23:31:44 +0200
commit9db67b201b80e9b7f824989df8517cefc587036d (patch)
tree18fba03617ae97e6a0eadeeb751eda97165f6123 /gui/internal/ui/forms.go
parent168fae0f72c3ca5ff9b307a42fb75173e58b5b17 (diff)
downloadkrino-9db67b201b80e9b7f824989df8517cefc587036d.tar.gz
krino-9db67b201b80e9b7f824989df8517cefc587036d.zip
gui: line numbers, a Check button, an operator for age and size, and Test rule
Diffstat (limited to 'gui/internal/ui/forms.go')
-rw-r--r--gui/internal/ui/forms.go107
1 files changed, 102 insertions, 5 deletions
diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go
index ecde546..55f6328 100644
--- a/gui/internal/ui/forms.go
+++ b/gui/internal/ui/forms.go
@@ -3,6 +3,7 @@
package ui
import (
+ "context"
"fmt"
"strings"
@@ -50,8 +51,8 @@ var condHints = map[string]string{
"name": `"^faktura" "^fv" (regexes, any may match)`,
"path": `"work/" (regex against the path under the root)`,
"content": `"invoice" "faktura" (keywords, any may match)`,
- "size": `> 10M`,
- "age": `> 90d`,
+ "size": `10M (B, K, M, G)`,
+ "age": `90d (s, m, h, d, w)`,
"duplicate": `"~/docs" (or empty: the directory's own tree)`,
"matched": `(no arguments)`,
"and": `(type pdf) (content "invoice")`,
@@ -72,6 +73,7 @@ type formsView struct {
list *gtk.ListBox
add, del, up, down *gtk.Button
+ test *gtk.Button
place *gtk.Box
note *gtk.Label
forms []model.Form
@@ -98,12 +100,14 @@ func newFormsView(w *Window, owner *rulesView) *formsView {
f.del = gtk.NewButtonWithLabel("Delete")
f.up = gtk.NewButtonWithLabel("Up")
f.down = gtk.NewButtonWithLabel("Down")
+ f.test = gtk.NewButtonWithLabel("Test rule")
+ f.test.SetTooltipText("scan the directory and list the files this rule would take")
buttons := gtk.NewBox(gtk.OrientationHorizontal, 4)
buttons.SetMarginStart(6)
buttons.SetMarginEnd(6)
buttons.SetMarginTop(4)
buttons.SetMarginBottom(4)
- for _, b := range []*gtk.Button{f.add, f.del, f.up, f.down} {
+ for _, b := range []*gtk.Button{f.add, f.del, f.up, f.down, f.test} {
buttons.Append(b)
}
@@ -148,6 +152,7 @@ func newFormsView(w *Window, owner *rulesView) *formsView {
f.del.ConnectClicked(f.onDelete)
f.up.ConnectClicked(func() { f.move(-1) })
f.down.ConnectClicked(func() { f.move(1) })
+ f.test.ConnectClicked(f.onTestRule)
return f
}
@@ -565,6 +570,64 @@ func (f *formsView) move(delta int) {
}
}
+// onTestRule scans the directory with the unsaved text and lists the files
+// the selected rule would take, in the pane on the right. It reads only -
+// no lock, nothing moved - but takes as long as a scan, so it runs off the
+// main loop (his request, 2026-09-16).
+func (f *formsView) onTestRule() {
+ if f.sel < 0 || f.sel >= len(f.forms) {
+ f.owner.showTestOutput("Select a rule in the list first.")
+ return
+ }
+ form := f.forms[f.sel]
+ if form.Kind != model.RuleForm {
+ f.owner.showTestOutput("Only a rule can be tested this way; an exclude sets files aside before the rules run.")
+ return
+ }
+ name := form.Label
+ f.owner.showTestOutput("scanning for the files " + name + " would take...")
+ f.test.SetSensitive(false)
+ var hits *model.RuleHits
+ runInBackground(func(ctx context.Context) error {
+ var err error
+ hits, err = f.owner.rules.TestRule(ctx, name)
+ return err
+ }, func(err error) {
+ f.test.SetSensitive(true)
+ if err != nil {
+ f.owner.showTestOutput(err.Error())
+ return
+ }
+ f.owner.showTestOutput(hitsText(hits, f.w.dirRoot(f.owner.rules.Name)))
+ })
+}
+
+// hitsText renders what a rule test found, destinations shortened against
+// the directory as the plan list shows them.
+func hitsText(h *model.RuleHits, root string) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "rule %s: %d of %d files\n", h.Rule, len(h.Files), h.Scanned)
+ if len(h.Files) == 0 {
+ b.WriteString("\nNo file in the directory reaches this rule. A rule above it may\nbe taking them first - Test on file explains one file in full.\n")
+ return b.String()
+ }
+ b.WriteString("\n")
+ for _, hit := range h.Files {
+ fmt.Fprintf(&b, "%s\n", hit.Rel)
+ for _, s := range hit.Steps {
+ switch {
+ case s.Skip != "":
+ fmt.Fprintf(&b, " %s skipped: %s\n", s.Kind, s.Skip)
+ case s.Dst == "":
+ fmt.Fprintf(&b, " %s\n", s.Kind)
+ default:
+ fmt.Fprintf(&b, " %s -> %s\n", s.Kind, shorten(s.Dst, root))
+ }
+ }
+ }
+ return b.String()
+}
+
// formEditor is the widgets of one form, and can write it back.
type formEditor struct {
kind model.FormKind
@@ -700,18 +763,28 @@ func (fe *formEditor) addAction(a config.Action) {
fe.acts.Append(row.root)
}
-// condRow is one condition: its kind, and its arguments as written.
+// compareOps are the comparisons (size ...) and (age ...) take.
+var compareOps = []string{">", ">=", "<", "<=", "="}
+
+// condRow is one condition: its kind, and its arguments. A size or an age
+// is a comparison, so it gets an operator of its own and a value to type
+// rather than one field holding both (his request, 2026-09-16).
type condRow struct {
root *gtk.Box
kind *gtk.DropDown
+ op *gtk.DropDown
args *gtk.Entry
gone bool
}
+// isCompare reports whether a kind is written as OPERATOR VALUE.
+func isCompare(kind string) bool { return kind == "size" || kind == "age" }
+
func newCondRow(n *sexp.Node, changed func()) *condRow {
c := &condRow{}
c.root = gtk.NewBox(gtk.OrientationHorizontal, 6)
c.kind = gtk.NewDropDownFromStrings(condItems())
+ c.op = gtk.NewDropDownFromStrings(compareOps)
c.args = gtk.NewEntry()
c.args.SetHExpand(true)
if n != nil {
@@ -719,6 +792,13 @@ func newCondRow(n *sexp.Node, changed func()) *condRow {
if i := indexOf(condKinds, head); i >= 0 {
c.kind.SetSelected(uint(i))
}
+ if isCompare(head) {
+ op, value := splitCompare(args)
+ if i := indexOf(compareOps, op); i >= 0 {
+ c.op.SetSelected(uint(i))
+ }
+ args = value
+ }
c.args.SetText(args)
}
c.showHint()
@@ -732,17 +812,32 @@ func newCondRow(n *sexp.Node, changed func()) *condRow {
c.showHint()
changed()
})
+ c.op.Connect("notify::selected", func() { changed() })
c.args.ConnectChanged(func() { changed() })
c.root.Append(c.kind)
+ c.root.Append(c.op)
c.root.Append(c.args)
c.root.Append(remove)
return c
}
+// splitCompare takes an operator and a value apart, as (age > 90d) writes
+// them; a value with no operator keeps the row's default.
+func splitCompare(args string) (op, value string) {
+ fields := strings.Fields(args)
+ if len(fields) >= 2 && indexOf(compareOps, fields[0]) >= 0 {
+ return fields[0], strings.Join(fields[1:], " ")
+ }
+ return "", args
+}
+
// text is the condition as it will be written.
func (c *condRow) text() string {
kind := condKinds[c.kind.Selected()]
args := strings.TrimSpace(c.args.Text())
+ if isCompare(kind) && args != "" {
+ args = compareOps[c.op.Selected()] + " " + args
+ }
if args == "" {
return "(" + kind + ")"
}
@@ -752,9 +847,11 @@ func (c *condRow) text() string {
// showHint puts the example for the chosen kind in the entry, where it
// shows while the row is empty and as its tooltip once it is not.
func (c *condRow) showHint() {
- hint := condHints[condKinds[c.kind.Selected()]]
+ kind := condKinds[c.kind.Selected()]
+ hint := condHints[kind]
c.args.SetPlaceholderText(hint)
c.args.SetTooltipText(hint)
+ c.op.SetVisible(isCompare(kind))
}
// actionRow is one action: what it does, and its argument.