// SPDX-License-Identifier: GPL-3.0-or-later package ui import ( "context" "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"} // condLabels is how the picker names them. The rows are already joined by // "all of these must hold", so the three operators say what they are for - // grouping conditions inside one row - rather than looking like the way to // join two rows (his report, 2026-09-16). var condLabels = map[string]string{ "and": "and (all of these)", "or": "or (any of these)", "not": "not (none of these)", } // condItems is condKinds as the picker shows them. func condItems() []string { out := make([]string, len(condKinds)) for i, k := range condKinds { out[i] = k if label, ok := condLabels[k]; ok { out[i] = label } } return out } // 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 (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")`, "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 test *gtk.Button place *gtk.Box note *gtk.Label forms []model.Form sel int editor *formEditor settingRows []*settingRow onSettings bool 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") 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, f.test} { 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 { return } // Row 0 is the directory itself; the forms follow it. if row.Index() == 0 { f.showSettings() return } f.show(row.Index() - 1) }) f.add.ConnectClicked(f.onAdd) 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 } // 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) f.list.Append(settingsRow()) 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 switch { case keep >= 0 && keep < len(forms): f.list.SelectRow(f.list.RowAtIndex(keep + 1)) f.show(keep) case f.onSettings: f.list.SelectRow(f.list.RowAtIndex(0)) f.showSettings() default: f.sel = -1 f.clearEditor("Select the directory or a rule to edit it, or Add rule.") } return nil } // settingsRow is the first line of the list: the directory's own settings. func settingsRow() *gtk.ListBoxRow { row := gtk.NewListBoxRow() label := gtk.NewLabel("the directory itself") label.SetXAlign(0) label.SetMarginStart(6) label.SetMarginEnd(6) label.SetMarginTop(2) label.SetMarginBottom(2) row.SetChild(label) return row } // showSettings builds the form for the directory's own settings: one row // per setting krino.conf(5) documents, each holding what the file writes, // and empty when the file leaves it out (GUI design §5.1). func (f *formsView) showSettings() { f.sel = -1 f.onSettings = true f.editor = nil f.note.SetText("These are the directory's own settings. An empty field is one the file does not set, and krino's default applies.") box := gtk.NewBox(gtk.OrientationVertical, 6) box.SetMarginStart(8) box.SetMarginEnd(8) box.SetMarginTop(6) box.SetMarginBottom(6) f.settingRows = nil for _, head := range model.DirSettings { args, _, err := f.owner.rules.Setting(head) if err != nil { f.owner.setCheck("settings: " + err.Error()) return } row := newSettingRow(head, args, f.armSettings) f.settingRows = append(f.settingRows, row) box.Append(row.root) } if child := f.place.FirstChild(); child != nil { f.place.Remove(child) } f.place.Append(box) } // armSettings writes the settings back after the same pause a form edit // waits. func (f *formsView) armSettings() { if f.pending != 0 { glib.SourceRemove(f.pending) } f.pending = glib.TimeoutAdd(checkDelay, func() bool { f.pending = 0 f.applySettings() return false }) } // applySettings writes every setting whose field has changed. func (f *formsView) applySettings() { changed := false for _, row := range f.settingRows { args := row.value() was, _, err := f.owner.rules.Setting(row.head) if err != nil { f.owner.setCheck("settings: " + err.Error()) return } if args == was { continue } if err := f.owner.rules.SetSetting(row.head, args); err != nil { f.owner.setCheck("settings: " + err.Error()) row.set(was) continue } changed = true } if changed { f.owner.textChangedByForm() } } // settingRow is one directory setting: its name, and what the file writes // for it. A setting with fixed choices gets them; the rest are written as // they are, so no value is out of reach. type settingRow struct { root *gtk.Box head string entry *gtk.Entry drop *gtk.DropDown items []string } // settingChoices are the settings whose values are a fixed few. var settingChoices = map[string][]string{ "recursive": {"", "yes", "no"}, "case": {"", "ignore", "strict"}, "fold": {"", "yes", "no"}, "on-conflict": {"", "suffix", "skip", "overwrite"}, } // settingHelp is what each setting does, shown when the pointer rests on // its row (his request, 2026-09-17). The wording follows krino.conf(5). var settingHelp = map[string]string{ "path": "the directory krino sorts; a directory's file must set it", "recursive": "look in subdirectories too, not only the directory itself", "max-depth": "how deep to look when recursive: 1 is the directory itself", "min-age": "leave a file alone until it has been untouched this long - protection against sorting a download still being written", "max-read": "the largest file whose text is read for (content ...) tests; 0 reads every size", "max-size": "skip a file larger than this entirely", "busy": "endings that mean a file is still being written, such as \".part\"; those files are left alone", "case": "whether name and path patterns tell capitals apart: ignore (the default) or strict", "fold": "whether a pattern without accents matches a name with them, so \"zazolc\" finds \"zażółć\"", "on-conflict": "what to do when the destination is taken: suffix (name-1), skip, or overwrite", "ignore": "gitignore patterns for files krino never looks at, such as \"*.part\" and \".*\"", "log": "where every run is written; krino undo reads it", } // settingHints is the example shown in an empty field. var settingHints = map[string]string{ "path": `"~/downloads"`, "max-depth": `3`, "min-age": `2m (0, 30s, 2m, 1h, 1d, 1w)`, "max-read": `50M (0 reads it all)`, "max-size": `2G`, "busy": `".part" ".aria2" ".crdownload"`, "ignore": `"*.part" ".*"`, } func newSettingRow(head, args string, changed func()) *settingRow { r := &settingRow{head: head} r.root = gtk.NewBox(gtk.OrientationHorizontal, 6) label := gtk.NewLabel(head) label.SetXAlign(0) label.SetSizeRequest(110, -1) help := settingHelp[head] label.SetTooltipText(help) r.root.SetTooltipText(help) r.root.Append(label) if items, ok := settingChoices[head]; ok { r.items = items shown := make([]string, len(items)) copy(shown, items) shown[0] = "default" r.drop = gtk.NewDropDownFromStrings(shown) r.drop.SetSelected(uint(indexOf(items, args))) if indexOf(items, args) < 0 { r.drop.SetSelected(0) } r.drop.SetTooltipText(help) r.drop.Connect("notify::selected", func() { changed() }) r.root.Append(r.drop) return r } r.entry = gtk.NewEntry() r.entry.SetText(args) r.entry.SetHExpand(true) r.entry.SetPlaceholderText(mainHint(head)) if help != "" { r.entry.SetTooltipText(help + "\n\nfor example: " + mainHint(head)) } else { r.entry.SetTooltipText(mainHint(head)) } r.entry.ConnectChanged(func() { changed() }) r.root.Append(r.entry) return r } // value is what the row would write; "" means the setting is left out. func (r *settingRow) value() string { if r.drop != nil { return r.items[r.drop.Selected()] } return strings.TrimSpace(r.entry.Text()) } // set puts a value back, after a refused edit. func (r *settingRow) set(args string) { if r.drop != nil { if i := indexOf(r.items, args); i >= 0 { r.drop.SetSelected(uint(i)) } return } r.entry.SetText(args) } // 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 || f.Rule == nil { 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 f.onSettings = false 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 { // Row 0 is the directory itself, so form i is row i+1: without the // offset every label moved up a row after an edit (his report). if row := f.list.RowAtIndex(i + 1); 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()) } } // 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 root *gtk.Box name *gtk.Entry conds *gtk.Box conditions []*model.Cond 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 && form.Rule != nil { 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, 2) fe.root.Append(heading("Conditions - all of these must hold")) fe.root.Append(fe.conds) fe.root.Append(leftAligned(fe.addMenu(func(kind string) { fe.conditions = append(fe.conditions, &model.Cond{Kind: kind}) }))) // An exclude has no rule behind it and a rule may have no (when ...) at // all, so neither pointer is followed without asking first: reading // form.Rule for an exclude crashed the window (found by the release // checklist, 2026-09-17). var when []*sexp.Node switch { case form.Kind == model.ExcludeForm: if form.Exclude != nil { when = form.Exclude.When } case form.Rule != nil && form.Rule.HasWhen: when = form.Rule.When } for _, c := range when { fe.conditions = append(fe.conditions, model.ParseCond(c)) } fe.drawConds() if form.Kind == model.RuleForm && form.Rule != nil { 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 _, c := range fe.conditions { text := c.Text() if text == "" { return "", fmt.Errorf("(%s ...) has no condition under it", c.Kind) } 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 } if fe.name == nil || fe.stop == nil { return "", fmt.Errorf("this form has no rule behind it") } 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 } // drawConds rebuilds the condition tree: one row per condition, indented by // how deep it sits, with the operators holding the conditions under them // (GUI design §5.1, his choice 2026-09-17). func (fe *formEditor) drawConds() { for child := fe.conds.FirstChild(); child != nil; child = fe.conds.FirstChild() { fe.conds.Remove(child) } for _, root := range fe.conditions { root.Walk(0, nil, func(node, parent *model.Cond, depth int) { fe.conds.Append(fe.condRow(node, parent, depth)) }) } } // addMenu is the "+" that puts a condition somewhere: a test, or one of the // three operators to hold more conditions under it. func (fe *formEditor) addMenu(add func(kind string)) *gtk.MenuButton { box := gtk.NewBox(gtk.OrientationVertical, 0) pop := gtk.NewPopover() pop.SetChild(box) button := gtk.NewMenuButton() button.SetLabel("+") button.SetTooltipText("add a test here, or and / or / not to hold more conditions") button.SetPopover(pop) for _, item := range []struct{ label, kind string }{ {"test", "type"}, {"and (all of these)", "and"}, {"or (any of these)", "or"}, {"not (none of these)", "not"}, } { b := gtk.NewButtonWithLabel(item.label) b.SetHasFrame(false) b.SetHAlign(gtk.AlignFill) kind := item.kind b.ConnectClicked(func() { pop.Popdown() add(kind) fe.drawConds() fe.changed() }) box.Append(b) } return button } // condRow is one line of the tree. func (fe *formEditor) condRow(node, parent *model.Cond, depth int) *gtk.Box { row := gtk.NewBox(gtk.OrientationHorizontal, 6) row.SetMarginStart(depth * 24) kind := gtk.NewDropDownFromStrings(condItems()) if i := indexOf(condKinds, node.Kind); i >= 0 { kind.SetSelected(uint(i)) } kind.Connect("notify::selected", func() { next := condKinds[kind.Selected()] if next == node.Kind { return } node.Kind = next if !model.CondOps[next] { node.Children = nil } fe.drawConds() fe.changed() }) row.Append(kind) if model.CondOps[node.Kind] { // An operator holds conditions rather than arguments. One button // puts them under it: four side by side ran off the pane. row.Append(fe.addMenu(func(kind string) { node.Add(kind) })) row.Append(gtk.NewLabel("")) } else { args := gtk.NewEntry() args.SetText(node.Args) args.SetHExpand(true) hint := condHints[node.Kind] args.SetPlaceholderText(hint) args.SetTooltipText(hint) if isCompare(node.Kind) { op := gtk.NewDropDownFromStrings(compareOps) opText, value := splitCompare(node.Args) if i := indexOf(compareOps, opText); i >= 0 { op.SetSelected(uint(i)) } args.SetText(value) write := func() { node.Args = compareOps[op.Selected()] + " " + strings.TrimSpace(args.Text()) fe.changed() } op.Connect("notify::selected", func() { write() }) args.ConnectChanged(func() { write() }) row.Append(op) } else { args.ConnectChanged(func() { node.Args = strings.TrimSpace(args.Text()) fe.changed() }) } row.Append(args) } remove := gtk.NewButtonWithLabel("-") remove.SetHasFrame(false) remove.SetTooltipText("take this condition out") remove.ConnectClicked(func() { if parent != nil { parent.Remove(node) } else { for i, c := range fe.conditions { if c == node { fe.conditions = append(fe.conditions[:i], fe.conditions[i+1:]...) break } } } fe.drawConds() fe.changed() }) row.Append(remove) return row } // 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) } // 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 { head, args := splitNode(n) 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() remove := gtk.NewButtonWithLabel("-") remove.ConnectClicked(func() { c.gone = true c.root.SetVisible(false) changed() }) c.kind.Connect("notify::selected", func() { 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 + ")" } 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() { 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. 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 }