// 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 sess *engine.Session plan *planView status *gtk.Label } // NewWindow builds the window for e; the session is the run everything in // it goes through. func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Window { w := &Window{app: app, engine: e, sess: sess} w.win = gtk.NewApplicationWindow(app) w.win.SetTitle("krino") w.win.SetDefaultSize(1000, 640) notebook := gtk.NewNotebook() w.plan = newPlanView(w) notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan")) notebook.AppendPage(placeholder("History and undo arrives with the next milestone."), gtk.NewLabel("History & undo")) notebook.AppendPage(placeholder("The rules editor arrives with a later milestone."), gtk.NewLabel("Rules")) w.status = gtk.NewLabel("") w.status.SetXAlign(0) w.status.SetMarginStart(8) w.status.SetMarginEnd(8) w.status.SetMarginTop(4) w.status.SetMarginBottom(4) box := gtk.NewBox(gtk.OrientationVertical, 0) notebook.SetVExpand(true) box.Append(notebook) box.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) box.Append(w.status) 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() return false }) return w } // Show puts the window on screen. func (w *Window) Show() { w.win.Show() } // 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() } // 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) }