diff options
Diffstat (limited to 'gui/internal/ui')
| -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 |
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) +} |
