// 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/glib/v2" "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 saveTimer glib.SourceHandle } // 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 }) themeColours(w.win) 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() } // addDirectory asks for a name and a path and makes krino sort that // directory too - the window had no way to do it (his report, 2026-09-17). func (w *Window) addDirectory() { d := gtk.NewWindow() d.SetTitle("Add a directory") d.SetTransientFor(&w.win.Window) d.SetModal(true) d.SetDefaultSize(520, 200) name := gtk.NewEntry() name.SetPlaceholderText("papers") name.SetTooltipText("the name krino knows it by: letters, digits, '.', '_' and '-'; its rules go in dirs/NAME.conf") path := gtk.NewEntry() path.SetPlaceholderText("~/papers") path.SetHExpand(true) path.SetTooltipText("the directory to sort") note := gtk.NewLabel("") note.SetXAlign(0) note.SetWrap(true) box := gtk.NewBox(gtk.OrientationVertical, 8) box.SetMarginStart(12) box.SetMarginEnd(12) box.SetMarginTop(12) box.SetMarginBottom(12) box.Append(field("name", name)) box.Append(field("path", path)) box.Append(note) create := gtk.NewButtonWithLabel("Add") create.AddCSSClass("suggested-action") cancel := gtk.NewButtonWithLabel("Cancel") cancel.ConnectClicked(func() { d.Close() }) buttons := gtk.NewBox(gtk.OrientationHorizontal, 6) buttons.SetHAlign(gtk.AlignEnd) buttons.Append(cancel) buttons.Append(create) box.Append(buttons) create.ConnectClicked(func() { file, err := model.AddDirectory(w.engine, strings.TrimSpace(name.Text()), strings.TrimSpace(path.Text())) if err != nil { note.SetText(escape(err.Error())) note.AddCSSClass("error") return } added := strings.TrimSpace(name.Text()) if err := w.reloadEngine(); err != nil { note.SetText(escape(err.Error())) note.AddCSSClass("error") return } w.plan.refreshDirs(w.plan.currentName()) w.rules.refreshDirs(added) w.setStatus("added %s; its rules are in %s", escape(added), escape(xdg.Abbrev(file))) d.Close() }) d.SetChild(box) d.Show() } // savePrefsSoon writes the preferences a moment after the last change, so // dragging a divider does not write the file on every pixel. func (w *Window) savePrefsSoon() { if w.saveTimer != 0 { glib.SourceRemove(w.saveTimer) } w.saveTimer = glib.TimeoutAdd(500, func() bool { w.saveTimer = 0 if err := w.prefs.Save(); err != nil { w.setStatus("settings: %v", err) } return false }) } // 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. They are the fallbacks; themeColours replaces them with the // running theme's own, so the window looks like the rest of the desktop // rather than like GNOME's palette (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", } // actionClasses name the CSS class each action's cell carries. The colour // is applied by a style sheet rather than by painting the text, so that a // selected row - which draws its own background - can take the colour back // and stay readable: his green accent on his green selection was not (his // report, 2026-09-17). var actionClasses = map[plan.Kind]string{ plan.Copy: "krino-copy", plan.Move: "krino-move", plan.Rename: "krino-rename", plan.Trash: "krino-trash", plan.DeletePermanent: "krino-delete", } // themeColours takes what it can from the GTK theme - the accent for a // move, the selection blue for a rename, the theme's own success, warning // and error for the rest - and installs the style sheet that paints the // action cells. A theme that names none of them leaves the fallbacks above. func themeColours(w gtk.Widgetter) { widget := gtk.BaseWidget(w) ctx := widget.StyleContext() pick := func(names ...string) string { for _, name := range names { if rgba, ok := ctx.LookupColor(name); ok { return fmt.Sprintf("#%02x%02x%02x", int(rgba.Red()*255), int(rgba.Green()*255), int(rgba.Blue()*255)) } } return "" } set := func(kind plan.Kind, colour string) { if colour != "" { actionColours[kind] = colour } } set(plan.Move, pick("accent_color", "theme_selected_bg_color", "accent_bg_color")) set(plan.Copy, pick("success_color", "success_bg_color")) set(plan.Rename, pick("theme_selected_bg_color", "accent_bg_color")) set(plan.Trash, pick("warning_color", "warning_bg_color")) set(plan.DeletePermanent, pick("error_color", "destructive_color", "error_bg_color")) var css strings.Builder css.WriteString(".krino-action { font-weight: bold; }\n") for kind, class := range actionClasses { fmt.Fprintf(&css, ".%s { color: %s; }\n", class, actionColours[kind]) } // A selected row paints its own background; the action takes that row's // foreground so it never sits on a colour of its own. fmt.Fprintf(&css, ".krino-warn { color: %s; }\n", actionColours[plan.Trash]) css.WriteString(".krino-title { font-weight: bold; font-size: 115%; }\n") css.WriteString("row:selected .krino-action { color: @theme_selected_fg_color; }\n") css.WriteString("row:selected .krino-action { color: @accent_fg_color; }\n") provider := gtk.NewCSSProvider() provider.LoadFromData(css.String()) if display := widget.Display(); display != nil { gtk.StyleContextAddProviderForDisplay(display, provider, 700) } } // 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 CSS class they are painted with. A row that would do nothing has // neither. func rowAction(r model.Row) (text, class 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", "" } return strings.Join(parts, "+"), actionClasses[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) }