aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui')
-rw-r--r--gui/internal/ui/idle.go11
-rw-r--r--gui/internal/ui/plan.go351
-rw-r--r--gui/internal/ui/window.go144
3 files changed, 506 insertions, 0 deletions
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)
+}