aboutsummaryrefslogtreecommitdiff
path: root/gui
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 20:23:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 20:23:24 +0200
commitcace98008d132e48f19b405b5bddc4a6067c0cae (patch)
tree8bfc6af8fa55160ae38c80b45b66894db025c1da /gui
parent4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d (diff)
downloadkrino-cace98008d132e48f19b405b5bddc4a6067c0cae.tar.gz
krino-cace98008d132e48f19b405b5bddc4a6067c0cae.zip
gui: the directory's own settings as a form
Diffstat (limited to 'gui')
-rw-r--r--gui/internal/model/settings.go135
-rw-r--r--gui/internal/model/settings_test.go154
-rw-r--r--gui/internal/ui/forms.go187
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 {