aboutsummaryrefslogtreecommitdiff
path: root/gui/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:51:15 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 00:51:15 +0200
commit5418ba6bc5c653b6a99572c6ece6abbf57836c43 (patch)
treeae0a430c468890770d20f531c3a026937c9bb948 /gui/internal
parentca0b703526151149d060d6d38f307e9e38c3dcd2 (diff)
downloadkrino-5418ba6bc5c653b6a99572c6ece6abbf57836c43.tar.gz
krino-5418ba6bc5c653b6a99572c6ece6abbf57836c43.zip
gui: coloured actions with headers, a filter over the plan, bulk trash or delete
Diffstat (limited to 'gui/internal')
-rw-r--r--gui/internal/model/filter.go107
-rw-r--r--gui/internal/model/filter_test.go99
-rw-r--r--gui/internal/model/plan.go19
-rw-r--r--gui/internal/model/plan_test.go48
-rw-r--r--gui/internal/model/prefs.go4
-rw-r--r--gui/internal/model/prefs_test.go2
-rw-r--r--gui/internal/ui/forms.go27
-rw-r--r--gui/internal/ui/plan.go250
-rw-r--r--gui/internal/ui/rules.go11
-rw-r--r--gui/internal/ui/settings.go20
-rw-r--r--gui/internal/ui/window.go105
11 files changed, 632 insertions, 60 deletions
diff --git a/gui/internal/model/filter.go b/gui/internal/model/filter.go
new file mode 100644
index 0000000..af617c3
--- /dev/null
+++ b/gui/internal/model/filter.go
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "strings"
+
+ "krino/internal/norm"
+)
+
+// FuzzyMatch reports whether every character of pattern appears in text in
+// order - the way fzf matches - and how good the match is. Capitals and
+// accents are ignored, using krino's own folding, so "zazolc" finds
+// "zażółć" exactly as a rule with (fold yes) would (his request,
+// 2026-09-17).
+//
+// The score rewards characters that follow one another and those at the
+// start of a word, so "lec" ranks "lectio-2026.pdf" above
+// "old-latex-certificate.pdf".
+func FuzzyMatch(pattern, text string) (score int, ok bool) {
+ p := []rune(norm.Fold(strings.ToLower(pattern)))
+ t := []rune(norm.Fold(strings.ToLower(text)))
+ if len(p) == 0 {
+ return 0, true
+ }
+ if len(p) > len(t) {
+ return 0, false
+ }
+ pi := 0
+ run := 0
+ for ti := 0; ti < len(t) && pi < len(p); ti++ {
+ if t[ti] != p[pi] {
+ run = 0
+ continue
+ }
+ score += 1 + run // a run of matching characters is worth more
+ if ti == 0 || isWordBreak(t[ti-1]) {
+ score += 4 // the start of a word counts for more still
+ }
+ run++
+ pi++
+ }
+ if pi < len(p) {
+ return 0, false
+ }
+ // A short name matching is a better match than a long one.
+ if len(t) > 0 {
+ score += 20 * len(p) / len(t)
+ }
+ return score, true
+}
+
+// isWordBreak reports whether a character separates words in a file name.
+func isWordBreak(r rune) bool {
+ switch r {
+ case ' ', '.', '-', '_', '/', ',', '(', ')', '[', ']':
+ return true
+ }
+ return false
+}
+
+// Matching is the rows a filter leaves, in the plan's own order - which is
+// what a list of files is read in - not in score order. An empty pattern
+// matches everything.
+func (t *PlanTab) Matching(pattern string) []int {
+ var out []int
+ for i, r := range t.Rows {
+ if _, ok := FuzzyMatch(pattern, r.Rel); ok {
+ out = append(out, i)
+ continue
+ }
+ // The rule a file took is worth searching too: "to-sort" finds
+ // everything that rule claimed.
+ if _, ok := FuzzyMatch(pattern, r.Rule); ok {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+// SelectOnly checks exactly the rows given - a filtered "Select all" - and
+// clears the rest, so what is applied is what was on screen.
+func (t *PlanTab) SelectOnly(rows []int) {
+ want := map[int]bool{}
+ for _, i := range rows {
+ want[i] = true
+ }
+ for i := range t.Rows {
+ t.Rows[i].Selected = want[i] && t.Rows[i].Actable
+ }
+}
+
+// HiddenSelected is how many checked files a filter is hiding, so the
+// window can say so rather than apply something out of sight.
+func (t *PlanTab) HiddenSelected(shown []int) int {
+ visible := map[int]bool{}
+ for _, i := range shown {
+ visible[i] = true
+ }
+ n := 0
+ for i, r := range t.Rows {
+ if r.Selected && !visible[i] {
+ n++
+ }
+ }
+ return n
+}
diff --git a/gui/internal/model/filter_test.go b/gui/internal/model/filter_test.go
new file mode 100644
index 0000000..47e4dc7
--- /dev/null
+++ b/gui/internal/model/filter_test.go
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "testing"
+)
+
+// TestFuzzyMatch: characters in order match, out of order do not, and
+// capitals and accents are ignored the way a rule with (fold yes) is.
+func TestFuzzyMatch(t *testing.T) {
+ yes := []struct{ pattern, text string }{
+ {"", "anything.pdf"},
+ {"lec", "lectio-2026-07-24.pdf"},
+ {"l24", "lectio-2026-07-24.pdf"},
+ {"LEC", "lectio-2026-07-24.pdf"},
+ {"zazolc", "zażółć gęślą jaźń.txt"},
+ {"faktura", "acme-ltd_faktura FV_12_2026.pdf"},
+ }
+ for _, c := range yes {
+ if _, ok := FuzzyMatch(c.pattern, c.text); !ok {
+ t.Errorf("%q does not match %q", c.pattern, c.text)
+ }
+ }
+ no := []struct{ pattern, text string }{
+ {"zzz", "lectio-2026-07-24.pdf"},
+ {"oitcel", "lectio.pdf"},
+ {"lectiox", "lectio.pdf"},
+ }
+ for _, c := range no {
+ if _, ok := FuzzyMatch(c.pattern, c.text); ok {
+ t.Errorf("%q matches %q and should not", c.pattern, c.text)
+ }
+ }
+}
+
+// TestFuzzyScorePrefersTheObviousMatch: a name that starts with the pattern
+// scores above one where the characters are scattered.
+func TestFuzzyScorePrefersTheObviousMatch(t *testing.T) {
+ close, ok1 := FuzzyMatch("lec", "lectio.pdf")
+ far, ok2 := FuzzyMatch("lec", "old-latex-certificate.pdf")
+ if !ok1 || !ok2 {
+ t.Fatalf("both should match: %v %v", ok1, ok2)
+ }
+ if close <= far {
+ t.Errorf("lectio.pdf scored %d, no better than the scattered match at %d", close, far)
+ }
+}
+
+// TestPlanFilter: the filter leaves the rows that match, in the plan's
+// order; checking "all" then applies to those only, and the tab can say how
+// many checked files the filter is hiding.
+func TestPlanFilter(t *testing.T) {
+ conf := "(path \"~/dl\")\n" +
+ "(rule \"pdfs\" (when (type pdf)) (move \"Docs\") (stop))\n" +
+ "(rule \"rest\" (move \"Other\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{
+ "lectio-one.pdf": "a", "lectio-two.pdf": "b", "notes.txt": "c",
+ })
+ tab := planTab(t, e)
+
+ all := tab.Matching("")
+ if len(all) != 3 {
+ t.Fatalf("an empty filter shows %d rows, want every one", len(all))
+ }
+ shown := tab.Matching("lectio")
+ if len(shown) != 2 {
+ t.Fatalf("filter shows %+v, want the two lectio files", shown)
+ }
+ for i := 1; i < len(shown); i++ {
+ if shown[i] <= shown[i-1] {
+ t.Error("the filter reordered the plan")
+ }
+ }
+ // A rule's name finds its files too.
+ if rows := tab.Matching("pdfs"); len(rows) != 2 {
+ t.Errorf("filtering by rule shows %+v, want the two pdfs", rows)
+ }
+
+ tab.SelectOnly(shown)
+ if tab.SelectedCount() != 2 {
+ t.Errorf("checked %d, want the two shown", tab.SelectedCount())
+ }
+ for _, r := range tab.Rows {
+ if r.Rel == "notes.txt" && r.Selected {
+ t.Error("a row the filter hid was checked")
+ }
+ }
+ // Now narrow the filter: one checked file is out of sight, and the tab
+ // says so.
+ narrow := tab.Matching("lectio-one")
+ if n := tab.HiddenSelected(narrow); n != 1 {
+ t.Errorf("HiddenSelected = %d, want 1", n)
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 86acab7..6d3e857 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -219,6 +219,25 @@ func (t *PlanTab) Replace(i int, kind plan.Kind) error {
return fmt.Errorf("model: %s is not in this plan", rel)
}
+// ReplaceSelected swaps the steps of every checked file for the one action
+// chosen - "Trash the checked files", "Delete them permanently" - and
+// reports how many were changed. Nothing happens on disk: like every other
+// review decision, it changes the plan, and Apply carries it out (his
+// request, 2026-09-17).
+func (t *PlanTab) ReplaceSelected(kind plan.Kind) (int, error) {
+ n := 0
+ for i, r := range t.Rows {
+ if !r.Selected {
+ continue
+ }
+ if err := t.Replace(i, kind); err != nil {
+ return n, err
+ }
+ n++
+ }
+ return n, nil
+}
+
// Apply acts on the selected files and logs the rest as declined, exactly
// as choosing per file in the terminal does. Each row then carries its
// outcome.
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index 746ddfc..ecbb81d 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -313,3 +313,51 @@ func equal(a, b []string) bool {
}
return true
}
+
+// TestReplaceSelected: one choice for every checked file at once - trash
+// them, or delete them - changing the plan and nothing on disk until Apply
+// (his request, 2026-09-17).
+func TestReplaceSelected(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two", "c.pdf": "three"})
+ tab := planTab(t, e)
+ tab.SelectNone()
+ for i, r := range tab.Rows {
+ if r.Rel != "c.pdf" {
+ tab.Toggle(i)
+ }
+ }
+ n, err := tab.ReplaceSelected(plan.Trash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 2 {
+ t.Errorf("changed %d files, want the 2 checked", n)
+ }
+ for _, r := range tab.Rows {
+ want := plan.Trash
+ if r.Rel == "c.pdf" {
+ want = plan.Move
+ }
+ if len(r.Steps) == 0 || r.Steps[0].Kind != want {
+ t.Errorf("%s: steps = %+v, want %s", r.Rel, r.Steps, want)
+ }
+ }
+ // Still nothing on disk.
+ for _, name := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(h, "dl", name)); err != nil {
+ t.Errorf("%s was touched before Apply: %v", name, err)
+ }
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 2 {
+ t.Errorf("the Trash holds %d files, want 2", len(entries))
+ }
+ // The unchecked file was declined, so it is where it was, and its plan
+ // still says move - the choice was made for the checked files only.
+ if _, err := os.Stat(filepath.Join(h, "dl", "c.pdf")); err != nil {
+ t.Errorf("the unchecked file did not stay put: %v", err)
+ }
+}
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index 52ed012..1a91194 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -14,8 +14,6 @@ import (
// 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.
@@ -33,7 +31,7 @@ const DefaultPreviewHeight = 280
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
- return Prefs{FontSize: 0, Colours: true, Preview: true, SelectAll: true,
+ return Prefs{Colours: true, Preview: true, SelectAll: true,
PreviewHeight: DefaultPreviewHeight}
}
diff --git a/gui/internal/model/prefs_test.go b/gui/internal/model/prefs_test.go
index 6c01dee..328ac31 100644
--- a/gui/internal/model/prefs_test.go
+++ b/gui/internal/model/prefs_test.go
@@ -25,7 +25,7 @@ func sandboxHome(t *testing.T) string {
func TestPrefsRoundTrip(t *testing.T) {
h := sandboxHome(t)
p := DefaultPrefs()
- p.FontSize = 13
+ p.PreviewHeight = 420
p.Colours = false
p.SelectAll = false
if err := p.Save(); err != nil {
diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go
index 6fcf973..8e1fd33 100644
--- a/gui/internal/ui/forms.go
+++ b/gui/internal/ui/forms.go
@@ -298,6 +298,23 @@ var settingChoices = map[string][]string{
"on-conflict": {"", "suffix", "skip", "overwrite"},
}
+// settingHelp is what each setting does, shown when the pointer rests on
+// its row (his request, 2026-09-17). The wording follows krino.conf(5).
+var settingHelp = map[string]string{
+ "path": "the directory krino sorts; a directory's file must set it",
+ "recursive": "look in subdirectories too, not only the directory itself",
+ "max-depth": "how deep to look when recursive: 1 is the directory itself",
+ "min-age": "leave a file alone until it has been untouched this long - protection against sorting a download still being written",
+ "max-read": "the largest file whose text is read for (content ...) tests; 0 reads every size",
+ "max-size": "skip a file larger than this entirely",
+ "busy": "endings that mean a file is still being written, such as \".part\"; those files are left alone",
+ "case": "whether name and path patterns tell capitals apart: ignore (the default) or strict",
+ "fold": "whether a pattern without accents matches a name with them, so \"zazolc\" finds \"zażółć\"",
+ "on-conflict": "what to do when the destination is taken: suffix (name-1), skip, or overwrite",
+ "ignore": "gitignore patterns for files krino never looks at, such as \"*.part\" and \".*\"",
+ "log": "where every run is written; krino undo reads it",
+}
+
// settingHints is the example shown in an empty field.
var settingHints = map[string]string{
"path": `"~/downloads"`,
@@ -315,6 +332,9 @@ func newSettingRow(head, args string, changed func()) *settingRow {
label := gtk.NewLabel(head)
label.SetXAlign(0)
label.SetSizeRequest(110, -1)
+ help := settingHelp[head]
+ label.SetTooltipText(help)
+ r.root.SetTooltipText(help)
r.root.Append(label)
if items, ok := settingChoices[head]; ok {
r.items = items
@@ -326,6 +346,7 @@ func newSettingRow(head, args string, changed func()) *settingRow {
if indexOf(items, args) < 0 {
r.drop.SetSelected(0)
}
+ r.drop.SetTooltipText(help)
r.drop.Connect("notify::selected", func() { changed() })
r.root.Append(r.drop)
return r
@@ -334,7 +355,11 @@ func newSettingRow(head, args string, changed func()) *settingRow {
r.entry.SetText(args)
r.entry.SetHExpand(true)
r.entry.SetPlaceholderText(mainHint(head))
- r.entry.SetTooltipText(mainHint(head))
+ if help != "" {
+ r.entry.SetTooltipText(help + "\n\nfor example: " + mainHint(head))
+ } else {
+ r.entry.SetTooltipText(mainHint(head))
+ }
r.entry.ConnectChanged(func() { changed() })
r.root.Append(r.entry)
return r
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 87afdff..740341c 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -34,9 +34,13 @@ type planView struct {
cancel *gtk.Button
selAll *gtk.Button
selNone *gtk.Button
+ checked *gtk.MenuButton
- list *gtk.ListBox
- details *gtk.TextView
+ filter *gtk.SearchEntry
+ shown []int
+ list *gtk.ListBox
+ headerBox *gtk.Box
+ details *gtk.TextView
previewNote *gtk.Label
picture *gtk.Picture
@@ -93,8 +97,18 @@ func newPlanView(w *Window) *planView {
bar.Append(p.dirs)
bar.Append(p.scan)
bar.Append(p.path)
+
+ // A filter over the plan: type a few letters of a name, as fzf does,
+ // and act on what is left (his request, 2026-09-17).
+ p.filter = gtk.NewSearchEntry()
+ p.filter.SetPlaceholderText("filter")
+ p.filter.SetTooltipText("show only the files whose name or rule has these letters, in order; Select all then checks those")
+ p.filter.SetSizeRequest(160, -1)
+ bar.Append(p.filter)
+
bar.Append(p.selAll)
bar.Append(p.selNone)
+ bar.Append(p.checkedMenu())
bar.Append(p.cancel)
bar.Append(p.apply)
@@ -104,6 +118,13 @@ func newPlanView(w *Window) *planView {
listScroll.SetChild(p.list)
listScroll.SetHExpand(true)
listScroll.SetVExpand(true)
+ p.headerBox = gtk.NewBox(gtk.OrientationHorizontal, 8)
+ p.headerBox.SetMarginTop(4)
+ p.headerBox.SetMarginBottom(4)
+ listSide := gtk.NewBox(gtk.OrientationVertical, 0)
+ listSide.Append(p.headerBox)
+ listSide.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ listSide.Append(listScroll)
p.details = gtk.NewTextView()
p.details.Buffer().SetText("Select a file to see what would happen to it, and why.")
@@ -172,7 +193,7 @@ func newPlanView(w *Window) *planView {
// 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.SetStartChild(listSide)
panes.SetEndChild(detailBox)
panes.SetResizeStartChild(true)
panes.SetResizeEndChild(false)
@@ -191,11 +212,12 @@ func newPlanView(w *Window) *planView {
p.cancelOp()
}
})
+ p.filter.ConnectSearchChanged(func() { p.fillList() })
p.selAll.ConnectClicked(func() { p.selectAll(true) })
p.selNone.ConnectClicked(func() { p.selectAll(false) })
p.list.ConnectRowSelected(func(row *gtk.ListBoxRow) {
if row != nil {
- p.showDetails(row.Index())
+ p.showDetails(p.planIndex(row.Index()))
}
})
// The right button on a row offers the two overrides the terminal
@@ -211,6 +233,83 @@ func newPlanView(w *Window) *planView {
return p
}
+// checkedMenu is "With checked": the same two overrides the row menu has,
+// for every file that is checked at once (his request, 2026-09-17).
+func (p *planView) checkedMenu() *gtk.MenuButton {
+ box := gtk.NewBox(gtk.OrientationVertical, 0)
+ trash := gtk.NewButtonWithLabel("Trash them instead")
+ perm := gtk.NewButtonWithLabel("Delete them permanently instead...")
+ for _, b := range []*gtk.Button{trash, perm} {
+ b.SetHasFrame(false)
+ b.SetHAlign(gtk.AlignFill)
+ box.Append(b)
+ }
+ pop := gtk.NewPopover()
+ pop.SetChild(box)
+ button := gtk.NewMenuButton()
+ button.SetLabel("With checked")
+ button.SetPopover(pop)
+ button.SetTooltipText("choose what happens to every checked file instead of what the rules decided")
+ trash.ConnectClicked(func() {
+ pop.Popdown()
+ p.replaceChecked(plan.Trash)
+ })
+ perm.ConnectClicked(func() {
+ pop.Popdown()
+ p.confirmDeleteChecked()
+ })
+ p.checked = button
+ return button
+}
+
+// replaceChecked gives every checked file the same action.
+func (p *planView) replaceChecked(kind plan.Kind) {
+ if p.tab == nil {
+ return
+ }
+ n, err := p.tab.ReplaceSelected(kind)
+ if err != nil {
+ p.w.setStatus("%v", err)
+ return
+ }
+ if n == 0 {
+ p.w.setStatus("no file is checked")
+ return
+ }
+ p.fillList()
+ p.w.setStatus("%d file(s) set to %s - press Apply to carry it out; nothing has moved yet", n, kind)
+}
+
+// confirmDeleteChecked asks before the one action nothing can undo, saying
+// how many files it would be.
+func (p *planView) confirmDeleteChecked() {
+ if p.tab == nil {
+ return
+ }
+ n := p.tab.SelectedCount()
+ if n == 0 {
+ p.w.setStatus("no file is checked")
+ return
+ }
+ d := gtk.NewMessageDialog(&p.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", fmt.Sprintf("Delete %d checked file(s) permanently?", n))
+ d.SetObjectProperty("secondary-text",
+ "They are not moved to the Trash and undo cannot bring them back. Nothing happens until you press Apply.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ del := d.AddButton("Delete permanently", int(gtk.ResponseAccept))
+ if b, ok := del.(*gtk.Button); ok {
+ b.AddCSSClass("destructive-action")
+ }
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ p.replaceChecked(plan.DeletePermanent)
+ }
+ })
+ d.Show()
+}
+
// newMenu builds the row menu: what to do with a file instead of what the
// rules decided.
func (p *planView) newMenu() *gtk.Popover {
@@ -237,6 +336,15 @@ func (p *planView) newMenu() *gtk.Popover {
return pop
}
+// planIndex is the plan row a list row stands for: with a filter on, the
+// two are not the same.
+func (p *planView) planIndex(listRow int) int {
+ if listRow < 0 || listRow >= len(p.shown) {
+ return -1
+ }
+ return p.shown[listRow]
+}
+
// onRightClick opens the menu on the row under the pointer. A plan that has
// been applied is history and cannot be changed.
func (p *planView) onRightClick(x, y float64) {
@@ -248,7 +356,10 @@ func (p *planView) onRightClick(x, y float64) {
return
}
p.list.SelectRow(row)
- p.menuRow = row.Index()
+ p.menuRow = p.planIndex(row.Index())
+ if p.menuRow < 0 {
+ return
+ }
at := gdk.NewRectangle(int(x), int(y), 1, 1)
p.menu.SetPointingTo(&at)
p.menu.Popup()
@@ -266,7 +377,8 @@ func (p *planView) replace(kind plan.Kind) {
rel := p.tab.Rows[p.menuRow].Rel
p.fillList()
p.showDetails(p.menuRow)
- p.w.setStatus("%s: %s instead", escape(rel), kind)
+ p.w.setStatus("%s: %s instead - press Apply to carry it out; nothing has moved yet",
+ escape(rel), kind)
}
// confirmDeletePermanent asks before a step nothing can undo, naming the
@@ -390,23 +502,47 @@ func (p *planView) setBusy(busy bool) {
p.dirs.SetSensitive(!busy)
p.selAll.SetSensitive(!busy)
p.selNone.SetSensitive(!busy)
+ if p.checked != nil {
+ p.checked.SetSensitive(!busy && p.tab != nil && !p.tab.Applied)
+ }
p.apply.SetSensitive(!busy && p.tab != nil && !p.tab.Applied)
p.cancel.SetSensitive(busy)
}
-// selectAll checks or unchecks every file that can be applied.
+// selectAll checks or unchecks every file that can be applied - and, while
+// a filter is on, only the files it leaves on screen, so what Apply acts on
+// is what was in front of him.
func (p *planView) selectAll(on bool) {
if p.tab == nil {
return
}
- if on {
- p.tab.SelectAll()
- } else {
+ switch {
+ case !on:
p.tab.SelectNone()
+ case p.filter.Text() == "":
+ p.tab.SelectAll()
+ default:
+ p.tab.SelectOnly(p.shown)
}
p.fillList()
}
+// sayWhatIsShown keeps the status line honest about a filter: how much of
+// the plan is on screen, and whether it is hiding something that is
+// checked and would be applied.
+func (p *planView) sayWhatIsShown() {
+ if p.tab == nil || p.filter.Text() == "" {
+ return
+ }
+ hidden := p.tab.HiddenSelected(p.shown)
+ if hidden > 0 {
+ p.w.setStatus("%d of %d files shown; %d checked file(s) are hidden and would still be applied",
+ len(p.shown), len(p.tab.Rows), hidden)
+ return
+ }
+ p.w.setStatus("%d of %d files shown", len(p.shown), len(p.tab.Rows))
+}
+
// fillList renders the rows: a checkbox, the file, what would happen, the
// rule, and the outcome once applied.
func (p *planView) fillList() {
@@ -415,36 +551,67 @@ func (p *planView) fillList() {
return
}
w := p.widths()
- for i, r := range p.tab.Rows {
- p.list.Append(p.rowWidget(i, r, w))
+ p.header(w)
+ p.shown = p.tab.Matching(p.filter.Text())
+ for _, i := range p.shown {
+ p.list.Append(p.rowWidget(i, p.tab.Rows[i], w))
}
p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount()))
p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0)
+ // The plan arrives after setBusy has already run, so the menu over the
+ // checked files is enabled here rather than there.
+ p.checked.SetSensitive(!p.tab.Applied)
+ p.sayWhatIsShown()
}
// widths is how wide each column has to be for this plan: enough for the
// longest value it holds, within limits, so a rule name or an outcome is
// shown whole rather than cut to an ellipsis. The list scrolls sideways
// when the total does not fit (his report, 2026-09-16).
-func (p *planView) widths() [4]int {
- w := [4]int{16, 16, 8, 6}
+func (p *planView) widths() [5]int {
+ w := [5]int{16, 6, 16, 8, 6}
root := p.dirRoot()
for _, r := range p.tab.Rows {
+ action, _ := rowAction(r)
w[0] = max(w[0], len([]rune(r.Rel)))
- w[1] = max(w[1], len([]rune(rowLabel(r, root))))
- w[2] = max(w[2], len([]rune(r.Rule)))
- w[3] = max(w[3], len([]rune(r.Outcome)))
+ w[1] = max(w[1], len([]rune(action)))
+ w[2] = max(w[2], len([]rune(rowWhere(r, root))))
+ w[3] = max(w[3], len([]rune(r.Rule)))
+ w[4] = max(w[4], len([]rune(r.Outcome)))
}
// Past these a single long value would push every other column off the
// window; the details pane holds the whole text either way.
- for i, cap := range [4]int{40, 52, 34, 24} {
+ for i, cap := range [5]int{40, 16, 52, 34, 24} {
w[i] = min(w[i], cap)
}
return w
}
+// header is the line above the list saying what each column is.
+func (p *planView) header(w [5]int) {
+ if child := p.headerBox.FirstChild(); child != nil {
+ for child != nil {
+ next := gtk.BaseWidget(child).NextSibling()
+ p.headerBox.Remove(child)
+ child = next
+ }
+ }
+ p.headerBox.SetMarginStart(6)
+ p.headerBox.SetMarginEnd(6)
+ // The checkbox has no title, but its width has to be accounted for.
+ spacer := gtk.NewLabel("")
+ spacer.SetSizeRequest(24, -1)
+ p.headerBox.Append(spacer)
+ for i, title := range []string{"file", "action", "where it would go", "rule", "outcome"} {
+ l := columnMin(title, w[i], yieldChars, i == 0 || i == 2)
+ l.AddCSSClass("heading")
+ l.SetTooltipText("")
+ p.headerBox.Append(l)
+ }
+}
+
// rowWidget is one line of the list.
-func (p *planView) rowWidget(i int, r model.Row, w [4]int) *gtk.ListBoxRow {
+func (p *planView) rowWidget(i int, r model.Row, w [5]int) *gtk.ListBoxRow {
box := gtk.NewBox(gtk.OrientationHorizontal, 8)
box.SetMarginStart(6)
box.SetMarginEnd(6)
@@ -463,14 +630,16 @@ func (p *planView) rowWidget(i int, r model.Row, w [4]int) *gtk.ListBoxRow {
})
box.Append(check)
- // The name and what-would-happen columns share whatever space is left
- // and shrink first; the rule keeps its width, so it is never the column
- // cut to an ellipsis.
+ // The name and destination columns share whatever space is left and
+ // shrink first; the action and the rule keep their width, so neither is
+ // ever the column cut to an ellipsis.
+ action, colour := rowAction(r)
box.Append(column(escape(r.Rel), w[0], true))
- box.Append(columnMin(escape(rowLabel(r, p.dirRoot())), w[1], 18, true))
- box.Append(column(escape(r.Rule), w[2], false))
+ box.Append(colouredColumn(action, w[1], colour))
+ box.Append(columnMin(escape(rowWhere(r, p.dirRoot())), w[2], 18, true))
+ box.Append(column(escape(r.Rule), w[3], false))
if r.Outcome != "" {
- box.Append(column(escape(r.Outcome), w[3], false))
+ box.Append(column(escape(r.Outcome), w[4], false))
}
row := gtk.NewListBoxRow()
row.SetChild(box)
@@ -513,6 +682,31 @@ func columnMin(text string, chars, floor int, expand bool) *gtk.Label {
return l
}
+// colouredColumn is a fixed column painted in a colour - the actions, so
+// that a MOVE and a DELETE do not read alike. An empty colour leaves the
+// theme's own.
+func colouredColumn(text string, chars int, colour string) *gtk.Label {
+ l := column(escape(text), chars, false)
+ if colour == "" || text == "" {
+ return l
+ }
+ r, g, b := rgb16(colour)
+ attrs := pango.NewAttrList()
+ fg := pango.NewAttrForeground(r, g, b)
+ attrs.Insert(fg)
+ attrs.Insert(pango.NewAttrWeight(pango.WeightBold))
+ l.SetAttributes(attrs)
+ return l
+}
+
+// rgb16 reads "#rrggbb" as the 16-bit channels pango wants.
+func rgb16(hex string) (r, g, b uint16) {
+ var rr, gg, bb int
+ fmt.Sscanf(hex, "#%02x%02x%02x", &rr, &gg, &bb)
+ scale := func(v int) uint16 { return uint16(v)<<8 | uint16(v) }
+ return scale(rr), scale(gg), scale(bb)
+}
+
// yieldChars is how narrow an expanding column may become.
const yieldChars = 14
@@ -654,6 +848,12 @@ func (p *planView) dropRendered() {
}
}
+// hasUnapplied reports whether a plan is open with files checked and
+// nothing done about them yet.
+func (p *planView) hasUnapplied() bool {
+ return p.tab != nil && !p.tab.Applied && p.tab.SelectedCount() > 0
+}
+
// closePreview removes what the previews left behind.
func (p *planView) closePreview() {
p.dropRendered()
diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go
index 97f3dcd..3bae8df 100644
--- a/gui/internal/ui/rules.go
+++ b/gui/internal/ui/rules.go
@@ -227,21 +227,12 @@ func newRulesView(w *Window) *rulesView {
return r
}
-// applyPrefs sets the editor's font and whether the text is coloured.
+// applyPrefs sets whether the configuration 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.
diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go
index 2db3450..732caf9 100644
--- a/gui/internal/ui/settings.go
+++ b/gui/internal/ui/settings.go
@@ -68,33 +68,35 @@ func (w *Window) showSettings() {
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)
+ colours.SetTooltipText("paint comments, strings, form heads and actions in the Text sub-tab")
preview := gtk.NewCheckButtonWithLabel("show the file behind the selected row")
preview.SetActive(prefs.Preview)
+ preview.SetTooltipText("show the selected file under its explanation: a picture, a PDF's first page, or the first lines of text")
selectAll := gtk.NewCheckButtonWithLabel("a scanned plan starts with every file checked")
selectAll.SetActive(prefs.SelectAll)
- box.Append(field("editor font", font))
+ selectAll.SetTooltipText("a freshly scanned plan starts with every file that can be acted on checked, as the terminal review does")
+ previewHeight := gtk.NewSpinButtonWithRange(80, 2000, 20)
+ previewHeight.SetValue(float64(prefs.PreviewHeight))
+ previewHeight.SetTooltipText("how tall the preview under an explanation is, in pixels; dragging the divider above it does the same")
+ box.Append(field("preview height", previewHeight))
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(),
+ Colours: colours.Active(),
+ Preview: preview.Active(),
+ SelectAll: selectAll.Active(),
+ PreviewHeight: int(previewHeight.Value()),
}
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)
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index 819591c..784fc64 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -15,6 +15,7 @@ import (
"krino/gui/internal/model"
"krino/internal/engine"
+ "krino/internal/plan"
"krino/internal/xdg"
)
@@ -30,6 +31,7 @@ type Window struct {
rules *rulesView
status *gtk.Label
prefs model.Prefs
+ leaving bool
}
// NewWindow builds the window for e. Each plan and each undo is its own
@@ -91,6 +93,12 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
// 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 {
+ // A plan is only a plan until Apply: leaving with one open throws
+ // it away, which is worth saying out loud (his report, 2026-09-17).
+ if w.plan.hasUnapplied() && !w.leaving {
+ w.confirmLeaving()
+ return true
+ }
w.plan.closeTab()
w.plan.closePreview()
w.history.closeTab()
@@ -118,6 +126,26 @@ func (w *Window) reloadEngine() error {
return nil
}
+// confirmLeaving asks before a window with an unapplied plan closes.
+func (w *Window) confirmLeaving() {
+ n := w.plan.tab.SelectedCount()
+ d := gtk.NewMessageDialog(&w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageQuestion, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Close without applying?")
+ d.SetObjectProperty("secondary-text", fmt.Sprintf(
+ "%d file(s) are checked but nothing has been moved: a plan lives in this window until Apply, and closing throws it away.", n))
+ d.AddButton("Stay", int(gtk.ResponseCancel))
+ d.AddButton("Close without applying", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ w.leaving = true
+ w.win.Close()
+ }
+ })
+ d.Show()
+}
+
// 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.
@@ -195,27 +223,82 @@ func runInBackground(work func(context.Context) error, done func(error)) context
return cancel
}
-// rowLabel is the middle cell of a row: what would happen to the file and
-// where it would land, or - when nothing would - why not. Destinations
-// inside root are shown relative to it, as the plan's own output does.
-func rowLabel(r model.Row, root string) string {
+// actionColours are what each action is painted in, so the eye finds the
+// deletions without reading: they are the ones that cannot be undone from
+// the window (his request, 2026-09-17).
+var actionColours = map[plan.Kind]string{
+ plan.Copy: "#2a9d8f",
+ plan.Move: "#3584e4",
+ plan.Rename: "#9141ac",
+ plan.Trash: "#c06014",
+ plan.DeletePermanent: "#c01c28",
+}
+
+// actionRank decides which action gives a row its colour when a file gets
+// several: the one that matters most to the reader.
+var actionRank = map[plan.Kind]int{
+ plan.Rename: 1, plan.Copy: 2, plan.Move: 3, plan.Trash: 4, plan.DeletePermanent: 5,
+}
+
+// rowAction is a row's actions in capitals - "MOVE", "RENAME+MOVE" - and
+// the colour they are shown in. A row that would do nothing has neither.
+func rowAction(r model.Row) (text, colour string) {
if len(r.Steps) == 0 {
- return strings.Join(r.Warnings, "; ")
+ return "", ""
}
var parts []string
+ var worst plan.Kind
+ rank := -1
+ skipped := 0
+ for _, s := range r.Steps {
+ if s.Skip != "" {
+ skipped++
+ continue
+ }
+ parts = append(parts, strings.ToUpper(s.Kind.String()))
+ if actionRank[s.Kind] > rank {
+ rank, worst = actionRank[s.Kind], s.Kind
+ }
+ }
+ if len(parts) == 0 {
+ return "SKIPPED", dimColour
+ }
+ return strings.Join(parts, "+"), actionColours[worst]
+}
+
+// rowWhere is where a row's file would end up - the last place its steps
+// put it - or, when nothing would happen, why not. Destinations inside root
+// are shown relative to it, as the plan's own output does.
+func rowWhere(r model.Row, root string) string {
+ if len(r.Steps) == 0 {
+ return strings.Join(r.Warnings, "; ")
+ }
+ where := ""
+ var notes []string
for _, s := range r.Steps {
switch {
case s.Skip != "":
- parts = append(parts, s.Kind.String()+" skipped: "+s.Skip)
- case s.Dst == "":
- parts = append(parts, s.Kind.String())
- default:
- parts = append(parts, s.Kind.String()+" "+shorten(s.Dst, root))
+ notes = append(notes, strings.ToLower(s.Kind.String())+" skipped: "+s.Skip)
+ case s.Kind == plan.Trash:
+ where = "the Trash"
+ case s.Kind == plan.DeletePermanent:
+ where = "gone for good"
+ case s.Dst != "":
+ where = shorten(s.Dst, root)
}
}
- return strings.Join(parts, ", ")
+ if where == "" {
+ return strings.Join(notes, "; ")
+ }
+ if len(notes) > 0 {
+ return where + " (" + strings.Join(notes, "; ") + ")"
+ }
+ return where
}
+// dimColour is for text that is not an action: a skip, a warning.
+const dimColour = "#8b8b8b"
+
// shorten writes a destination inside root relative to it, and any other
// with ~ for the home directory.
func shorten(dst, root string) string {