diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 23:31:44 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 23:31:44 +0200 |
| commit | 9db67b201b80e9b7f824989df8517cefc587036d (patch) | |
| tree | 18fba03617ae97e6a0eadeeb751eda97165f6123 /gui/internal/ui | |
| parent | 168fae0f72c3ca5ff9b307a42fb75173e58b5b17 (diff) | |
| download | krino-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')
| -rw-r--r-- | gui/internal/ui/forms.go | 107 | ||||
| -rw-r--r-- | gui/internal/ui/rules.go | 82 |
2 files changed, 177 insertions, 12 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. diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go index 939d77f..0a10996 100644 --- a/gui/internal/ui/rules.go +++ b/gui/internal/ui/rules.go @@ -27,13 +27,15 @@ type rulesView struct { w *Window root *gtk.Box - dirs *gtk.DropDown - names []string - save *gtk.Button - revert *gtk.Button - reload *gtk.Button + dirs *gtk.DropDown + names []string + save *gtk.Button + revert *gtk.Button + reload *gtk.Button + checkNow *gtk.Button view *gtk.TextView + nums *gtk.TextView buf *gtk.TextBuffer check *gtk.Label diags *gtk.ListBox @@ -46,6 +48,7 @@ type rulesView struct { forms *formsView rules *model.Rules pending glib.SourceHandle + lines int quiet bool // set while the view is being filled, so no check is armed inSwap bool // set while a sub-tab switch is being handled } @@ -64,6 +67,7 @@ func newRulesView(w *Window) *rulesView { r.save.AddCSSClass("suggested-action") r.revert = gtk.NewButtonWithLabel("Revert") r.reload = gtk.NewButtonWithLabel("Reload") + r.checkNow = gtk.NewButtonWithLabel("Check") bar := gtk.NewBox(gtk.OrientationHorizontal, 6) bar.SetMarginTop(6) @@ -78,6 +82,7 @@ func newRulesView(w *Window) *rulesView { r.check.SetEllipsize(pango.EllipsizeEnd) r.check.SetMaxWidthChars(20) bar.Append(r.check) + bar.Append(r.checkNow) bar.Append(r.reload) bar.Append(r.revert) bar.Append(r.save) @@ -88,8 +93,31 @@ func newRulesView(w *Window) *rulesView { r.view.SetRightMargin(8) r.view.SetTopMargin(6) r.buf = r.view.Buffer() + + // Line numbers: their own view beside the editor, in the same scrolled + // window so the two always line up. The editor does not wrap, so one + // line of text is one line on screen (his request, 2026-09-16). + r.nums = gtk.NewTextView() + r.nums.SetMonospace(true) + r.nums.SetEditable(false) + r.nums.SetCursorVisible(false) + r.nums.SetJustification(gtk.JustifyRight) + r.nums.SetLeftMargin(6) + r.nums.SetRightMargin(6) + r.nums.SetTopMargin(6) + r.nums.AddCSSClass("dim-label") + r.nums.SetCanFocus(false) + r.nums.SetVAlign(gtk.AlignStart) + r.nums.SetSizeRequest(46, -1) + r.view.SetVAlign(gtk.AlignStart) + editRow := gtk.NewBox(gtk.OrientationHorizontal, 0) + editRow.Append(r.nums) + editRow.Append(gtk.NewSeparator(gtk.OrientationVertical)) + editRow.Append(r.view) + r.view.SetHExpand(true) + editScroll := gtk.NewScrolledWindow() - editScroll.SetChild(r.view) + editScroll.SetChild(editRow) editScroll.SetVExpand(true) editScroll.SetHExpand(true) @@ -182,6 +210,14 @@ func newRulesView(w *Window) *rulesView { } }) r.reload.ConnectClicked(func() { r.reloadFromDisk() }) + r.checkNow.ConnectClicked(func() { + r.runCheck() + if len(r.rules.Diags) == 0 { + r.w.setStatus("check: no errors in %s", escape(xdg.Abbrev(r.rules.File))) + } else { + r.w.setStatus("check: %d problem(s); the list is under the editor", len(r.rules.Diags)) + } + }) r.testGo.ConnectClicked(r.onTest) r.testPath.ConnectActivate(r.onTest) @@ -201,6 +237,12 @@ func (r *rulesView) formsOf() ([]model.Form, error) { // setCheck writes one line in the bar where the check result goes. func (r *rulesView) setCheck(text string) { r.check.SetText(escape(text)) } +// showTestOutput writes in the pane on the right, where both Test on file +// and Test rule report. +func (r *rulesView) showTestOutput(text string) { + r.out.Buffer().SetText(escapeText(text)) +} + // showText brings the Text sub-tab forward. func (r *rulesView) showText() { r.inSwap = true @@ -327,8 +369,34 @@ func (r *rulesView) runCheck() { row.SetName(fmt.Sprintf("%d", line)) } } - r.save.SetSensitive(len(diags) == 0 && r.rules.Modified()) + canSave := len(diags) == 0 && r.rules.Modified() + r.save.SetSensitive(canSave) r.revert.SetSensitive(r.rules.Modified()) + switch { + case canSave: + r.save.SetTooltipText("write " + xdg.Abbrev(r.rules.File) + + ", keeping the previous text as " + r.rules.Name + ".conf.bak") + case len(diags) > 0: + r.save.SetTooltipText("Save waits until the problems below are fixed") + r.check.SetText(r.check.Text() + " - Save waits until they are fixed") + default: + r.save.SetTooltipText("nothing to save: the file matches what is on disk") + } + r.updateLineNumbers() +} + +// updateLineNumbers refills the gutter when the number of lines changes. +func (r *rulesView) updateLineNumbers() { + n := r.buf.LineCount() + if n == r.lines { + return + } + r.lines = n + var b strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&b, "%3d\n", i) + } + r.nums.Buffer().SetText(strings.TrimSuffix(b.String(), "\n")) } // goToLine puts the cursor on the line a diagnostic names, so clicking one |
