aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui/rules.go
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui/rules.go')
-rw-r--r--gui/internal/ui/rules.go388
1 files changed, 388 insertions, 0 deletions
diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go
new file mode 100644
index 0000000..741f6b1
--- /dev/null
+++ b/gui/internal/ui/rules.go
@@ -0,0 +1,388 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/diamondburned/gotk4/pkg/pango"
+
+ "krino/gui/internal/model"
+ "krino/internal/xdg"
+)
+
+// checkDelay is how long the editor waits after a keystroke before it
+// checks the text (GUI design §5.3).
+const checkDelay = 300
+
+// rulesView is the Rules tab: one directory's file as text, checked as it
+// is typed, with Test on file and Save (GUI design §5.2, §5.3).
+type rulesView struct {
+ w *Window
+ root *gtk.Box
+
+ dirs *gtk.DropDown
+ names []string
+ save *gtk.Button
+ revert *gtk.Button
+ reload *gtk.Button
+
+ view *gtk.TextView
+ buf *gtk.TextBuffer
+ check *gtk.Label
+ diags *gtk.ListBox
+
+ testPath *gtk.Entry
+ testGo *gtk.Button
+ out *gtk.TextView
+
+ rules *model.Rules
+ pending glib.SourceHandle
+ quiet bool // set while the view is being filled, so no check is armed
+}
+
+func newRulesView(w *Window) *rulesView {
+ r := &rulesView{w: w}
+ r.root = gtk.NewBox(gtk.OrientationVertical, 0)
+
+ r.names = model.RuleFiles(w.engine)
+ names := r.names
+ if len(names) == 0 {
+ names = []string{"none"}
+ }
+ r.dirs = gtk.NewDropDownFromStrings(names)
+ r.save = gtk.NewButtonWithLabel("Save")
+ r.save.AddCSSClass("suggested-action")
+ r.revert = gtk.NewButtonWithLabel("Revert")
+ r.reload = gtk.NewButtonWithLabel("Reload")
+
+ bar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ bar.SetMarginTop(6)
+ bar.SetMarginStart(6)
+ bar.SetMarginEnd(6)
+ bar.SetMarginBottom(6)
+ bar.Append(gtk.NewLabel("Rules for"))
+ bar.Append(r.dirs)
+ r.check = gtk.NewLabel("")
+ r.check.SetXAlign(0)
+ r.check.SetHExpand(true)
+ r.check.SetEllipsize(pango.EllipsizeEnd)
+ r.check.SetMaxWidthChars(20)
+ bar.Append(r.check)
+ bar.Append(r.reload)
+ bar.Append(r.revert)
+ bar.Append(r.save)
+
+ r.view = gtk.NewTextView()
+ r.view.SetMonospace(true)
+ r.view.SetLeftMargin(8)
+ r.view.SetRightMargin(8)
+ r.view.SetTopMargin(6)
+ r.buf = r.view.Buffer()
+ editScroll := gtk.NewScrolledWindow()
+ editScroll.SetChild(r.view)
+ editScroll.SetVExpand(true)
+ editScroll.SetHExpand(true)
+
+ r.diags = gtk.NewListBox()
+ r.diags.SetSelectionMode(gtk.SelectionSingle)
+ diagScroll := gtk.NewScrolledWindow()
+ diagScroll.SetChild(r.diags)
+ diagScroll.SetSizeRequest(-1, 110)
+
+ left := gtk.NewBox(gtk.OrientationVertical, 0)
+ left.Append(editScroll)
+ left.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ left.Append(diagScroll)
+
+ // Test on file: any file under the directory, answered from the text in
+ // the editor rather than from what is saved.
+ r.testPath = gtk.NewEntry()
+ r.testPath.SetPlaceholderText("a file to test, e.g. ~/downloads/a.pdf")
+ r.testPath.SetHExpand(true)
+ r.testGo = gtk.NewButtonWithLabel("Test on file")
+ testBar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ testBar.SetMarginStart(6)
+ testBar.SetMarginEnd(6)
+ testBar.SetMarginTop(6)
+ testBar.SetMarginBottom(6)
+ testBar.Append(r.testPath)
+ testBar.Append(r.testGo)
+
+ r.out = gtk.NewTextView()
+ r.out.SetEditable(false)
+ r.out.SetMonospace(true)
+ r.out.SetWrapMode(gtk.WrapWordChar)
+ r.out.SetLeftMargin(8)
+ r.out.SetRightMargin(8)
+ r.out.SetTopMargin(6)
+ r.out.Buffer().SetText("Type a file's path and press Test on file to see what these rules would do to it.")
+ outScroll := gtk.NewScrolledWindow()
+ outScroll.SetChild(r.out)
+ outScroll.SetVExpand(true)
+ right := gtk.NewBox(gtk.OrientationVertical, 0)
+ right.Append(testBar)
+ right.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ right.Append(outScroll)
+
+ panes := gtk.NewPaned(gtk.OrientationHorizontal)
+ panes.SetStartChild(left)
+ panes.SetEndChild(right)
+ panes.SetResizeStartChild(true)
+ panes.SetResizeEndChild(false)
+ panes.SetShrinkEndChild(false)
+ panes.SetPosition(620)
+ panes.SetVExpand(true)
+
+ r.root.Append(bar)
+ r.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ r.root.Append(panes)
+
+ r.buf.ConnectChanged(r.onChanged)
+ r.diags.ConnectRowSelected(func(row *gtk.ListBoxRow) {
+ if row != nil {
+ r.goToLine(row.Name())
+ }
+ })
+ r.dirs.Connect("notify::selected", func() { r.open(r.selectedName()) })
+ r.save.ConnectClicked(r.onSave)
+ r.revert.ConnectClicked(func() {
+ if r.rules != nil {
+ r.rules.Revert()
+ r.fill()
+ }
+ })
+ r.reload.ConnectClicked(func() { r.reloadFromDisk() })
+ r.testGo.ConnectClicked(r.onTest)
+ r.testPath.ConnectActivate(r.onTest)
+
+ r.open(r.selectedName())
+ return r
+}
+
+// selectedName is the directory the picker names, "" when none is offered.
+func (r *rulesView) selectedName() string {
+ i := int(r.dirs.Selected())
+ if i < 0 || i >= len(r.names) {
+ return ""
+ }
+ return r.names[i]
+}
+
+// open reads a directory's file into the editor.
+func (r *rulesView) open(name string) {
+ if name == "" {
+ r.check.SetText("no directory has rules of its own yet; add one with: krino new NAME PATH")
+ r.setEditable(false)
+ return
+ }
+ rules, err := model.OpenRules(r.w.engine, name)
+ if err != nil {
+ r.check.SetText(escape(err.Error()))
+ r.setEditable(false)
+ return
+ }
+ r.rules = rules
+ r.setEditable(true)
+ if root := r.w.dirRoot(name); root != "" {
+ r.testPath.SetText(xdg.Abbrev(root) + "/")
+ }
+ r.fill()
+}
+
+// fill puts the model's text in the view and checks it.
+func (r *rulesView) fill() {
+ r.quiet = true
+ r.buf.SetText(r.rules.Text)
+ r.quiet = false
+ r.runCheck()
+}
+
+// setEditable turns the editor on or off as a whole.
+func (r *rulesView) setEditable(on bool) {
+ r.view.SetEditable(on)
+ r.save.SetSensitive(false)
+ r.revert.SetSensitive(on)
+ r.reload.SetSensitive(on)
+ r.testGo.SetSensitive(on)
+}
+
+// onChanged arms the check, one timer at a time: checking on every
+// keystroke would load the whole configuration as the user types.
+func (r *rulesView) onChanged() {
+ if r.quiet || r.rules == nil {
+ return
+ }
+ if r.pending != 0 {
+ glib.SourceRemove(r.pending)
+ }
+ r.pending = glib.TimeoutAdd(checkDelay, func() bool {
+ r.pending = 0
+ r.runCheck()
+ return false
+ })
+}
+
+// text is what the editor holds.
+func (r *rulesView) text() string {
+ start, end := r.buf.Bounds()
+ return r.buf.Text(start, end, true)
+}
+
+// runCheck loads the configuration with the unsaved text and shows what is
+// wrong with it.
+func (r *rulesView) runCheck() {
+ if r.rules == nil {
+ return
+ }
+ r.rules.SetText(r.text())
+ diags := r.rules.Check()
+ clearList(r.diags)
+ switch {
+ case len(diags) == 0:
+ r.check.SetText("check: no errors")
+ case len(diags) == 1:
+ r.check.SetText("check: 1 problem")
+ default:
+ r.check.SetText(fmt.Sprintf("check: %d problems", len(diags)))
+ }
+ for _, d := range diags {
+ where := xdg.Abbrev(d.File)
+ if d.Pos.Line > 0 {
+ where = fmt.Sprintf("%s:%d:%d", where, d.Pos.Line, d.Pos.Col)
+ }
+ line := d.Pos.Line
+ row := gtk.NewListBoxRow()
+ label := gtk.NewLabel(escape(where + ": " + d.Msg))
+ label.SetXAlign(0)
+ label.SetMarginStart(6)
+ label.SetMarginEnd(6)
+ label.SetWrap(true)
+ label.AddCSSClass("error")
+ row.SetChild(label)
+ r.diags.Append(row)
+ if d.File == r.rules.File && line > 0 {
+ row.SetName(fmt.Sprintf("%d", line))
+ }
+ }
+ r.save.SetSensitive(len(diags) == 0 && r.rules.Modified())
+ r.revert.SetSensitive(r.rules.Modified())
+}
+
+// goToLine puts the cursor on the line a diagnostic names, so clicking one
+// shows what it is about. A diagnostic in another file carries no line.
+func (r *rulesView) goToLine(name string) {
+ line, err := strconv.Atoi(name)
+ if err != nil || line <= 0 {
+ return
+ }
+ iter, ok := r.buf.IterAtLine(line - 1)
+ if !ok {
+ return
+ }
+ r.buf.PlaceCursor(iter)
+ r.view.ScrollToIter(iter, 0, true, 0, 0.3)
+ r.view.GrabFocus()
+}
+
+// onSave writes the file, or asks what to do when someone else has.
+func (r *rulesView) onSave() {
+ if r.rules == nil {
+ return
+ }
+ err := r.rules.Save()
+ switch {
+ case err == nil:
+ r.w.setStatus("saved %s; the previous text is beside it as %s.bak",
+ escape(xdg.Abbrev(r.rules.File)), escape(r.rules.Name+".conf"))
+ r.reloadEngine()
+ r.runCheck()
+ case errors.Is(err, model.ErrChangedOnDisk):
+ r.askAboutDiskChange()
+ default:
+ r.w.setStatus("save: %v", err)
+ }
+}
+
+// askAboutDiskChange offers Reload, Overwrite or Cancel when the file has
+// changed since it was opened (GUI design §5.3).
+func (r *rulesView) askAboutDiskChange() {
+ d := gtk.NewMessageDialog(&r.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Something else changed "+escape(xdg.Abbrev(r.rules.File)))
+ d.SetObjectProperty("secondary-text",
+ "Reload takes what is on disk and drops your changes. Overwrite keeps yours and puts the other text in the .bak file.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ d.AddButton("Reload", int(gtk.ResponseReject))
+ d.AddButton("Overwrite", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ switch response {
+ case int(gtk.ResponseReject):
+ r.reloadFromDisk()
+ case int(gtk.ResponseAccept):
+ if err := r.rules.SaveOverwriting(); err != nil {
+ r.w.setStatus("save: %v", err)
+ return
+ }
+ r.w.setStatus("saved over the other change; it is in the .bak file")
+ r.reloadEngine()
+ r.runCheck()
+ }
+ })
+ d.Show()
+}
+
+// reloadFromDisk drops unsaved text and re-reads the file.
+func (r *rulesView) reloadFromDisk() {
+ if r.rules == nil {
+ return
+ }
+ if err := r.rules.Reload(); err != nil {
+ r.w.setStatus("reload: %v", err)
+ return
+ }
+ r.fill()
+}
+
+// reloadEngine makes the saved rules the ones the other tabs use.
+func (r *rulesView) reloadEngine() {
+ if err := r.w.reloadEngine(); err != nil {
+ r.w.setStatus("saved, but the new rules do not load: %v", err)
+ }
+}
+
+// onTest explains one file against the unsaved text, off the main loop:
+// content extraction can take seconds.
+func (r *rulesView) onTest() {
+ if r.rules == nil {
+ return
+ }
+ path := strings.TrimSpace(r.testPath.Text())
+ if path == "" {
+ r.out.Buffer().SetText("Type the path of a file under the directory first.")
+ return
+ }
+ r.rules.SetText(r.text())
+ r.testGo.SetSensitive(false)
+ r.out.Buffer().SetText("testing " + escape(path) + "...")
+ var text string
+ runInBackground(func(ctx context.Context) error {
+ var err error
+ text, err = r.rules.Explain(ctx, path)
+ return err
+ }, func(err error) {
+ r.testGo.SetSensitive(true)
+ if err != nil {
+ r.out.Buffer().SetText(escapeText(err.Error()))
+ return
+ }
+ r.out.Buffer().SetText(escapeText(text))
+ })
+}