aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 09:17:08 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 09:17:08 +0200
commitbcfeb773afcc28641ef2fb47749c91fd0767054a (patch)
treef8593c29bad0b204f1ecc9cae650b55f3cc755d6
parent4f98392c9a062e9d4dd8363a2ede9a919ad14006 (diff)
downloadkrino-bcfeb773afcc28641ef2fb47749c91fd0767054a.tar.gz
krino-bcfeb773afcc28641ef2fb47749c91fd0767054a.zip
gui: the Plan tab - scan, review, apply, with the directory locked
-rw-r--r--gui/cmd/krino-gui/main.go33
-rw-r--r--gui/cmd/krino-gui/paths.go42
-rw-r--r--gui/go.mod8
-rw-r--r--gui/go.sum8
-rw-r--r--gui/internal/model/plan.go37
-rw-r--r--gui/internal/model/plan_test.go118
-rw-r--r--gui/internal/ui/idle.go11
-rw-r--r--gui/internal/ui/plan.go351
-rw-r--r--gui/internal/ui/window.go144
9 files changed, 735 insertions, 17 deletions
diff --git a/gui/cmd/krino-gui/main.go b/gui/cmd/krino-gui/main.go
index 4142e8c..9b46b22 100644
--- a/gui/cmd/krino-gui/main.go
+++ b/gui/cmd/krino-gui/main.go
@@ -6,29 +6,44 @@
package main
import (
+ "flag"
"fmt"
"os"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
+
+ "krino/gui/internal/ui"
+ "krino/internal/engine"
)
// version is stamped at build time, as krino's is.
var version = "dev"
func main() {
- if len(os.Args) > 1 && os.Args[1] == "--version" {
+ conf := flag.String("c", "", "use FILE instead of ~/.config/krino/krino.conf")
+ showVersion := flag.Bool("version", false, "print the version and exit")
+ flag.Parse()
+ if *showVersion {
fmt.Println("krino-gui " + version)
return
}
+
+ e, diags := engine.Load(mainFile(*conf))
+ if len(diags) > 0 {
+ printDiags(diags)
+ os.Exit(2)
+ }
+ e.CacheDir = cacheDir()
+ sess, err := e.NewSession(false)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "krino-gui: "+err.Error())
+ os.Exit(1)
+ }
+ defer sess.Close()
+
app := gtk.NewApplication("xyz.labunix.krino", 0)
- app.ConnectActivate(func() {
- win := gtk.NewApplicationWindow(app)
- win.SetTitle("krino")
- win.SetDefaultSize(900, 600)
- win.SetChild(gtk.NewLabel("krino-gui"))
- win.Show()
- })
- if code := app.Run(os.Args); code != 0 {
+ app.ConnectActivate(func() { ui.NewWindow(app, e, sess).Show() })
+ if code := app.Run(nil); code != 0 {
os.Exit(code)
}
}
diff --git a/gui/cmd/krino-gui/paths.go b/gui/cmd/krino-gui/paths.go
new file mode 100644
index 0000000..447fc15
--- /dev/null
+++ b/gui/cmd/krino-gui/paths.go
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "krino/internal/config"
+ "krino/internal/xdg"
+)
+
+// cacheDir is where every directory's keyword cache lives (spec §6.1), the
+// same place krino uses.
+func cacheDir() string {
+ return filepath.Join(xdg.CacheHome(), "krino")
+}
+
+// mainFile is -c FILE, or the default krino.conf - the same rule the
+// command line follows, tilde and all.
+func mainFile(conf string) string {
+ if conf != "" {
+ return xdg.Expand(conf)
+ }
+ return config.DefaultFile()
+}
+
+// printDiags reports configuration problems the way krino(1) does, so a
+// file that will not load reads the same however it was opened.
+func printDiags(errs []*config.Diag) {
+ for _, e := range errs {
+ d := *e
+ d.File = xdg.Abbrev(d.File)
+ fmt.Fprintln(os.Stderr, &d)
+ }
+ if len(errs) == 1 {
+ fmt.Fprintln(os.Stderr, "krino-gui: 1 problem found")
+ } else {
+ fmt.Fprintf(os.Stderr, "krino-gui: %d problems found\n", len(errs))
+ }
+}
diff --git a/gui/go.mod b/gui/go.mod
index 3e8439d..418422e 100644
--- a/gui/go.mod
+++ b/gui/go.mod
@@ -4,9 +4,13 @@ go 1.25.0
toolchain go1.26.8
-require github.com/diamondburned/gotk4/pkg v0.4.1
+require github.com/diamondburned/gotk4/pkg v0.3.1
-require golang.org/x/text v0.41.0 // indirect
+require (
+ github.com/KarpelesLab/weak v0.1.1 // indirect
+ go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
+ golang.org/x/text v0.41.0 // indirect
+)
require (
golang.org/x/sync v0.22.0 // indirect
diff --git a/gui/go.sum b/gui/go.sum
index 1b0f789..9652399 100644
--- a/gui/go.sum
+++ b/gui/go.sum
@@ -1,5 +1,9 @@
-github.com/diamondburned/gotk4/pkg v0.4.1 h1:w4BwVUUBkKyNLz5HKHN2Oz3kZCTLHfpfKgB/su1p6uo=
-github.com/diamondburned/gotk4/pkg v0.4.1/go.mod h1:BAEEDXtj5srQ1S2++5HX2K1s9c6CtuWSa60dAywrkRM=
+github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g=
+github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw=
+github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg=
+github.com/diamondburned/gotk4/pkg v0.3.1/go.mod h1:DqeOW+MxSZFg9OO+esk4JgQk0TiUJJUBfMltKhG+ub4=
+go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4=
+go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 607fa29..7496347 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -11,6 +11,7 @@ import (
"sort"
"krino/internal/engine"
+ "krino/internal/lock"
"krino/internal/plan"
)
@@ -40,6 +41,7 @@ type PlanTab struct {
sess *engine.Session
dp *engine.DirPlan
+ lock *lock.Lock
}
// Counts is the plan's summary line.
@@ -47,19 +49,42 @@ type Counts struct {
Scanned, Acting, Excluded, Skipped, Unmatched, Warned int
}
-// Plan locks dir and plans it, returning the tab to show. The lock is held
-// until Apply or Close: what the user sees stays the truth while they
-// choose (GUI design §3).
+// Plan locks d and plans it, returning the tab to show. The lock is held
+// until Close, so what the window shows stays the truth while the user
+// chooses, and no other krino moves the files under them (GUI design §3).
+// A directory another run is already working in is not planned at all: the
+// error is lock.ErrHeld, naming the directory.
func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, error) {
+ l, err := sess.Lock(ctx, d, false)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", d.Name, err)
+ }
+ // Start from what this run has actually landed: a name a plan the user
+ // closed had reserved is free again, so looking twice never creeps up
+ // through name-1, name-2 (spec §7.4).
+ sess.FinishDirectory()
dp, err := sess.Plan(ctx, d)
if err != nil {
+ l.Release()
return nil, err
}
- t := &PlanTab{Dir: d, sess: sess, dp: dp}
+ t := &PlanTab{Dir: d, sess: sess, dp: dp, lock: l}
t.fill()
return t, nil
}
+// Close releases the directory's lock. The session outlives the tab, so
+// closing one plan to open another keeps the run - and its claims - going.
+// Closing twice is not an error.
+func (t *PlanTab) Close() error {
+ if t.lock == nil {
+ return nil
+ }
+ l := t.lock
+ t.lock = nil
+ return l.Release()
+}
+
// fill turns the engine's plan into rows and counts.
func (t *PlanTab) fill() {
r := t.dp.Result
@@ -192,6 +217,10 @@ func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
}
res, err := t.sess.Apply(ctx, t.dp, approved)
t.Applied = true
+ // The disk is now the truth: a destination this plan reserved but never
+ // used is free again, while one it did use stays protected for the rest
+ // of the run (spec §7.4).
+ t.sess.FinishDirectory()
if res != nil {
t.record(res)
}
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index c157b1d..8b7e32f 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -4,13 +4,16 @@ package model
import (
"context"
+ "errors"
"os"
"path/filepath"
+ "sort"
"strings"
"testing"
"time"
"krino/internal/engine"
+ "krino/internal/lock"
"krino/internal/plan"
)
@@ -185,3 +188,118 @@ func TestPlanLeavesTheDirectoryAlone(t *testing.T) {
t.Errorf("planning created the destination: %v", err)
}
}
+
+// TestPlanHoldsTheLock: while a plan is open nothing else may work in that
+// directory - what the window shows stays the truth while the user chooses -
+// and closing the tab lets the next run in (GUI design §3).
+func TestPlanHoldsTheLock(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ tab, _ := planTab(t, e)
+ if _, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false); !errors.Is(err, lock.ErrHeld) {
+ t.Fatalf("an open plan does not hold the lock: %v", err)
+ }
+ if err := tab.Close(); err != nil {
+ t.Fatal(err)
+ }
+ l, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false)
+ if err != nil {
+ t.Fatalf("closing the tab did not release the lock: %v", err)
+ }
+ l.Release()
+ if err := tab.Close(); err != nil {
+ t.Errorf("closing twice: %v", err)
+ }
+}
+
+// TestPlanRefusesAHeldDirectory: a directory another krino is working in is
+// not planned at all, and the message names it.
+func TestPlanRefusesAHeldDirectory(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ held, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Release()
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if _, err := Plan(context.Background(), s, e.Dirs[0]); !errors.Is(err, lock.ErrHeld) {
+ t.Fatalf("Plan = %v, want lock.ErrHeld", err)
+ } else if !strings.Contains(err.Error(), "dl") {
+ t.Errorf("the message does not name the directory: %v", err)
+ }
+}
+
+// TestRescanForgetsUnusedNames: a destination the last plan claimed but
+// never used is free again on the next scan, so looking twice at the same
+// directory does not creep up through name-1, name-2 (spec §7.4).
+func TestRescanForgetsUnusedNames(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\") (rename \"same.pdf\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ tab, s := planTab(t, e)
+ // Each file moves into Out and is then renamed; the second file's name
+ // is taken, so the plan reserves a suffixed one for it.
+ want := destinations(t, tab)
+ if len(want) != 4 || filepath.Base(want[2]) != "same.pdf" || filepath.Base(want[3]) != "same_1.pdf" {
+ t.Fatalf("first plan = %v", want)
+ }
+ // Looking again without applying anything: the names the closed plan
+ // reserved are free, so the second look reads the same as the first.
+ if err := tab.Close(); err != nil {
+ t.Fatal(err)
+ }
+ again, err := Plan(context.Background(), s, e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := destinations(t, again); !equal(got, want) {
+ t.Errorf("second plan = %v, want %v", got, want)
+ }
+ // And again after an apply that applied nothing.
+ again.SelectNone()
+ if _, err := again.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if err := again.Close(); err != nil {
+ t.Fatal(err)
+ }
+ third, err := Plan(context.Background(), s, e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer third.Close()
+ if got := destinations(t, third); !equal(got, want) {
+ t.Errorf("third plan = %v, want %v", got, want)
+ }
+}
+
+// destinations is every step's destination in the tab, sorted.
+func destinations(t *testing.T, tab *PlanTab) []string {
+ t.Helper()
+ var out []string
+ for _, r := range tab.Rows {
+ for _, st := range r.Steps {
+ if st.Dst != "" {
+ out = append(out, st.Dst)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/gui/internal/ui/idle.go b/gui/internal/ui/idle.go
new file mode 100644
index 0000000..cc2a61b
--- /dev/null
+++ b/gui/internal/ui/idle.go
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import "github.com/diamondburned/gotk4/pkg/glib/v2"
+
+// idleAdd runs f on the GTK main loop. Every widget touch from a goroutine
+// goes through it.
+func idleAdd(f func()) {
+ glib.IdleAdd(f)
+}
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
new file mode 100644
index 0000000..0fa429a
--- /dev/null
+++ b/gui/internal/ui/plan.go
@@ -0,0 +1,351 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/diamondburned/gotk4/pkg/pango"
+
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+
+ "krino/gui/internal/model"
+ "krino/internal/engine"
+ "krino/internal/plan"
+ "krino/internal/xdg"
+)
+
+// planView is the Plan tab: a directory picker and Scan, the plan as a list
+// with a checkbox per file, the selected file's explanation beside it, and
+// Apply (GUI design §3).
+type planView struct {
+ w *Window
+ root *gtk.Box
+
+ dirs *gtk.DropDown
+ path *gtk.Label
+ scan *gtk.Button
+ apply *gtk.Button
+ cancel *gtk.Button
+ selAll *gtk.Button
+ selNone *gtk.Button
+
+ list *gtk.ListBox
+ details *gtk.TextView
+
+ tab *model.PlanTab
+ cancelOp context.CancelFunc
+}
+
+func newPlanView(w *Window) *planView {
+ p := &planView{w: w}
+ p.root = gtk.NewBox(gtk.OrientationVertical, 0)
+
+ // The picker holds names only: a long path in it would widen the
+ // window past the screen, so the path goes in a label that ellipsizes.
+ names := make([]string, len(w.engine.Dirs))
+ for i, d := range w.engine.Dirs {
+ names[i] = d.Name
+ }
+ if len(names) == 0 {
+ names = []string{"none"}
+ }
+ p.dirs = gtk.NewDropDownFromStrings(names)
+ p.path = gtk.NewLabel("")
+ p.path.SetXAlign(0)
+ p.path.SetHExpand(true)
+ p.path.SetEllipsize(pango.EllipsizeMiddle)
+ p.path.SetMaxWidthChars(20)
+ p.dirs.Connect("notify::selected", p.showPath)
+ p.scan = gtk.NewButtonWithLabel("Scan")
+ p.selAll = gtk.NewButtonWithLabel("Select all")
+ p.selNone = gtk.NewButtonWithLabel("None")
+ p.apply = gtk.NewButtonWithLabel("Apply")
+ p.apply.AddCSSClass("suggested-action")
+ p.cancel = gtk.NewButtonWithLabel("Cancel")
+ p.cancel.SetSensitive(false)
+
+ bar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ bar.SetMarginTop(6)
+ bar.SetMarginStart(6)
+ bar.SetMarginEnd(6)
+ bar.SetMarginBottom(6)
+ bar.Append(gtk.NewLabel("Directory"))
+ bar.Append(p.dirs)
+ bar.Append(p.scan)
+ bar.Append(p.path)
+ bar.Append(p.selAll)
+ bar.Append(p.selNone)
+ bar.Append(p.cancel)
+ bar.Append(p.apply)
+
+ p.list = gtk.NewListBox()
+ p.list.SetSelectionMode(gtk.SelectionSingle)
+ listScroll := gtk.NewScrolledWindow()
+ listScroll.SetChild(p.list)
+ listScroll.SetHExpand(true)
+ listScroll.SetVExpand(true)
+
+ p.details = gtk.NewTextView()
+ p.details.Buffer().SetText("Select a file to see what would happen to it, and why.")
+ p.details.SetEditable(false)
+ p.details.SetMonospace(true)
+ p.details.SetWrapMode(gtk.WrapWord)
+ p.details.SetLeftMargin(8)
+ p.details.SetRightMargin(8)
+ p.details.SetTopMargin(6)
+ p.details.SetBottomMargin(6)
+ detailScroll := gtk.NewScrolledWindow()
+ detailScroll.SetChild(p.details)
+ detailScroll.SetSizeRequest(320, -1)
+
+ // A pane the user can drag: on a narrow window the list needs the room,
+ // on a wide one the explanation does.
+ panes := gtk.NewPaned(gtk.OrientationHorizontal)
+ panes.SetStartChild(listScroll)
+ panes.SetEndChild(detailScroll)
+ panes.SetResizeStartChild(true)
+ panes.SetResizeEndChild(false)
+ panes.SetShrinkEndChild(false)
+ panes.SetPosition(640)
+ panes.SetVExpand(true)
+
+ p.root.Append(bar)
+ p.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ p.root.Append(panes)
+
+ p.scan.ConnectClicked(p.onScan)
+ p.apply.ConnectClicked(p.onApply)
+ p.cancel.ConnectClicked(func() {
+ if p.cancelOp != nil {
+ p.cancelOp()
+ }
+ })
+ p.selAll.ConnectClicked(func() { p.selectAll(true) })
+ p.selNone.ConnectClicked(func() { p.selectAll(false) })
+ p.list.ConnectRowSelected(func(row *gtk.ListBoxRow) {
+ if row != nil {
+ p.showDetails(row.Index())
+ }
+ })
+ p.showPath()
+ p.setBusy(false)
+ return p
+}
+
+// showPath writes the chosen directory's path beside the picker.
+func (p *planView) showPath() {
+ if d := p.currentDir(); d != nil {
+ p.path.SetText(escape(xdg.Abbrev(d.Root)))
+ return
+ }
+ p.path.SetText("no directory is included; add one with: krino new NAME PATH")
+}
+
+// currentDir is the directory the picker names.
+func (p *planView) currentDir() *engine.Dir {
+ i := int(p.dirs.Selected())
+ if i < 0 || i >= len(p.w.engine.Dirs) {
+ return nil
+ }
+ return p.w.engine.Dirs[i]
+}
+
+// onScan plans the chosen directory, off the main loop.
+func (p *planView) onScan() {
+ d := p.currentDir()
+ if d == nil {
+ p.w.setStatus("no directory is included; add one with: krino new NAME PATH")
+ return
+ }
+ // One plan at a time: the open one holds its directory's lock, and
+ // scanning again - the same directory or another - replaces it.
+ p.closeTab()
+ p.setBusy(true)
+ p.w.setStatus("scanning %s...", escape(d.Name))
+ var tab *model.PlanTab
+ p.cancelOp = runInBackground(func(ctx context.Context) error {
+ var err error
+ tab, err = model.Plan(ctx, p.w.sess, d)
+ return err
+ }, func(err error) {
+ p.setBusy(false)
+ if err != nil {
+ p.w.setStatus("%s: %v", escape(d.Name), err)
+ return
+ }
+ p.tab = tab
+ p.fillList()
+ c := tab.Counts
+ p.w.setStatus("%d scanned, %d to act on, %d excluded, %d skipped, %d with warnings",
+ c.Scanned, c.Acting, c.Excluded, c.Skipped, c.Warned)
+ })
+}
+
+// onApply acts on the checked files, off the main loop.
+func (p *planView) onApply() {
+ if p.tab == nil {
+ return
+ }
+ n := p.tab.SelectedCount()
+ p.setBusy(true)
+ p.w.setStatus("applying %d file(s)...", n)
+ var res *engine.ApplyResult
+ p.cancelOp = runInBackground(func(ctx context.Context) error {
+ var err error
+ res, err = p.tab.Apply(ctx)
+ return err
+ }, func(err error) {
+ p.setBusy(false)
+ p.apply.SetSensitive(false)
+ p.fillList()
+ if err != nil {
+ p.w.setStatus("apply: %v", err)
+ return
+ }
+ p.w.setStatus("%d applied, %d failed, %d declined", res.Applied, res.Failed, res.Declined)
+ })
+}
+
+// closeTab drops the open plan and releases the directory's lock.
+func (p *planView) closeTab() {
+ if p.tab == nil {
+ return
+ }
+ if err := p.tab.Close(); err != nil {
+ p.w.setStatus("%s: %v", escape(p.tab.Dir.Name), err)
+ }
+ p.tab = nil
+ p.fillList()
+}
+
+// setBusy turns the buttons on or off around a background operation.
+func (p *planView) setBusy(busy bool) {
+ p.scan.SetSensitive(!busy)
+ p.dirs.SetSensitive(!busy)
+ p.selAll.SetSensitive(!busy)
+ p.selNone.SetSensitive(!busy)
+ p.apply.SetSensitive(!busy && p.tab != nil && !p.tab.Applied)
+ p.cancel.SetSensitive(busy)
+}
+
+// selectAll checks or unchecks every file that can be applied.
+func (p *planView) selectAll(on bool) {
+ if p.tab == nil {
+ return
+ }
+ if on {
+ p.tab.SelectAll()
+ } else {
+ p.tab.SelectNone()
+ }
+ p.fillList()
+}
+
+// fillList renders the rows: a checkbox, the file, what would happen, the
+// rule, and the outcome once applied.
+func (p *planView) fillList() {
+ for {
+ row := p.list.RowAtIndex(0)
+ if row == nil {
+ break
+ }
+ p.list.Remove(row)
+ }
+ if p.tab == nil {
+ return
+ }
+ for i, r := range p.tab.Rows {
+ p.list.Append(p.rowWidget(i, r))
+ }
+ p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount()))
+ p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0)
+}
+
+// rowWidget is one line of the list.
+func (p *planView) rowWidget(i int, r model.Row) *gtk.ListBoxRow {
+ box := gtk.NewBox(gtk.OrientationHorizontal, 8)
+ box.SetMarginStart(6)
+ box.SetMarginEnd(6)
+ box.SetMarginTop(2)
+ box.SetMarginBottom(2)
+
+ check := gtk.NewCheckButton()
+ check.SetActive(r.Selected)
+ check.SetSensitive(r.Actable && (p.tab == nil || !p.tab.Applied))
+ check.ConnectToggled(func() {
+ if p.tab != nil && p.tab.Rows[i].Selected != check.Active() {
+ p.tab.Toggle(i)
+ p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount()))
+ p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0)
+ }
+ })
+ box.Append(check)
+
+ box.Append(column(escape(r.Rel), 24, true))
+ box.Append(column(escape(rowLabel(r, p.dirRoot())), 30, false))
+ box.Append(column(escape(r.Rule), 12, false))
+ if r.Outcome != "" {
+ box.Append(column(escape(r.Outcome), 12, false))
+ }
+ row := gtk.NewListBoxRow()
+ row.SetChild(box)
+ return row
+}
+
+// dirRoot is the directory the list belongs to, for shortening destinations.
+func (p *planView) dirRoot() string {
+ if p.tab != nil {
+ return p.tab.Dir.Root
+ }
+ return ""
+}
+
+// column is one cell of a row: left-aligned, and ellipsized to chars so
+// that a long name or warning cannot widen the window past the screen. The
+// whole text is in the details pane beside the list.
+func column(text string, chars int, expand bool) *gtk.Label {
+ l := gtk.NewLabel(text)
+ l.SetXAlign(0)
+ l.SetEllipsize(pango.EllipsizeEnd)
+ l.SetWidthChars(chars)
+ l.SetMaxWidthChars(chars)
+ l.SetHExpand(expand)
+ l.SetTooltipText(text)
+ return l
+}
+
+// showDetails writes the selected file's steps and warnings into the pane.
+func (p *planView) showDetails(i int) {
+ if p.tab == nil || i < 0 || i >= len(p.tab.Rows) {
+ return
+ }
+ r := p.tab.Rows[i]
+ var b strings.Builder
+ b.WriteString(escape(r.Rel) + "\n\n")
+ for _, s := range r.Steps {
+ switch {
+ case s.Skip != "":
+ fmt.Fprintf(&b, "%s skipped: %s\n", s.Kind, escape(s.Skip))
+ case s.Kind == plan.Trash || s.Kind == plan.DeletePermanent:
+ fmt.Fprintf(&b, "%s\n", s.Kind)
+ default:
+ fmt.Fprintf(&b, "%s -> %s\n", s.Kind, escape(shorten(s.Dst, p.dirRoot())))
+ }
+ if s.Rule != "" {
+ fmt.Fprintf(&b, " rule %s\n", escape(s.Rule))
+ }
+ if s.Reason != "" {
+ fmt.Fprintf(&b, " because %s\n", escape(s.Reason))
+ }
+ }
+ for _, warn := range r.Warnings {
+ b.WriteString("\n" + escape(warn) + "\n")
+ }
+ if r.Outcome != "" {
+ b.WriteString("\n" + escape(r.Outcome) + "\n")
+ }
+ p.details.Buffer().SetText(b.String())
+}
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)
+}