summaryrefslogtreecommitdiff
path: root/gui/internal/ui/window.go
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui/window.go')
-rw-r--r--gui/internal/ui/window.go144
1 files changed, 144 insertions, 0 deletions
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
new file mode 100644
index 0000000..e35a363
--- /dev/null
+++ b/gui/internal/ui/window.go
@@ -0,0 +1,144 @@
+// 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)
+}