// SPDX-License-Identifier: GPL-3.0-or-later package ui import ( "context" "fmt" "strings" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/pango" "github.com/diamondburned/gotk4/pkg/gtk/v4" "krino/gui/internal/model" "krino/internal/engine" "krino/internal/plan" "krino/internal/xdg" ) // planView is the Plan tab: a directory picker and Scan, the plan as a list // with a checkbox per file, the selected file's explanation beside it, and // Apply (GUI design §3). type planView struct { w *Window root *gtk.Box dirs *gtk.DropDown path *gtk.Label scan *gtk.Button apply *gtk.Button cancel *gtk.Button selAll *gtk.Button selNone *gtk.Button list *gtk.ListBox details *gtk.TextView menu *gtk.Popover menuRow int tab *model.PlanTab cancelOp context.CancelFunc } func newPlanView(w *Window) *planView { p := &planView{w: w} p.root = gtk.NewBox(gtk.OrientationVertical, 0) // The picker holds names only: a long path in it would widen the // window past the screen, so the path goes in a label that ellipsizes. names := make([]string, len(w.engine.Dirs)) for i, d := range w.engine.Dirs { names[i] = d.Name } if len(names) == 0 { names = []string{"none"} } p.dirs = gtk.NewDropDownFromStrings(names) p.path = gtk.NewLabel("") p.path.SetXAlign(0) p.path.SetHExpand(true) p.path.SetEllipsize(pango.EllipsizeMiddle) p.path.SetMaxWidthChars(20) p.dirs.Connect("notify::selected", p.showPath) p.scan = gtk.NewButtonWithLabel("Scan") p.selAll = gtk.NewButtonWithLabel("Select all") p.selNone = gtk.NewButtonWithLabel("None") p.apply = gtk.NewButtonWithLabel("Apply") p.apply.AddCSSClass("suggested-action") p.cancel = gtk.NewButtonWithLabel("Cancel") p.cancel.SetSensitive(false) bar := gtk.NewBox(gtk.OrientationHorizontal, 6) bar.SetMarginTop(6) bar.SetMarginStart(6) bar.SetMarginEnd(6) bar.SetMarginBottom(6) bar.Append(gtk.NewLabel("Directory")) bar.Append(p.dirs) bar.Append(p.scan) bar.Append(p.path) bar.Append(p.selAll) bar.Append(p.selNone) bar.Append(p.cancel) bar.Append(p.apply) p.list = gtk.NewListBox() p.list.SetSelectionMode(gtk.SelectionSingle) listScroll := gtk.NewScrolledWindow() listScroll.SetChild(p.list) listScroll.SetHExpand(true) listScroll.SetVExpand(true) p.details = gtk.NewTextView() p.details.Buffer().SetText("Select a file to see what would happen to it, and why.") p.details.SetEditable(false) p.details.SetMonospace(true) p.details.SetWrapMode(gtk.WrapWord) p.details.SetLeftMargin(8) p.details.SetRightMargin(8) p.details.SetTopMargin(6) p.details.SetBottomMargin(6) detailScroll := gtk.NewScrolledWindow() detailScroll.SetChild(p.details) detailScroll.SetSizeRequest(320, -1) // A pane the user can drag: on a narrow window the list needs the room, // on a wide one the explanation does. panes := gtk.NewPaned(gtk.OrientationHorizontal) panes.SetStartChild(listScroll) panes.SetEndChild(detailScroll) panes.SetResizeStartChild(true) panes.SetResizeEndChild(false) panes.SetShrinkEndChild(false) panes.SetPosition(780) panes.SetVExpand(true) p.root.Append(bar) p.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) p.root.Append(panes) p.scan.ConnectClicked(p.onScan) p.apply.ConnectClicked(p.onApply) p.cancel.ConnectClicked(func() { if p.cancelOp != nil { p.cancelOp() } }) 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()) } }) // The right button on a row offers the two overrides the terminal // review has on t and d (GUI design §3). p.menu = p.newMenu() click := gtk.NewGestureClick() click.SetButton(3) click.ConnectPressed(func(_ int, x, y float64) { p.onRightClick(x, y) }) p.list.AddController(click) p.showPath() p.setBusy(false) return p } // newMenu builds the row menu: what to do with a file instead of what the // rules decided. func (p *planView) newMenu() *gtk.Popover { box := gtk.NewBox(gtk.OrientationVertical, 0) trash := gtk.NewButtonWithLabel("Trash instead") perm := gtk.NewButtonWithLabel("Delete 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) pop.SetParent(p.list) pop.SetHasArrow(false) trash.ConnectClicked(func() { pop.Popdown() p.replace(plan.Trash) }) perm.ConnectClicked(func() { pop.Popdown() p.confirmDeletePermanent() }) return pop } // 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) { if p.tab == nil || p.tab.Applied { return } row := p.list.RowAtY(int(y)) if row == nil { return } p.list.SelectRow(row) p.menuRow = row.Index() at := gdk.NewRectangle(int(x), int(y), 1, 1) p.menu.SetPointingTo(&at) p.menu.Popup() } // replace swaps the menu row's steps for the one the user chose. func (p *planView) replace(kind plan.Kind) { if p.tab == nil { return } if err := p.tab.Replace(p.menuRow, kind); err != nil { p.w.setStatus("%v", err) return } rel := p.tab.Rows[p.menuRow].Rel p.fillList() p.showDetails(p.menuRow) p.w.setStatus("%s: %s instead", escape(rel), kind) } // confirmDeletePermanent asks before a step nothing can undo, naming the // file (GUI design §3). func (p *planView) confirmDeletePermanent() { if p.tab == nil || p.menuRow < 0 || p.menuRow >= len(p.tab.Rows) { return } rel := p.tab.Rows[p.menuRow].Rel d := gtk.NewMessageDialog(&p.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent, gtk.MessageWarning, gtk.ButtonsNone) d.SetObjectProperty("text", "Delete "+escape(rel)+" permanently?") d.SetObjectProperty("secondary-text", "It is not moved to the Trash and undo cannot bring it back.") 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.replace(plan.DeletePermanent) } }) d.Show() } // showPath writes the chosen directory's path beside the picker. func (p *planView) showPath() { if d := p.currentDir(); d != nil { p.path.SetText(escape(xdg.Abbrev(d.Root))) return } p.path.SetText("no directory is included; add one with: krino new NAME PATH") } // currentDir is the directory the picker names. func (p *planView) currentDir() *engine.Dir { i := int(p.dirs.Selected()) if i < 0 || i >= len(p.w.engine.Dirs) { return nil } return p.w.engine.Dirs[i] } // onScan plans the chosen directory, off the main loop. func (p *planView) onScan() { d := p.currentDir() if d == nil { p.w.setStatus("no directory is included; add one with: krino new NAME PATH") return } // One plan at a time: the open one holds its directory's lock, and // scanning again - the same directory or another - replaces it. p.closeTab() p.setBusy(true) p.w.setStatus("scanning %s...", escape(d.Name)) var tab *model.PlanTab p.cancelOp = runInBackground(func(ctx context.Context) error { var err error tab, err = model.Plan(ctx, p.w.engine, d) return err }, func(err error) { p.setBusy(false) if err != nil { p.w.setStatus("%s: %v", escape(d.Name), err) return } p.tab = tab p.fillList() c := tab.Counts p.w.setStatus("%d scanned, %d to act on, %d excluded, %d skipped, %d with warnings", c.Scanned, c.Acting, c.Excluded, c.Skipped, c.Warned) }) } // onApply acts on the checked files, off the main loop. func (p *planView) onApply() { if p.tab == nil { return } n := p.tab.SelectedCount() p.setBusy(true) p.w.setStatus("applying %d file(s)...", n) var res *engine.ApplyResult p.cancelOp = runInBackground(func(ctx context.Context) error { var err error res, err = p.tab.Apply(ctx) return err }, func(err error) { p.setBusy(false) p.apply.SetSensitive(false) p.fillList() if err != nil { p.w.setStatus("apply: %v", err) return } p.w.setStatus("%d applied, %d failed, %d declined", res.Applied, res.Failed, res.Declined) }) } // closeTab drops the open plan and releases the directory's lock. func (p *planView) closeTab() { if p.tab == nil { return } if err := p.tab.Close(); err != nil { p.w.setStatus("%s: %v", escape(p.tab.Dir.Name), err) } p.tab = nil p.fillList() } // setBusy turns the buttons on or off around a background operation. func (p *planView) setBusy(busy bool) { p.scan.SetSensitive(!busy) p.dirs.SetSensitive(!busy) p.selAll.SetSensitive(!busy) p.selNone.SetSensitive(!busy) p.apply.SetSensitive(!busy && p.tab != nil && !p.tab.Applied) p.cancel.SetSensitive(busy) } // selectAll checks or unchecks every file that can be applied. func (p *planView) selectAll(on bool) { if p.tab == nil { return } if on { p.tab.SelectAll() } else { p.tab.SelectNone() } p.fillList() } // fillList renders the rows: a checkbox, the file, what would happen, the // rule, and the outcome once applied. func (p *planView) fillList() { clearList(p.list) if p.tab == nil { return } w := p.widths() for i, r := range p.tab.Rows { p.list.Append(p.rowWidget(i, r, w)) } p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount())) p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0) } // 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} root := p.dirRoot() for _, r := range p.tab.Rows { 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))) } // 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} { w[i] = min(w[i], cap) } return w } // rowWidget is one line of the list. func (p *planView) rowWidget(i int, r model.Row, w [4]int) *gtk.ListBoxRow { box := gtk.NewBox(gtk.OrientationHorizontal, 8) box.SetMarginStart(6) box.SetMarginEnd(6) box.SetMarginTop(2) box.SetMarginBottom(2) check := gtk.NewCheckButton() check.SetActive(r.Selected) check.SetSensitive(r.Actable && (p.tab == nil || !p.tab.Applied)) check.ConnectToggled(func() { if p.tab != nil && p.tab.Rows[i].Selected != check.Active() { p.tab.Toggle(i) p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount())) p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0) } }) 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. 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)) if r.Outcome != "" { box.Append(column(escape(r.Outcome), w[3], false)) } row := gtk.NewListBoxRow() row.SetChild(box) return row } // dirRoot is the directory the list belongs to, for shortening destinations. func (p *planView) dirRoot() string { if p.tab != nil { return p.tab.Dir.Root } return "" } // column is one cell of a row: left-aligned, and ellipsized to chars so // that a long name or warning cannot widen the window past the screen. The // whole text is in the details pane beside the list. // // The expanding column - the file's name - may shrink to yieldChars when // the window is too narrow for every column, so it is the one that gives // way and the columns beside it (what would happen, and which rule decided) // stay readable. A fixed column keeps its width and the list scrolls. func column(text string, chars int, expand bool) *gtk.Label { return columnMin(text, chars, yieldChars, expand) } // columnMin is column with the width it may shrink to given explicitly. func columnMin(text string, chars, floor int, expand bool) *gtk.Label { l := gtk.NewLabel(text) l.SetXAlign(0) l.SetEllipsize(pango.EllipsizeEnd) if expand { l.SetWidthChars(min(chars, floor)) } else { l.SetWidthChars(chars) } l.SetMaxWidthChars(chars) l.SetHExpand(expand) l.SetTooltipText(text) return l } // yieldChars is how narrow an expanding column may become. const yieldChars = 14 // showDetails writes the selected file's steps and warnings into the pane. func (p *planView) showDetails(i int) { if p.tab == nil || i < 0 || i >= len(p.tab.Rows) { return } r := p.tab.Rows[i] var b strings.Builder b.WriteString(escape(r.Rel) + "\n\n") for _, s := range r.Steps { switch { case s.Skip != "": fmt.Fprintf(&b, "%s skipped: %s\n", s.Kind, escape(s.Skip)) case s.Kind == plan.Trash || s.Kind == plan.DeletePermanent: fmt.Fprintf(&b, "%s\n", s.Kind) default: fmt.Fprintf(&b, "%s -> %s\n", s.Kind, escape(shorten(s.Dst, p.dirRoot()))) } if s.Rule != "" { fmt.Fprintf(&b, " rule %s\n", escape(s.Rule)) } if s.Reason != "" { fmt.Fprintf(&b, " because %s\n", escape(s.Reason)) } } for _, warn := range r.Warnings { b.WriteString("\n" + escape(warn) + "\n") } if r.Outcome != "" { b.WriteString("\n" + escape(r.Outcome) + "\n") } p.details.Buffer().SetText(b.String()) }