From 04b4243ad31d144c4caf9be4c5096a0f27a2648e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 00:23:51 +0200 Subject: gui: syntax colours, a file preview, and a settings window --- docs/gui-checklist.md | 11 ++ gui/internal/model/highlight.go | 97 ++++++++++++++++++ gui/internal/model/highlight_test.go | 83 +++++++++++++++ gui/internal/model/mainconf.go | 184 +++++++++++++++++++++++++++++++++ gui/internal/model/mainconf_test.go | 169 +++++++++++++++++++++++++++++++ gui/internal/model/prefs.go | 62 ++++++++++++ gui/internal/model/prefs_test.go | 59 +++++++++++ gui/internal/model/preview.go | 173 +++++++++++++++++++++++++++++++ gui/internal/model/preview_test.go | 98 ++++++++++++++++++ gui/internal/ui/forms.go | 4 +- gui/internal/ui/highlight.go | 77 ++++++++++++++ gui/internal/ui/plan.go | 128 ++++++++++++++++++++++- gui/internal/ui/rules.go | 20 ++++ gui/internal/ui/settings.go | 191 +++++++++++++++++++++++++++++++++++ gui/internal/ui/window.go | 24 ++++- internal/cond/types.go | 5 + man/krino-gui.1 | 50 +++++++-- 17 files changed, 1417 insertions(+), 18 deletions(-) create mode 100644 gui/internal/model/highlight.go create mode 100644 gui/internal/model/highlight_test.go create mode 100644 gui/internal/model/mainconf.go create mode 100644 gui/internal/model/mainconf_test.go create mode 100644 gui/internal/model/prefs.go create mode 100644 gui/internal/model/prefs_test.go create mode 100644 gui/internal/model/preview.go create mode 100644 gui/internal/model/preview_test.go create mode 100644 gui/internal/ui/highlight.go create mode 100644 gui/internal/ui/settings.go diff --git a/docs/gui-checklist.md b/docs/gui-checklist.md index 04cb6af..903e857 100644 --- a/docs/gui-checklist.md +++ b/docs/gui-checklist.md @@ -89,3 +89,14 @@ A dialog is its own window: take it by its own id, not the main window's. 24. Closing the window releases the directory lock it held. 25. A configuration that does not load prints the problem and exits 2 rather than opening a window. + +## Settings and preview + +26. Selecting a row in the Plan tab shows the file under its explanation: a + picture for an image, the rendered first page for a PDF, the first + lines for text, and a plain note for anything else. +27. Settings writes krino's defaults into `krino.conf` - refused while the + configuration would not load, previous text kept as `krino.conf.bak` - + and the tabs work from the new defaults at once. +28. The window preferences take effect as they are changed (font size, + colours, preview, a plan starting unchecked) and survive a restart. diff --git a/gui/internal/model/highlight.go b/gui/internal/model/highlight.go new file mode 100644 index 0000000..e34d9d9 --- /dev/null +++ b/gui/internal/model/highlight.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +// SpanKind is what a stretch of a configuration file is, for colouring it. +type SpanKind int + +const ( + SpanComment SpanKind = iota + SpanString + SpanHead // the symbol a form starts with + SpanAction // a head that does something to a file + SpanParen +) + +// Span is one stretch of text to colour, in character offsets - what a +// GtkTextBuffer counts in, not bytes. +type Span struct { + From, To int + Kind SpanKind +} + +// actionHeads are the forms that act on a file rather than describe one. +var actionHeads = map[string]bool{ + "copy": true, "move": true, "rename": true, "delete": true, "stop": true, +} + +// Spans reads a configuration and says what to colour: a ";" outside a +// string comments out the rest of its line; a string runs to its closing +// quote, a backslash escaping the next character; the first symbol after +// "(" is the form's head. Nothing here knows about widgets, so the rules of +// the little language stay testable (his request, 2026-09-16). +func Spans(text string) []Span { + var out []Span + runes := []rune(text) + afterParen := false + for i := 0; i < len(runes); i++ { + switch c := runes[i]; { + case c == ';': + j := i + for j < len(runes) && runes[j] != '\n' { + j++ + } + out = append(out, Span{i, j, SpanComment}) + i = j + afterParen = false + case c == '"': + j := i + 1 + for j < len(runes) { + if runes[j] == '\\' { + j += 2 + continue + } + if runes[j] == '"' { + j++ + break + } + j++ + } + if j > len(runes) { + j = len(runes) + } + out = append(out, Span{i, j, SpanString}) + i = j - 1 + afterParen = false + case c == '(' || c == ')': + out = append(out, Span{i, i + 1, SpanParen}) + afterParen = c == '(' + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + // whitespace between "(" and the head is allowed + default: + j := i + for j < len(runes) && !isDelimiter(runes[j]) { + j++ + } + if afterParen { + kind := SpanHead + if actionHeads[string(runes[i:j])] { + kind = SpanAction + } + out = append(out, Span{i, j, kind}) + } + i = j - 1 + afterParen = false + } + } + return out +} + +// isDelimiter reports whether a character ends a symbol. +func isDelimiter(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '(', ')', '"', ';': + return true + } + return false +} diff --git a/gui/internal/model/highlight_test.go b/gui/internal/model/highlight_test.go new file mode 100644 index 0000000..f5c1e28 --- /dev/null +++ b/gui/internal/model/highlight_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import "testing" + +// textOf is the stretch a span covers, by character offset. +func textOf(text string, s Span) string { + return string([]rune(text)[s.From:s.To]) +} + +// find is the first span of a kind whose text matches, for the assertions +// below. +func find(t *testing.T, text string, kind SpanKind, want string) { + t.Helper() + for _, s := range Spans(text) { + if s.Kind == kind && textOf(text, s) == want { + return + } + } + t.Errorf("no %v span %q in %q", kind, want, text) +} + +// TestSpansPaintsTheParts: heads, actions, strings and comments each come +// back as their own kind. +func TestSpansPaintsTheParts(t *testing.T) { + text := "(rule \"invoices\" ; the ones from suppliers\n (when (type pdf))\n (move \"Docs\"))\n" + find(t, text, SpanHead, "rule") + find(t, text, SpanHead, "when") + find(t, text, SpanHead, "type") + find(t, text, SpanAction, "move") + find(t, text, SpanString, `"invoices"`) + find(t, text, SpanString, `"Docs"`) + find(t, text, SpanComment, "; the ones from suppliers") +} + +// TestSpansKeepsQuotingStraight: a semicolon inside a string is not a +// comment, a quote inside a comment does not start a string, and an escaped +// quote does not end one. +func TestSpansKeepsQuotingStraight(t *testing.T) { + text := `(rule "a;b" (when (name "say \"hi\"")) (move "Out"))` + "\n;; \"not a string\"\n" + find(t, text, SpanString, `"a;b"`) + find(t, text, SpanString, `"say \"hi\""`) + find(t, text, SpanComment, `;; "not a string"`) + for _, s := range Spans(text) { + if s.Kind == SpanComment && textOf(text, s) == ";b\" (when (name \"say \\\"hi\\\"\")) (move \"Out\"))" { + t.Error("a semicolon inside a string started a comment") + } + } +} + +// TestSpansOnlyHeadsAreHeads: an argument that happens to be a symbol is +// not painted as a head. +func TestSpansOnlyHeadsAreHeads(t *testing.T) { + text := "(when (type pdf doc))\n" + for _, s := range Spans(text) { + if s.Kind == SpanHead && (textOf(text, s) == "pdf" || textOf(text, s) == "doc") { + t.Errorf("argument %q painted as a head", textOf(text, s)) + } + } + find(t, text, SpanHead, "type") +} + +// TestSpansCountsCharactersNotBytes: offsets are what a text buffer counts, +// so a file with Polish letters in it still colours the right stretch. +func TestSpansCountsCharactersNotBytes(t *testing.T) { + text := "(rule \"spółka\" (move \"Księgowość\"))\n" + find(t, text, SpanString, `"spółka"`) + find(t, text, SpanString, `"Księgowość"`) + find(t, text, SpanAction, "move") +} + +// TestSpansSurvivesUnclosedForms: text being typed is not yet valid, and +// the colouring must not run off the end of it. +func TestSpansSurvivesUnclosedForms(t *testing.T) { + for _, text := range []string{`(rule "half`, "(when (name ", ";; just a comment", `"`} { + for _, s := range Spans(text) { + if s.From < 0 || s.To > len([]rune(text)) || s.From > s.To { + t.Errorf("%q: span %+v is outside the text", text, s) + } + } + } +} diff --git a/gui/internal/model/mainconf.go b/gui/internal/model/mainconf.go new file mode 100644 index 0000000..327451e --- /dev/null +++ b/gui/internal/model/mainconf.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "crypto/sha256" + "fmt" + "os" + "strings" + + "krino/internal/config" + "krino/internal/engine" + "krino/internal/sexp" +) + +// MainSettings are the settings krino.conf carries for every directory, +// inside its (defaults ...) form, in the order the window shows them. +var MainSettings = []string{ + "case", "fold", "recursive", "min-age", "max-read", "max-size", + "busy", "on-conflict", +} + +// MainConf is krino.conf open for editing. Only its (defaults ...) block +// and the log path are edited here - the includes and the excludes are the +// file's own business, and the Text tab is where those are written (GUI +// design §5). +type MainConf struct { + File string + Text string + + e *engine.Engine + saved string + stamp [32]byte +} + +// OpenMain reads krino.conf. +func OpenMain(e *engine.Engine) (*MainConf, error) { + text, err := os.ReadFile(e.MainFile) + if err != nil { + return nil, err + } + return &MainConf{File: e.MainFile, Text: string(text), e: e, + saved: string(text), stamp: sha256.Sum256(text)}, nil +} + +// Modified reports whether the text differs from what is on disk. +func (m *MainConf) Modified() bool { return m.Text != m.saved } + +// Check loads the whole configuration with this text in place of the file +// and reports what is wrong with it. +func (m *MainConf) Check() []*config.Diag { + over := map[string][]byte{m.File: []byte(m.Text)} + _, diags := engine.LoadWith(m.File, over) + return diags +} + +// Setting reads one default as it is written: "log" from the top level, +// everything else from inside (defaults ...). +func (m *MainConf) Setting(head string) (args string, set bool, err error) { + if head != "log" && !isMainSetting(head) { + return "", false, fmt.Errorf("model: %s is not a setting of krino.conf", head) + } + node, err := m.node(head) + if err != nil || node == nil { + return "", false, err + } + return argsOf(node, m.Text), true, nil +} + +// SetSetting writes one default, or takes it out when args is empty. A +// setting that is not in the file yet is written into (defaults ...), which +// is created if the file has none. +func (m *MainConf) SetSetting(head, args string) error { + if head != "log" && !isMainSetting(head) { + return fmt.Errorf("model: %s is not a setting of krino.conf", head) + } + args = strings.TrimSpace(args) + node, err := m.node(head) + if err != nil { + return err + } + switch { + case node != nil && args == "": + start, end := lineStart(m.Text, node.Pos.Offset), lineEnd(m.Text, node.End.Offset) + m.Text = join(m.Text[:start], m.Text[end:]) + case node != nil: + m.Text = m.Text[:node.Pos.Offset] + "(" + head + " " + args + ")" + m.Text[node.End.Offset:] + case head == "log": + m.Text = join(m.Text, "\n(log "+args+")\n") + default: + if err := m.setInDefaults(head, args); err != nil { + return err + } + } + return nil +} + +// setInDefaults writes a setting inside (defaults ...), adding the form +// when the file has none. +func (m *MainConf) setInDefaults(head, args string) error { + defaults, err := m.topLevel("defaults") + if err != nil { + return err + } + if defaults == nil { + m.Text = join(m.Text, "\n(defaults\n ("+head+" "+args+"))\n") + return nil + } + // Just inside the closing paren, on a line of its own. + at := defaults.End.Offset - 1 + m.Text = m.Text[:at] + "\n (" + head + " " + args + ")" + m.Text[at:] + return nil +} + +// node is the form for a setting: (log ...) at the top level, the rest +// inside (defaults ...). +func (m *MainConf) node(head string) (*sexp.Node, error) { + if head == "log" { + return m.topLevel("log") + } + defaults, err := m.topLevel("defaults") + if err != nil || defaults == nil { + return nil, err + } + for _, n := range defaults.Args() { + if n.Kind == sexp.List && n.Head() == head { + return n, nil + } + } + return nil, nil +} + +// topLevel is the top-level form with that head, or nil. +func (m *MainConf) topLevel(head string) (*sexp.Node, error) { + nodes, err := sexp.Parse(m.File, []byte(m.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 +} + +// Save writes krino.conf, refusing a file that will not load and one that +// changed on disk since it was opened - the rules the directory files are +// saved by (GUI design §5.3). The previous text is kept as krino.conf.bak. +func (m *MainConf) Save() error { + if diags := m.Check(); len(diags) > 0 { + return fmt.Errorf("%s", diags[0]) + } + on, err := os.ReadFile(m.File) + if err != nil { + return err + } + if sha256.Sum256(on) != m.stamp { + return ErrChangedOnDisk + } + mode := os.FileMode(0o600) + if fi, err := os.Stat(m.File); err == nil { + mode = fi.Mode().Perm() + } + if err := os.WriteFile(m.File+".bak", on, mode); err != nil { + return err + } + text := []byte(m.Text) + if err := config.Replace(m.File, text); err != nil { + return err + } + m.saved, m.stamp = m.Text, sha256.Sum256(text) + return nil +} + +// isMainSetting reports whether head is one of the defaults. +func isMainSetting(head string) bool { + for _, s := range MainSettings { + if s == head { + return true + } + } + return false +} diff --git a/gui/internal/model/mainconf_test.go b/gui/internal/model/mainconf_test.go new file mode 100644 index 0000000..bae739a --- /dev/null +++ b/gui/internal/model/mainconf_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "krino/internal/engine" +) + +// mainConf opens krino.conf over a sandbox whose main file holds text. +func mainConf(t *testing.T, main string) (*MainConf, *engine.Engine, string) { + t.Helper() + e, h := sandboxDir(t, "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n", + map[string]string{"a.pdf": "one"}) + if main != "" { + if err := os.WriteFile(e.MainFile, []byte(main), 0o644); err != nil { + t.Fatal(err) + } + reloaded, diags := engine.Load(e.MainFile) + if len(diags) > 0 { + t.Fatalf("the fixture does not load: %v", diags) + } + e = reloaded + } + m, err := OpenMain(e) + if err != nil { + t.Fatal(err) + } + return m, e, h +} + +// TestMainSettingsReadAndWrite: a default is read as written, changed in +// place, and taken out again, with everything else in the file untouched. +func TestMainSettingsReadAndWrite(t *testing.T) { + main := ";; the main file\n(include \"dl\")\n\n(defaults\n (min-age 2m) ; leave fresh files\n (on-conflict suffix))\n" + m, _, _ := mainConf(t, main) + + if args, set, err := m.Setting("min-age"); err != nil || !set || args != "2m" { + t.Errorf("min-age = %q, %v, %v", args, set, err) + } + if _, set, _ := m.Setting("fold"); set { + t.Error("fold reads as set") + } + if err := m.SetSetting("min-age", "1d"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(min-age 1d)") || strings.Contains(m.Text, "(min-age 2m)") { + t.Errorf("min-age not rewritten:\n%s", m.Text) + } + for _, keep := range []string{";; the main file", "(include \"dl\")", "; leave fresh files", + "(on-conflict suffix)"} { + if !strings.Contains(m.Text, keep) { + t.Errorf("writing one setting lost %q:\n%s", keep, m.Text) + } + } + // A setting the file does not have goes inside (defaults ...). + if err := m.SetSetting("fold", "no"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(fold no)") { + t.Errorf("fold not written:\n%s", m.Text) + } + if strings.Index(m.Text, "(fold no)") < strings.Index(m.Text, "(defaults") { + t.Errorf("fold landed outside the defaults:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Fatalf("the file no longer loads: %v", diags) + } + // And out again. + if err := m.SetSetting("min-age", ""); err != nil { + t.Fatal(err) + } + if strings.Contains(m.Text, "min-age") { + t.Errorf("min-age still there:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestMainSettingsWithoutADefaultsForm: a file with no (defaults ...) gets +// one, and it loads. +func TestMainSettingsWithoutADefaultsForm(t *testing.T) { + m, _, _ := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("recursive", "yes"); err != nil { + t.Fatal(err) + } + if !strings.Contains(m.Text, "(defaults") || !strings.Contains(m.Text, "(recursive yes)") { + t.Errorf("no defaults form was written:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestMainLogIsTopLevel: the log path is not a default; it is written at +// the top level, where krino.conf(5) puts it. +func TestMainLogIsTopLevel(t *testing.T) { + m, _, h := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("log", "\"~/tmp/krino.log\""); err != nil { + t.Fatal(err) + } + if strings.Contains(m.Text, "(defaults") { + t.Errorf("the log went into the defaults:\n%s", m.Text) + } + if diags := m.Check(); len(diags) > 0 { + t.Fatalf("the file no longer loads: %v", diags) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + e, diags := engine.Load(filepath.Join(h, ".config", "krino", "krino.conf")) + if len(diags) > 0 { + t.Fatal(diags) + } + if !strings.HasSuffix(e.Config.LogFile(), "tmp/krino.log") { + t.Errorf("the saved log path is not in effect: %s", e.Config.LogFile()) + } +} + +// TestMainSaveGuards: krino.conf is saved by the same rules as a directory +// file - never broken, never over someone else's edit, and the previous +// text is kept. +func TestMainSaveGuards(t *testing.T) { + main := "(include \"dl\")\n" + m, _, _ := mainConf(t, main) + + m.Text = "(include \"dl\")\n(defaults (min-age nonsense))\n" + if err := m.Save(); err == nil { + t.Error("a file that will not load was saved") + } + if on, _ := os.ReadFile(m.File); string(on) != main { + t.Errorf("the refused save wrote anyway: %q", on) + } + + m.Text = "(include \"dl\")\n(defaults (min-age 1d))\n" + if err := m.Save(); err != nil { + t.Fatal(err) + } + if on, _ := os.ReadFile(m.File + ".bak"); string(on) != main { + t.Errorf("backup = %q, want the previous text", on) + } + + // Someone else edits it, and the next save refuses. + if err := os.WriteFile(m.File, []byte("(include \"dl\")\n;; theirs\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Text = "(include \"dl\")\n;; mine\n" + if err := m.Save(); !errors.Is(err, ErrChangedOnDisk) { + t.Errorf("Save = %v, want ErrChangedOnDisk", err) + } +} + +// TestMainSettingRefusesAnUnknownHead: only the settings krino.conf(5) +// documents can be written this way. +func TestMainSettingRefusesAnUnknownHead(t *testing.T) { + m, _, _ := mainConf(t, "(include \"dl\")\n") + if err := m.SetSetting("nonsense", "1"); err == nil { + t.Error("an unknown setting was accepted") + } + if _, _, err := m.Setting("nonsense"); err == nil { + t.Error("an unknown setting was read") + } +} diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go new file mode 100644 index 0000000..ce9ee42 --- /dev/null +++ b/gui/internal/model/prefs.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "encoding/json" + "os" + "path/filepath" + + "krino/internal/xdg" +) + +// Prefs is how the window behaves - nothing about what krino does to +// files, which belongs in the configuration. It lives beside krino.conf as +// gui.json, a file krino itself never reads (his request, 2026-09-16). +type Prefs struct { + // FontSize is the editor's font in points; 0 keeps the theme's. + FontSize int `json:"font_size"` + // Colours paints the configuration in the Text tab. + Colours bool `json:"colours"` + // Preview shows the file behind the selected row in the Plan tab. + Preview bool `json:"preview"` + // SelectAll starts a plan's rows checked, as the terminal review does. + SelectAll bool `json:"select_all"` +} + +// DefaultPrefs is what a window does before anything is chosen. +func DefaultPrefs() Prefs { + return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true} +} + +// PrefsFile is where they are kept. +func PrefsFile() string { + return filepath.Join(xdg.ConfigHome(), "krino", "gui.json") +} + +// LoadPrefs reads them. A missing or unreadable file is not an error: the +// window opens with the defaults, as it does the first time. +func LoadPrefs() Prefs { + p := DefaultPrefs() + data, err := os.ReadFile(PrefsFile()) + if err != nil { + return p + } + if err := json.Unmarshal(data, &p); err != nil { + return DefaultPrefs() + } + return p +} + +// Save writes them, creating the directory if it is not there yet. +func (p Prefs) Save() error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + file := PrefsFile() + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return err + } + return os.WriteFile(file, append(data, '\n'), 0o644) +} diff --git a/gui/internal/model/prefs_test.go b/gui/internal/model/prefs_test.go new file mode 100644 index 0000000..6c01dee --- /dev/null +++ b/gui/internal/model/prefs_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// sandboxHome points HOME and every XDG_* at a temporary directory, as the +// other tests here do. +func sandboxHome(t *testing.T) string { + t.Helper() + h := t.TempDir() + t.Setenv("HOME", h) + for _, v := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { + t.Setenv(v, "") + } + return h +} + +// TestPrefsRoundTrip: what is saved comes back, and the file lands beside +// krino.conf. +func TestPrefsRoundTrip(t *testing.T) { + h := sandboxHome(t) + p := DefaultPrefs() + p.FontSize = 13 + p.Colours = false + p.SelectAll = false + if err := p.Save(); err != nil { + t.Fatal(err) + } + if got := LoadPrefs(); got != p { + t.Errorf("loaded %+v, want %+v", got, p) + } + want := filepath.Join(h, ".config", "krino", "gui.json") + if _, err := os.Stat(want); err != nil { + t.Errorf("not written to %s: %v", want, err) + } +} + +// TestPrefsWithoutAFile: the first run has no file, and damaged text is not +// worth refusing to open a window over. +func TestPrefsWithoutAFile(t *testing.T) { + sandboxHome(t) + if got := LoadPrefs(); got != DefaultPrefs() { + t.Errorf("with no file: %+v, want the defaults", got) + } + if err := os.MkdirAll(filepath.Dir(PrefsFile()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PrefsFile(), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if got := LoadPrefs(); got != DefaultPrefs() { + t.Errorf("with damaged text: %+v, want the defaults", got) + } +} diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go new file mode 100644 index 0000000..6ca029e --- /dev/null +++ b/gui/internal/model/preview.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode/utf8" + + "krino/internal/cond" +) + +// PreviewKind is what a preview holds. +type PreviewKind int + +const ( + PreviewNone PreviewKind = iota + PreviewImage + PreviewText +) + +// Preview is what to show of a file beside its explanation: a picture, some +// of its text, or nothing with the reason (his request, 2026-09-16). +type Preview struct { + Kind PreviewKind + Image string // a file to show: the file itself, or a rendered page + Text string + Note string // what this is, or why there is nothing +} + +// previewBytes is how much of a text file is read, and previewLines how +// much of it is shown: enough to recognise a document, not to read it. +const ( + previewBytes = 64 << 10 + previewLines = 200 + // imageLimit is the size above which an image is described rather than + // loaded: decoding a huge photograph would stall the window. + imageLimit = 40 << 20 +) + +// MakePreview looks at one file. tmp is a directory the caller owns, where +// a rendered PDF page is written; the caller removes it. Nothing is written +// anywhere else, and the file itself is only read. +func MakePreview(ctx context.Context, path, tmp string) Preview { + fi, err := os.Stat(path) + if err != nil { + return Preview{Note: err.Error()} + } + if fi.IsDir() { + return Preview{Note: "a directory"} + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) + switch { + case isImageExt(ext): + if fi.Size() > imageLimit { + return Preview{Note: fmt.Sprintf("image of %s, too large to show", size(fi.Size()))} + } + return Preview{Kind: PreviewImage, Image: path, Note: fmt.Sprintf("image, %s", size(fi.Size()))} + case ext == "pdf": + return pdfPreview(ctx, path, tmp, fi.Size()) + } + text, ok := textHead(path) + if !ok { + return Preview{Note: fmt.Sprintf(".%s file, %s - no preview", ext, size(fi.Size()))} + } + return Preview{Kind: PreviewText, Text: text, Note: fmt.Sprintf("first lines of %s", size(fi.Size()))} +} + +// pdfPreview renders the first page if poppler can, and falls back to the +// document's text - the same pdftotext krino's own (content ...) tests use. +func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview { + if _, err := exec.LookPath("pdftoppm"); err == nil { + out := filepath.Join(tmp, "page") + cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1", + "-scale-to", "700", "--", path, out) + if err := cmd.Run(); err == nil { + if rendered := firstMatch(out + "*.png"); rendered != "" { + return Preview{Kind: PreviewImage, Image: rendered, + Note: fmt.Sprintf("page 1, %s", size(sz))} + } + } + } + if _, err := exec.LookPath("pdftotext"); err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - install poppler-utils to see it", size(sz))} + } + cmd := exec.CommandContext(ctx, "pdftotext", "-l", "2", "--", path, "-") + text, err := cmd.Output() + if err != nil { + return Preview{Note: fmt.Sprintf("PDF, %s - pdftotext cannot read it", size(sz))} + } + return Preview{Kind: PreviewText, Text: head(string(text)), + Note: fmt.Sprintf("text of the first pages, %s", size(sz))} +} + +// firstMatch is the first file matching a glob, "" when there is none. +func firstMatch(glob string) string { + names, err := filepath.Glob(glob) + if err != nil || len(names) == 0 { + return "" + } + return names[0] +} + +// textHead reads the start of a file and reports whether it is text: no NUL +// bytes, and valid UTF-8 as far as it was read. +func textHead(path string) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + buf := make([]byte, previewBytes) + n, _ := f.Read(buf) + buf = buf[:n] + if n == 0 { + return "", false + } + if strings.IndexByte(string(buf), 0) >= 0 { + return "", false + } + // A cut multi-byte character at the end is not a reason to call a file + // binary, so the last few bytes are dropped before the check. + trimmed := buf + for len(trimmed) > 0 && !utf8.Valid(trimmed) && len(buf)-len(trimmed) < 4 { + trimmed = trimmed[:len(trimmed)-1] + } + if !utf8.Valid(trimmed) { + return "", false + } + return head(string(trimmed)), true +} + +// head is the first previewLines lines of s. +func head(s string) string { + lines := strings.SplitN(s, "\n", previewLines+1) + if len(lines) > previewLines { + lines = lines[:previewLines] + return strings.Join(lines, "\n") + "\n..." + } + return strings.Join(lines, "\n") +} + +// isImageExt reports whether an extension is one krino's (type image) would +// take, minus the formats GTK cannot draw without extra libraries. +func isImageExt(ext string) bool { + switch ext { + case "svg", "ico", "raw", "cr2", "nef", "arw", "dng", "heic", "heif", "avif": + return false + } + for _, e := range cond.Group("image") { + if e == ext { + return true + } + } + return false +} + +// size is a file's size in the units krino's own settings use. +func size(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1fG", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1fM", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1fK", float64(n)/(1<<10)) + } + return fmt.Sprintf("%dB", n) +} diff --git a/gui/internal/model/preview_test.go b/gui/internal/model/preview_test.go new file mode 100644 index 0000000..8c19922 --- /dev/null +++ b/gui/internal/model/preview_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestPreviewKinds: a text file comes back as text, a picture as a picture, +// and something krino cannot show says so instead of guessing. +func TestPreviewKinds(t *testing.T) { + dir := t.TempDir() + write := func(name string, body []byte) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatal(err) + } + return p + } + text := write("notes.txt", []byte("first line\nsecond line\n")) + png := write("a.png", []byte("\x89PNG\r\n\x1a\nnot really, but the name is what counts here")) + binary := write("a.bin", []byte{0, 1, 2, 3, 0}) + tmp := t.TempDir() + + if p := MakePreview(context.Background(), text, tmp); p.Kind != PreviewText || + !strings.Contains(p.Text, "second line") { + t.Errorf("text preview = %+v", p) + } + if p := MakePreview(context.Background(), png, tmp); p.Kind != PreviewImage || p.Image != png { + t.Errorf("image preview = %+v", p) + } + if p := MakePreview(context.Background(), binary, tmp); p.Kind != PreviewNone || + !strings.Contains(p.Note, "no preview") { + t.Errorf("binary preview = %+v", p) + } + if p := MakePreview(context.Background(), filepath.Join(dir, "gone.txt"), tmp); p.Kind != PreviewNone { + t.Errorf("a missing file = %+v", p) + } + if p := MakePreview(context.Background(), dir, tmp); p.Kind != PreviewNone { + t.Errorf("a directory = %+v", p) + } +} + +// TestPreviewReadsOnly: looking at a file changes nothing about it, and +// nothing is left beside it. +func TestPreviewReadsOnly(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "notes.txt") + body := []byte("one\ntwo\n") + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatal(err) + } + before, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + MakePreview(context.Background(), p, t.TempDir()) + after, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if !before.ModTime().Equal(after.ModTime()) || before.Size() != after.Size() { + t.Error("the file changed") + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("the directory holds %d files, want the one", len(entries)) + } + if on, _ := os.ReadFile(p); string(on) != string(body) { + t.Error("the contents changed") + } +} + +// TestPreviewShowsOnlyTheHead: a long file is cut, and says it was. +func TestPreviewShowsOnlyTheHead(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "long.txt") + var b strings.Builder + for i := 0; i < previewLines*2; i++ { + b.WriteString("line\n") + } + if err := os.WriteFile(p, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + pv := MakePreview(context.Background(), p, t.TempDir()) + if pv.Kind != PreviewText { + t.Fatalf("preview = %+v", pv) + } + if n := strings.Count(pv.Text, "\n"); n > previewLines+1 { + t.Errorf("%d lines shown, want at most %d", n, previewLines) + } + if !strings.HasSuffix(pv.Text, "...") { + t.Error("a cut file does not say it was cut") + } +} diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go index 55f6328..6fcf973 100644 --- a/gui/internal/ui/forms.go +++ b/gui/internal/ui/forms.go @@ -333,8 +333,8 @@ func newSettingRow(head, args string, changed func()) *settingRow { r.entry = gtk.NewEntry() r.entry.SetText(args) r.entry.SetHExpand(true) - r.entry.SetPlaceholderText(settingHints[head]) - r.entry.SetTooltipText(settingHints[head]) + r.entry.SetPlaceholderText(mainHint(head)) + r.entry.SetTooltipText(mainHint(head)) r.entry.ConnectChanged(func() { changed() }) r.root.Append(r.entry) return r diff --git a/gui/internal/ui/highlight.go b/gui/internal/ui/highlight.go new file mode 100644 index 0000000..8a4acd4 --- /dev/null +++ b/gui/internal/ui/highlight.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ui + +import ( + "github.com/diamondburned/gotk4/pkg/gtk/v4" + + "krino/gui/internal/model" +) + +// The colours a configuration is painted in. They are chosen to read on a +// light and a dark theme alike, since the window follows whatever GTK theme +// is in use (his request, 2026-09-16). +var spanColours = map[model.SpanKind]string{ + model.SpanComment: "#8b8b8b", + model.SpanString: "#2e8b57", + model.SpanHead: "#3584e4", + model.SpanAction: "#c06014", + model.SpanParen: "#9a9a9a", +} + +// highlighter paints a configuration in a TextView. +type highlighter struct { + buf *gtk.TextBuffer + tags map[model.SpanKind]*gtk.TextTag + on bool +} + +// newHighlighter registers one tag per kind, once per buffer. +func newHighlighter(buf *gtk.TextBuffer) *highlighter { + h := &highlighter{buf: buf, tags: map[model.SpanKind]*gtk.TextTag{}, on: true} + table := buf.TagTable() + names := map[model.SpanKind]string{ + model.SpanComment: "krino-comment", + model.SpanString: "krino-string", + model.SpanHead: "krino-head", + model.SpanAction: "krino-action", + model.SpanParen: "krino-paren", + } + for kind, name := range names { + t := gtk.NewTextTag(name) + t.SetObjectProperty("foreground", spanColours[kind]) + if kind == model.SpanComment { + t.SetObjectProperty("style", 2) // PANGO_STYLE_ITALIC + } + table.Add(t) + h.tags[kind] = t + } + return h +} + +// setEnabled turns the colours on or off. +func (h *highlighter) setEnabled(on bool) { + h.on = on + if !on { + h.clear() + } +} + +// clear takes every colour off the buffer. +func (h *highlighter) clear() { + start, end := h.buf.Bounds() + h.buf.RemoveAllTags(start, end) +} + +// paint colours the text now in the buffer. +func (h *highlighter) paint(text string) { + h.clear() + if !h.on { + return + } + for _, s := range model.Spans(text) { + if tag, ok := h.tags[s.Kind]; ok { + h.buf.ApplyTag(tag, h.buf.IterAtOffset(s.From), h.buf.IterAtOffset(s.To)) + } + } +} diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index f6f2952..97d8599 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -5,6 +5,8 @@ package ui import ( "context" "fmt" + "os" + "path/filepath" "strings" "github.com/diamondburned/gotk4/pkg/gdk/v4" @@ -35,8 +37,18 @@ type planView struct { list *gtk.ListBox details *gtk.TextView - menu *gtk.Popover - menuRow int + + previewNote *gtk.Label + picture *gtk.Picture + pictureFrame *gtk.ScrolledWindow + previewText *gtk.TextView + previewScroll *gtk.ScrolledWindow + previewTmp string + previewFor string + previewOff bool + startSelected bool + menu *gtk.Popover + menuRow int tab *model.PlanTab cancelOp context.CancelFunc @@ -102,13 +114,53 @@ func newPlanView(w *Window) *planView { p.details.SetBottomMargin(6) detailScroll := gtk.NewScrolledWindow() detailScroll.SetChild(p.details) - detailScroll.SetSizeRequest(320, -1) + detailScroll.SetVExpand(true) + detailScroll.SetSizeRequest(-1, 160) + + // Under the explanation, a look at the file itself: a picture for an + // image, the first page for a PDF, the first lines for anything that is + // text (his request, 2026-09-16). + p.previewNote = gtk.NewLabel("") + p.previewNote.SetXAlign(0) + p.previewNote.SetMarginStart(8) + p.previewNote.SetMarginEnd(8) + p.previewNote.SetEllipsize(pango.EllipsizeEnd) + p.previewNote.SetMaxWidthChars(20) + p.previewNote.AddCSSClass("dim-label") + p.picture = gtk.NewPicture() + p.picture.SetCanShrink(true) + p.picture.SetContentFit(gtk.ContentFitContain) + p.picture.SetVisible(false) + pictureFrame := gtk.NewScrolledWindow() + pictureFrame.SetChild(p.picture) + pictureFrame.SetSizeRequest(-1, 280) + pictureFrame.SetVisible(false) + p.pictureFrame = pictureFrame + p.previewText = gtk.NewTextView() + p.previewText.SetEditable(false) + p.previewText.SetMonospace(true) + p.previewText.SetLeftMargin(8) + p.previewText.SetRightMargin(8) + p.previewText.SetTopMargin(6) + previewScroll := gtk.NewScrolledWindow() + previewScroll.SetChild(p.previewText) + previewScroll.SetSizeRequest(-1, 260) + previewScroll.SetVisible(false) + p.previewScroll = previewScroll + + detailBox := gtk.NewBox(gtk.OrientationVertical, 0) + detailBox.Append(detailScroll) + detailBox.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) + detailBox.Append(p.previewNote) + detailBox.Append(pictureFrame) + detailBox.Append(previewScroll) + detailBox.SetSizeRequest(340, -1) // A pane the user can drag: on a narrow window the list needs the room, // on a wide one the explanation does. panes := gtk.NewPaned(gtk.OrientationHorizontal) panes.SetStartChild(listScroll) - panes.SetEndChild(detailScroll) + panes.SetEndChild(detailBox) panes.SetResizeStartChild(true) panes.SetResizeEndChild(false) panes.SetShrinkEndChild(false) @@ -272,6 +324,9 @@ func (p *planView) onScan() { return } p.tab = tab + if !p.startSelected { + tab.SelectNone() + } p.fillList() c := tab.Counts p.w.setStatus("%d scanned, %d to act on, %d excluded, %d skipped, %d with warnings", @@ -479,4 +534,69 @@ func (p *planView) showDetails(i int) { b.WriteString("\n" + escape(r.Outcome) + "\n") } p.details.Buffer().SetText(b.String()) + p.showPreview(r.Rel) +} + +// showPreview looks at the file behind row rel, off the main loop: reading +// it, and rendering a PDF page, takes long enough to stutter the window. +func (p *planView) showPreview(rel string) { + if p.tab == nil || p.previewOff { + return + } + path := filepath.Join(p.tab.Dir.Root, filepath.FromSlash(rel)) + if p.previewFor == path { + return + } + p.previewFor = path + p.pictureFrame.SetVisible(false) + p.previewScroll.SetVisible(false) + p.previewNote.SetText("looking at " + escape(rel) + "...") + if p.previewTmp == "" { + dir, err := os.MkdirTemp("", "krino-preview-") + if err != nil { + p.previewNote.SetText(escape(err.Error())) + return + } + p.previewTmp = dir + } + var pv model.Preview + runInBackground(func(ctx context.Context) error { + pv = model.MakePreview(ctx, path, p.previewTmp) + return nil + }, func(error) { + if p.previewFor != path { + return // another row was chosen while this one was read + } + p.previewNote.SetText(escape(pv.Note)) + switch pv.Kind { + case model.PreviewImage: + p.picture.SetFilename(pv.Image) + p.picture.SetVisible(true) + p.pictureFrame.SetVisible(true) + case model.PreviewText: + p.previewText.Buffer().SetText(escapeText(pv.Text)) + p.previewScroll.SetVisible(true) + } + }) +} + +// applyPrefs turns the file preview on or off, and says how a fresh plan +// starts. +func (p *planView) applyPrefs(prefs model.Prefs) { + p.previewOff = !prefs.Preview + p.startSelected = prefs.SelectAll + if p.previewOff { + p.previewFor = "" + p.pictureFrame.SetVisible(false) + p.previewScroll.SetVisible(false) + p.previewNote.SetText("") + } +} + +// closePreview removes what the previews left behind. +func (p *planView) closePreview() { + if p.previewTmp != "" { + os.RemoveAll(p.previewTmp) + p.previewTmp = "" + } } diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go index 0a10996..97f3dcd 100644 --- a/gui/internal/ui/rules.go +++ b/gui/internal/ui/rules.go @@ -44,6 +44,7 @@ type rulesView struct { testGo *gtk.Button out *gtk.TextView + colours *highlighter sub *gtk.Notebook forms *formsView rules *model.Rules @@ -93,6 +94,7 @@ func newRulesView(w *Window) *rulesView { r.view.SetRightMargin(8) r.view.SetTopMargin(6) r.buf = r.view.Buffer() + r.colours = newHighlighter(r.buf) // Line numbers: their own view beside the editor, in the same scrolled // window so the two always line up. The editor does not wrap, so one @@ -225,6 +227,23 @@ func newRulesView(w *Window) *rulesView { return r } +// applyPrefs sets the editor's font and whether the text is coloured. +func (r *rulesView) applyPrefs(p model.Prefs) { + r.colours.setEnabled(p.Colours) + if r.rules != nil && p.Colours { + r.colours.paint(r.rules.Text) + } + css := gtk.NewCSSProvider() + if p.FontSize > 0 { + css.LoadFromData(fmt.Sprintf("textview { font-size: %dpt; }", p.FontSize)) + } else { + css.LoadFromData("") + } + for _, v := range []*gtk.TextView{r.view, r.nums, r.out} { + v.StyleContext().AddProvider(css, 800) + } +} + // 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 { @@ -383,6 +402,7 @@ func (r *rulesView) runCheck() { r.save.SetTooltipText("nothing to save: the file matches what is on disk") } r.updateLineNumbers() + r.colours.paint(r.rules.Text) } // updateLineNumbers refills the gutter when the number of lines changes. diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go new file mode 100644 index 0000000..2db3450 --- /dev/null +++ b/gui/internal/ui/settings.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ui + +import ( + "github.com/diamondburned/gotk4/pkg/gtk/v4" + + "krino/gui/internal/model" + "krino/internal/xdg" +) + +// settingsWindow is Settings: krino's defaults, which every directory +// inherits, and how this window behaves. The first are written to +// krino.conf with the care a rules file is written with - checked first, +// the previous text kept as krino.conf.bak - and the second to gui.json, +// which krino itself never reads (his request, 2026-09-16). +type settingsWindow struct { + w *Window + win *gtk.Window + main *model.MainConf + + rows []*settingRow + check *gtk.Label + save *gtk.Button + toSave bool +} + +// showSettings opens it, one at a time. +func (w *Window) showSettings() { + main, err := model.OpenMain(w.engine) + if err != nil { + w.setStatus("settings: %v", err) + return + } + s := &settingsWindow{w: w, main: main} + s.win = gtk.NewWindow() + s.win.SetTitle("krino settings") + s.win.SetTransientFor(&w.win.Window) + s.win.SetModal(true) + s.win.SetDefaultSize(560, 640) + + box := gtk.NewBox(gtk.OrientationVertical, 8) + box.SetMarginStart(12) + box.SetMarginEnd(12) + box.SetMarginTop(12) + box.SetMarginBottom(12) + + box.Append(heading("Defaults for every directory - " + xdg.Abbrev(main.File))) + note := gtk.NewLabel("An empty field is one krino.conf does not set, and krino's own default applies. A directory's file overrides these.") + note.SetXAlign(0) + note.SetWrap(true) + note.AddCSSClass("dim-label") + box.Append(note) + for _, head := range append(append([]string{}, model.MainSettings...), "log") { + args, _, err := main.Setting(head) + if err != nil { + w.setStatus("settings: %v", err) + return + } + row := newSettingRow(head, args, s.armSave) + s.rows = append(s.rows, row) + box.Append(row.root) + } + s.check = gtk.NewLabel("") + s.check.SetXAlign(0) + s.check.SetWrap(true) + box.Append(s.check) + + box.Append(heading("This window")) + prefs := w.prefs + font := gtk.NewSpinButtonWithRange(0, 32, 1) + font.SetValue(float64(prefs.FontSize)) + font.SetTooltipText("the editor's font size in points; 0 keeps the theme's") + colours := gtk.NewCheckButtonWithLabel("colour the configuration in the Text tab") + colours.SetActive(prefs.Colours) + preview := gtk.NewCheckButtonWithLabel("show the file behind the selected row") + preview.SetActive(prefs.Preview) + selectAll := gtk.NewCheckButtonWithLabel("a scanned plan starts with every file checked") + selectAll.SetActive(prefs.SelectAll) + box.Append(field("editor font", font)) + box.Append(colours) + box.Append(preview) + box.Append(selectAll) + + apply := func() { + p := model.Prefs{ + FontSize: int(font.Value()), + Colours: colours.Active(), + Preview: preview.Active(), + SelectAll: selectAll.Active(), + } + w.applyPrefs(p) + if err := p.Save(); err != nil { + w.setStatus("settings: %v", err) + } + } + font.ConnectValueChanged(apply) + colours.ConnectToggled(apply) + preview.ConnectToggled(apply) + selectAll.ConnectToggled(apply) + + s.save = gtk.NewButtonWithLabel("Save krino.conf") + s.save.AddCSSClass("suggested-action") + s.save.SetSensitive(false) + s.save.ConnectClicked(s.onSave) + closeBtn := gtk.NewButtonWithLabel("Close") + closeBtn.ConnectClicked(func() { s.win.Close() }) + // The buttons sit outside the scrolled area: on a short screen they + // would otherwise be below the fold, which is where he found them. + buttons := gtk.NewBox(gtk.OrientationHorizontal, 6) + buttons.SetHAlign(gtk.AlignEnd) + buttons.SetMarginStart(12) + buttons.SetMarginEnd(12) + buttons.SetMarginTop(8) + buttons.SetMarginBottom(12) + buttons.Append(closeBtn) + buttons.Append(s.save) + + scroll := gtk.NewScrolledWindow() + scroll.SetChild(box) + scroll.SetVExpand(true) + outer := gtk.NewBox(gtk.OrientationVertical, 0) + outer.Append(scroll) + outer.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) + outer.Append(buttons) + s.win.SetChild(outer) + s.win.Show() +} + +// armSave writes what the fields hold into the text and checks it, without +// touching the file: Save is what writes. +func (s *settingsWindow) armSave() { + for _, row := range s.rows { + value := row.value() + was, _, err := s.main.Setting(row.head) + if err != nil { + s.check.SetText(escape(err.Error())) + return + } + if value == was { + continue + } + if err := s.main.SetSetting(row.head, value); err != nil { + s.check.SetText(escape(err.Error())) + row.set(was) + continue + } + s.toSave = true + } + diags := s.main.Check() + switch { + case len(diags) == 0: + s.check.SetText("") + s.save.SetSensitive(s.toSave && s.main.Modified()) + default: + s.check.SetText(escape(diags[0].Error())) + s.check.AddCSSClass("error") + s.save.SetSensitive(false) + } +} + +// onSave writes krino.conf and makes the new defaults the ones every tab +// works from. +func (s *settingsWindow) onSave() { + if err := s.main.Save(); err != nil { + s.check.SetText(escape(err.Error())) + return + } + s.check.RemoveCSSClass("error") + s.check.SetText("saved; the previous text is beside it as krino.conf.bak") + s.save.SetSensitive(false) + if err := s.w.reloadEngine(); err != nil { + s.w.setStatus("saved, but the new configuration does not load: %v", err) + return + } + s.w.setStatus("saved %s", escape(xdg.Abbrev(s.main.File))) +} + +// settingHintsMain is the example shown in an empty krino.conf field, for +// the settings the directory form does not already describe. +var settingHintsMain = map[string]string{ + "log": `"~/.local/state/krino/krino.log"`, +} + +// mainHint is the example for a krino.conf setting. +func mainHint(head string) string { + if h, ok := settingHintsMain[head]; ok { + return h + } + return settingHints[head] +} diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go index 27e1730..9727846 100644 --- a/gui/internal/ui/window.go +++ b/gui/internal/ui/window.go @@ -29,13 +29,14 @@ type Window struct { history *historyView rules *rulesView status *gtk.Label + prefs model.Prefs } // NewWindow builds the window for e. Each plan and each undo is its own // run of krino, with its own run id in the log, so the window itself holds // no session. func NewWindow(app *gtk.Application, e *engine.Engine) *Window { - w := &Window{app: app, engine: e} + w := &Window{app: app, engine: e, prefs: model.LoadPrefs()} w.win = gtk.NewApplicationWindow(app) w.win.SetTitle("krino") w.win.SetDefaultSize(1200, 720) @@ -64,19 +65,29 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { w.status.SetMarginTop(4) w.status.SetMarginBottom(4) + settings := gtk.NewButtonWithLabel("Settings") + settings.SetHasFrame(false) + settings.ConnectClicked(func() { w.showSettings() }) + statusRow := gtk.NewBox(gtk.OrientationHorizontal, 6) + w.status.SetHExpand(true) + statusRow.Append(w.status) + statusRow.Append(settings) + box := gtk.NewBox(gtk.OrientationVertical, 0) notebook.SetVExpand(true) box.Append(notebook) box.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) - box.Append(w.status) + box.Append(statusRow) w.win.SetChild(box) // Closing the window releases whatever directory lock the open plan // holds, rather than leaving a lock file for the next run to find. w.win.ConnectCloseRequest(func() bool { w.plan.closeTab() + w.plan.closePreview() w.history.closeTab() return false }) + w.applyPrefs(w.prefs) return w } @@ -98,6 +109,15 @@ func (w *Window) reloadEngine() error { return nil } +// applyPrefs takes a change from the settings window: the font of the +// editor, whether the configuration is coloured, and whether the file +// behind a row is shown. What is already on screen changes at once. +func (w *Window) applyPrefs(p model.Prefs) { + w.prefs = p + w.rules.applyPrefs(p) + w.plan.applyPrefs(p) +} + // dirRoot is the path of the configured directory called name, or "" if // the configuration no longer has one - a log entry can outlive its // directory. diff --git a/internal/cond/types.go b/internal/cond/types.go index 28a4fc9..62a6640 100644 --- a/internal/cond/types.go +++ b/internal/cond/types.go @@ -131,6 +131,11 @@ func (c *Cond) NameGroups() []int { // groups maps a (type ...) group name to the extensions it expands to, // spec Appendix A. +// Group is the extensions a built-in type group holds - what (type image) +// means - so a front end can treat a file the way the rules do rather than +// keeping a second list of its own. The result is not to be modified. +func Group(name string) []string { return groups[name] } + var groups = map[string][]string{ "image": {"jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif", "avif", "svg", "ico", "raw", "cr2", "nef", "arw", "dng"}, "video": {"mp4", "mkv", "webm", "mov", "avi", "m4v", "mpg", "mpeg", "wmv", "flv", "3gp"}, diff --git a/man/krino-gui.1 b/man/krino-gui.1 index 5f7b803..881637b 100644 --- a/man/krino-gui.1 +++ b/man/krino-gui.1 @@ -1,5 +1,5 @@ .\" SPDX-License-Identifier: GPL-3.0-or-later -.Dd September 16, 2026 +.Dd September 17, 2026 .Dt KRINO-GUI 1 .Os .Sh NAME @@ -51,7 +51,9 @@ decide about - unreadable content, a failed duplicate check - is listed with the reason and cannot be selected. .Pp Selecting a row explains it: every step with its rule and reason, and any -warning. The right button offers +warning, and shows the file itself underneath: a picture for an image, the +first page for a PDF, the first lines for anything that is text. Nothing +else is opened, and the file is only read. The right button offers .Cm Trash instead and .Cm Delete permanently instead , @@ -77,11 +79,12 @@ as plain .Cm krino undo does. .Ss Rules -Only a directory's own file, -.Pa dirs/NAME.conf , -is edited; +This tab edits a directory's own file, +.Pa dirs/NAME.conf ; +the includes and excludes of .Pa krino.conf -is not. +are edited by hand, and its defaults under +.Cm Settings . .Pp .Cm Forms lists the directory itself, its excludes and its rules. A rule's form holds @@ -102,13 +105,20 @@ and move a rule with the comment lines directly above it. .Pp .Cm Text -is the whole file. Either way the configuration is checked as it is edited, -about a second after typing stops: problems are listed with +is the whole file, with line numbers and the forms coloured: comments, +strings, the head of each form, and the actions that move or delete a file. +.Pp +Either way the configuration is checked as it is edited, about a second +after typing stops, and +.Cm Check +asks for it at once. Problems are listed under both, with .Ar file : Ns Ar line : Ns Ar column , and clicking one goes to that line. .Cm Test on file answers what the unsaved rules would do to one file, with the trace, the -captures and the chain. +captures and the chain; +.Cm Test rule +scans the directory and lists the files the selected rule would take. .Pp .Cm Save is refused while the check reports anything, so a window cannot leave krino @@ -116,6 +126,22 @@ unable to run. It writes atomically, keeps what was there as .Pa NAME.conf.bak , and, if the file changed on disk since it was opened, offers to reload, overwrite or cancel. Nothing on disk changes until Apply, Undo or Save. +.Ss Settings +.Cm Settings , +at the bottom of the window, holds two things. The first is krino's own +defaults - case, fold, recursive, min-age, max-read, max-size, busy, +on-conflict and the log path - which every directory inherits and its own +file may override; they are written to +.Pa krino.conf +by the rules above: refused while the configuration would not load, the +previous text kept as +.Pa krino.conf.bak . +The second is how this window behaves - the editor's font size, whether the +configuration is coloured, whether the file behind a row is shown, and +whether a scanned plan starts with every file checked. Those take effect as +they are changed and are kept in +.Pa gui.json , +which krino itself never reads. .Sh FILES .Bl -tag -width Ds .It Pa ~/.config/krino/krino.conf @@ -124,7 +150,11 @@ The main configuration, as for .It Pa ~/.config/krino/dirs/NAME.conf A directory's rules, and what the Rules tab edits. .It Pa ~/.config/krino/dirs/NAME.conf.bak -The text a save replaced. +The text a save replaced; +.Pa krino.conf.bak +likewise for the main file. +.It Pa ~/.config/krino/gui.json +How this window behaves. Deleting it restores the defaults. .El .Sh EXIT STATUS .Ex -std -- cgit v1.3