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 | |
| parent | 4f98392c9a062e9d4dd8363a2ede9a919ad14006 (diff) | |
| download | krino-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.go | 33 | ||||
| -rw-r--r-- | gui/cmd/krino-gui/paths.go | 42 | ||||
| -rw-r--r-- | gui/go.mod | 8 | ||||
| -rw-r--r-- | gui/go.sum | 8 | ||||
| -rw-r--r-- | gui/internal/model/plan.go | 37 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 118 | ||||
| -rw-r--r-- | gui/internal/ui/idle.go | 11 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 351 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 144 |
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)) + } +} @@ -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 @@ -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) +} |
