summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 08:39:48 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 08:39:48 +0200
commit1e3dd67868e1b6d3c12f0bca201d5e60a12c3723 (patch)
tree3cea9254020a25bf69e1bb75096503db778bcff3
parent5418ba6bc5c653b6a99572c6ece6abbf57836c43 (diff)
downloadkrino-1e3dd67868e1b6d3c12f0bca201d5e60a12c3723.tar.gz
krino-1e3dd67868e1b6d3c12f0bca201d5e60a12c3723.zip
gui: aligned columns, theme colours, a second layout, and a scan-selection setting
-rw-r--r--docs/gui-checklist.md7
-rw-r--r--gui/internal/model/prefs.go15
-rw-r--r--gui/internal/ui/plan.go159
-rw-r--r--gui/internal/ui/settings.go30
-rw-r--r--gui/internal/ui/window.go33
-rw-r--r--man/krino-gui.113
6 files changed, 205 insertions, 52 deletions
diff --git a/docs/gui-checklist.md b/docs/gui-checklist.md
index 1bbcd74..3c9e379 100644
--- a/docs/gui-checklist.md
+++ b/docs/gui-checklist.md
@@ -112,3 +112,10 @@ A dialog is its own window: take it by its own id, not the main window's.
and nothing moves until Apply.
32. Closing the window with files checked and nothing applied asks first.
33. Every setting in Settings explains itself when the pointer rests on it.
+34. The columns line up with their headings, whatever the file names are.
+35. The layout setting switches between the file list beside the
+ explanation and the file list on top with the preview beside it, and
+ the choice survives a restart.
+36. "after a scan" decides whether a fresh plan starts checked or not.
+37. The action colours follow the GTK theme: they are its accent, warning
+ and error colours, not a palette of krino's own.
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index 1a91194..3c249a3 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -23,8 +23,18 @@ type Prefs struct {
// PreviewHeight is how tall the preview under the explanation is, in
// pixels; the divider between them sets it, and so does Settings.
PreviewHeight int `json:"preview_height"`
+ // Layout is how the Plan tab is arranged: "side" puts the file list
+ // beside the explanation, "stacked" puts it above, with the preview
+ // beside the explanation underneath.
+ Layout string `json:"layout"`
}
+// The two arrangements of the Plan tab.
+const (
+ LayoutSide = "side"
+ LayoutStacked = "stacked"
+)
+
// DefaultPreviewHeight is the preview's height until one is chosen, and the
// floor a smaller one is raised to.
const DefaultPreviewHeight = 280
@@ -32,7 +42,7 @@ const DefaultPreviewHeight = 280
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
return Prefs{Colours: true, Preview: true, SelectAll: true,
- PreviewHeight: DefaultPreviewHeight}
+ PreviewHeight: DefaultPreviewHeight, Layout: LayoutSide}
}
// PrefsFile is where they are kept.
@@ -54,6 +64,9 @@ func LoadPrefs() Prefs {
if p.PreviewHeight < 80 {
p.PreviewHeight = DefaultPreviewHeight
}
+ if p.Layout != LayoutSide && p.Layout != LayoutStacked {
+ p.Layout = LayoutSide
+ }
return p
}
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 740341c..472350a 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -36,6 +36,7 @@ type planView struct {
selNone *gtk.Button
checked *gtk.MenuButton
+ groups [6]*gtk.SizeGroup
filter *gtk.SearchEntry
shown []int
list *gtk.ListBox
@@ -49,6 +50,11 @@ type planView struct {
previewTmp string
previewFor string
detailPane *gtk.Paned
+ arrangement *gtk.Paned
+ listSide *gtk.Box
+ detailScroll *gtk.ScrolledWindow
+ previewBox *gtk.Box
+ layout string
renderedDir string
previewHeight int
previewOff bool
@@ -96,16 +102,16 @@ func newPlanView(w *Window) *planView {
bar.Append(gtk.NewLabel("Directory"))
bar.Append(p.dirs)
bar.Append(p.scan)
- bar.Append(p.path)
-
// A filter over the plan: type a few letters of a name, as fzf does,
- // and act on what is left (his request, 2026-09-17).
+ // and act on what is left. It sits by the Scan button, where the eye
+ // starts (his request, 2026-09-17).
p.filter = gtk.NewSearchEntry()
p.filter.SetPlaceholderText("filter")
p.filter.SetTooltipText("show only the files whose name or rule has these letters, in order; Select all then checks those")
- p.filter.SetSizeRequest(160, -1)
+ p.filter.SetSizeRequest(200, -1)
bar.Append(p.filter)
+ bar.Append(p.path)
bar.Append(p.selAll)
bar.Append(p.selNone)
bar.Append(p.checkedMenu())
@@ -118,6 +124,9 @@ func newPlanView(w *Window) *planView {
listScroll.SetChild(p.list)
listScroll.SetHExpand(true)
listScroll.SetVExpand(true)
+ for i := range p.groups {
+ p.groups[i] = gtk.NewSizeGroup(gtk.SizeGroupHorizontal)
+ }
p.headerBox = gtk.NewBox(gtk.OrientationHorizontal, 8)
p.headerBox.SetMarginTop(4)
p.headerBox.SetMarginBottom(4)
@@ -176,34 +185,13 @@ func newPlanView(w *Window) *planView {
previewBox.Append(p.picture)
previewBox.Append(previewScroll)
- // The divider between the explanation and the preview is the size
- // control: drag it, and the preview stays that tall (his request,
- // 2026-09-17).
- p.detailPane = gtk.NewPaned(gtk.OrientationVertical)
- p.detailPane.SetStartChild(detailScroll)
- p.detailPane.SetEndChild(previewBox)
- p.detailPane.SetResizeStartChild(true)
- p.detailPane.SetResizeEndChild(false)
- p.detailPane.SetShrinkEndChild(false)
- p.detailPane.SetVExpand(true)
- p.detailPane.SetSizeRequest(340, -1)
- p.detailPane.Connect("notify::position", p.rememberPreviewHeight)
- detailBox := p.detailPane
-
- // 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(listSide)
- panes.SetEndChild(detailBox)
- panes.SetResizeStartChild(true)
- panes.SetResizeEndChild(false)
- panes.SetShrinkEndChild(false)
- panes.SetPosition(780)
- panes.SetVExpand(true)
+ p.listSide = listSide
+ p.detailScroll = detailScroll
+ p.previewBox = previewBox
p.root.Append(bar)
p.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
- p.root.Append(panes)
+ p.setLayout(model.LayoutSide)
p.scan.ConnectClicked(p.onScan)
p.apply.ConnectClicked(p.onApply)
@@ -547,6 +535,9 @@ func (p *planView) sayWhatIsShown() {
// rule, and the outcome once applied.
func (p *planView) fillList() {
clearList(p.list)
+ for i := range p.groups {
+ p.groups[i] = gtk.NewSizeGroup(gtk.SizeGroupHorizontal)
+ }
if p.tab == nil {
return
}
@@ -584,9 +575,22 @@ func (p *planView) widths() [5]int {
for i, cap := range [5]int{40, 16, 52, 34, 24} {
w[i] = min(w[i], cap)
}
+ // A column is never narrower than its own heading, or the heading is
+ // the thing that ends in an ellipsis (his report, 2026-09-17).
+ for i, title := range columnTitles {
+ w[i] = max(w[i], len([]rune(title)))
+ }
return w
}
+// showOutcome reports whether there is anything to put in the last column.
+func (p *planView) showOutcome() bool {
+ return p.tab != nil && p.tab.Applied
+}
+
+// columnTitles name the columns of the plan.
+var columnTitles = [5]string{"file", "action", "where it would go", "rule", "outcome"}
+
// header is the line above the list saying what each column is.
func (p *planView) header(w [5]int) {
if child := p.headerBox.FirstChild(); child != nil {
@@ -601,11 +605,16 @@ func (p *planView) header(w [5]int) {
// The checkbox has no title, but its width has to be accounted for.
spacer := gtk.NewLabel("")
spacer.SetSizeRequest(24, -1)
+ p.groups[0].AddWidget(spacer)
p.headerBox.Append(spacer)
- for i, title := range []string{"file", "action", "where it would go", "rule", "outcome"} {
+ for i, title := range columnTitles {
+ if i == 4 && !p.showOutcome() {
+ continue
+ }
l := columnMin(title, w[i], yieldChars, i == 0 || i == 2)
l.AddCSSClass("heading")
l.SetTooltipText("")
+ p.groups[i+1].AddWidget(l)
p.headerBox.Append(l)
}
}
@@ -628,18 +637,27 @@ func (p *planView) rowWidget(i int, r model.Row, w [5]int) *gtk.ListBoxRow {
p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0)
}
})
+ p.groups[0].AddWidget(check)
box.Append(check)
// The name and destination columns share whatever space is left and
// shrink first; the action and the rule keep their width, so neither is
// ever the column cut to an ellipsis.
action, colour := rowAction(r)
- box.Append(column(escape(r.Rel), w[0], true))
- box.Append(colouredColumn(action, w[1], colour))
- box.Append(columnMin(escape(rowWhere(r, p.dirRoot())), w[2], 18, true))
- box.Append(column(escape(r.Rule), w[3], false))
- if r.Outcome != "" {
- box.Append(column(escape(r.Outcome), w[4], false))
+ cells := []gtk.Widgetter{
+ column(escape(r.Rel), w[0], true),
+ colouredColumn(action, w[1], colour),
+ columnMin(escape(rowWhere(r, p.dirRoot())), w[2], 18, true),
+ column(escape(r.Rule), w[3], false),
+ }
+ // The outcome column appears once a plan has been applied: before that
+ // it would be an empty column with a heading over it.
+ if p.showOutcome() {
+ cells = append(cells, column(escape(r.Outcome), w[4], false))
+ }
+ for i, cell := range cells {
+ p.groups[i+1].AddWidget(cell)
+ box.Append(cell)
}
row := gtk.NewListBoxRow()
row.SetChild(box)
@@ -793,10 +811,64 @@ func (p *planView) showPreview(rel string) {
})
}
+// setLayout arranges the tab the way the settings ask: the file list beside
+// the explanation, or above it with the preview to its side (his sketch,
+// 2026-09-17). The widgets are the same either way; only the panes holding
+// them change.
+func (p *planView) setLayout(which string) {
+ if p.layout == which && p.arrangement != nil {
+ return
+ }
+ p.layout = which
+ if p.arrangement != nil {
+ p.detailPane.SetStartChild(nil)
+ p.detailPane.SetEndChild(nil)
+ p.arrangement.SetStartChild(nil)
+ p.arrangement.SetEndChild(nil)
+ p.root.Remove(p.arrangement)
+ }
+
+ // The divider between the explanation and the preview is the size
+ // control: drag it, and the preview stays that big.
+ detailOrientation, outerOrientation := gtk.OrientationVertical, gtk.OrientationHorizontal
+ if which == model.LayoutStacked {
+ detailOrientation, outerOrientation = gtk.OrientationHorizontal, gtk.OrientationVertical
+ }
+ p.detailPane = gtk.NewPaned(detailOrientation)
+ p.detailPane.SetStartChild(p.detailScroll)
+ p.detailPane.SetEndChild(p.previewBox)
+ p.detailPane.SetResizeStartChild(true)
+ p.detailPane.SetResizeEndChild(false)
+ p.detailPane.SetShrinkEndChild(false)
+ p.detailPane.SetVExpand(true)
+ p.detailPane.Connect("notify::position", p.rememberPreviewHeight)
+
+ p.arrangement = gtk.NewPaned(outerOrientation)
+ p.arrangement.SetStartChild(p.listSide)
+ p.arrangement.SetEndChild(p.detailPane)
+ p.arrangement.SetResizeStartChild(true)
+ p.arrangement.SetResizeEndChild(false)
+ p.arrangement.SetShrinkEndChild(false)
+ p.arrangement.SetVExpand(true)
+ if which == model.LayoutSide {
+ p.detailPane.SetSizeRequest(340, -1)
+ p.arrangement.SetPosition(780)
+ } else {
+ p.detailPane.SetSizeRequest(-1, 240)
+ p.arrangement.SetPosition(420)
+ }
+ p.root.Append(p.arrangement)
+ p.setPreviewHeight(p.previewHeight)
+}
+
// rememberPreviewHeight keeps what a drag of the divider chose, so the next
// window opens the same way.
func (p *planView) rememberPreviewHeight() {
- height := p.detailPane.Height() - p.detailPane.Position()
+ total := p.detailPane.Height()
+ if p.layout == model.LayoutStacked {
+ total = p.detailPane.Width()
+ }
+ height := total - p.detailPane.Position()
if height < 80 || height == p.previewHeight {
return
}
@@ -815,14 +887,22 @@ func (p *planView) setPreviewHeight(height int) {
height = model.DefaultPreviewHeight
}
p.previewHeight = height
- if total := p.detailPane.Height(); total > height+80 {
+ total := p.detailPane.Height()
+ if p.layout == model.LayoutStacked {
+ total = p.detailPane.Width()
+ }
+ if total > height+80 {
p.detailPane.SetPosition(total - height)
return
}
// Before the window is drawn there is no height to subtract from, so
// the end child's own request puts the divider in the right place.
if child := p.detailPane.EndChild(); child != nil {
- gtk.BaseWidget(child).SetSizeRequest(-1, height)
+ if p.layout == model.LayoutStacked {
+ gtk.BaseWidget(child).SetSizeRequest(height, -1)
+ } else {
+ gtk.BaseWidget(child).SetSizeRequest(-1, height)
+ }
}
}
@@ -831,6 +911,7 @@ func (p *planView) setPreviewHeight(height int) {
func (p *planView) applyPrefs(prefs model.Prefs) {
p.previewOff = !prefs.Preview
p.startSelected = prefs.SelectAll
+ p.setLayout(prefs.Layout)
p.setPreviewHeight(prefs.PreviewHeight)
if p.previewOff {
p.previewFor = ""
diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go
index 732caf9..e90c691 100644
--- a/gui/internal/ui/settings.go
+++ b/gui/internal/ui/settings.go
@@ -74,23 +74,40 @@ func (w *Window) showSettings() {
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.NewCheckButtonWithLabel("a scanned plan starts with every file checked")
- selectAll.SetActive(prefs.SelectAll)
- selectAll.SetTooltipText("a freshly scanned plan starts with every file that can be acted on checked, as the terminal review does")
+ 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")
+ box.Append(field("layout", layout))
box.Append(field("preview height", previewHeight))
+ box.Append(field("after a scan", selectAll))
box.Append(colours)
box.Append(preview)
- box.Append(selectAll)
apply := func() {
+ which := model.LayoutSide
+ if layout.Selected() == 1 {
+ which = model.LayoutStacked
+ }
p := model.Prefs{
Colours: colours.Active(),
Preview: preview.Active(),
- SelectAll: selectAll.Active(),
+ SelectAll: selectAll.Selected() == 0,
PreviewHeight: int(previewHeight.Value()),
+ Layout: which,
}
w.applyPrefs(p)
if err := p.Save(); err != nil {
@@ -99,7 +116,8 @@ func (w *Window) showSettings() {
}
colours.ConnectToggled(apply)
preview.ConnectToggled(apply)
- selectAll.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")
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index 784fc64..61fe157 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -104,6 +104,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
w.history.closeTab()
return false
})
+ themeColours(w.win)
w.applyPrefs(w.prefs)
return w
}
@@ -225,7 +226,9 @@ func runInBackground(work func(context.Context) error, done func(error)) context
// actionColours are what each action is painted in, so the eye finds the
// deletions without reading: they are the ones that cannot be undone from
-// the window (his request, 2026-09-17).
+// the window. They are the fallbacks; themeColours replaces them with the
+// running theme's own, so the window looks like the rest of the desktop
+// rather than like GNOME's palette (his request, 2026-09-17).
var actionColours = map[plan.Kind]string{
plan.Copy: "#2a9d8f",
plan.Move: "#3584e4",
@@ -234,6 +237,34 @@ var actionColours = map[plan.Kind]string{
plan.DeletePermanent: "#c01c28",
}
+// themeColours takes what it can from the GTK theme: the accent for the
+// actions that file a document, the theme's own warning and error colours
+// for the two that take it away. A theme that names none of them leaves the
+// fallbacks above.
+func themeColours(w gtk.Widgetter) {
+ ctx := gtk.BaseWidget(w).StyleContext()
+ pick := func(names ...string) string {
+ for _, name := range names {
+ if rgba, ok := ctx.LookupColor(name); ok {
+ return fmt.Sprintf("#%02x%02x%02x",
+ int(rgba.Red()*255), int(rgba.Green()*255), int(rgba.Blue()*255))
+ }
+ }
+ return ""
+ }
+ set := func(kind plan.Kind, colour string) {
+ if colour != "" {
+ actionColours[kind] = colour
+ }
+ }
+ accent := pick("accent_color", "theme_selected_bg_color", "accent_bg_color")
+ set(plan.Move, accent)
+ set(plan.Copy, pick("success_color", "success_bg_color"))
+ set(plan.Rename, accent)
+ set(plan.Trash, pick("warning_color", "warning_bg_color"))
+ set(plan.DeletePermanent, pick("error_color", "destructive_color", "error_bg_color"))
+}
+
// actionRank decides which action gives a row its colour when a file gets
// several: the one that matters most to the reader.
var actionRank = map[plan.Kind]int{
diff --git a/man/krino-gui.1 b/man/krino-gui.1
index 67dbecb..1cfb76d 100644
--- a/man/krino-gui.1
+++ b/man/krino-gui.1
@@ -53,9 +53,10 @@ checks what is on screen and the status line says how many checked files
the filter is hiding.
Scanning takes that directory's lock, which is held while the plan is
shown, so nothing moves underneath it; a directory another krino is working
-in is reported rather than waited for. Each row is a file, the action in capitals and coloured by what it does,
-where the file would go, and the rule that decided; a header names the
-columns. A file krino could not
+in is reported rather than waited for. Each row is a file, the action in capitals and coloured by what it does -
+the colours are the running GTK theme's own accent, warning and error - where
+the file would go, and the rule that decided; a header names the columns,
+and an outcome column appears once a plan has been applied. A file krino could not
decide about - unreadable content, a failed duplicate check - is listed with
the reason and cannot be selected.
.Pp
@@ -155,8 +156,10 @@ file may override; they are written to
by the rules above: refused while the configuration would not load, the
previous text kept as
.Pa krino.conf.bak .
-The second is how this window behaves - how tall the preview is, whether the configuration is coloured, whether the file behind a
-row is shown, and whether a scanned plan starts with every file checked. Those take effect as
+The second is how this window behaves - how the tab is arranged (the file
+list beside the explanation, or above it with the preview to its side), how
+tall the preview is, whether a scan starts with every file checked or none, whether the configuration is coloured, and whether the file behind a
+row is shown. Those take effect as
they are changed and are kept in
.Pa gui.json ,
which krino itself never reads.