summaryrefslogtreecommitdiff
path: root/gui
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:23:51 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:23:51 +0200
commit04b4243ad31d144c4caf9be4c5096a0f27a2648e (patch)
tree056515d145ba8e4fba29a2150b2c58199133b0c1 /gui
parent9db67b201b80e9b7f824989df8517cefc587036d (diff)
downloadkrino-04b4243ad31d144c4caf9be4c5096a0f27a2648e.tar.gz
krino-04b4243ad31d144c4caf9be4c5096a0f27a2648e.zip
gui: syntax colours, a file preview, and a settings window
Diffstat (limited to 'gui')
-rw-r--r--gui/internal/model/highlight.go97
-rw-r--r--gui/internal/model/highlight_test.go83
-rw-r--r--gui/internal/model/mainconf.go184
-rw-r--r--gui/internal/model/mainconf_test.go169
-rw-r--r--gui/internal/model/prefs.go62
-rw-r--r--gui/internal/model/prefs_test.go59
-rw-r--r--gui/internal/model/preview.go173
-rw-r--r--gui/internal/model/preview_test.go98
-rw-r--r--gui/internal/ui/forms.go4
-rw-r--r--gui/internal/ui/highlight.go77
-rw-r--r--gui/internal/ui/plan.go128
-rw-r--r--gui/internal/ui/rules.go20
-rw-r--r--gui/internal/ui/settings.go191
-rw-r--r--gui/internal/ui/window.go24
14 files changed, 1361 insertions, 8 deletions
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.