// SPDX-License-Identifier: GPL-3.0-or-later package ui import ( "context" "fmt" "os" "path/filepath" "strings" "time" "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 checked *gtk.MenuButton groups [8]*gtk.SizeGroup listScroll *gtk.ScrolledWindow filter *gtk.SearchEntry shown []int list *gtk.ListBox headerBox *gtk.Box details *gtk.Box previewNote *gtk.Label picture *gtk.Picture previewText *gtk.TextView previewScroll *gtk.ScrolledWindow previewTmp string previewFor string detailPane *gtk.Paned arrangement *gtk.Paned listSide *gtk.Box detailScroll *gtk.ScrolledWindow previewBox *gtk.Box layout string renderedDir string previewHeight int previewOff bool startSelected bool 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) // A filter over the plan: type a few letters of a name, as fzf does, // and act on what is left. It sits by the Scan button, where the eye // starts (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(200, -1) bar.Append(p.filter) bar.Append(p.path) bar.Append(p.selAll) bar.Append(p.selNone) bar.Append(p.checkedMenu()) 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.listScroll = listScroll for i := range p.groups { p.groups[i] = gtk.NewSizeGroup(gtk.SizeGroupHorizontal) } 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) // The explanation is laid out rather than printed: the file's name, then // a line per step with the action in its own colour, centred in the // pane so the eye lands on it (his request, 2026-09-17). p.details = gtk.NewBox(gtk.OrientationVertical, 6) p.details.SetHAlign(gtk.AlignCenter) p.details.SetVAlign(gtk.AlignStart) p.details.SetMarginTop(16) p.details.SetMarginBottom(12) p.details.SetMarginStart(12) p.details.SetMarginEnd(12) p.detailsHint("Select a file to see what would happen to it, and why.") detailScroll := gtk.NewScrolledWindow() detailScroll.SetChild(p.details) detailScroll.SetVExpand(true) detailScroll.SetSizeRequest(-1, 200) // Under the explanation, a look at the file itself: a picture for an // image, the first page for a PDF, the first lines for anything that is // text (his request, 2026-09-16). p.previewNote = gtk.NewLabel("") p.previewNote.SetXAlign(0) p.previewNote.SetMarginStart(8) p.previewNote.SetMarginEnd(8) p.previewNote.SetEllipsize(pango.EllipsizeEnd) p.previewNote.SetMaxWidthChars(20) p.previewNote.AddCSSClass("dim-label") // The picture fills whatever the divider leaves it. Inside a scrolled // window it would be given its smallest size instead, which is what // made the page a stamp (his report, 2026-09-17). p.picture = gtk.NewPicture() p.picture.SetCanShrink(true) p.picture.SetContentFit(gtk.ContentFitContain) p.picture.SetVisible(false) p.picture.SetVExpand(true) p.picture.SetHExpand(true) p.previewText = gtk.NewTextView() p.previewText.SetEditable(false) p.previewText.SetMonospace(true) p.previewText.SetLeftMargin(8) p.previewText.SetRightMargin(8) p.previewText.SetTopMargin(6) previewScroll := gtk.NewScrolledWindow() previewScroll.SetChild(p.previewText) previewScroll.SetVisible(false) previewScroll.SetVExpand(true) p.previewScroll = previewScroll previewBox := gtk.NewBox(gtk.OrientationVertical, 0) previewBox.Append(p.previewNote) previewBox.Append(p.picture) previewBox.Append(previewScroll) p.listSide = listSide p.detailScroll = detailScroll p.previewBox = previewBox p.root.Append(bar) p.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) p.setLayout(model.LayoutSide) p.scan.ConnectClicked(p.onScan) p.apply.ConnectClicked(p.onApply) p.cancel.ConnectClicked(func() { if p.cancelOp != nil { 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(p.planIndex(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 } // 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 { 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 } // 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) { if p.tab == nil || p.tab.Applied { return } row := p.list.RowAtY(int(y)) if row == nil { return } p.list.SelectRow(row) 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() } // 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 - press Apply to carry it out; nothing has moved yet", 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 if !p.startSelected { tab.SelectNone() } 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 - this plan is done; press Scan for a fresh one", 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) done := p.tab != nil && p.tab.Applied p.selAll.SetSensitive(!busy && !done) p.selNone.SetSensitive(!busy && !done) 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 - 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 } 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 { return } if p.tab.Applied { if p.filter.Text() != "" { p.w.setStatus("%d of %d files shown; this plan has been applied - press Scan for a fresh one", len(p.shown), len(p.tab.Rows)) } return } if 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() { clearList(p.list) for i := range p.groups { p.groups[i] = gtk.NewSizeGroup(gtk.SizeGroupHorizontal) } if p.tab == nil { return } w := p.widths() 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.selAll.SetSensitive(!p.tab.Applied) p.selNone.SetSensitive(!p.tab.Applied) p.sayWhatIsShown() // A fresh list starts at its left edge: without this the view can open // scrolled sideways, with the file names out of sight (his report, // 2026-09-17). if adj := p.listScroll.HAdjustment(); adj != nil { adj.SetValue(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() [7]int { w := [7]int{16, 4, 3, 6, 16, 8, 6} root := p.dirRoot() now := time.Now() for _, r := range p.tab.Rows { action, _ := rowAction(r) w[colFile] = max(w[colFile], len([]rune(r.Rel))) w[colSize] = max(w[colSize], len([]rune(model.SizeText(r.Size)))) w[colAge] = max(w[colAge], len([]rune(model.AgeText(r.ModTime, now)))) w[colAction] = max(w[colAction], len([]rune(action))) w[colWhere] = max(w[colWhere], len([]rune(rowWhere(r, root)))) w[colRule] = max(w[colRule], len([]rune(r.Rule))) w[colOutcome] = max(w[colOutcome], 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 [7]int{40, 7, 5, 16, 52, 34, 24} { w[i] = min(w[i], cap) } // A column is never narrower than its own heading, or the heading is // the thing that ends in an ellipsis (his report, 2026-09-17). for i, title := range columnTitles { w[i] = max(w[i], len([]rune(title))) } return w } // The columns of the plan, in the order they are shown. const ( colFile = iota colSize colAge colAction colWhere colRule colOutcome ) // showColumn reports whether a column is shown: three of them are settings, // and the outcome appears only once a plan has been applied. func (p *planView) showColumn(i int) bool { switch i { case colSize: return p.w.prefs.ShowSize case colAge: return p.w.prefs.ShowAge case colRule: return p.w.prefs.ShowRule case colOutcome: return p.showOutcome() } return true } // showOutcome reports whether there is anything to put in the last column. func (p *planView) showOutcome() bool { return p.tab != nil && p.tab.Applied } // headerFloors are how narrow each heading may become, matching the cells // under it. var headerFloors = [7]int{12, 4, 3, 6, 16, 10, 8} // columnTitles name the columns of the plan. var columnTitles = [7]string{"file", "size", "age", "action", "where it would go", "rule", "outcome"} // header is the line above the list saying what each column is. func (p *planView) header(w [7]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.groups[0].AddWidget(spacer) p.headerBox.Append(spacer) for i, title := range columnTitles { if !p.showColumn(i) { continue } l := columnMin(title, w[i], headerFloors[i], i == colFile || i == colWhere) l.AddCSSClass("heading") l.SetTooltipText("") p.groups[i+1].AddWidget(l) p.headerBox.Append(l) } } // rowWidget is one line of the list. func (p *planView) rowWidget(i int, r model.Row, w [7]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) } }) p.groups[0].AddWidget(check) box.Append(check) // 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) // Every column can shrink: a pane narrower than their natural widths // used to push the whole row out of view to the left (his report, // 2026-09-17). cells := [7]gtk.Widgetter{ colFile: columnMin(escape(r.Rel), w[colFile], 12, true), colSize: columnMin(model.SizeText(r.Size), w[colSize], 4, false), colAge: columnMin(model.AgeText(r.ModTime, time.Now()), w[colAge], 3, false), colAction: colouredColumn(action, w[colAction], colour), colWhere: columnMin(escape(rowWhere(r, p.dirRoot())), w[colWhere], 16, true), colRule: columnMin(escape(r.Rule), w[colRule], 10, false), colOutcome: columnMin(escape(r.Outcome), w[colOutcome], 8, false), } for i, cell := range cells { if !p.showColumn(i) { continue } p.groups[i+1].AddWidget(cell) box.Append(cell) } 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 } // colouredColumn is a fixed column carrying the CSS classes that colour an // action, so that a MOVE and a DELETE do not read alike - and so that a // selected row can take the colour back for readability. func colouredColumn(text string, chars int, class string) *gtk.Label { l := column(escape(text), chars, false) if class == "" || text == "" { return l } l.AddCSSClass("krino-action") l.AddCSSClass(class) return l } // yieldChars is how narrow an expanding column may become. const yieldChars = 14 // detailsHint empties the explanation and leaves one dim line in it. func (p *planView) detailsHint(text string) { p.clearDetails() l := gtk.NewLabel(text) l.SetWrap(true) l.SetJustify(gtk.JustifyCenter) l.AddCSSClass("dim-label") p.details.Append(l) } // clearDetails takes the last explanation out of the pane. func (p *planView) clearDetails() { for child := p.details.FirstChild(); child != nil; child = p.details.FirstChild() { p.details.Remove(child) } } // detailLine is one centred line of the explanation. func detailLine(text string, classes ...string) *gtk.Label { l := gtk.NewLabel(text) l.SetWrap(true) l.SetJustify(gtk.JustifyCenter) l.SetMaxWidthChars(60) l.SetSelectable(true) for _, c := range classes { l.AddCSSClass(c) } return l } // showDetails lays out the selected file's steps and warnings. func (p *planView) showDetails(i int) { if p.tab == nil || i < 0 || i >= len(p.tab.Rows) { return } r := p.tab.Rows[i] p.clearDetails() title := detailLine(escape(r.Rel), "krino-title") p.details.Append(title) if what := describeFile(r); what != "" { p.details.Append(detailLine(what, "dim-label")) } for _, s := range r.Steps { row := gtk.NewBox(gtk.OrientationHorizontal, 8) row.SetHAlign(gtk.AlignCenter) row.SetMarginTop(6) action := gtk.NewLabel(strings.ToUpper(s.Kind.String())) action.AddCSSClass("krino-action") if class, ok := actionClasses[s.Kind]; ok { action.AddCSSClass(class) } row.Append(action) switch { case s.Skip != "": row.Append(detailLine("skipped: "+escape(s.Skip), "dim-label")) case s.Kind == plan.Trash: row.Append(detailLine("to the Trash")) case s.Kind == plan.DeletePermanent: row.Append(detailLine("gone for good", "krino-warn")) default: row.Append(detailLine("→ " + escape(shorten(s.Dst, p.dirRoot())))) } p.details.Append(row) if s.Rule != "" { p.details.Append(detailLine("rule "+escape(s.Rule), "dim-label")) } if s.Reason != "" { p.details.Append(detailLine("because "+escape(s.Reason), "dim-label")) } } for _, warn := range r.Warnings { p.details.Append(detailLine(escape(warn), "krino-warn")) } if r.Outcome != "" { p.details.Append(detailLine(escape(r.Outcome), "krino-title")) } if adj := p.detailScroll.VAdjustment(); adj != nil { adj.SetValue(0) } p.showPreview(r.Rel) } // describeFile is the line under the name: how big the file is and how long // it has been sitting there. func describeFile(r model.Row) string { var parts []string if r.Size > 0 { parts = append(parts, model.SizeText(r.Size)) } if age := model.AgeText(r.ModTime, time.Now()); age != "" { parts = append(parts, "last written "+age+" ago") } return strings.Join(parts, ", ") } // showPreview looks at the file behind row rel, off the main loop: reading // it, and rendering a PDF page, takes long enough to stutter the window. func (p *planView) showPreview(rel string) { if p.tab == nil || p.previewOff { return } path := filepath.Join(p.tab.Dir.Root, filepath.FromSlash(rel)) if p.previewFor == path { return } p.previewFor = path p.picture.SetVisible(false) p.previewScroll.SetVisible(false) p.previewNote.SetText("looking at " + escape(rel) + "...") if p.previewTmp == "" { dir, err := os.MkdirTemp("", "krino-preview-") if err != nil { p.previewNote.SetText(escape(err.Error())) return } p.previewTmp = dir } var pv model.Preview runInBackground(func(ctx context.Context) error { pv = model.MakePreview(ctx, path, p.previewTmp, p.previewHeight) return nil }, func(error) { if p.previewFor != path { return // another row was chosen while this one was read } p.previewNote.SetText(escape(pv.Note)) switch pv.Kind { case model.PreviewImage: p.picture.SetFilename(pv.Image) p.picture.SetVisible(true) // The picture is loaded, so the page rendered for the row // before it can go. p.dropRendered() p.renderedDir = pv.Dir case model.PreviewText: p.previewText.Buffer().SetText(escapeText(pv.Text)) p.previewScroll.SetVisible(true) p.dropRendered() default: p.dropRendered() } }) } // setLayout arranges the tab the way the settings ask: the file list beside // the explanation, or above it with the preview to its side (his sketch, // 2026-09-17). The widgets are the same either way; only the panes holding // them change. func (p *planView) setLayout(which string) { if p.layout == which && p.arrangement != nil { return } p.layout = which if p.arrangement != nil { p.detailPane.SetStartChild(nil) p.detailPane.SetEndChild(nil) p.arrangement.SetStartChild(nil) p.arrangement.SetEndChild(nil) p.root.Remove(p.arrangement) } // The divider between the explanation and the preview is the size // control: drag it, and the preview stays that big. detailOrientation, outerOrientation := gtk.OrientationVertical, gtk.OrientationHorizontal if which == model.LayoutStacked { detailOrientation, outerOrientation = gtk.OrientationHorizontal, gtk.OrientationVertical } p.detailPane = gtk.NewPaned(detailOrientation) p.detailPane.SetStartChild(p.detailScroll) p.detailPane.SetEndChild(p.previewBox) p.detailPane.SetResizeStartChild(true) p.detailPane.SetResizeEndChild(false) p.detailPane.SetShrinkEndChild(false) p.detailPane.SetVExpand(true) p.detailPane.Connect("notify::position", p.rememberPreviewHeight) p.arrangement = gtk.NewPaned(outerOrientation) p.arrangement.SetStartChild(p.listSide) p.arrangement.SetEndChild(p.detailPane) p.arrangement.SetResizeStartChild(true) p.arrangement.SetResizeEndChild(false) p.arrangement.SetShrinkEndChild(false) p.arrangement.SetVExpand(true) prefs := p.w.prefs if which == model.LayoutSide { p.detailPane.SetSizeRequest(340, -1) p.arrangement.SetPosition(orDefault(prefs.ListWidth, model.DefaultListWidth)) p.previewHeight = orDefault(prefs.PreviewHeight, model.DefaultPreviewHeight) } else { p.detailPane.SetSizeRequest(-1, 240) p.arrangement.SetPosition(orDefault(prefs.ListHeight, model.DefaultListHeight)) p.previewHeight = orDefault(prefs.PreviewWidth, model.DefaultPreviewWidth) } p.arrangement.Connect("notify::position", p.rememberListSize) p.root.Append(p.arrangement) p.setPreviewHeight(p.previewHeight) } // orDefault is n, or the default when nothing has been chosen yet. func orDefault(n, def int) int { if n < 80 { return def } return n } // rememberListSize keeps where the divider between the file list and the // explanation was left, for this layout. func (p *planView) rememberListSize() { at := p.arrangement.Position() if at < 80 { return } prefs := p.w.prefs if p.layout == model.LayoutStacked { if prefs.ListHeight == at { return } prefs.ListHeight = at } else { if prefs.ListWidth == at { return } prefs.ListWidth = at } p.w.prefs = prefs p.w.savePrefsSoon() } // rememberPreviewHeight keeps what a drag of the divider chose, so the next // window opens the same way. func (p *planView) rememberPreviewHeight() { total := p.detailPane.Height() if p.layout == model.LayoutStacked { total = p.detailPane.Width() } height := total - p.detailPane.Position() if height < 80 || height == p.previewHeight { return } p.previewHeight = height prefs := p.w.prefs if p.layout == model.LayoutStacked { prefs.PreviewWidth = height } else { prefs.PreviewHeight = height } p.w.prefs = prefs p.w.savePrefsSoon() } // setPreviewHeight puts the divider where a height asks for. func (p *planView) setPreviewHeight(height int) { if height < 80 { height = model.DefaultPreviewHeight } p.previewHeight = height total := p.detailPane.Height() if p.layout == model.LayoutStacked { total = p.detailPane.Width() } if total > height+80 { p.detailPane.SetPosition(total - height) return } // Before the window is drawn there is no height to subtract from, so // the end child's own request puts the divider in the right place. if child := p.detailPane.EndChild(); child != nil { if p.layout == model.LayoutStacked { gtk.BaseWidget(child).SetSizeRequest(height, -1) } else { gtk.BaseWidget(child).SetSizeRequest(-1, height) } } } // applyPrefs turns the file preview on or off, sets how tall it is, and // says how a fresh plan starts. func (p *planView) applyPrefs(prefs model.Prefs) { p.previewOff = !prefs.Preview p.startSelected = prefs.SelectAll p.setLayout(prefs.Layout) if p.tab != nil { p.fillList() } if prefs.Layout == model.LayoutStacked { p.setPreviewHeight(orDefault(prefs.PreviewWidth, model.DefaultPreviewWidth)) } else { p.setPreviewHeight(orDefault(prefs.PreviewHeight, model.DefaultPreviewHeight)) } if p.previewOff { p.previewFor = "" p.picture.SetVisible(false) p.previewScroll.SetVisible(false) p.previewNote.SetText("") } } // dropRendered removes the page rendered for the previous row. func (p *planView) dropRendered() { if p.renderedDir != "" { os.RemoveAll(p.renderedDir) p.renderedDir = "" } } // 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() if p.previewTmp != "" { os.RemoveAll(p.previewTmp) p.previewTmp = "" } }