// SPDX-License-Identifier: GPL-3.0-or-later // Package ui is krino-gui's GTK4 layer: it renders what package model // holds and forwards what the user does back to it. It makes no decisions // of its own (GUI design §1.2). package ui import ( "context" "fmt" "os" "strings" "github.com/diamondburned/gotk4/pkg/gtk/v4" "krino/gui/internal/model" "krino/internal/engine" "krino/internal/plan" "krino/internal/xdg" ) // Window is krino-gui's one window: three tabs, and a status bar. type Window struct { app *gtk.Application win *gtk.ApplicationWindow engine *engine.Engine plan *planView history *historyView rules *rulesView status *gtk.Label prefs model.Prefs leaving bool } // NewWindow builds the window for e. Each plan and each undo is its own // run of krino, with its own run id in the log, so the window itself holds // no session. func NewWindow(app *gtk.Application, e *engine.Engine) *Window { w := &Window{app: app, engine: e, prefs: model.LoadPrefs()} w.win = gtk.NewApplicationWindow(app) w.win.SetTitle("krino") w.win.SetDefaultSize(1200, 720) notebook := gtk.NewNotebook() w.plan = newPlanView(w) notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan")) w.history = newHistoryView(w) notebook.AppendPage(w.history.root, gtk.NewLabel("History & undo")) w.rules = newRulesView(w) notebook.AppendPage(w.rules.root, gtk.NewLabel("Rules")) // The log is read when the tab is first opened, not at start-up: a // window that only sorts never reads it. loaded := false notebook.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) { if page == 1 && !loaded { loaded = true w.history.loadRuns() } }) // Settings sits at the end of the tab strip - the window's top right - // with a gear beside the word (his request, 2026-09-17). settings := gtk.NewButton() settingsBox := gtk.NewBox(gtk.OrientationHorizontal, 6) settingsBox.Append(gtk.NewImageFromIconName("emblem-system-symbolic")) settingsBox.Append(gtk.NewLabel("Settings")) settings.SetChild(settingsBox) settings.SetHasFrame(false) settings.SetTooltipText("krino's defaults, and how this window behaves") settings.SetMarginEnd(6) settings.ConnectClicked(func() { w.showSettings() }) notebook.SetActionWidget(settings, gtk.PackEnd) w.status = gtk.NewLabel("") w.status.SetXAlign(0) w.status.SetMarginStart(8) w.status.SetMarginEnd(8) w.status.SetMarginTop(4) w.status.SetMarginBottom(4) statusRow := gtk.NewBox(gtk.OrientationHorizontal, 6) w.status.SetHExpand(true) statusRow.Append(w.status) box := gtk.NewBox(gtk.OrientationVertical, 0) notebook.SetVExpand(true) box.Append(notebook) box.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) box.Append(statusRow) w.win.SetChild(box) // 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() return false }) w.applyPrefs(w.prefs) return w } // Show puts the window on screen. func (w *Window) Show() { w.win.Show() } // reloadEngine re-reads the configuration, after the rules editor saves, so // every tab works from the rules the user just wrote. An open plan came // from the old ones, so it is closed and its lock released. func (w *Window) reloadEngine() error { e, diags := engine.Load(w.engine.MainFile) if len(diags) > 0 { return diags[0] } e.CacheDir = w.engine.CacheDir w.plan.closeTab() w.history.closeTab() w.engine = e 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. func (w *Window) applyPrefs(p model.Prefs) { w.prefs = p w.rules.applyPrefs(p) w.plan.applyPrefs(p) } // dirRoot is the path of the configured directory called name, or "" if // the configuration no longer has one - a log entry can outlive its // directory. func (w *Window) dirRoot(name string) string { for _, d := range w.engine.Dirs { if d.Name == name { return d.Root } } return "" } // setStatus writes the line at the bottom of the window. func (w *Window) setStatus(format string, args ...any) { w.status.SetText(fmt.Sprintf(format, args...)) } // placeholder is the page of a tab that is not built yet. func placeholder(text string) gtk.Widgetter { l := gtk.NewLabel(text) l.SetVExpand(true) return l } // escape returns text as it is safe to show: control, bidirectional and // separator characters are written as escapes, the way the terminal // front end does it (spec §15.1), so a hostile file name cannot reorder or // restyle the window. GTK labels are set as plain text, never as markup. func escape(s string) string { var b strings.Builder for _, r := range s { switch { case r == '\t': b.WriteString("\\t") case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f, r == 0x061c, r >= 0x200e && r <= 0x200f, r >= 0x202a && r <= 0x202e, r >= 0x2066 && r <= 0x2069, r == 0x2028, r == 0x2029: fmt.Fprintf(&b, "\\u%04x", r) default: b.WriteRune(r) } } return b.String() } // escapeText is escape for text shown in a pane rather than on one line: // line breaks are the layout, so they are kept, and every other control or // bidirectional character is still written as an escape. func escapeText(s string) string { var b strings.Builder for _, line := range strings.Split(s, "\n") { b.WriteString(escape(line)) b.WriteString("\n") } return strings.TrimSuffix(b.String(), "\n") } // runInBackground runs work off the main loop and hands its result back to // the GTK thread with done. GTK may only be touched from the main loop. func runInBackground(work func(context.Context) error, done func(error)) context.CancelFunc { ctx, cancel := context.WithCancel(context.Background()) go func() { err := work(ctx) idleAdd(func() { done(err) }) }() return cancel } // 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 "", "" } 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 != "": 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) } } 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 { if root != "" && strings.HasPrefix(dst, root+string(os.PathSeparator)) { return dst[len(root)+1:] } return xdg.Abbrev(dst) }