diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:51:15 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:51:15 +0200 |
| commit | 5418ba6bc5c653b6a99572c6ece6abbf57836c43 (patch) | |
| tree | ae0a430c468890770d20f531c3a026937c9bb948 | |
| parent | ca0b703526151149d060d6d38f307e9e38c3dcd2 (diff) | |
| download | krino-5418ba6bc5c653b6a99572c6ece6abbf57836c43.tar.gz krino-5418ba6bc5c653b6a99572c6ece6abbf57836c43.zip | |
gui: coloured actions with headers, a filter over the plan, bulk trash or delete
| -rw-r--r-- | docs/gui-checklist.md | 8 | ||||
| -rw-r--r-- | gui/internal/model/filter.go | 107 | ||||
| -rw-r--r-- | gui/internal/model/filter_test.go | 99 | ||||
| -rw-r--r-- | gui/internal/model/plan.go | 19 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 48 | ||||
| -rw-r--r-- | gui/internal/model/prefs.go | 4 | ||||
| -rw-r--r-- | gui/internal/model/prefs_test.go | 2 | ||||
| -rw-r--r-- | gui/internal/ui/forms.go | 27 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 250 | ||||
| -rw-r--r-- | gui/internal/ui/rules.go | 11 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 20 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 105 | ||||
| -rw-r--r-- | man/krino-gui.1 | 24 |
13 files changed, 659 insertions, 65 deletions
diff --git a/docs/gui-checklist.md b/docs/gui-checklist.md index cf00d1c..1bbcd74 100644 --- a/docs/gui-checklist.md +++ b/docs/gui-checklist.md @@ -104,3 +104,11 @@ A dialog is its own window: take it by its own id, not the main window's. 29. Dragging the divider above the preview resizes it, the size is still there after a restart, and a PDF page is rendered large enough to suit it rather than magnified. +30. The filter box narrows the plan as it is typed, `Select all` then + checks only what is shown, and the status line says how many checked + files are hidden. +31. `With checked` sets every checked file to Trash, or - after a + confirmation naming the count - to a permanent delete; the rows change + and nothing moves until Apply. +32. Closing the window with files checked and nothing applied asks first. +33. Every setting in Settings explains itself when the pointer rests on it. 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 { diff --git a/man/krino-gui.1 b/man/krino-gui.1 index 1a883fa..67dbecb 100644 --- a/man/krino-gui.1 +++ b/man/krino-gui.1 @@ -43,10 +43,19 @@ Pick one of the directories .Pa krino.conf includes and press .Cm Scan . +The box beside it filters the plan: type a few letters of a name or of a +rule, in order, and only the files they appear in are listed - capitals and +accents ignored, as a rule with +.Ic (fold yes) +would. While a filter is on, +.Cm Select all +checks what is on screen and the status line says how many checked files +the filter is hiding. Scanning takes that directory's lock, which is held while the plan is shown, so nothing moves underneath it; a directory another krino is working -in is reported rather than waited for. Each row is a file, what would -happen to it and where, and the rule that decided. A file krino could not +in is reported rather than waited for. Each row is a file, the action in capitals and coloured by what it does, +where the file would go, and the rule that decided; a header names the +columns. A file krino could not decide about - unreadable content, a failed duplicate check - is listed with the reason and cannot be selected. .Pp @@ -65,7 +74,13 @@ the second only after a confirmation naming the file, as the .Cm t and .Cm d -keys do in the terminal review. +keys do in the terminal review; +.Cm With checked +does the same for every checked file at once. Either way the plan changes +and nothing else: the files move when +.Cm Apply +is pressed, and closing the window with a plan still unapplied says so +before it goes. .Pp .Cm Apply acts on the checked files and logs the rest as declined, exactly as @@ -140,8 +155,7 @@ file may override; they are written to 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, how tall the -preview is, whether the configuration is coloured, whether the file behind a +The second is how this window behaves - how tall the preview is, 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 , |
