// 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/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 } // 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 { 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 } // 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 } // rowLabel is the middle cell of a row: what would happen to the file and // where it would land, or - when nothing would - why not. Destinations // inside root are shown relative to it, as the plan's own output does. func rowLabel(r model.Row, root string) string { if len(r.Steps) == 0 { return strings.Join(r.Warnings, "; ") } var parts []string for _, s := range r.Steps { switch { case s.Skip != "": parts = append(parts, s.Kind.String()+" skipped: "+s.Skip) case s.Dst == "": parts = append(parts, s.Kind.String()) default: parts = append(parts, s.Kind.String()+" "+shorten(s.Dst, root)) } } return strings.Join(parts, ", ") } // 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) }