Files
gosentry/docs/GUI-LAYOUT-REVIEW.md
T
mixeme 70aa4cbc4d docs: GUI layout review — findings for the composition pass
Carries out the "GUI review — custom layouts and composition" roadmap item and
records the result in docs/GUI-LAYOUT-REVIEW.md. Composition only; no code was
changed. Every number was measured with a throwaway headless Fyne probe at text
size 14 and 20, not estimated.

Headline finding: the Settings tab sets a minimum content width of 1165.5 px for
a typical install — wider than the 1024x660 window run.go asks for — so Fyne
silently widens the window on open and the user cannot drag it narrower. It also
grows with the length of the config-file path (1501 px for a 75-character one).
Two independent causes: the seven settingsControlWidth wrappers, which measure
identically to bare controls at every reachable width and only inflate MinSize,
and the Config JSON path label, the one value label in the tab without
truncation. Fixing both takes the floor to 993.3.

Also found: compactVBoxLayout is a byte-for-byte re-implementation of stock
layout.NewCustomPaddedVBoxLayout (identical geometry at every spacing tested);
minJobsSidebarWidth (400) never binds because the toolbar row already needs 448;
the negative row spacings are theme.InnerPadding() written as a magic number;
the History column widths truncate their own content on a scaled UI; and the
History table re-sorts its whole event slice once per cell — 126 sorts of a
300-element copy for one redraw.

Fifteen findings in all, each with a disposition (single fix or roadmap) and a
suggested order. fixedHeightLayout, which the roadmap singled out, is kept: no
stock layout forces an exact height, and the alternative depends on the parent
staying a Border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:03:50 +03:00

21 KiB
Raw Blame History

GUI layout review — custom layouts and composition

Findings for the "GUI review — custom layouts and composition" item in ROADMAP.md. Scope is composition only: the four custom fyne.Layout implementations in src/ui/layout.go, the tuned constants the views drive them with, and how the assembled views behave when the window or the theme scale changes. It is not a general code review.

Reviewed at Fyne v2.7.4. Every number below was measured with a throwaway headless probe (test.NewApp() + theme.DefaultTheme()), not estimated: at the default text size (14; theme.Padding() = 4, theme.InnerPadding() = 8) and, where scale matters, at text size 20. The probes were deleted after the measurements were taken; the numbers are reproducible from the recipes quoted in each finding.

Nothing in the code was changed by this pass. Each finding carries a disposition: single fix (goes straight in) or roadmap (larger than one fix, comes back to ROADMAP).

Summary

The four custom layouts are mostly justified — but one of them (compactVBoxLayout) is a re-implementation of a stock Fyne layout that has existed since 2.5, and another (minWidthLayout) is applied at nine sites of which eight enforce a width that turns out to have no visual effect at all.

The bigger result is about the constants rather than the layouts. Three raw-pixel width constants (settingsLabelWidth, settingsControlWidth, minJobsSidebarWidth) are floors, and content already reaches or exceeds all three at the default text size or one step above it. They no longer shape anything on screen. What they still do is set the minimum size of the window — and that minimum (1165×543 for a typical install) is wider than the 1024×660 window the app asks for at startup. GoSentry cannot open at its own default size, and the user cannot drag it narrower.

Per-layout verdict

The roadmap asks three questions of each layout: is it still needed, is it the smallest thing that works, does it hold up at other window sizes and theme scales.

Layout Call sites Still needed Smallest thing that works Holds up when scaled
minWidthLayout 9 Partly — 1 site is load-bearing, 8 are not No — see F2, F7 Yes: it takes the max with the child's own minimum, so it can never clip
compactVBoxLayout 3 No — stock layout.NewCustomPaddedVBoxLayout is identical No — delete the type (F4) Yes, but the spacing values do not (F5)
fixedHeightLayout 1 Yes — keep Yes (see below) Yes: its one caller derives the height from the theme
captionValueLayout 1 (via detailRow, 11 rows) Yes Yes Yes for the caption; the value column has no floor of its own (F9)

On fixedHeightLayout, which the roadmap singles out as a one-call-site type: there is no stock layout that forces an exact height. The closest stock construction is container.NewStack(list, rect) with a transparent canvas.Rectangle carrying SetMinSize(fyne.NewSize(0, activityRowsHeight(3))), which is equivalent here only because the parent is a Border bottom slot and a bottom slot grants exactly MinSize().Height. That is a 29-line type traded for a one-line invisible-rectangle trick that depends on the parent staying a Border. Keep the type — and note the codebase already uses the invisible-rectangle trick twice in settings_view.go (F8), so the two idioms currently coexist for no reason. Standardise on the named layout.

Per-constant verdict

Constant Value What content actually needs Binds?
settingsLabelWidth 180 169.2 — widest caption, "Default overlap policy" Only below text size 15 (180.1 at 15). Visible effect: yes, it sets the caption column
settingsControlWidth 330 306.7 — widest control, the notifications checkbox Only below text size 16. Visible effect: none (F2)
minJobsSidebarWidth 400 448.0 — the 5-button toolbar row Never (F7)
detailRowSpacing 8 = theme.InnerPadding() at the default theme Yes — but the relation to the theme is only implicit (F5)
jobRowSpacing 8 as above as above
settingsRowSpacing 6 no principled relation to any theme metric as above
logColumnMinWidth / MaxWidth / Padding 240 / 520 / 24 a real log name measures 268.9 at text size 14, 373.7 at 20 Yes; the min/max are sane, the 24 is a guess at 2×InnerPadding = 16 (F14)
History column widths 150/90/170/90/260 148.4/75.7/114.9/86.7/144.2 at text size 14 Yes — Time has 1.6 px of headroom, State 3.3, which a scaled UI eats (F6)
activityRowsHeight() derived Correct by construction — this is the model
detailCaptionWidth() derived Correct by construction — this is the model

Findings

F1 — The window cannot open at the size it asks for (high, roadmap)

run.go:54-56 asks for 1024×660 (or the persisted preference). The assembled content's minimum is 1165.5×542.9 for an install whose config path is 53 characters (C:\Users\alice\AppData\Roaming\GoSentry\gosentry.json). Fyne enforces that minimum in two places: window.Resize takes size.Max(content.MinSize()), and fitContent calls view.SetSizeLimits(minWidth, minHeight, …). So the window silently opens ~140 px wider than requested and cannot be dragged narrower.

The height is fine — 543 leaves room on a 720p screen, which is what the condensed details pane was for. The problem is entirely horizontal, and it comes from the Settings tab: AppTabs reports the maximum of its children, and the three tabs measure 920.0 (Jobs), 40.0 (History), 1165.5 (Settings).

The Settings minimum is also not a constant — it tracks the length of the config file path: 1040 for a 10-character path, 1165 for 53, 1501 for 75. A user with a long Windows account name gets a window that will not fit on a 1366×768 laptop screen.

container.NewVScroll (settings_view.go:304) caps the tab's minimum height at 32, and the comment there explains exactly why. The same reasoning was never applied to the width. Two independent causes, F2 and F3, both need fixing to bring the floor under 1024; measured together they take it to 993.3, at which point the binding row is the Notifications checkbox (490.7) and the tab no longer widens with the config path at all.

This also interacts with the frozen Window size persistence roadmap item: a restored width below the content minimum would be silently widened anyway.

F2 — settingsControlWidth wrappers change nothing but the minimum (medium, single fix)

Seven rows wrap their control in container.New(minWidthLayout{width: 330}, …) (settings_view.go:248, 252, 253, 254, 262, 263, 264). Measured with a select at three row widths, wrapped versus bare:

Row width Wrapped select size Bare select size
700 516 516
514 330 330
420 236 236

Identical, at every width, because settingsRow puts the control in a Border centre slot, which already stretches it to whatever the column gives it — and minWidthLayout.Layout in turn resizes its child to the container size. The wrapper's only observable effect is on MinSize: the row reports 514 instead of 311.9, ×2 columns, which is the 1040 floor in F1.

Fix: delete the constant and the seven wrappers. No visual change at any window size the user can reach; seven containers fewer; the Settings floor drops by 46.7 px (the notifications checkbox then binds at 490.7). minWidthLayout itself stays — its remaining caller, the caption box in settingsRow, sits in a Border left slot, which grants exactly MinSize().Width, so there the constant is what renders.

F3 — One label in Settings is not truncated, and it sets the window width (medium, single fix)

settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)) (settings_view.go:273) is the only value label in the tab with default wrapping, so its minimum width is the full pixel width of the path: the label measures 392.8 for a 53-character path (12.7 truncated), which makes the row 576.8 against 196.7. Fyne's grid gives every column the widest cell's width, so that row alone costs 2× its width in the tab minimum.

The rule this row is missing is already established one screen away — autostartStatus.Wrapping = fyne.TextTruncate at settings_view.go:128, with the comment "Truncating keeps a long status message from forcing the column wider." The read-only path row simply never got it.

Fix: truncate it like its neighbour. The full path stays readable whenever the window is wide enough, which it will be by default.

F4 — compactVBoxLayout re-implements a stock Fyne layout (medium, single fix)

layout.NewCustomPaddedVBoxLayout(padding float32) has existed since Fyne 2.5 and does exactly what compactVBoxLayout does, negative padding included. Compared directly at spacings 8, 6, 0 and 4, the two produce identical MinSize values and identical position and size for every child; they also agree on a hidden middle child (62.16), an empty object list (0×0) and a single child (43.25×35.08). The stock layout is a strict superset — it additionally distributes layout.Spacer objects, which the local copy ignores.

Fix: delete the type (≈40 lines) and replace its three call sites with container.New(layout.NewCustomPaddedVBoxLayout(spacing), …). Keep the comment explaining why the spacing is negative — that rationale is not in the stock docs.

F5 — The negative spacings are magic numbers with a theme expression available (medium, single fix)

detailRowSpacing = -8 and jobRowSpacing = -8 are exactly -theme.InnerPadding() at the default theme, and the match is not accidental: two stacked labels contribute 8 px of inner padding each at their shared edge, so 8 removes one label's worth and leaves the other. Written as 8 the reasoning is invisible and the value stops tracking a theme that changes SizeNameInnerPadding — the exact class of breakage the roadmap flags.

Text does not currently overlap at either measured scale (line height 19.1 within a 35.1 label at text size 14; 27.2 within 43.2 at text size 20), so this is a robustness fix, not a rendering bug.

settingsRowSpacing = -6 has no such derivation — it is 0.75 of the inner padding, chosen by eye.

Fix: replace the three constants with one function, in the style activityRowsHeight and detailCaptionWidth already set:

// rowOverlap pulls stacked label rows together by exactly one label's vertical
// inner padding, which is the whitespace two adjacent labels double up on.
func rowOverlap() float32 { return -theme.InnerPadding() }

A function, not a const, because theme.InnerPadding() must be read after the app exists. Settings then either adopts the same value (8, a 2 px change) or keeps a documented fraction of it.

F6 — History column widths are raw pixels and truncate on a scaled UI (medium, single fix)

table.SetColumnWidth(0…4, 150/90/170/90/260) leaves as little as 1.6 px of headroom at the default text size (Time, holding 2026-06-01 10:00:00) and 3.3 (State, holding Succeeded). At text size 20 three of the five columns truncate their own content:

Column Width Needs at text size 20
Time 150 204.9 (2026-06-01 10:00:00)
Trigger 90 101.3 (Schedule)
State 90 117.0 (Succeeded)

The Log column is already immune — logColumnWidth measures its content with fyne.MeasureText(…, theme.TextSize(), …). The remaining five columns should be sized the same way, from a representative sample string per column (a timestamp, the longest trigger and state names) rather than from a pixel count.

F7 — minJobsSidebarWidth never binds (low, single fix)

The Jobs sidebar's natural minimum is 448.0, set by the toolbar row of five buttons; the constant is 400. It has never had an effect at the default theme, and scaling only widens the gap. The Border left slot gives the sidebar exactly its MinSize width, so the wrapper contributes nothing.

Fix: delete the constant and the wrapper. One caveat: jobsSidebar in jobs_view_test.go locates the sidebar by looking for a container whose layout is minWidthLayout, so it needs a different anchor in the same change. See also F15 — an HSplit would remove the question.

F8 — The settings button row is indented twice as far as its comment claims (low, single fix)

settings_view.go:299-311 builds two transparent canvas.Rectangle spacers, the second to "[indent] the buttons from the left edge the same amount" as theme.Padding(). Measured, the first button lands at x = 8, not 4: an HBox inserts its own theme.Padding() gap after the spacer, so the inset is doubled.

Fix: drop both rectangles for the stock layout.NewCustomPaddedLayout(2*theme.Padding(), 0, theme.Padding(), 0) wrapped around a plain HBox. That reproduces the current 12 px top gap exactly (VBox padding + 2× padding) while giving the intended 4 px left inset — and removes the codebase's second spacing idiom (see the fixedHeightLayout note above). If the 8 px inset is what was actually wanted, the constant should say so instead.

F9 — The details value column has no floor of its own (low, roadmap)

captionValueLayout gives the caption a fixed width and hands the remainder to the value, with no lower bound: valueWidth = size.Width - captionWidth - padding, clamped at 0. Measured across the pane's reachable widths:

Window width Caption Value
1583 116.2 439.3
1024 116.2 159.8
920 (the panel's own minimum) 116.2 107.8

So the value never actually vanishes — but only because commandOutputScroll.SetMinSize(fyne.NewSize(460, 70)) (jobs_view_details.go:62) keeps the pane 460 px wide, and that constant exists for an unrelated reason (readable command output). Lower it and the value column silently starves; feed the layout 240 px directly and the value renders at width 0 with no warning.

The coupling is invisible in both files. Either give captionValueLayout its own minimum (shrink the caption once the value would drop below some floor, so the caption truncates first), or record the dependency at both ends.

Related, same type: MinSize and Layout both return silently when len(objects) != 2. A miswired caller renders an empty row rather than failing. The type is package-private with one constructor (detailRow), so this is a documentation-grade nit, not a defect.

F10 — The detail caption list is written twice (low, single fix)

detailCaptionWidth() (jobs_view_details.go:165) hard-codes the eleven caption strings to measure the widest; container() writes the same eleven strings again, thirty lines above, to build the rows. They match today. Add a twelfth row and forget the list, and the new caption silently truncates — with no test to catch it, because the width is correct for the captions the function knows about.

Fix: build the rows from one []struct{caption string; value fyne.CanvasObject} and derive the width from that same slice.

F11 — The History table re-sorts its whole backing slice once per cell (medium, single fix)

func(id widget.TableCellID, item fyne.CanvasObject) {
    label.SetText(historyCellText(id, sortedEvents()))

sortedEvents() copies the event slice and sort.SliceStables it — and it is called from the per-cell update callback. Measured with 300 events in a 1200×800 window: 126 UpdateCell calls per Refresh, so 126 copies and 126 sorts of a 300-element slice for one redraw. A redraw runs on every recorded run, every service error, and every UI action, since mainwindow.go's observer calls refresh() unconditionally.

Fix: sort once per refresh into a slice the callback reads. The same callback also assigns a constant fyne.TextStyle{} that the row template already carries, then calls label.Refresh() for that assignment; with the assignment gone the Refresh goes too, because SetText already refreshes.

F12 — Jobs handlers repeat the work refreshView is about to do (low, single fix)

refreshView already calls syncFromService(), recomputes filteredJobs, updates the details panel and calls list.Refresh(). Six handlers call list.Refresh() immediately before calling refreshView() (jobs_view.go:238, 256, 270, 302, 315, 347, plus 181/191 in the folder filter), and the pause handler additionally repeats syncFromService(). widget.List.Refresh() re-creates the row template and re-measures the row height, so this is not free. Where the earlier syncFromService() is genuinely needed — folderOptions(jobs) reads the refreshed slice — it should stay, with only the redundant list.Refresh() removed.

F13 — settings_view.go is 445 lines and speaks two dialects (low, roadmap)

Past the ~250-line guideline in ARCHITECTURE.md, and the split jobs_view.go already demonstrates the shape. Three seams are visible in the file as it stands:

  • settings_view.gosettingsView: field construction, save/load/validate.
  • settings_view_layout.gosettingsSection, settingsRow, the two columns, the button row.
  • settings_view_helpers.gofyneVersion, mustParseURL, settingsFolderPath, openFolder, the file/folder pickers.

Two composition inconsistencies to settle in the same pass rather than carry across the split:

  • The Application and About blocks use settingsSection (condensed spacing); Queue and Storage inline container.NewVBox(header, rows…) (theme spacing). Two spellings of "a titled block of rows" in one function. Both comments justify the difference, but a settingsSection(title, spacing, rows…) — or two named constructors — would say it once.
  • chooseFile and chooseJSONFile are the same eight lines apart from SetFilter. One function taking a filter (nil for none) removes the copy. Note chooseFile is also used by job_dialog.go, so it belongs with the helpers, not with Settings.

F14 — logColumnPadding = 24 (low, single fix)

The cell is a widget.Label, whose text is inset by theme.InnerPadding() on each side: 16 px, plus table padding. 24 is a hand-tuned guess at that. Express it as 2*theme.InnerPadding() (plus whatever margin is wanted, named) so it tracks the theme like the width it is added to already does. The three constants are also untyped int while every other width constant in the package is a typed float32.

F15 — Master/detail is a fixed Border, not a split (low, roadmap)

jobs_view.go:365-366 pins the sidebar at its MinSize in a Border left slot, so the user can never give the details pane more room or the job list less. This is what container.NewHSplit is for: a draggable divider, SetOffset for the initial ratio, and no minWidthLayout wrapper or minJobsSidebarWidth constant needed. It changes behaviour rather than just structure, so it is a roadmap item, not a cleanup — but it subsumes F7 outright, gives F9 a user-controlled escape (drag the divider left to widen the value column), and is the idiomatic Fyne composition for this screen.

What holds up — do not "fix" these

  • activityRowsHeight() and detailCaptionWidth(). Both derive their result by measuring a real widget under the current theme. They are the pattern everything else in the package should converge on, and they already behave correctly at text size 20 (114.2→138.7 and 116.2→159.1).
  • minWidthLayout degrades safely. Because MinSize takes the max of the configured width and the child's own minimum, a width routed through this layout can never clip its content — it can only inflate a minimum. That is why F1 is a sizing problem and not a rendering one, and it is the property the History column widths in F6 lack.
  • The compact/detailed row mechanism. applyRowMode expressing the mode as visibility, with the comment about widget.List caching the template's MinSize, is correct and non-obvious; both call sites are needed.
  • The negative spacing itself. It does not overlap text at either measured scale. F5 is about how the number is written, not about abandoning the technique.
  • container.NewVScroll around Settings. It correctly keeps the tab from dictating the window's minimum height. F1 is the same idea left half-applied.

Suggested order

  1. F2 + F3 together — they are the two causes of F1, and neither changes anything visible above the minimum window width. Measured result: 1165.5 → 993.3. Verify by asserting the assembled content's MinSize().Width stays under the 1024 default in a test; that is the regression guard F1 has been missing. If more headroom is wanted afterwards, deriving settingsLabelWidth from the widest caption the way detailCaptionWidth does buys another 21.7 (→ 971.7).
  2. F4, F7, F14 — deletions, no behaviour change. F7 needs the test helper re-anchored in the same commit.
  3. F11, F12 — redraw cost, self-contained.
  4. F5, F8, F10 — the workaround-shaped constants, once the deletions have settled.
  5. F6 — needs a per-column sample string decided first.
  6. F9, F13, F15 — back to ROADMAP.md.