aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui')
-rw-r--r--gui/internal/ui/plan.go159
-rw-r--r--gui/internal/ui/settings.go30
-rw-r--r--gui/internal/ui/window.go33
3 files changed, 176 insertions, 46 deletions
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{