aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui/plan.go
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/ui/plan.go
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/ui/plan.go')
-rw-r--r--gui/internal/ui/plan.go250
1 files changed, 225 insertions, 25 deletions
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()