feat(ui): draggable divider between the jobs list and the details pane
The jobs view pinned the sidebar in a Border left slot, so it rendered at its MinSize and nothing else: the user could never trade list width for detail width, and the details value column had no escape from the width the metadata grid left it. container.NewHSplit replaces the Border. Either pane can now be dragged, and container.Split clamps both at their own content minimum, so the divider degrades a pane rather than clipping it. The initial offset is derived, not a literal: SetOffset takes a ratio, but the sidebar's natural width is absolute (448 px), and a ratio that fits 1024 px would hand the sidebar 700 px at 1600 px. initialSplitOffset divides that width by the default window width less the divider, which opens the sidebar at exactly its content width with the rest to details. The divider position is not persisted; a restart reopens at the computed default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -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
|
||||
}
|
||||
|
||||
+50
-16
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user