diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index c751b21..1c6da03 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -345,6 +345,11 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar) sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list) - panel := container.NewBorder(nil, nil, sidebar, nil, container.NewPadded(dp.container())) + // A split rather than a Border left slot: the border pinned the sidebar at its + // MinSize forever, so the user could never trade list width for detail width. + // The divider lets either pane grow, and neither can be dragged below its own + // content minimum. + panel := container.NewHSplit(sidebar, container.NewPadded(dp.container())) + panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width)) return panel, refreshView } diff --git a/src/ui/jobs_view_test.go b/src/ui/jobs_view_test.go index 5495359..116f4b2 100644 --- a/src/ui/jobs_view_test.go +++ b/src/ui/jobs_view_test.go @@ -7,6 +7,7 @@ import ( "gitea.mixdep.ru/mix/gosentry/src/domain" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/widget" ) @@ -135,19 +136,24 @@ func findFirst(root fyne.CanvasObject, match func(fyne.CanvasObject) bool) fyne. return nil } +// jobsSplit returns the view's master/detail split. newJobsView assembles the +// panel as container.NewHSplit(sidebar, details), so the two panes are reached +// through Leading and Trailing. +func jobsSplit(t *testing.T, content fyne.CanvasObject) *container.Split { + t.Helper() + split, ok := content.(*container.Split) + if !ok { + t.Fatal("jobs view is not the expected Split container") + } + return split +} + // jobsSidebar narrows the search to the left pane. The details panel has a // widget.List of its own (the activity log), so a search from the whole view -// would find the wrong one. newJobsView assembles the panel as -// container.NewBorder(nil, nil, sidebar, nil, ...); NewBorder keeps the centre -// object first and appends the border slots after it, so panel.Objects[1] is -// the left (sidebar) slot. +// would find the wrong one. func jobsSidebar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject { t.Helper() - panel, ok := content.(*fyne.Container) - if !ok || len(panel.Objects) < 2 { - t.Fatal("jobs view is not the expected Border container") - } - return panel.Objects[1] + return jobsSplit(t, content).Leading } func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List { @@ -193,15 +199,10 @@ func jobsToolbarButton(t *testing.T, content fyne.CanvasObject, text string) *wi return found.(*widget.Button) } -// jobsDetails narrows the search to the right pane. NewBorder keeps the centre -// object first, so panel.Objects[0] is the details pane (see jobsSidebar). +// jobsDetails narrows the search to the right pane (see jobsSidebar). func jobsDetails(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject { t.Helper() - panel, ok := content.(*fyne.Container) - if !ok || len(panel.Objects) == 0 { - t.Fatal("jobs view is not the expected Border container") - } - return panel.Objects[0] + return jobsSplit(t, content).Trailing } // jobsDetailsActivity returns the "Selected job activity" list, the only @@ -347,6 +348,39 @@ func TestJobsSidebarWidthIsItsContent(t *testing.T) { } } +// TestJobsSplitOpensAtTheSidebarWidth is the guard for the derived initial +// offset (F15): at the default window width the divider must open at the +// sidebar's own width — enough that the toolbar is never born clipped, and no +// more, since every extra pixel is taken from the details pane. Split's own +// clamp guarantees the lower bound, so the upper bound is what actually proves +// the offset was derived rather than left at the 0.5 default. +func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + w := testApp.NewWindow("test") + defer w.Close() + + store := newTestStore(t) + svc := app.NewService(store, nil) + defer svc.Stop() + + content, _ := newJobsView(w, svc) + w.SetContent(content) + + split := jobsSplit(t, content) + split.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight)) + + want := split.Leading.MinSize().Width + got := split.Leading.Size().Width + // One pixel of slack for the float32 round trip through the offset ratio. + if got < want || got > want+1 { + t.Errorf("leading pane opens at %v, want its content minimum %v", got, want) + } + if trailing := split.Trailing.Size().Width; trailing < split.Trailing.MinSize().Width { + t.Errorf("trailing pane opens at %v, below its minimum %v", trailing, split.Trailing.MinSize().Width) + } +} + // TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the // toolbar handlers no longer re-read the service or refresh the list // themselves, so refreshView alone has to re-snapshot the jobs and repopulate diff --git a/src/ui/layout.go b/src/ui/layout.go index 7261740..e5bfb75 100644 --- a/src/ui/layout.go +++ b/src/ui/layout.go @@ -59,6 +59,25 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { // than hard-coded so it follows a theme that changes SizeNameInnerPadding. func rowOverlap() float32 { return -theme.InnerPadding() } +// initialSplitOffset returns the container.Split offset that opens a horizontal +// split with its leading pane at the given natural width. SetOffset takes a +// ratio, but a pane's natural width is absolute, so the ratio is derived from +// the window width the app opens at rather than written as a literal: 0.44 fits +// 1024 px but would hand a 448 px sidebar 700 px on a 1600 px-wide window. +// +// The divider sits between the panes and is excluded from the ratio, matching +// container.Split's own arithmetic (its divider is two theme paddings thick). +// Split clamps the offset to both panes' minimums when it lays out, so a result +// that is slightly off — the window is a little wider than its content area — +// costs at most a few pixels and can never clip either pane. +func initialSplitOffset(leadingWidth float32) float64 { + available := float64(defaultWindowWidth - 2*theme.Padding()) + if available <= 0 { + return 0 + } + return float64(leadingWidth) / available +} + // fixedHeightLayout forces its contents to a fixed height while leaving the // width to the parent container. It is used to reserve a stable amount of space // for the activity panel so a neighbouring widget can absorb the rest.