diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:23:51 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 00:23:51 +0200 |
| commit | 04b4243ad31d144c4caf9be4c5096a0f27a2648e (patch) | |
| tree | 056515d145ba8e4fba29a2150b2c58199133b0c1 /gui/internal/ui | |
| parent | 9db67b201b80e9b7f824989df8517cefc587036d (diff) | |
| download | krino-04b4243ad31d144c4caf9be4c5096a0f27a2648e.tar.gz krino-04b4243ad31d144c4caf9be4c5096a0f27a2648e.zip | |
gui: syntax colours, a file preview, and a settings window
Diffstat (limited to 'gui/internal/ui')
| -rw-r--r-- | gui/internal/ui/forms.go | 4 | ||||
| -rw-r--r-- | gui/internal/ui/highlight.go | 77 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 128 | ||||
| -rw-r--r-- | gui/internal/ui/rules.go | 20 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 191 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 24 |
6 files changed, 436 insertions, 8 deletions
diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go index 55f6328..6fcf973 100644 --- a/gui/internal/ui/forms.go +++ b/gui/internal/ui/forms.go @@ -333,8 +333,8 @@ func newSettingRow(head, args string, changed func()) *settingRow { r.entry = gtk.NewEntry() r.entry.SetText(args) r.entry.SetHExpand(true) - r.entry.SetPlaceholderText(settingHints[head]) - r.entry.SetTooltipText(settingHints[head]) + r.entry.SetPlaceholderText(mainHint(head)) + r.entry.SetTooltipText(mainHint(head)) r.entry.ConnectChanged(func() { changed() }) r.root.Append(r.entry) return r diff --git a/gui/internal/ui/highlight.go b/gui/internal/ui/highlight.go new file mode 100644 index 0000000..8a4acd4 --- /dev/null +++ b/gui/internal/ui/highlight.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ui + +import ( + "github.com/diamondburned/gotk4/pkg/gtk/v4" + + "krino/gui/internal/model" +) + +// The colours a configuration is painted in. They are chosen to read on a +// light and a dark theme alike, since the window follows whatever GTK theme +// is in use (his request, 2026-09-16). +var spanColours = map[model.SpanKind]string{ + model.SpanComment: "#8b8b8b", + model.SpanString: "#2e8b57", + model.SpanHead: "#3584e4", + model.SpanAction: "#c06014", + model.SpanParen: "#9a9a9a", +} + +// highlighter paints a configuration in a TextView. +type highlighter struct { + buf *gtk.TextBuffer + tags map[model.SpanKind]*gtk.TextTag + on bool +} + +// newHighlighter registers one tag per kind, once per buffer. +func newHighlighter(buf *gtk.TextBuffer) *highlighter { + h := &highlighter{buf: buf, tags: map[model.SpanKind]*gtk.TextTag{}, on: true} + table := buf.TagTable() + names := map[model.SpanKind]string{ + model.SpanComment: "krino-comment", + model.SpanString: "krino-string", + model.SpanHead: "krino-head", + model.SpanAction: "krino-action", + model.SpanParen: "krino-paren", + } + for kind, name := range names { + t := gtk.NewTextTag(name) + t.SetObjectProperty("foreground", spanColours[kind]) + if kind == model.SpanComment { + t.SetObjectProperty("style", 2) // PANGO_STYLE_ITALIC + } + table.Add(t) + h.tags[kind] = t + } + return h +} + +// setEnabled turns the colours on or off. +func (h *highlighter) setEnabled(on bool) { + h.on = on + if !on { + h.clear() + } +} + +// clear takes every colour off the buffer. +func (h *highlighter) clear() { + start, end := h.buf.Bounds() + h.buf.RemoveAllTags(start, end) +} + +// paint colours the text now in the buffer. +func (h *highlighter) paint(text string) { + h.clear() + if !h.on { + return + } + for _, s := range model.Spans(text) { + if tag, ok := h.tags[s.Kind]; ok { + h.buf.ApplyTag(tag, h.buf.IterAtOffset(s.From), h.buf.IterAtOffset(s.To)) + } + } +} diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index f6f2952..97d8599 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -5,6 +5,8 @@ package ui import ( "context" "fmt" + "os" + "path/filepath" "strings" "github.com/diamondburned/gotk4/pkg/gdk/v4" @@ -35,8 +37,18 @@ type planView struct { list *gtk.ListBox details *gtk.TextView - menu *gtk.Popover - menuRow int + + previewNote *gtk.Label + picture *gtk.Picture + pictureFrame *gtk.ScrolledWindow + previewText *gtk.TextView + previewScroll *gtk.ScrolledWindow + previewTmp string + previewFor string + previewOff bool + startSelected bool + menu *gtk.Popover + menuRow int tab *model.PlanTab cancelOp context.CancelFunc @@ -102,13 +114,53 @@ func newPlanView(w *Window) *planView { p.details.SetBottomMargin(6) detailScroll := gtk.NewScrolledWindow() detailScroll.SetChild(p.details) - detailScroll.SetSizeRequest(320, -1) + detailScroll.SetVExpand(true) + detailScroll.SetSizeRequest(-1, 160) + + // Under the explanation, a look at the file itself: a picture for an + // image, the first page for a PDF, the first lines for anything that is + // text (his request, 2026-09-16). + p.previewNote = gtk.NewLabel("") + p.previewNote.SetXAlign(0) + p.previewNote.SetMarginStart(8) + p.previewNote.SetMarginEnd(8) + p.previewNote.SetEllipsize(pango.EllipsizeEnd) + p.previewNote.SetMaxWidthChars(20) + p.previewNote.AddCSSClass("dim-label") + p.picture = gtk.NewPicture() + p.picture.SetCanShrink(true) + p.picture.SetContentFit(gtk.ContentFitContain) + p.picture.SetVisible(false) + pictureFrame := gtk.NewScrolledWindow() + pictureFrame.SetChild(p.picture) + pictureFrame.SetSizeRequest(-1, 280) + pictureFrame.SetVisible(false) + p.pictureFrame = pictureFrame + p.previewText = gtk.NewTextView() + p.previewText.SetEditable(false) + p.previewText.SetMonospace(true) + p.previewText.SetLeftMargin(8) + p.previewText.SetRightMargin(8) + p.previewText.SetTopMargin(6) + previewScroll := gtk.NewScrolledWindow() + previewScroll.SetChild(p.previewText) + previewScroll.SetSizeRequest(-1, 260) + previewScroll.SetVisible(false) + p.previewScroll = previewScroll + + detailBox := gtk.NewBox(gtk.OrientationVertical, 0) + detailBox.Append(detailScroll) + detailBox.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) + detailBox.Append(p.previewNote) + detailBox.Append(pictureFrame) + detailBox.Append(previewScroll) + detailBox.SetSizeRequest(340, -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.SetEndChild(detailBox) panes.SetResizeStartChild(true) panes.SetResizeEndChild(false) panes.SetShrinkEndChild(false) @@ -272,6 +324,9 @@ func (p *planView) onScan() { return } p.tab = tab + if !p.startSelected { + tab.SelectNone() + } p.fillList() c := tab.Counts p.w.setStatus("%d scanned, %d to act on, %d excluded, %d skipped, %d with warnings", @@ -479,4 +534,69 @@ func (p *planView) showDetails(i int) { b.WriteString("\n" + escape(r.Outcome) + "\n") } p.details.Buffer().SetText(b.String()) + p.showPreview(r.Rel) +} + +// showPreview looks at the file behind row rel, off the main loop: reading +// it, and rendering a PDF page, takes long enough to stutter the window. +func (p *planView) showPreview(rel string) { + if p.tab == nil || p.previewOff { + return + } + path := filepath.Join(p.tab.Dir.Root, filepath.FromSlash(rel)) + if p.previewFor == path { + return + } + p.previewFor = path + p.pictureFrame.SetVisible(false) + p.previewScroll.SetVisible(false) + p.previewNote.SetText("looking at " + escape(rel) + "...") + if p.previewTmp == "" { + dir, err := os.MkdirTemp("", "krino-preview-") + if err != nil { + p.previewNote.SetText(escape(err.Error())) + return + } + p.previewTmp = dir + } + var pv model.Preview + runInBackground(func(ctx context.Context) error { + pv = model.MakePreview(ctx, path, p.previewTmp) + return nil + }, func(error) { + if p.previewFor != path { + return // another row was chosen while this one was read + } + p.previewNote.SetText(escape(pv.Note)) + switch pv.Kind { + case model.PreviewImage: + p.picture.SetFilename(pv.Image) + p.picture.SetVisible(true) + p.pictureFrame.SetVisible(true) + case model.PreviewText: + p.previewText.Buffer().SetText(escapeText(pv.Text)) + p.previewScroll.SetVisible(true) + } + }) +} + +// applyPrefs turns the file preview on or off, and says how a fresh plan +// starts. +func (p *planView) applyPrefs(prefs model.Prefs) { + p.previewOff = !prefs.Preview + p.startSelected = prefs.SelectAll + if p.previewOff { + p.previewFor = "" + p.pictureFrame.SetVisible(false) + p.previewScroll.SetVisible(false) + p.previewNote.SetText("") + } +} + +// closePreview removes what the previews left behind. +func (p *planView) closePreview() { + if p.previewTmp != "" { + os.RemoveAll(p.previewTmp) + p.previewTmp = "" + } } diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go index 0a10996..97f3dcd 100644 --- a/gui/internal/ui/rules.go +++ b/gui/internal/ui/rules.go @@ -44,6 +44,7 @@ type rulesView struct { testGo *gtk.Button out *gtk.TextView + colours *highlighter sub *gtk.Notebook forms *formsView rules *model.Rules @@ -93,6 +94,7 @@ func newRulesView(w *Window) *rulesView { r.view.SetRightMargin(8) r.view.SetTopMargin(6) r.buf = r.view.Buffer() + r.colours = newHighlighter(r.buf) // 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 @@ -225,6 +227,23 @@ func newRulesView(w *Window) *rulesView { return r } +// applyPrefs sets the editor's font and whether the text is coloured. +func (r *rulesView) applyPrefs(p model.Prefs) { + r.colours.setEnabled(p.Colours) + if r.rules != nil && p.Colours { + r.colours.paint(r.rules.Text) + } + css := gtk.NewCSSProvider() + if p.FontSize > 0 { + css.LoadFromData(fmt.Sprintf("textview { font-size: %dpt; }", p.FontSize)) + } else { + css.LoadFromData("") + } + for _, v := range []*gtk.TextView{r.view, r.nums, r.out} { + v.StyleContext().AddProvider(css, 800) + } +} + // 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 { @@ -383,6 +402,7 @@ func (r *rulesView) runCheck() { r.save.SetTooltipText("nothing to save: the file matches what is on disk") } r.updateLineNumbers() + r.colours.paint(r.rules.Text) } // updateLineNumbers refills the gutter when the number of lines changes. diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go new file mode 100644 index 0000000..2db3450 --- /dev/null +++ b/gui/internal/ui/settings.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package ui + +import ( + "github.com/diamondburned/gotk4/pkg/gtk/v4" + + "krino/gui/internal/model" + "krino/internal/xdg" +) + +// settingsWindow is Settings: krino's defaults, which every directory +// inherits, and how this window behaves. The first are written to +// krino.conf with the care a rules file is written with - checked first, +// the previous text kept as krino.conf.bak - and the second to gui.json, +// which krino itself never reads (his request, 2026-09-16). +type settingsWindow struct { + w *Window + win *gtk.Window + main *model.MainConf + + rows []*settingRow + check *gtk.Label + save *gtk.Button + toSave bool +} + +// showSettings opens it, one at a time. +func (w *Window) showSettings() { + main, err := model.OpenMain(w.engine) + if err != nil { + w.setStatus("settings: %v", err) + return + } + s := &settingsWindow{w: w, main: main} + s.win = gtk.NewWindow() + s.win.SetTitle("krino settings") + s.win.SetTransientFor(&w.win.Window) + s.win.SetModal(true) + s.win.SetDefaultSize(560, 640) + + box := gtk.NewBox(gtk.OrientationVertical, 8) + box.SetMarginStart(12) + box.SetMarginEnd(12) + box.SetMarginTop(12) + box.SetMarginBottom(12) + + box.Append(heading("Defaults for every directory - " + xdg.Abbrev(main.File))) + note := gtk.NewLabel("An empty field is one krino.conf does not set, and krino's own default applies. A directory's file overrides these.") + note.SetXAlign(0) + note.SetWrap(true) + note.AddCSSClass("dim-label") + box.Append(note) + for _, head := range append(append([]string{}, model.MainSettings...), "log") { + args, _, err := main.Setting(head) + if err != nil { + w.setStatus("settings: %v", err) + return + } + row := newSettingRow(head, args, s.armSave) + s.rows = append(s.rows, row) + box.Append(row.root) + } + s.check = gtk.NewLabel("") + s.check.SetXAlign(0) + s.check.SetWrap(true) + box.Append(s.check) + + box.Append(heading("This window")) + prefs := w.prefs + font := gtk.NewSpinButtonWithRange(0, 32, 1) + font.SetValue(float64(prefs.FontSize)) + font.SetTooltipText("the editor's font size in points; 0 keeps the theme's") + colours := gtk.NewCheckButtonWithLabel("colour the configuration in the Text tab") + colours.SetActive(prefs.Colours) + preview := gtk.NewCheckButtonWithLabel("show the file behind the selected row") + preview.SetActive(prefs.Preview) + selectAll := gtk.NewCheckButtonWithLabel("a scanned plan starts with every file checked") + selectAll.SetActive(prefs.SelectAll) + box.Append(field("editor font", font)) + box.Append(colours) + box.Append(preview) + box.Append(selectAll) + + apply := func() { + p := model.Prefs{ + FontSize: int(font.Value()), + Colours: colours.Active(), + Preview: preview.Active(), + SelectAll: selectAll.Active(), + } + w.applyPrefs(p) + if err := p.Save(); err != nil { + w.setStatus("settings: %v", err) + } + } + font.ConnectValueChanged(apply) + colours.ConnectToggled(apply) + preview.ConnectToggled(apply) + selectAll.ConnectToggled(apply) + + s.save = gtk.NewButtonWithLabel("Save krino.conf") + s.save.AddCSSClass("suggested-action") + s.save.SetSensitive(false) + s.save.ConnectClicked(s.onSave) + closeBtn := gtk.NewButtonWithLabel("Close") + closeBtn.ConnectClicked(func() { s.win.Close() }) + // The buttons sit outside the scrolled area: on a short screen they + // would otherwise be below the fold, which is where he found them. + buttons := gtk.NewBox(gtk.OrientationHorizontal, 6) + buttons.SetHAlign(gtk.AlignEnd) + buttons.SetMarginStart(12) + buttons.SetMarginEnd(12) + buttons.SetMarginTop(8) + buttons.SetMarginBottom(12) + buttons.Append(closeBtn) + buttons.Append(s.save) + + scroll := gtk.NewScrolledWindow() + scroll.SetChild(box) + scroll.SetVExpand(true) + outer := gtk.NewBox(gtk.OrientationVertical, 0) + outer.Append(scroll) + outer.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) + outer.Append(buttons) + s.win.SetChild(outer) + s.win.Show() +} + +// armSave writes what the fields hold into the text and checks it, without +// touching the file: Save is what writes. +func (s *settingsWindow) armSave() { + for _, row := range s.rows { + value := row.value() + was, _, err := s.main.Setting(row.head) + if err != nil { + s.check.SetText(escape(err.Error())) + return + } + if value == was { + continue + } + if err := s.main.SetSetting(row.head, value); err != nil { + s.check.SetText(escape(err.Error())) + row.set(was) + continue + } + s.toSave = true + } + diags := s.main.Check() + switch { + case len(diags) == 0: + s.check.SetText("") + s.save.SetSensitive(s.toSave && s.main.Modified()) + default: + s.check.SetText(escape(diags[0].Error())) + s.check.AddCSSClass("error") + s.save.SetSensitive(false) + } +} + +// onSave writes krino.conf and makes the new defaults the ones every tab +// works from. +func (s *settingsWindow) onSave() { + if err := s.main.Save(); err != nil { + s.check.SetText(escape(err.Error())) + return + } + s.check.RemoveCSSClass("error") + s.check.SetText("saved; the previous text is beside it as krino.conf.bak") + s.save.SetSensitive(false) + if err := s.w.reloadEngine(); err != nil { + s.w.setStatus("saved, but the new configuration does not load: %v", err) + return + } + s.w.setStatus("saved %s", escape(xdg.Abbrev(s.main.File))) +} + +// settingHintsMain is the example shown in an empty krino.conf field, for +// the settings the directory form does not already describe. +var settingHintsMain = map[string]string{ + "log": `"~/.local/state/krino/krino.log"`, +} + +// mainHint is the example for a krino.conf setting. +func mainHint(head string) string { + if h, ok := settingHintsMain[head]; ok { + return h + } + return settingHints[head] +} diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go index 27e1730..9727846 100644 --- a/gui/internal/ui/window.go +++ b/gui/internal/ui/window.go @@ -29,13 +29,14 @@ type Window struct { history *historyView rules *rulesView status *gtk.Label + prefs model.Prefs } // NewWindow builds the window for e. Each plan and each undo is its own // run of krino, with its own run id in the log, so the window itself holds // no session. func NewWindow(app *gtk.Application, e *engine.Engine) *Window { - w := &Window{app: app, engine: e} + w := &Window{app: app, engine: e, prefs: model.LoadPrefs()} w.win = gtk.NewApplicationWindow(app) w.win.SetTitle("krino") w.win.SetDefaultSize(1200, 720) @@ -64,19 +65,29 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { w.status.SetMarginTop(4) w.status.SetMarginBottom(4) + settings := gtk.NewButtonWithLabel("Settings") + settings.SetHasFrame(false) + settings.ConnectClicked(func() { w.showSettings() }) + statusRow := gtk.NewBox(gtk.OrientationHorizontal, 6) + w.status.SetHExpand(true) + statusRow.Append(w.status) + statusRow.Append(settings) + box := gtk.NewBox(gtk.OrientationVertical, 0) notebook.SetVExpand(true) box.Append(notebook) box.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) - box.Append(w.status) + box.Append(statusRow) 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() + w.plan.closePreview() w.history.closeTab() return false }) + w.applyPrefs(w.prefs) return w } @@ -98,6 +109,15 @@ func (w *Window) reloadEngine() error { return nil } +// applyPrefs takes a change from the settings window: the font of the +// editor, whether the configuration is coloured, and whether the file +// behind a row is shown. What is already on screen changes at once. +func (w *Window) applyPrefs(p model.Prefs) { + w.prefs = p + w.rules.applyPrefs(p) + w.plan.applyPrefs(p) +} + // 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. |
