diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 14:13:26 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 14:13:26 +0200 |
| commit | 85d65ebe0adf1b156324a3a4c220e415a79ba9ce (patch) | |
| tree | 4e648edce13cbc370e2685475944cafc2236f44a /gui/internal/ui | |
| parent | 6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 (diff) | |
| download | krino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.tar.gz krino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.zip | |
gui: Rules tab - the file as text, checked as you type, tested and saved
Diffstat (limited to 'gui/internal/ui')
| -rw-r--r-- | gui/internal/ui/rules.go | 388 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 31 |
2 files changed, 418 insertions, 1 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)) + }) +} diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go index c42a7e8..2453444 100644 --- a/gui/internal/ui/window.go +++ b/gui/internal/ui/window.go @@ -27,6 +27,7 @@ type Window struct { plan *planView history *historyView + rules *rulesView status *gtk.Label } @@ -44,7 +45,8 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan")) w.history = newHistoryView(w) notebook.AppendPage(w.history.root, gtk.NewLabel("History & undo")) - notebook.AppendPage(placeholder("The rules editor arrives with a later milestone."), gtk.NewLabel("Rules")) + w.rules = newRulesView(w) + notebook.AppendPage(w.rules.root, gtk.NewLabel("Rules")) // The log is read when the tab is first opened, not at start-up: a // window that only sorts never reads it. loaded := false @@ -81,6 +83,21 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { // Show puts the window on screen. func (w *Window) Show() { w.win.Show() } +// reloadEngine re-reads the configuration, after the rules editor saves, so +// every tab works from the rules the user just wrote. An open plan came +// from the old ones, so it is closed and its lock released. +func (w *Window) reloadEngine() error { + e, diags := engine.Load(w.engine.MainFile) + if len(diags) > 0 { + return diags[0] + } + e.CacheDir = w.engine.CacheDir + w.plan.closeTab() + w.history.closeTab() + w.engine = e + return nil +} + // dirRoot is the path of the configured directory called name, or "" if // the configuration no longer has one - a log entry can outlive its // directory. @@ -126,6 +143,18 @@ func escape(s string) string { return b.String() } +// escapeText is escape for text shown in a pane rather than on one line: +// line breaks are the layout, so they are kept, and every other control or +// bidirectional character is still written as an escape. +func escapeText(s string) string { + var b strings.Builder + for _, line := range strings.Split(s, "\n") { + b.WriteString(escape(line)) + b.WriteString("\n") + } + return strings.TrimSuffix(b.String(), "\n") +} + // 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 { |
