diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 20:23:24 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 20:23:24 +0200 |
| commit | cace98008d132e48f19b405b5bddc4a6067c0cae (patch) | |
| tree | 8bfc6af8fa55160ae38c80b45b66894db025c1da | |
| parent | 4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d (diff) | |
| download | krino-cace98008d132e48f19b405b5bddc4a6067c0cae.tar.gz krino-cace98008d132e48f19b405b5bddc4a6067c0cae.zip | |
gui: the directory's own settings as a form
| -rw-r--r-- | gui/internal/model/settings.go | 135 | ||||
| -rw-r--r-- | gui/internal/model/settings_test.go | 154 | ||||
| -rw-r--r-- | gui/internal/ui/forms.go | 187 |
3 files changed, 470 insertions, 6 deletions
diff --git a/gui/internal/model/settings.go b/gui/internal/model/settings.go new file mode 100644 index 0000000..7c31997 --- /dev/null +++ b/gui/internal/model/settings.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "fmt" + "strings" + + "krino/internal/sexp" +) + +// DirSettings are the forms a directory file may carry above its rules, in +// the order the form shows them. They are the settings krino.conf(5) +// documents for a directory; anything else in the file is a rule, an +// exclude, or not krino's (GUI design §5.1). +var DirSettings = []string{ + "path", "recursive", "max-depth", "min-age", "max-read", "max-size", + "busy", "case", "fold", "on-conflict", "ignore", +} + +// Setting reads one directory setting as it is written - the form's +// arguments, not their meaning - and whether the file sets it at all. +func (r *Rules) Setting(head string) (args string, set bool, err error) { + if !isDirSetting(head) { + return "", false, fmt.Errorf("model: %s is not a directory setting", head) + } + node, err := r.settingNode(head) + if err != nil || node == nil { + return "", false, err + } + return argsOf(node, r.Text), true, nil +} + +// SetSetting writes one directory setting: args as they are to be written, +// or "" to take the setting out of the file. A setting already in the file +// is rewritten where it stands; a new one goes at the end of the header, +// above the first exclude or rule, so a directory file keeps its shape. +// The path is the one setting that cannot be cleared - without it the file +// does not load. +func (r *Rules) SetSetting(head, args string) error { + if !isDirSetting(head) { + return fmt.Errorf("model: %s is not a directory setting", head) + } + args = strings.TrimSpace(args) + if args == "" && head == "path" { + return fmt.Errorf("model: a directory needs its (path ...)") + } + node, err := r.settingNode(head) + if err != nil { + return err + } + switch { + case node != nil && args == "": + start, end := r.lineSpan(node) + r.Text = join(r.Text[:start], r.Text[end:]) + case node != nil: + r.Text = r.Text[:node.Pos.Offset] + "(" + head + " " + args + ")" + r.Text[node.End.Offset:] + default: + at, err := r.headerEnd() + if err != nil { + return err + } + r.Text = r.Text[:at] + "(" + head + " " + args + ")\n" + r.Text[at:] + } + return nil +} + +// isDirSetting reports whether head is one of the directory settings. +func isDirSetting(head string) bool { + for _, s := range DirSettings { + if s == head { + return true + } + } + return false +} + +// settingNode is the form for head in the current text, or nil. A file that +// does not parse has no settings to read. +func (r *Rules) settingNode(head string) (*sexp.Node, error) { + nodes, err := sexp.Parse(r.File, []byte(r.Text)) + if err != nil { + return nil, err + } + for _, n := range nodes { + if n.Kind == sexp.List && n.Head() == head { + return n, nil + } + } + return nil, nil +} + +// headerEnd is where a new setting goes: after the last setting already +// written, and in any case above the first exclude or rule. +func (r *Rules) headerEnd() (int, error) { + nodes, err := sexp.Parse(r.File, []byte(r.Text)) + if err != nil { + return 0, err + } + at := 0 + for _, n := range nodes { + if n.Kind != sexp.List { + continue + } + switch { + case isDirSetting(n.Head()): + at = lineEnd(r.Text, n.End.Offset) + case n.Head() == "exclude" || n.Head() == "rule": + if at == 0 { + at = lineStart(r.Text, n.Pos.Offset) + } + return at, nil + } + } + if at == 0 { + at = len(r.Text) + } + return at, nil +} + +// lineSpan is the whole line a setting is written on, so clearing it takes +// the comment that trails it rather than leaving it to dangle. +func (r *Rules) lineSpan(n *sexp.Node) (start, end int) { + return lineStart(r.Text, n.Pos.Offset), lineEnd(r.Text, n.End.Offset) +} + +// argsOf is a form's arguments exactly as the file writes them. +func argsOf(n *sexp.Node, text string) string { + if len(n.Children) < 2 { + return "" + } + from := n.Children[1].Pos.Offset + to := n.Children[len(n.Children)-1].End.Offset + return strings.TrimSpace(text[from:to]) +} diff --git a/gui/internal/model/settings_test.go b/gui/internal/model/settings_test.go new file mode 100644 index 0000000..46f8b75 --- /dev/null +++ b/gui/internal/model/settings_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "strings" + "testing" +) + +// settingsFile has a header with some settings set and others left out, and +// comments around them that must survive every edit. +const settingsFile = `;; the directory krino sorts +(path "~/dl") +(min-age 2m) ; leave fresh files alone +(ignore "*.part" ".*") + +(rule "all" + (move "Out")) +` + +// TestSettingReads: a directory setting is read as it is written, and one +// that is not in the file reads as absent. +func TestSettingReads(t *testing.T) { + r := openForms(t, settingsFile) + for _, c := range []struct { + head, want string + set bool + }{ + {"path", `"~/dl"`, true}, + {"min-age", "2m", true}, + {"ignore", `"*.part" ".*"`, true}, + {"recursive", "", false}, + {"on-conflict", "", false}, + } { + got, ok, err := r.Setting(c.head) + if err != nil { + t.Fatal(err) + } + if ok != c.set || got != c.want { + t.Errorf("Setting(%q) = %q, %v; want %q, %v", c.head, got, ok, c.want, c.set) + } + } +} + +// TestSettingWrites: changing a setting rewrites that form and nothing +// else; the comment on its line stays. +func TestSettingWrites(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("min-age", "1d"); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, "(min-age 1d)") { + t.Errorf("the new value is not in the file:\n%s", r.Text) + } + if strings.Contains(r.Text, "(min-age 2m)") { + t.Errorf("the old value is still there:\n%s", r.Text) + } + for _, keep := range []string{";; the directory krino sorts", + "; leave fresh files alone", `(ignore "*.part" ".*")`, `(rule "all"`} { + if !strings.Contains(r.Text, keep) { + t.Errorf("writing one setting lost %q:\n%s", keep, r.Text) + } + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestSettingAdds: a setting the file does not have is written into the +// header, above the rules, and the file still loads. +func TestSettingAdds(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("on-conflict", "skip"); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, "(on-conflict skip)") { + t.Errorf("the setting was not written:\n%s", r.Text) + } + if strings.Index(r.Text, "(on-conflict skip)") > strings.Index(r.Text, `(rule "all"`) { + t.Errorf("the setting landed below the rules:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } + got, ok, err := r.Setting("on-conflict") + if err != nil || !ok || got != "skip" { + t.Errorf("reading it back = %q, %v, %v", got, ok, err) + } +} + +// TestSettingAddsAboveTheRules: a setting written below the rules - legal, +// since order does not matter to krino - does not drag a new setting down +// with it. The header is where settings go. +func TestSettingAddsAboveTheRules(t *testing.T) { + r := openForms(t, "(path \"~/dl\")\n\n(rule \"all\"\n (move \"Out\"))\n\n(min-age 2m)\n") + if err := r.SetSetting("on-conflict", "skip"); err != nil { + t.Fatal(err) + } + if strings.Index(r.Text, "(on-conflict skip)") > strings.Index(r.Text, `(rule "all"`) { + t.Errorf("the new setting landed below the rules:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestSettingRemoves: clearing a setting takes its whole line, leaving the +// rest of the header as it was. +func TestSettingRemoves(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("min-age", ""); err != nil { + t.Fatal(err) + } + if strings.Contains(r.Text, "min-age") { + t.Errorf("the setting is still there:\n%s", r.Text) + } + if strings.Contains(r.Text, "leave fresh files alone") { + t.Errorf("the comment on its line was left stranded:\n%s", r.Text) + } + if !strings.Contains(r.Text, `(path "~/dl")`) || !strings.Contains(r.Text, ";; the directory krino sorts") { + t.Errorf("removing one setting took more than its line:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } + if _, ok, _ := r.Setting("min-age"); ok { + t.Error("the removed setting still reads as set") + } +} + +// TestSettingRefusesThePathAway: a directory file without (path ...) does +// not load, so clearing it is refused rather than written. +func TestSettingRefusesThePathAway(t *testing.T) { + r := openForms(t, settingsFile) + before := r.Text + if err := r.SetSetting("path", ""); err == nil { + t.Error("clearing the path was accepted") + } + if r.Text != before { + t.Error("the refused edit changed the file") + } +} + +// TestSettingRefusesAnUnknownHead: only the forms krino.conf(5) documents +// as directory settings can be written this way. +func TestSettingRefusesAnUnknownHead(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("nonsense", "1"); err == nil { + t.Error("an unknown setting was accepted") + } + if _, _, err := r.Setting("nonsense"); err == nil { + t.Error("an unknown setting was read") + } +} diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go index 928c17e..1606903 100644 --- a/gui/internal/ui/forms.go +++ b/gui/internal/ui/forms.go @@ -55,6 +55,8 @@ type formsView struct { forms []model.Form sel int editor *formEditor + settingRows []*settingRow + onSettings bool pending glib.SourceHandle quiet bool commentsAcknowledged map[string]bool @@ -110,9 +112,15 @@ func newFormsView(w *Window, owner *rulesView) *formsView { f.root.Append(right) f.list.ConnectRowSelected(func(row *gtk.ListBoxRow) { - if row != nil && !f.quiet { - f.show(row.Index()) + 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) @@ -133,6 +141,7 @@ func (f *formsView) reload() error { 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))) @@ -148,16 +157,181 @@ func (f *formsView) reload() error { f.list.Append(row) } f.quiet = false - if keep >= 0 && keep < len(forms) { - f.list.SelectRow(f.list.RowAtIndex(keep)) + switch { + case keep >= 0 && keep < len(forms): + f.list.SelectRow(f.list.RowAtIndex(keep + 1)) f.show(keep) - } else { + case f.onSettings: + f.list.SelectRow(f.list.RowAtIndex(0)) + f.showSettings() + default: f.sel = -1 - f.clearEditor("Select a rule to edit it, or Add rule.") + 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"}, +} + +// 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) + 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.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(settingHints[head]) + r.entry.SetTooltipText(settingHints[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 { @@ -179,6 +353,7 @@ func (f *formsView) show(i int) { 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 { |
