aboutsummaryrefslogtreecommitdiff
path: root/gui/internal
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal')
-rw-r--r--gui/internal/model/forms.go254
-rw-r--r--gui/internal/model/forms_test.go240
-rw-r--r--gui/internal/ui/forms.go753
-rw-r--r--gui/internal/ui/rules.go58
-rw-r--r--gui/internal/ui/window.go2
5 files changed, 1304 insertions, 3 deletions
diff --git a/gui/internal/model/forms.go b/gui/internal/model/forms.go
new file mode 100644
index 0000000..681c15c
--- /dev/null
+++ b/gui/internal/model/forms.go
@@ -0,0 +1,254 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ "krino/internal/config"
+ "krino/internal/sexp"
+)
+
+// FormKind is what an editable form is.
+type FormKind int
+
+const (
+ RuleForm FormKind = iota
+ ExcludeForm
+)
+
+// Form is one editable form of a directory's file, as the list on the left
+// of the Rules tab shows it (GUI design §5.1).
+type Form struct {
+ Kind FormKind
+ Label string // the rule's name, or the exclude as written
+ Pos sexp.Pos
+ End sexp.Pos
+ Rule *config.Rule // RuleForm
+ Exclude *config.Exclude // ExcludeForm
+}
+
+// Forms parses the text in the editor and lists what can be edited, in file
+// order: the excludes and rules of this directory. Text that does not parse
+// has no forms - the Text tab keeps it until it is fixed (GUI design §5.2).
+func (r *Rules) Forms() ([]Form, error) {
+ over := map[string][]byte{r.File: []byte(r.Text)}
+ cfg, diags := config.LoadWith(r.e.MainFile, over, r.Name)
+ if len(diags) > 0 {
+ return nil, fmt.Errorf("%s", diags[0])
+ }
+ var d *config.Dir
+ for _, cd := range cfg.Dirs {
+ if cd.Name == r.Name {
+ d = cd
+ break
+ }
+ }
+ if d == nil {
+ return nil, fmt.Errorf("model: %s is not in the configuration", r.Name)
+ }
+ var out []Form
+ for _, x := range d.Excludes {
+ out = append(out, Form{Kind: ExcludeForm, Label: x.Text, Pos: x.Pos, End: x.End, Exclude: x})
+ }
+ for _, rule := range d.Rules {
+ out = append(out, Form{Kind: RuleForm, Label: rule.Name, Pos: rule.Pos, End: rule.End, Rule: rule})
+ }
+ sortByPosition(out)
+ return out, nil
+}
+
+// sortByPosition puts forms in the order they are written, since excludes
+// and rules are parsed into separate lists.
+func sortByPosition(forms []Form) {
+ for i := 1; i < len(forms); i++ {
+ for j := i; j > 0 && forms[j].Pos.Offset < forms[j-1].Pos.Offset; j-- {
+ forms[j], forms[j-1] = forms[j-1], forms[j]
+ }
+ }
+}
+
+// ReplaceForm swaps form i's text for text - what config.PrintRule or
+// PrintExclude wrote for the edited form - leaving every other byte of the
+// file, comments and layout included, exactly as it was (GUI design §5.1).
+func (r *Rules) ReplaceForm(i int, text string) error {
+ f, err := r.form(i)
+ if err != nil {
+ return err
+ }
+ out, err := config.Splice([]byte(r.Text), f.Pos, f.End, strings.TrimRight(text, "\n"))
+ if err != nil {
+ return err
+ }
+ r.Text = string(out)
+ return nil
+}
+
+// DeleteForm removes form i and the comment lines directly above it - its
+// block (GUI design §5.1). A comment separated from the form by a blank
+// line belongs to the file, not to the form, and stays.
+func (r *Rules) DeleteForm(i int) error {
+ f, err := r.form(i)
+ if err != nil {
+ return err
+ }
+ start, end := r.block(f)
+ r.Text = join(r.Text[:start], r.Text[end:])
+ return nil
+}
+
+// MoveForm moves form i one place up (delta -1) or down (delta 1), with its
+// comments. Moving past either end does nothing.
+func (r *Rules) MoveForm(i, delta int) error {
+ forms, err := r.Forms()
+ if err != nil {
+ return err
+ }
+ j := i + delta
+ if i < 0 || i >= len(forms) || j < 0 || j >= len(forms) {
+ return nil
+ }
+ a, b := forms[i], forms[j]
+ if a.Pos.Offset > b.Pos.Offset {
+ a, b = b, a
+ }
+ as, ae := r.block(a)
+ bs, be := r.block(b)
+ if ae > bs {
+ return fmt.Errorf("model: the two forms share lines")
+ }
+ r.Text = r.Text[:as] + r.Text[bs:be] + r.Text[ae:bs] + r.Text[as:ae] + r.Text[be:]
+ return nil
+}
+
+// AddRuleAfter inserts a new rule after form i - after every form when i is
+// out of range - and returns its place in the new list. The rule it writes
+// is the smallest one that loads, for the form editor to fill in.
+func (r *Rules) AddRuleAfter(i int, name string) (int, error) {
+ forms, err := r.Forms()
+ if err != nil {
+ return 0, err
+ }
+ text := config.PrintRule(&config.Rule{
+ Name: name,
+ Actions: []config.Action{{Kind: config.Move, Arg: "TODO"}},
+ })
+ at := len(r.Text)
+ if i >= 0 && i < len(forms) {
+ _, at = r.block(forms[i])
+ }
+ r.Text = join(r.Text[:at], "\n"+text+r.Text[at:])
+ after, err := r.Forms()
+ if err != nil {
+ return 0, err
+ }
+ for n, f := range after {
+ if f.Kind == RuleForm && f.Label == name && !was(forms, f) {
+ return n, nil
+ }
+ }
+ return 0, fmt.Errorf("model: the new rule is not in the file")
+}
+
+// was reports whether a form of the same kind and label was already there
+// at the same offset, so AddRuleAfter can tell its new rule from a rule of
+// the same name that existed before.
+func was(before []Form, f Form) bool {
+ for _, b := range before {
+ if b.Kind == f.Kind && b.Label == f.Label && b.Pos.Offset == f.Pos.Offset {
+ return true
+ }
+ }
+ return false
+}
+
+// FormHasComments reports whether form i holds a comment inside it. A form
+// editor writes the form back from what was parsed, and the parser drops
+// comments, so the caller warns before replacing one (GUI design §5.1).
+func (r *Rules) FormHasComments(i int) (bool, error) {
+ f, err := r.form(i)
+ if err != nil {
+ return false, err
+ }
+ return hasComment(r.Text[f.Pos.Offset:f.End.Offset]), nil
+}
+
+// hasComment reports whether s holds a ";" that starts a comment: one
+// outside a string, since a name pattern may well contain a semicolon.
+func hasComment(s string) bool {
+ inString, escaped := false, false
+ for _, c := range s {
+ switch {
+ case escaped:
+ escaped = false
+ case c == '\\' && inString:
+ escaped = true
+ case c == '"':
+ inString = !inString
+ case c == ';' && !inString:
+ return true
+ }
+ }
+ return false
+}
+
+// form is form i of the current text.
+func (r *Rules) form(i int) (Form, error) {
+ forms, err := r.Forms()
+ if err != nil {
+ return Form{}, err
+ }
+ if i < 0 || i >= len(forms) {
+ return Form{}, fmt.Errorf("model: no form %d", i)
+ }
+ return forms[i], nil
+}
+
+// block is the span a form moves and is deleted with: from the start of the
+// first comment line that touches it, to the end of the line its closing
+// paren is on.
+func (r *Rules) block(f Form) (start, end int) {
+ start = lineStart(r.Text, f.Pos.Offset)
+ for start > 0 {
+ prev := lineStart(r.Text, start-1)
+ line := strings.TrimSpace(r.Text[prev : start-1])
+ if !strings.HasPrefix(line, ";") {
+ break
+ }
+ start = prev
+ }
+ end = lineEnd(r.Text, f.End.Offset)
+ return start, end
+}
+
+// lineStart is the offset just after the newline before at.
+func lineStart(s string, at int) int {
+ if at > len(s) {
+ at = len(s)
+ }
+ if i := strings.LastIndexByte(s[:at], '\n'); i >= 0 {
+ return i + 1
+ }
+ return 0
+}
+
+// lineEnd is the offset just past the newline that ends at's line.
+func lineEnd(s string, at int) int {
+ if at >= len(s) {
+ return len(s)
+ }
+ if i := strings.IndexByte(s[at:], '\n'); i >= 0 {
+ return at + i + 1
+ }
+ return len(s)
+}
+
+// join puts two pieces of a file back together without leaving a run of
+// blank lines where something was taken out or put in.
+func join(before, after string) string {
+ for strings.HasSuffix(before, "\n\n") && strings.HasPrefix(after, "\n") {
+ after = after[1:]
+ }
+ return before + after
+}
diff --git a/gui/internal/model/forms_test.go b/gui/internal/model/forms_test.go
new file mode 100644
index 0000000..2c23462
--- /dev/null
+++ b/gui/internal/model/forms_test.go
@@ -0,0 +1,240 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "strings"
+ "testing"
+
+ "krino/internal/config"
+)
+
+// formsFile is a directory file with comments in the places that matter:
+// above a rule (its own), inside a rule, and free-standing.
+const formsFile = `(path "~/dl")
+
+(exclude (name "^keep-"))
+
+;; invoices go to their year
+(rule "invoices"
+ (when (and (type pdf) (content "invoice")))
+ (move "Invoices/{mtime:%Y}")
+ (stop))
+
+(rule "images"
+ (when (type image)) ; anything the type table calls an image
+ (move "Images")
+ (stop))
+
+;; last resort
+
+(rule "rest"
+ (move "Other"))
+`
+
+// openForms puts text in an editor over the sandbox's file.
+func openForms(t *testing.T, text string) *Rules {
+ t.Helper()
+ e, _ := sandboxDir(t, formsFile, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if text != "" {
+ r.SetText(text)
+ }
+ return r
+}
+
+// TestFormsLists: every editable form of the file, in file order, named the
+// way the list shows it.
+func TestFormsLists(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, f := range forms {
+ got = append(got, f.Label)
+ }
+ want := []string{`(exclude (name "^keep-"))`, "invoices", "images", "rest"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Errorf("forms = %v, want %v", got, want)
+ }
+ if forms[0].Kind != ExcludeForm || forms[1].Kind != RuleForm {
+ t.Errorf("kinds = %v, %v", forms[0].Kind, forms[1].Kind)
+ }
+ if forms[1].Rule == nil || forms[1].Rule.Name != "invoices" {
+ t.Errorf("rule form carries no rule: %+v", forms[1])
+ }
+}
+
+// TestDeleteFormTakesItsComments: a rule's block is its form plus the
+// comment lines directly above it with no blank line between (GUI design
+// §5.1), so deleting it does not leave its comment stranded - while a
+// comment separated by a blank line stays.
+func TestDeleteFormTakesItsComments(t *testing.T) {
+ r := openForms(t, "")
+ if err := r.DeleteForm(1); err != nil { // invoices, with its comment
+ t.Fatal(err)
+ }
+ if strings.Contains(r.Text, "invoices go to their year") {
+ t.Errorf("the rule's own comment was left behind:\n%s", r.Text)
+ }
+ if strings.Contains(r.Text, `(rule "invoices"`) {
+ t.Errorf("the rule is still there:\n%s", r.Text)
+ }
+ // The free-standing comment above "rest" is separated by a blank line,
+ // so deleting that rule leaves it alone.
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.DeleteForm(len(forms) - 1); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(r.Text, ";; last resort") {
+ t.Errorf("a comment separated by a blank line was taken too:\n%s", r.Text)
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestMoveForm: a rule moves with its comments, and the order of everything
+// else is kept.
+func TestMoveForm(t *testing.T) {
+ r := openForms(t, "")
+ if err := r.MoveForm(2, -1); err != nil { // images, up past invoices
+ t.Fatal(err)
+ }
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, f := range forms {
+ got = append(got, f.Label)
+ }
+ want := []string{`(exclude (name "^keep-"))`, "images", "invoices", "rest"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("after moving up: %v, want %v", got, want)
+ }
+ // The comment travelled with its rule, and still sits above it.
+ iInvoices := strings.Index(r.Text, `(rule "invoices"`)
+ iComment := strings.Index(r.Text, ";; invoices go to their year")
+ if iComment < 0 || iComment > iInvoices {
+ t.Errorf("the comment did not travel with its rule:\n%s", r.Text)
+ }
+ if strings.Index(r.Text, `(rule "images"`) > iComment {
+ t.Errorf("images did not move above invoices:\n%s", r.Text)
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+ // Moving the first form up, or the last down, does nothing.
+ before := r.Text
+ if err := r.MoveForm(0, -1); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.MoveForm(len(forms)-1, 1); err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != before {
+ t.Error("moving past the ends changed the file")
+ }
+}
+
+// TestAddRuleAfter: a new rule lands after the one selected, is the form
+// the printer writes, and the file still loads.
+func TestAddRuleAfter(t *testing.T) {
+ r := openForms(t, "")
+ i, err := r.AddRuleAfter(1, "new one")
+ if err != nil {
+ t.Fatal(err)
+ }
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if i != 2 || forms[2].Label != "new one" {
+ t.Fatalf("new rule at %d: %v", i, labels(forms))
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestReplaceForm: editing one form rewrites that form's text and nothing
+// else - every comment and every other line stays byte for byte.
+func TestReplaceForm(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rule := *forms[1].Rule // invoices
+ rule.Actions = []config.Action{{Kind: config.Move, Arg: "Faktury"}}
+ if err := r.ReplaceForm(1, config.PrintRule(&rule)); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(r.Text, `(move "Faktury")`) {
+ t.Errorf("the edit did not land:\n%s", r.Text)
+ }
+ if strings.Contains(r.Text, `(move "Invoices/{mtime:%Y}")`) {
+ t.Errorf("the old action is still there:\n%s", r.Text)
+ }
+ for _, keep := range []string{";; invoices go to their year", ";; last resort",
+ "; anything the type table calls an image", `(exclude (name "^keep-"))`} {
+ if !strings.Contains(r.Text, keep) {
+ t.Errorf("replacing one form lost %q:\n%s", keep, r.Text)
+ }
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestFormHasComments: a form with a comment inside it cannot be rewritten
+// from a form editor without losing it, so the caller is warned; a
+// semicolon inside a string is not a comment (GUI design §5.1).
+func TestFormHasComments(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := r.FormHasComments(2); err != nil || !got { // images
+ t.Errorf("FormHasComments(images) = %v, %v; want true", got, err)
+ }
+ if got, err := r.FormHasComments(1); err != nil || got { // invoices
+ t.Errorf("FormHasComments(invoices) = %v, %v; want false", got, err)
+ }
+ _ = forms
+
+ r.SetText("(path \"~/dl\")\n(rule \"semi\" (when (name \"a;b\")) (move \"Out\"))\n")
+ if got, err := r.FormHasComments(0); err != nil || got {
+ t.Errorf("a semicolon inside a string counted as a comment: %v, %v", got, err)
+ }
+}
+
+// TestFormsRefusesBrokenText: the forms list comes from parsing, so text
+// that does not parse has no forms to show, and says so.
+func TestFormsRefusesBrokenText(t *testing.T) {
+ r := openForms(t, "(path \"~/dl\")\n(rule \"unclosed\"\n")
+ if _, err := r.Forms(); err == nil {
+ t.Error("Forms answered for text that does not parse")
+ }
+ if err := r.DeleteForm(0); err == nil {
+ t.Error("DeleteForm acted on text that does not parse")
+ }
+}
+
+func labels(forms []Form) []string {
+ var out []string
+ for _, f := range forms {
+ out = append(out, f.Label)
+ }
+ return out
+}
diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go
new file mode 100644
index 0000000..928c17e
--- /dev/null
+++ b/gui/internal/ui/forms.go
@@ -0,0 +1,753 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+
+ "krino/gui/internal/model"
+ "krino/internal/config"
+ "krino/internal/sexp"
+)
+
+// condKinds are the tests a condition row offers, plus the three operators
+// that hold other conditions. A row's entry holds that form's arguments
+// exactly as they are written, so no test is out of reach of the form
+// editor and none is silently rewritten (see docs/gui-design.md §5.1 and
+// the deviation noted in plan 17).
+var condKinds = []string{"type", "name", "path", "content", "size", "age",
+ "duplicate", "matched", "and", "or", "not"}
+
+// condHints is the example shown beside a row, by kind.
+var condHints = map[string]string{
+ "type": `pdf doc (extensions or a group)`,
+ "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`,
+ "duplicate": `"~/docs" (or empty: the directory's own tree)`,
+ "matched": `(no arguments)`,
+ "and": `(type pdf) (content "invoice")`,
+ "or": `(type doc) (type docx)`,
+ "not": `(duplicate)`,
+}
+
+// actionKinds are the actions a rule can carry, in the order the form
+// offers them.
+var actionKinds = []string{"copy", "move", "rename", "delete", "delete permanent"}
+
+// formsView is the Forms half of the Rules tab: the directory's excludes
+// and rules on the left, the selected one as a form in the middle.
+type formsView struct {
+ w *Window
+ owner *rulesView
+ root *gtk.Box
+
+ list *gtk.ListBox
+ add, del, up, down *gtk.Button
+ place *gtk.Box
+ note *gtk.Label
+ forms []model.Form
+ sel int
+ editor *formEditor
+ pending glib.SourceHandle
+ quiet bool
+ commentsAcknowledged map[string]bool
+}
+
+func newFormsView(w *Window, owner *rulesView) *formsView {
+ f := &formsView{w: w, owner: owner, sel: -1, commentsAcknowledged: map[string]bool{}}
+ f.root = gtk.NewBox(gtk.OrientationHorizontal, 0)
+
+ f.list = gtk.NewListBox()
+ f.list.SetSelectionMode(gtk.SelectionSingle)
+ listScroll := gtk.NewScrolledWindow()
+ listScroll.SetChild(f.list)
+ listScroll.SetVExpand(true)
+
+ f.add = gtk.NewButtonWithLabel("Add rule")
+ f.del = gtk.NewButtonWithLabel("Delete")
+ f.up = gtk.NewButtonWithLabel("Up")
+ f.down = gtk.NewButtonWithLabel("Down")
+ 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} {
+ buttons.Append(b)
+ }
+
+ left := gtk.NewBox(gtk.OrientationVertical, 0)
+ left.Append(listScroll)
+ left.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ left.Append(buttons)
+ left.SetSizeRequest(260, -1)
+
+ f.note = gtk.NewLabel("")
+ f.note.SetXAlign(0)
+ f.note.SetWrap(true)
+ f.note.SetMarginStart(8)
+ f.note.SetMarginEnd(8)
+ f.note.SetMarginTop(6)
+ f.place = gtk.NewBox(gtk.OrientationVertical, 0)
+ f.place.SetVExpand(true)
+ placeScroll := gtk.NewScrolledWindow()
+ placeScroll.SetChild(f.place)
+ placeScroll.SetVExpand(true)
+ placeScroll.SetHExpand(true)
+ right := gtk.NewBox(gtk.OrientationVertical, 0)
+ right.Append(f.note)
+ right.Append(placeScroll)
+
+ f.root.Append(left)
+ f.root.Append(gtk.NewSeparator(gtk.OrientationVertical))
+ f.root.Append(right)
+
+ f.list.ConnectRowSelected(func(row *gtk.ListBoxRow) {
+ if row != nil && !f.quiet {
+ f.show(row.Index())
+ }
+ })
+ f.add.ConnectClicked(f.onAdd)
+ f.del.ConnectClicked(f.onDelete)
+ f.up.ConnectClicked(func() { f.move(-1) })
+ f.down.ConnectClicked(func() { f.move(1) })
+ return f
+}
+
+// reload re-reads the text in the editor and rebuilds the list. Text that
+// does not parse has no forms: the caller keeps the Text tab (GUI design
+// §5.2).
+func (f *formsView) reload() error {
+ forms, err := f.owner.formsOf()
+ if err != nil {
+ return err
+ }
+ f.forms = forms
+ keep := f.sel
+ f.quiet = true
+ clearList(f.list)
+ for _, form := range forms {
+ row := gtk.NewListBoxRow()
+ label := gtk.NewLabel(escape(formLabel(form)))
+ label.SetXAlign(0)
+ label.SetMarginStart(6)
+ label.SetMarginEnd(6)
+ label.SetMarginTop(2)
+ label.SetMarginBottom(2)
+ label.SetEllipsize(3) // end
+ label.SetMaxWidthChars(30)
+ label.SetTooltipText(escape(form.Label))
+ row.SetChild(label)
+ f.list.Append(row)
+ }
+ f.quiet = false
+ if keep >= 0 && keep < len(forms) {
+ f.list.SelectRow(f.list.RowAtIndex(keep))
+ f.show(keep)
+ } else {
+ f.sel = -1
+ f.clearEditor("Select a rule to edit it, or Add rule.")
+ }
+ return nil
+}
+
+// formLabel is one line of the list: what the form is, and what it does.
+func formLabel(f model.Form) string {
+ if f.Kind == model.ExcludeForm {
+ return "exclude: " + f.Label
+ }
+ var what []string
+ for _, a := range f.Rule.Actions {
+ what = append(what, a.Kind.String())
+ }
+ if len(what) == 0 {
+ return f.Label
+ }
+ return f.Label + " (" + strings.Join(what, ", ") + ")"
+}
+
+// show builds the editor for form i.
+func (f *formsView) show(i int) {
+ if i < 0 || i >= len(f.forms) {
+ return
+ }
+ f.sel = i
+ form := f.forms[i]
+ f.note.SetText("")
+ if has, err := f.owner.rules.FormHasComments(i); err == nil && has {
+ f.note.SetText("This form has comments inside it. The form editor writes it back from what krino parsed, so those comments would be dropped - the Text tab keeps them.")
+ }
+ f.editor = newFormEditor(form, f.armApply)
+ if child := f.place.FirstChild(); child != nil {
+ f.place.Remove(child)
+ }
+ f.place.Append(f.editor.root)
+}
+
+// clearEditor empties the middle pane.
+func (f *formsView) clearEditor(text string) {
+ f.editor = nil
+ if child := f.place.FirstChild(); child != nil {
+ f.place.Remove(child)
+ }
+ f.note.SetText(text)
+}
+
+// armApply writes the form back after a pause, so typing in a field does
+// not rewrite the file on every keystroke.
+func (f *formsView) armApply() {
+ if f.pending != 0 {
+ glib.SourceRemove(f.pending)
+ }
+ f.pending = glib.TimeoutAdd(checkDelay, func() bool {
+ f.pending = 0
+ f.apply()
+ return false
+ })
+}
+
+// apply replaces the selected form's text with what the editor holds.
+func (f *formsView) apply() {
+ if f.editor == nil || f.sel < 0 {
+ return
+ }
+ text, err := f.editor.text()
+ if err != nil {
+ f.owner.setCheck("form: " + err.Error())
+ return
+ }
+ if !f.confirmComments() {
+ return
+ }
+ if err := f.owner.rules.ReplaceForm(f.sel, text); err != nil {
+ f.owner.setCheck("form: " + err.Error())
+ return
+ }
+ f.owner.textChangedByForm()
+ f.refreshLabels()
+}
+
+// confirmComments asks once per form before an edit drops the comments
+// inside it (GUI design §5.1).
+func (f *formsView) confirmComments() bool {
+ has, err := f.owner.rules.FormHasComments(f.sel)
+ if err != nil || !has {
+ return true
+ }
+ key := fmt.Sprintf("%d:%s", f.sel, f.forms[f.sel].Label)
+ if f.commentsAcknowledged[key] {
+ return true
+ }
+ d := gtk.NewMessageDialog(&f.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "This form has comments inside it")
+ d.SetObjectProperty("secondary-text",
+ "Editing it here writes it back from what krino parsed, which drops those comments. The Text tab keeps them.")
+ d.AddButton("Go to the Text tab", int(gtk.ResponseCancel))
+ d.AddButton("Edit here anyway", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ f.commentsAcknowledged[key] = true
+ f.apply()
+ return
+ }
+ f.owner.showText()
+ })
+ d.Show()
+ return false
+}
+
+// refreshLabels re-reads the forms so the list shows what the edit did,
+// keeping the selection.
+func (f *formsView) refreshLabels() {
+ forms, err := f.owner.formsOf()
+ if err != nil || len(forms) != len(f.forms) {
+ return
+ }
+ f.forms = forms
+ for i, form := range forms {
+ if row := f.list.RowAtIndex(i); row != nil {
+ if label, ok := row.Child().(*gtk.Label); ok {
+ label.SetText(escape(formLabel(form)))
+ }
+ }
+ }
+}
+
+// onAdd puts a new rule after the selected one and opens it.
+func (f *formsView) onAdd() {
+ name := "new rule"
+ for i := 2; f.nameTaken(name); i++ {
+ name = fmt.Sprintf("new rule %d", i)
+ }
+ i, err := f.owner.rules.AddRuleAfter(f.sel, name)
+ if err != nil {
+ f.owner.setCheck("add: " + err.Error())
+ return
+ }
+ f.owner.textChangedByForm()
+ f.sel = i
+ if err := f.reload(); err != nil {
+ f.owner.setCheck("add: " + err.Error())
+ }
+}
+
+// nameTaken reports whether a rule of that name is already in the file.
+func (f *formsView) nameTaken(name string) bool {
+ for _, form := range f.forms {
+ if form.Kind == model.RuleForm && form.Label == name {
+ return true
+ }
+ }
+ return false
+}
+
+// onDelete removes the selected form, after asking.
+func (f *formsView) onDelete() {
+ if f.sel < 0 || f.sel >= len(f.forms) {
+ return
+ }
+ form := f.forms[f.sel]
+ d := gtk.NewMessageDialog(&f.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageQuestion, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Delete "+escape(formLabel(form))+"?")
+ d.SetObjectProperty("secondary-text",
+ "It goes from the text in the editor, with the comment lines directly above it. Nothing is written until you press Save.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ del := d.AddButton("Delete", int(gtk.ResponseAccept))
+ if b, ok := del.(*gtk.Button); ok {
+ b.AddCSSClass("destructive-action")
+ }
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response != int(gtk.ResponseAccept) {
+ return
+ }
+ if err := f.owner.rules.DeleteForm(f.sel); err != nil {
+ f.owner.setCheck("delete: " + err.Error())
+ return
+ }
+ f.owner.textChangedByForm()
+ if f.sel >= len(f.forms)-1 {
+ f.sel = len(f.forms) - 2
+ }
+ if err := f.reload(); err != nil {
+ f.owner.setCheck("delete: " + err.Error())
+ }
+ })
+ d.Show()
+}
+
+// move shifts the selected form one place up or down, with its comments.
+func (f *formsView) move(delta int) {
+ if f.sel < 0 {
+ return
+ }
+ to := f.sel + delta
+ if to < 0 || to >= len(f.forms) {
+ return
+ }
+ if err := f.owner.rules.MoveForm(f.sel, delta); err != nil {
+ f.owner.setCheck("move: " + err.Error())
+ return
+ }
+ f.owner.textChangedByForm()
+ f.sel = to
+ if err := f.reload(); err != nil {
+ f.owner.setCheck("move: " + err.Error())
+ }
+}
+
+// formEditor is the widgets of one form, and can write it back.
+type formEditor struct {
+ kind model.FormKind
+ root *gtk.Box
+ name *gtk.Entry
+ conds *gtk.Box
+ rows []*condRow
+ acts *gtk.Box
+ arows []*actionRow
+ stop *gtk.CheckButton
+ cse *gtk.DropDown
+ fold *gtk.DropDown
+ onConf *gtk.DropDown
+ changed func()
+}
+
+func newFormEditor(form model.Form, changed func()) *formEditor {
+ fe := &formEditor{kind: form.Kind, changed: changed}
+ fe.root = gtk.NewBox(gtk.OrientationVertical, 6)
+ fe.root.SetMarginStart(8)
+ fe.root.SetMarginEnd(8)
+ fe.root.SetMarginTop(6)
+ fe.root.SetMarginBottom(6)
+
+ if form.Kind == model.RuleForm {
+ fe.name = gtk.NewEntry()
+ fe.name.SetText(form.Rule.Name)
+ fe.name.SetHExpand(true)
+ fe.name.ConnectChanged(func() { changed() })
+ fe.root.Append(field("Name", fe.name))
+ }
+
+ fe.conds = gtk.NewBox(gtk.OrientationVertical, 4)
+ fe.root.Append(heading("Conditions - all of these must hold"))
+ fe.root.Append(fe.conds)
+ addCond := gtk.NewButtonWithLabel("+ test")
+ addCond.ConnectClicked(func() {
+ fe.addCond(nil)
+ changed()
+ })
+ fe.root.Append(leftAligned(addCond))
+
+ when := form.Rule.When
+ if form.Kind == model.ExcludeForm {
+ when = form.Exclude.When
+ } else if !form.Rule.HasWhen {
+ when = nil
+ }
+ for _, c := range when {
+ fe.addCond(c)
+ }
+
+ if form.Kind == model.RuleForm {
+ fe.acts = gtk.NewBox(gtk.OrientationVertical, 4)
+ fe.root.Append(heading("Actions - in order"))
+ fe.root.Append(fe.acts)
+ addAct := gtk.NewButtonWithLabel("+ action")
+ addAct.ConnectClicked(func() {
+ fe.addAction(config.Action{Kind: config.Move})
+ changed()
+ })
+ fe.root.Append(leftAligned(addAct))
+ for _, a := range form.Rule.Actions {
+ fe.addAction(a)
+ }
+
+ fe.stop = gtk.NewCheckButtonWithLabel("(stop) - a file this rule matches takes no later rule")
+ fe.stop.SetActive(form.Rule.Stop)
+ fe.stop.ConnectToggled(func() { changed() })
+ fe.root.Append(fe.stop)
+
+ fe.root.Append(heading("Settings for this rule"))
+ fe.cse = dropDown([]string{"default", "ignore", "strict"}, caseIndex(form.Rule.Settings), changed)
+ fe.fold = dropDown([]string{"default", "yes", "no"}, foldIndex(form.Rule.Settings), changed)
+ fe.onConf = dropDown([]string{"default", "suffix", "skip", "overwrite"}, conflictIndex(form.Rule.Settings), changed)
+ fe.root.Append(field("case", fe.cse))
+ fe.root.Append(field("fold", fe.fold))
+ fe.root.Append(field("on-conflict", fe.onConf))
+ }
+ return fe
+}
+
+// text is the form as the printer writes it, from what the widgets hold.
+func (fe *formEditor) text() (string, error) {
+ var when []*sexp.Node
+ for _, row := range fe.rows {
+ if row.gone {
+ continue
+ }
+ text := row.text()
+ nodes, err := sexp.Parse("form", []byte(text))
+ if err != nil {
+ return "", fmt.Errorf("%s: %v", text, err)
+ }
+ if len(nodes) != 1 {
+ return "", fmt.Errorf("%s is not one condition", text)
+ }
+ when = append(when, nodes[0])
+ }
+ if fe.kind == model.ExcludeForm {
+ if len(when) == 0 {
+ return "", fmt.Errorf("an exclude needs a condition")
+ }
+ return config.PrintExclude(&config.Exclude{When: when}), nil
+ }
+ rule := &config.Rule{
+ Name: fe.name.Text(),
+ HasWhen: len(when) > 0,
+ When: when,
+ Stop: fe.stop.Active(),
+ }
+ for _, row := range fe.arows {
+ if row.gone {
+ continue
+ }
+ rule.Actions = append(rule.Actions, row.action())
+ }
+ rule.Settings = settingsFrom(fe.cse, fe.fold, fe.onConf)
+ return config.PrintRule(rule), nil
+}
+
+// addCond adds a condition row, filled in from n when there is one.
+func (fe *formEditor) addCond(n *sexp.Node) {
+ row := newCondRow(n, fe.changed)
+ fe.rows = append(fe.rows, row)
+ fe.conds.Append(row.root)
+}
+
+// addAction adds an action row.
+func (fe *formEditor) addAction(a config.Action) {
+ row := newActionRow(a, fe.changed)
+ fe.arows = append(fe.arows, row)
+ fe.acts.Append(row.root)
+}
+
+// condRow is one condition: its kind, and its arguments as written.
+type condRow struct {
+ root *gtk.Box
+ kind *gtk.DropDown
+ args *gtk.Entry
+ gone bool
+}
+
+func newCondRow(n *sexp.Node, changed func()) *condRow {
+ c := &condRow{}
+ c.root = gtk.NewBox(gtk.OrientationHorizontal, 6)
+ c.kind = gtk.NewDropDownFromStrings(condKinds)
+ c.args = gtk.NewEntry()
+ c.args.SetHExpand(true)
+ if n != nil {
+ head, args := splitNode(n)
+ if i := indexOf(condKinds, head); i >= 0 {
+ c.kind.SetSelected(uint(i))
+ }
+ c.args.SetText(args)
+ }
+ c.showHint()
+ remove := gtk.NewButtonWithLabel("-")
+ remove.ConnectClicked(func() {
+ c.gone = true
+ c.root.SetVisible(false)
+ changed()
+ })
+ c.kind.Connect("notify::selected", func() {
+ c.showHint()
+ changed()
+ })
+ c.args.ConnectChanged(func() { changed() })
+ c.root.Append(c.kind)
+ c.root.Append(c.args)
+ c.root.Append(remove)
+ return c
+}
+
+// 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 args == "" {
+ return "(" + kind + ")"
+ }
+ return "(" + kind + " " + args + ")"
+}
+
+// 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()]]
+ c.args.SetPlaceholderText(hint)
+ c.args.SetTooltipText(hint)
+}
+
+// actionRow is one action: what it does, and its argument.
+type actionRow struct {
+ root *gtk.Box
+ kind *gtk.DropDown
+ arg *gtk.Entry
+ gone bool
+}
+
+func newActionRow(a config.Action, changed func()) *actionRow {
+ r := &actionRow{}
+ r.root = gtk.NewBox(gtk.OrientationHorizontal, 6)
+ r.kind = gtk.NewDropDownFromStrings(actionKinds)
+ r.kind.SetSelected(uint(actionIndex(a.Kind)))
+ r.arg = gtk.NewEntry()
+ r.arg.SetText(a.Arg)
+ r.arg.SetHExpand(true)
+ r.arg.SetPlaceholderText("Invoices/{mtime:%Y} - {name} {ext} {1} {mtime:FMT} {now:FMT}")
+ r.setArgSensitive()
+ remove := gtk.NewButtonWithLabel("-")
+ remove.ConnectClicked(func() {
+ r.gone = true
+ r.root.SetVisible(false)
+ changed()
+ })
+ r.kind.Connect("notify::selected", func() {
+ r.setArgSensitive()
+ changed()
+ })
+ r.arg.ConnectChanged(func() { changed() })
+ r.root.Append(r.kind)
+ r.root.Append(r.arg)
+ r.root.Append(remove)
+ return r
+}
+
+// setArgSensitive turns the argument off for the two deletes, which take
+// none.
+func (r *actionRow) setArgSensitive() {
+ kind := actionKinds[r.kind.Selected()]
+ r.arg.SetSensitive(kind != "delete" && kind != "delete permanent")
+}
+
+// action is the action the row holds.
+func (r *actionRow) action() config.Action {
+ switch actionKinds[r.kind.Selected()] {
+ case "copy":
+ return config.Action{Kind: config.Copy, Arg: r.arg.Text()}
+ case "rename":
+ return config.Action{Kind: config.Rename, Arg: r.arg.Text()}
+ case "delete":
+ return config.Action{Kind: config.Delete}
+ case "delete permanent":
+ return config.Action{Kind: config.DeletePermanent}
+ }
+ return config.Action{Kind: config.Move, Arg: r.arg.Text()}
+}
+
+// settingsFrom reads the three rule settings; "default" leaves one out.
+func settingsFrom(cse, fold, onConf *gtk.DropDown) config.Settings {
+ var s config.Settings
+ switch cse.Selected() {
+ case 1:
+ m := config.CaseIgnore
+ s.Case = &m
+ case 2:
+ m := config.CaseStrict
+ s.Case = &m
+ }
+ switch fold.Selected() {
+ case 1:
+ yes := true
+ s.Fold = &yes
+ case 2:
+ no := false
+ s.Fold = &no
+ }
+ switch onConf.Selected() {
+ case 1:
+ c := config.ConflictSuffix
+ s.OnConflict = &c
+ case 2:
+ c := config.ConflictSkip
+ s.OnConflict = &c
+ case 3:
+ c := config.ConflictOverwrite
+ s.OnConflict = &c
+ }
+ return s
+}
+
+func caseIndex(s config.Settings) int {
+ if s.Case == nil {
+ return 0
+ }
+ if *s.Case == config.CaseStrict {
+ return 2
+ }
+ return 1
+}
+
+func foldIndex(s config.Settings) int {
+ if s.Fold == nil {
+ return 0
+ }
+ if *s.Fold {
+ return 1
+ }
+ return 2
+}
+
+func conflictIndex(s config.Settings) int {
+ if s.OnConflict == nil {
+ return 0
+ }
+ switch *s.OnConflict {
+ case config.ConflictSkip:
+ return 2
+ case config.ConflictOverwrite:
+ return 3
+ }
+ return 1
+}
+
+func actionIndex(k config.ActionKind) int {
+ switch k {
+ case config.Copy:
+ return 0
+ case config.Rename:
+ return 2
+ case config.Delete:
+ return 3
+ case config.DeletePermanent:
+ return 4
+ }
+ return 1
+}
+
+// splitNode is a condition's head and the rest of it as written, which is
+// what a row's entry holds.
+func splitNode(n *sexp.Node) (head, args string) {
+ if n.Kind != sexp.List || len(n.Children) == 0 {
+ return "", n.String()
+ }
+ parts := make([]string, 0, len(n.Children)-1)
+ for _, c := range n.Children[1:] {
+ parts = append(parts, c.String())
+ }
+ return n.Head(), strings.Join(parts, " ")
+}
+
+func indexOf(ss []string, s string) int {
+ for i, v := range ss {
+ if v == s {
+ return i
+ }
+ }
+ return -1
+}
+
+// field is a labelled row of the form.
+func field(label string, child gtk.Widgetter) *gtk.Box {
+ box := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ l := gtk.NewLabel(label)
+ l.SetXAlign(0)
+ l.SetSizeRequest(90, -1)
+ box.Append(l)
+ box.Append(child)
+ return box
+}
+
+// heading is a section title inside the form.
+func heading(text string) *gtk.Label {
+ l := gtk.NewLabel(text)
+ l.SetXAlign(0)
+ l.SetMarginTop(6)
+ l.AddCSSClass("heading")
+ return l
+}
+
+// leftAligned keeps a button from stretching across the form.
+func leftAligned(w gtk.Widgetter) *gtk.Box {
+ box := gtk.NewBox(gtk.OrientationHorizontal, 0)
+ box.Append(w)
+ return box
+}
+
+// dropDown is a combo with its starting choice and change handler.
+func dropDown(items []string, selected int, changed func()) *gtk.DropDown {
+ d := gtk.NewDropDownFromStrings(items)
+ d.SetSelected(uint(selected))
+ d.Connect("notify::selected", func() { changed() })
+ return d
+}
diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go
index 741f6b1..d582f1e 100644
--- a/gui/internal/ui/rules.go
+++ b/gui/internal/ui/rules.go
@@ -42,9 +42,12 @@ type rulesView struct {
testGo *gtk.Button
out *gtk.TextView
+ sub *gtk.Notebook
+ forms *formsView
rules *model.Rules
pending glib.SourceHandle
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
}
func newRulesView(w *Window) *rulesView {
@@ -131,19 +134,37 @@ func newRulesView(w *Window) *rulesView {
right.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
right.Append(outScroll)
+ // Two ways to edit the same file: forms, and the text itself.
+ r.sub = gtk.NewNotebook()
+ r.forms = newFormsView(w, r)
+ r.sub.AppendPage(r.forms.root, gtk.NewLabel("Forms"))
+ r.sub.AppendPage(left, gtk.NewLabel("Text"))
+
panes := gtk.NewPaned(gtk.OrientationHorizontal)
- panes.SetStartChild(left)
+ panes.SetStartChild(r.sub)
panes.SetEndChild(right)
panes.SetResizeStartChild(true)
panes.SetResizeEndChild(false)
panes.SetShrinkEndChild(false)
- panes.SetPosition(620)
+ panes.SetPosition(820)
panes.SetVExpand(true)
r.root.Append(bar)
r.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
r.root.Append(panes)
+ // Switching to Forms re-reads the text; text that does not parse keeps
+ // the Text tab until it is fixed (GUI design §5.2).
+ r.sub.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) {
+ if page != 0 || r.inSwap || r.rules == nil {
+ return
+ }
+ if err := r.forms.reload(); err != nil {
+ r.setCheck("the text does not parse yet: " + err.Error())
+ r.showText()
+ }
+ })
+
r.buf.ConnectChanged(r.onChanged)
r.diags.ConnectRowSelected(func(row *gtk.ListBoxRow) {
if row != nil {
@@ -166,6 +187,34 @@ func newRulesView(w *Window) *rulesView {
return r
}
+// formsOf is the forms of the text in the editor, for the Forms sub-tab.
+func (r *rulesView) formsOf() ([]model.Form, error) {
+ if r.rules == nil {
+ return nil, fmt.Errorf("no file is open")
+ }
+ r.rules.SetText(r.text())
+ return r.rules.Forms()
+}
+
+// setCheck writes one line in the bar where the check result goes.
+func (r *rulesView) setCheck(text string) { r.check.SetText(escape(text)) }
+
+// showText brings the Text sub-tab forward.
+func (r *rulesView) showText() {
+ r.inSwap = true
+ r.sub.SetCurrentPage(1)
+ r.inSwap = false
+}
+
+// textChangedByForm puts the model's text - just rewritten by a form edit -
+// in the view, and checks it.
+func (r *rulesView) textChangedByForm() {
+ r.quiet = true
+ r.buf.SetText(r.rules.Text)
+ r.quiet = false
+ r.runCheck()
+}
+
// selectedName is the directory the picker names, "" when none is offered.
func (r *rulesView) selectedName() string {
i := int(r.dirs.Selected())
@@ -190,6 +239,11 @@ func (r *rulesView) open(name string) {
}
r.rules = rules
r.setEditable(true)
+ defer func() {
+ if err := r.forms.reload(); err != nil {
+ r.forms.clearEditor("the text does not parse yet: " + err.Error())
+ }
+ }()
if root := r.w.dirRoot(name); root != "" {
r.testPath.SetText(xdg.Abbrev(root) + "/")
}
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index 2453444..27e1730 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -38,7 +38,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
w := &Window{app: app, engine: e}
w.win = gtk.NewApplicationWindow(app)
w.win.SetTitle("krino")
- w.win.SetDefaultSize(1000, 640)
+ w.win.SetDefaultSize(1200, 720)
notebook := gtk.NewNotebook()
w.plan = newPlanView(w)