diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:17:08 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:17:08 +0200 |
| commit | bcfeb773afcc28641ef2fb47749c91fd0767054a (patch) | |
| tree | f8593c29bad0b204f1ecc9cae650b55f3cc755d6 /gui/internal/ui/window.go | |
| parent | 4f98392c9a062e9d4dd8363a2ede9a919ad14006 (diff) | |
| download | krino-bcfeb773afcc28641ef2fb47749c91fd0767054a.tar.gz krino-bcfeb773afcc28641ef2fb47749c91fd0767054a.zip | |
gui: the Plan tab - scan, review, apply, with the directory locked
Diffstat (limited to 'gui/internal/ui/window.go')
| -rw-r--r-- | gui/internal/ui/window.go | 144 |
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) +} |
