aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui')
-rw-r--r--gui/internal/ui/forms.go4
-rw-r--r--gui/internal/ui/highlight.go77
-rw-r--r--gui/internal/ui/plan.go128
-rw-r--r--gui/internal/ui/rules.go20
-rw-r--r--gui/internal/ui/settings.go191
-rw-r--r--gui/internal/ui/window.go24
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.