// 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 checkNow *gtk.Button view *gtk.TextView nums *gtk.TextView buf *gtk.TextBuffer check *gtk.Label diags *gtk.ListBox testPath *gtk.Entry testGo *gtk.Button out *gtk.TextView sub *gtk.Notebook forms *formsView rules *model.Rules pending glib.SourceHandle lines int quiet bool // set while the view is being filled, so no check is armed inSwap bool // set while a sub-tab switch is being handled } 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") r.checkNow = gtk.NewButtonWithLabel("Check") 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.checkNow) 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() // Line numbers: their own view beside the editor, in the same scrolled // window so the two always line up. The editor does not wrap, so one // line of text is one line on screen (his request, 2026-09-16). r.nums = gtk.NewTextView() r.nums.SetMonospace(true) r.nums.SetEditable(false) r.nums.SetCursorVisible(false) r.nums.SetJustification(gtk.JustifyRight) r.nums.SetLeftMargin(6) r.nums.SetRightMargin(6) r.nums.SetTopMargin(6) r.nums.AddCSSClass("dim-label") r.nums.SetCanFocus(false) r.nums.SetVAlign(gtk.AlignStart) r.nums.SetSizeRequest(46, -1) r.view.SetVAlign(gtk.AlignStart) editRow := gtk.NewBox(gtk.OrientationHorizontal, 0) editRow.Append(r.nums) editRow.Append(gtk.NewSeparator(gtk.OrientationVertical)) editRow.Append(r.view) r.view.SetHExpand(true) editScroll := gtk.NewScrolledWindow() editScroll.SetChild(editRow) 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) // 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) // Two ways to edit the same file: forms, and the text itself. r.sub = gtk.NewNotebook() r.forms = newFormsView(w, r) r.sub.AppendPage(r.forms.root, gtk.NewLabel("Forms")) r.sub.AppendPage(left, gtk.NewLabel("Text")) panes := gtk.NewPaned(gtk.OrientationHorizontal) panes.SetStartChild(r.sub) panes.SetEndChild(right) panes.SetResizeStartChild(true) panes.SetResizeEndChild(false) panes.SetShrinkEndChild(false) panes.SetPosition(820) panes.SetVExpand(true) r.root.Append(bar) r.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) r.root.Append(panes) // The problems sit under both sub-tabs: a form's mistake is reported // where the form is, not only in the text (his report, 2026-09-16). r.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) r.root.Append(diagScroll) // Switching to Forms re-reads the text; text that does not parse keeps // the Text tab until it is fixed (GUI design §5.2). r.sub.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) { if page != 0 || r.inSwap || r.rules == nil { return } if err := r.forms.reload(); err != nil { r.setCheck("the text does not parse yet: " + err.Error()) r.showText() } }) 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.checkNow.ConnectClicked(func() { r.runCheck() if len(r.rules.Diags) == 0 { r.w.setStatus("check: no errors in %s", escape(xdg.Abbrev(r.rules.File))) } else { r.w.setStatus("check: %d problem(s); the list is under the editor", len(r.rules.Diags)) } }) r.testGo.ConnectClicked(r.onTest) r.testPath.ConnectActivate(r.onTest) r.open(r.selectedName()) return r } // formsOf is the forms of the text in the editor, for the Forms sub-tab. func (r *rulesView) formsOf() ([]model.Form, error) { if r.rules == nil { return nil, fmt.Errorf("no file is open") } r.rules.SetText(r.text()) return r.rules.Forms() } // setCheck writes one line in the bar where the check result goes. func (r *rulesView) setCheck(text string) { r.check.SetText(escape(text)) } // showTestOutput writes in the pane on the right, where both Test on file // and Test rule report. func (r *rulesView) showTestOutput(text string) { r.out.Buffer().SetText(escapeText(text)) } // showText brings the Text sub-tab forward. func (r *rulesView) showText() { r.inSwap = true r.sub.SetCurrentPage(1) r.inSwap = false } // textChangedByForm puts the model's text - just rewritten by a form edit - // in the view, and checks it. func (r *rulesView) textChangedByForm() { r.quiet = true r.buf.SetText(r.rules.Text) r.quiet = false r.runCheck() } // 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) defer func() { if err := r.forms.reload(); err != nil { r.forms.clearEditor("the text does not parse yet: " + err.Error()) } }() 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)) } } canSave := len(diags) == 0 && r.rules.Modified() r.save.SetSensitive(canSave) r.revert.SetSensitive(r.rules.Modified()) switch { case canSave: r.save.SetTooltipText("write " + xdg.Abbrev(r.rules.File) + ", keeping the previous text as " + r.rules.Name + ".conf.bak") case len(diags) > 0: r.save.SetTooltipText("Save waits until the problems below are fixed") r.check.SetText(r.check.Text() + " - Save waits until they are fixed") default: r.save.SetTooltipText("nothing to save: the file matches what is on disk") } r.updateLineNumbers() } // updateLineNumbers refills the gutter when the number of lines changes. func (r *rulesView) updateLineNumbers() { n := r.buf.LineCount() if n == r.lines { return } r.lines = n var b strings.Builder for i := 1; i <= n; i++ { fmt.Fprintf(&b, "%3d\n", i) } r.nums.Buffer().SetText(strings.TrimSuffix(b.String(), "\n")) } // 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 } r.showText() 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)) }) }