diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 08:39:48 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 08:39:48 +0200 |
| commit | 1e3dd67868e1b6d3c12f0bca201d5e60a12c3723 (patch) | |
| tree | 3cea9254020a25bf69e1bb75096503db778bcff3 /gui/internal/ui | |
| parent | 5418ba6bc5c653b6a99572c6ece6abbf57836c43 (diff) | |
| download | krino-1e3dd67868e1b6d3c12f0bca201d5e60a12c3723.tar.gz krino-1e3dd67868e1b6d3c12f0bca201d5e60a12c3723.zip | |
gui: aligned columns, theme colours, a second layout, and a scan-selection setting
Diffstat (limited to 'gui/internal/ui')
| -rw-r--r-- | gui/internal/ui/plan.go | 159 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 30 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 33 |
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{ |
