// 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")) kept := gtk.NewLabel("These are kept as you change them; the button below writes krino.conf, and is only lit when something above has changed.") kept.SetXAlign(0) kept.SetWrap(true) kept.AddCSSClass("dim-label") box.Append(kept) prefs := w.prefs colours := gtk.NewCheckButtonWithLabel("colour the configuration in the Text tab") colours.SetActive(prefs.Colours) colours.SetTooltipText("paint comments, strings, form heads and actions in the Text sub-tab") preview := gtk.NewCheckButtonWithLabel("show the file behind the selected row") preview.SetActive(prefs.Preview) preview.SetTooltipText("show the selected file under its explanation: a picture, a PDF's first page, or the first lines of text") selectAll := gtk.NewDropDownFromStrings([]string{"every file checked", "nothing checked"}) if !prefs.SelectAll { selectAll.SetSelected(1) } selectAll.SetTooltipText("what a freshly scanned plan starts as: every file that can be acted on checked, as the terminal review does, or nothing checked so you pick") layout := gtk.NewDropDownFromStrings([]string{ "file list beside the explanation", "file list on top, preview beside the explanation", }) if prefs.Layout == model.LayoutStacked { layout.SetSelected(1) } layout.SetTooltipText("how the Plan tab is arranged") previewHeight := gtk.NewSpinButtonWithRange(80, 2000, 20) previewHeight.SetValue(float64(prefs.PreviewHeight)) previewHeight.SetTooltipText("how tall the preview under an explanation is, in pixels; dragging the divider above it does the same") showSize := gtk.NewCheckButtonWithLabel("size") showSize.SetActive(prefs.ShowSize) showAge := gtk.NewCheckButtonWithLabel("age") showAge.SetActive(prefs.ShowAge) showRule := gtk.NewCheckButtonWithLabel("rule") showRule.SetActive(prefs.ShowRule) columns := gtk.NewBox(gtk.OrientationHorizontal, 12) columns.Append(showSize) columns.Append(showAge) columns.Append(showRule) columns.SetTooltipText("which of the optional columns the plan shows; file, action and where it would go are always there") sortOrder := gtk.NewDropDownFromStrings(sortItems()) for i, o := range model.SortOrders { if o == prefs.Sort { sortOrder.SetSelected(uint(i)) } } sortOrder.SetTooltipText("the order a freshly scanned plan is listed in; the picker in the toolbar changes it for one window") box.Append(field("sort by", sortOrder)) box.Append(field("layout", layout)) box.Append(field("columns", columns)) box.Append(field("preview height", previewHeight)) box.Append(field("after a scan", selectAll)) box.Append(colours) box.Append(preview) apply := func() { which := model.LayoutSide if layout.Selected() == 1 { which = model.LayoutStacked } order := model.SortDefault if i := int(sortOrder.Selected()); i >= 0 && i < len(model.SortOrders) { order = model.SortOrders[i] } p := model.Prefs{ Sort: order, ShowSize: showSize.Active(), ShowAge: showAge.Active(), ShowRule: showRule.Active(), Colours: colours.Active(), Preview: preview.Active(), SelectAll: selectAll.Selected() == 0, PreviewHeight: int(previewHeight.Value()), Layout: which, } w.applyPrefs(p) if err := p.Save(); err != nil { w.setStatus("settings: %v", err) } } colours.ConnectToggled(apply) preview.ConnectToggled(apply) selectAll.Connect("notify::selected", func() { apply() }) layout.Connect("notify::selected", func() { apply() }) s.save = gtk.NewButtonWithLabel("Save krino.conf") s.save.AddCSSClass("suggested-action") s.save.SetSensitive(false) s.save.SetTooltipText("nothing to save: the defaults above are as krino.conf has them. The window settings below need no saving - they are kept as you change them.") 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("") canSave := s.toSave && s.main.Modified() s.save.SetSensitive(canSave) if canSave { s.save.SetTooltipText("write krino.conf, keeping the previous text as krino.conf.bak") } 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] }