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 /gui/internal/ui/plan.go | |
| 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
Diffstat (limited to 'gui/internal/ui/plan.go')
| -rw-r--r-- | gui/internal/ui/plan.go | 250 |
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() |
