From 85d65ebe0adf1b156324a3a4c220e415a79ba9ce Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 16 Sep 2026 14:13:26 +0200 Subject: gui: Rules tab - the file as text, checked as you type, tested and saved --- gui/internal/ui/rules.go | 388 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 gui/internal/ui/rules.go (limited to 'gui/internal/ui/rules.go') 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)) + }) +} -- cgit v1.3