From 4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 16 Sep 2026 15:33:23 +0200 Subject: gui: forms editor - rules and excludes as forms, with add, delete and move --- gui/internal/model/forms.go | 254 +++++++++++++ gui/internal/model/forms_test.go | 240 +++++++++++++ gui/internal/ui/forms.go | 753 +++++++++++++++++++++++++++++++++++++++ gui/internal/ui/rules.go | 58 ++- gui/internal/ui/window.go | 2 +- 5 files changed, 1304 insertions(+), 3 deletions(-) create mode 100644 gui/internal/model/forms.go create mode 100644 gui/internal/model/forms_test.go create mode 100644 gui/internal/ui/forms.go 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) -- cgit v1.3