perf(ui): sort History once per redraw, drop duplicate jobs refreshes

Stage 4 of the GUI layout plan (F11, F12).

History: the cell callback copied and sorted the whole event list on every
call, and a full-window Refresh issues one call per visible cell — 126 sorts
of a 300-element slice per redraw, measured. The sorted snapshot now lives in
`rows`, refilled by `resort()` at build time, on a sort toggle, and from
`refresh()`. The length callback moves to `len(rows)` with it: cells and the
row count have to read the same slice, which was only incidentally true while
each cell re-derived the order for itself. The column captions become a
package-level array instead of a slice reallocated per header update, and the
per-cell `TextStyle`/`Refresh()` pair goes — the template already carries the
zero style and `SetText` refreshes.

Jobs: `refreshView()` already re-reads the service snapshot and refreshes the
list, so the six `list.Refresh()` calls that preceded it, and the duplicate
`syncFromService()` in the pause handler, were redundant. The folder filter's
early-return path never reaches `refreshView()`, so its `list.Refresh()` moves
into that branch rather than being deleted.

Both changes carry regression tests, each verified to fail against the
behaviour it guards: `TestHistorySortToggleKeepsRowsInSync` for the
cache-versus-length hazard, `TestToolbarButtonRedrawsRowAndDetails` for the
handlers that now rely on `refreshView` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-27 14:09:51 +03:00
parent 60aceb75af
commit 57e6fe410e
4 changed files with 239 additions and 25 deletions
+3 -8
View File
@@ -166,12 +166,14 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
if len(filteredJobs) == 0 {
// The "No folder" filter is intentionally allowed to be empty. It is a
// real filter choice, not an error state, so the selection is cleared.
// This path returns without reaching refreshView(), so it is the one
// place the list has to be redrawn by hand.
selected = -1
updateDetails(-1)
list.Refresh()
return
}
selected = filteredJobs[0]
@@ -223,7 +225,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
}
selected = indexOfID(jobs, created.ID)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
list.Select(app.DisplayIndex(filteredJobs, selected))
refreshView()
})
@@ -241,7 +242,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
list.Refresh()
refreshView()
})
})
@@ -255,7 +255,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
dialog.ShowError(err, w)
return
}
list.Refresh()
refreshView()
})
@@ -287,7 +286,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
stopAllButton.SetText("Disable auto")
stopAllButton.SetIcon(theme.MediaPauseIcon())
}
list.Refresh()
refreshView()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
@@ -299,8 +297,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
dialog.ShowError(err, w)
return
}
syncFromService()
list.Refresh()
refreshView()
})
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
@@ -332,7 +328,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
} else {
selected = filteredJobs[0]
}
list.Refresh()
if selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}