Compare commits

..

4 Commits

Author SHA1 Message Date
mixeme f53447d3e8 feat(ui): align settings spacing, fix label clipping, gate Save button
- Give the Queue selects the same default spacing as the Storage fields
- Widen the settings caption column so "Default overlap policy" fits
- Disable Save until a field differs from the saved config, re-enabling on
  change and re-disabling after a successful save

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:49:03 +03:00
mixeme 24a8140e26 chore: release 0.11.0 with paused manual runs and two-column layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:52:10 +03:00
mixeme 9ecb8b61f8 feat(ui): two-column settings/details, compact job list, manual run while paused
- Allow manual "Run now" while the scheduler is paused: pause now stops only
  automatic scheduled runs (RunDue), not the user's explicit action. Drop the
  paused guard in Service.RunNow and the UI pause dialog; update tests.
- Cap the details metadata caption width via a new captionValueLayout so a wider
  window feeds extra space to the value column instead of the short caption.
- Reorganize the Settings tab into two columns (Application+Queue / Storage+About)
  with Save spanning the full width; move the Autostart status onto its own line.
- Condense the Jobs list rows with compactVBoxLayout to fit more jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:51:03 +03:00
mixeme 43809e5076 fix(ui): refresh activity panel when switching selected job
The "Selected job activity" list kept showing the previous job's entries
when a different job was selected. dp.update() reassigned the backing
slice but never refreshed the widget.List, and only the refreshView path
appended an explicit refresh. Move d.logs.Refresh() into update()/clear()
so every caller redraws the panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:34:19 +03:00
8 changed files with 231 additions and 63 deletions
+30
View File
@@ -2,6 +2,36 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## 0.11.0 - 2026-06-25
**Manual runs while paused, two-column Settings/details, and a more compact job list.**
**Scheduler:**
- "Run now" is now allowed while the scheduler is globally paused. The global
pause stops only automatic scheduled runs; an explicit manual run is the user's
own one-off action and is no longer blocked (the already-running and
sequential-mode guards still apply).
**Jobs details panel:**
- Metadata captions (Folder, Command, Run mode, …) are pinned to a fixed width
instead of an even split, so widening the window now grows the value column
rather than the short caption.
- Fixed a bug where the "Selected job activity" panel kept showing the previous
job's entries when a different job was selected; the list now refreshes on
every selection change.
**Jobs list:**
- List rows (name, schedule/command, status) are condensed with a tight,
negative-gap layout so more jobs are visible without scrolling.
**Settings tab:**
- The form is reorganized into two columns — Application and Queue on the left,
Storage and About on the right — with the Save button spanning the full width
below. The Autostart status moved onto its own line so the section fits a
half-width column.
- Removed the blank row that sat between the Save button and the following
separator.
## 0.10.2 - 2026-06-25 ## 0.10.2 - 2026-06-25
**Condensed details/settings panels and a window that shrinks to 720p.** **Condensed details/settings panels and a window that shrinks to 720p.**
+29 -14
View File
@@ -335,21 +335,27 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
} }
} }
func TestRunNowRefusedWhilePaused(t *testing.T) { func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran bool done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
ran = true select {
return domain.RunRecord{} case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}
} }
if err := svc.SetGlobalPause(true); err != nil { if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err) t.Fatalf("SetGlobalPause: %v", err)
} }
if err := svc.RunNow(1); err == nil { // Pause stops only scheduled runs; an explicit manual run is still allowed.
t.Error("expected RunNow to be refused while paused") if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow should be allowed while paused: %v", err)
} }
if ran { select {
t.Error("runner must not be invoked while paused") case <-done:
case <-time.After(2 * time.Second):
t.Error("runner was not invoked for a manual run while paused")
} }
} }
@@ -586,23 +592,32 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
svc2 := NewService(svc.store, svc.Jobs()) svc2 := NewService(svc.store, svc.Jobs())
var ran int32 var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
default:
}
return domain.RunRecord{} return domain.RunRecord{}
} }
// RunDue must not start any job while paused. // RunDue must not start any job while paused: the scheduler stays paused after
// a restart that rebuilt the service from a paused store.
svc2.RunDue(time.Now().Add(2 * time.Minute)) svc2.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 { if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store") t.Error("RunDue ran a job on a service rebuilt from a paused store")
} }
// RunNow must be refused. // A manual RunNow is still allowed while paused — pause only stops the
if err := svc2.RunNow(1); err == nil { // scheduler, not the user's explicit action.
t.Error("RunNow should be refused on a service rebuilt from a paused store") if err := svc2.RunNow(1); err != nil {
t.Errorf("RunNow should be allowed while paused: %v", err)
} }
if atomic.LoadInt32(&ran) != 0 { select {
t.Error("runner was invoked despite global pause") case <-runStarted:
case <-time.After(2 * time.Second):
t.Error("manual run was not started on a service rebuilt from a paused store")
} }
} }
+3 -6
View File
@@ -11,8 +11,9 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/runner"
) )
// RunNow starts a manual run of a job. It refuses to run while globally paused — // RunNow starts a manual run of a job. Global pause stops only the scheduler's
// the pause is an emergency stop for all execution — and will not start a job // automatic runs (see RunDue), so a manual "Run now" is allowed even while
// paused — it is the user's explicit, one-off action. It will not start a job
// that is already running. In sequential execution mode it also refuses while // that is already running. In sequential execution mode it also refuses while
// any other job is running, so a manual run never breaks the one-at-a-time // any other job is running, so a manual run never breaks the one-at-a-time
// guarantee. The run itself happens on a background goroutine that records the // guarantee. The run itself happens on a background goroutine that records the
@@ -21,10 +22,6 @@ import (
// "Running" status), not the run's own outcome. // "Running" status), not the run's own outcome.
func (s *Service) RunNow(id int) error { func (s *Service) RunNow(id int) error {
s.mu.Lock() s.mu.Lock()
if s.paused {
s.mu.Unlock()
return errors.New("scheduler is paused")
}
job := s.findByIDLocked(id) job := s.findByIDLocked(id)
if job == nil { if job == nil {
s.mu.Unlock() s.mu.Unlock()
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build // Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds // scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build. // can override it with Go ldflags when CI tags a build.
var Version = "0.10.2" var Version = "0.11.0"
+11 -9
View File
@@ -28,6 +28,12 @@ const maxJobActivityRows = 3
// padding, tightening the block so it fits comfortably on 720p screens. // padding, tightening the block so it fits comfortably on 720p screens.
const detailRowSpacing float32 = -8 const detailRowSpacing float32 = -8
// jobRowSpacing is the (negative) gap between the name, metadata, and status
// lines within each job list row. Like the details panel, it overlaps the
// labels' built-in vertical padding so each row reads as one compact block and
// more jobs are visible without scrolling.
const jobRowSpacing float32 = -8
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar. // newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// It returns the assembled panel and a refresh function the caller invokes // It returns the assembled panel and a refresh function the caller invokes
// whenever the service state may have changed (e.g., from the event subscriber // whenever the service state may have changed (e.g., from the event subscriber
@@ -97,7 +103,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
meta := widget.NewLabel("schedule") meta := widget.NewLabel("schedule")
status := widget.NewLabel("status") status := widget.NewLabel("status")
return container.NewVBox(name, meta, status) return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status)
}, },
func(id widget.ListItemID, item fyne.CanvasObject) { func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container) row := item.(*fyne.Container)
@@ -185,14 +191,10 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
} }
if schedulerPaused { // A manual run is allowed even while the scheduler is paused: pause only
// The global pause is treated as an emergency stop for all execution, // stops automatic scheduled runs, not the user's explicit "Run now".
// including manual "Run now", so the user has one reliable switch. // RunNow still refuses an already-running job (it returns an error); the UI
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) // has always ignored that case silently, so the run simply does not start.
return
}
// RunNow refuses an already-running job (it returns an error); the UI has
// always ignored that case silently, so the run simply does not start.
if err := svc.RunNow(jobs[selected].ID); err != nil { if err := svc.RunNow(jobs[selected].ID); err != nil {
return return
} }
+37 -11
View File
@@ -87,6 +87,10 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy
d.stats.SetText(app.DisplayStats(rt)) d.stats.SetText(app.DisplayStats(rt))
d.commandOutput.SetText(rt.Output) d.commandOutput.SetText(rt.Output)
d.selectedLogs = lastJobLogs(rt.Logs) d.selectedLogs = lastJobLogs(rt.Logs)
// The activity list renders d.selectedLogs but, unlike the labels above whose
// SetText refreshes them, only its backing slice changed. Refresh it here so
// switching jobs immediately redraws the panel instead of keeping stale rows.
d.logs.Refresh()
} }
func (d *detailsPanel) clear() { func (d *detailsPanel) clear() {
@@ -103,6 +107,7 @@ func (d *detailsPanel) clear() {
d.stats.SetText("") d.stats.SetText("")
d.commandOutput.SetText("") d.commandOutput.SetText("")
d.selectedLogs = nil d.selectedLogs = nil
d.logs.Refresh()
} }
// container assembles the details pane layout: metadata rows pin to the top, // container assembles the details pane layout: metadata rows pin to the top,
@@ -111,12 +116,13 @@ func (d *detailsPanel) container() fyne.CanvasObject {
// Metadata is laid out in two columns so the block stays half as tall, // Metadata is laid out in two columns so the block stays half as tall,
// keeping the details pane usable on 720p screens where a single column of // keeping the details pane usable on 720p screens where a single column of
// ten rows pushes the minimum window height past the available space. // ten rows pushes the minimum window height past the available space.
capW := detailCaptionWidth()
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing}, rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
detailRowPair("Folder", d.folder, "Schedule", d.schedule), detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
detailRowPair("Command", d.command, "Arguments", d.arguments), detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy), detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
detailRowPair("Last run", d.lastRun, "Next run", d.nextRun), detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
detailRowPair("State", d.state, "Statistics", d.stats), detailRowPair(capW, "State", d.state, "Statistics", d.stats),
) )
top := container.NewVBox( top := container.NewVBox(
d.title, d.title,
@@ -147,16 +153,36 @@ func activityRowsHeight(rows int) float32 {
return (itemHeight+padding)*float32(rows) - padding + 1 return (itemHeight+padding)*float32(rows) - padding + 1
} }
// detailRowPair places two label/value pairs side by side, producing the // detailCaptionWidth returns the width reserved for every metadata caption,
// four-column caption|value|caption|value rows the compact metadata grid uses. // derived from the widest caption label so the value columns all start at the
func detailRowPair(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject { // same x and no caption truncates. Measuring a real label keeps it DPI- and
return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2)) // theme-aware instead of relying on a hand-tuned constant.
func detailCaptionWidth() float32 {
captions := []string{
"Folder", "Schedule", "Command", "Arguments", "Run mode",
"Overlap policy", "Last run", "Next run", "State", "Statistics",
}
var width float32
for _, c := range captions {
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
width = w
}
}
return width
} }
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { // detailRowPair places two label/value pairs side by side, producing the
// four-column caption|value|caption|value rows the compact metadata grid uses.
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, detailRow(captionWidth, l1, v1), detailRow(captionWidth, l2, v2))
}
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate caption.Wrapping = fyne.TextTruncate
return container.NewGridWithColumns(2, caption, value) // A fixed caption width (rather than an even split) means widening the window
// feeds the extra space to the value, not the short caption.
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
} }
func newJobDetailLabel(text string) *widget.Label { func newJobDetailLabel(text string) *widget.Label {
+43
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
) )
type minWidthLayout struct { type minWidthLayout struct {
@@ -107,3 +108,45 @@ func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
object.Resize(fyne.NewSize(size.Width, l.height)) object.Resize(fyne.NewSize(size.Width, l.height))
} }
} }
// captionValueLayout places a fixed-width caption on the left and lets the value
// fill the remaining width, separated by one theme padding. Capping the caption
// stops it from growing with the window (as an even two-column grid would), so
// the extra space a wider window provides goes entirely to the value column. It
// expects exactly two children: caption first, value second.
type captionValueLayout struct {
captionWidth float32
}
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
if len(objects) != 2 {
return fyne.Size{}
}
captionMin, valueMin := objects[0].MinSize(), objects[1].MinSize()
height := captionMin.Height
if valueMin.Height > height {
height = valueMin.Height
}
return fyne.NewSize(l.captionWidth+theme.Padding()+valueMin.Width, height)
}
func (l captionValueLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
if len(objects) != 2 {
return
}
caption, value := objects[0], objects[1]
captionWidth := l.captionWidth
if captionWidth > size.Width {
captionWidth = size.Width
}
caption.Move(fyne.NewPos(0, 0))
caption.Resize(fyne.NewSize(captionWidth, size.Height))
valueX := captionWidth + theme.Padding()
valueWidth := size.Width - valueX
if valueWidth < 0 {
valueWidth = 0
}
value.Move(fyne.NewPos(valueX, 0))
value.Resize(fyne.NewSize(valueWidth, size.Height))
}
+77 -22
View File
@@ -17,9 +17,11 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
const settingsLabelWidth float32 = 140 // settingsLabelWidth is wide enough to show the longest caption ("Default
// overlap policy") in full; the captions truncate, so a narrower width would
// clip it. All rows share this width so their value controls stay aligned.
const settingsLabelWidth float32 = 180
const settingsControlWidth float32 = 330 const settingsControlWidth float32 = 330
const settingsStatusWidth float32 = 280
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
// settingsRowSpacing is the (negative) gap between rows of the settings form, // settingsRowSpacing is the (negative) gap between rows of the settings form,
@@ -29,6 +31,10 @@ const settingsRowSpacing float32 = -6
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store() store := svc.Store()
// updateSaveState compares the form to the saved config and enables Save only
// when something differs. It is defined below (once Save and every field
// exist) but declared here so the field change handlers can reference it.
var updateSaveState func()
startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
@@ -43,39 +49,52 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
startOnLogin.OnChanged = func(bool) { startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin { if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply") autostartStatus.SetText("Pending: save settings to apply")
return } else {
}
refreshAutostartStatus() refreshAutostartStatus()
} }
updateSaveState()
}
refreshAutostartStatus() refreshAutostartStatus()
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray) minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure) notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() }
executionModeSelect := widget.NewSelect( executionModeSelect := widget.NewSelect(
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)}, []string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
nil, nil,
) )
executionModeSelect.SetSelected(string(store.Config.ExecutionMode)) executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
executionModeSelect.OnChanged = func(string) { updateSaveState() }
overlapPolicySelect := widget.NewSelect( overlapPolicySelect := widget.NewSelect(
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)}, []string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil, nil,
) )
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy)) overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
jobsDir := widget.NewEntry() jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir) jobsDir.SetText(store.Config.JobsDir)
jobsDir.OnChanged = func(string) { updateSaveState() }
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir) chooseFolder(w, jobsDir)
}) })
logsDir := widget.NewEntry() logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir) logsDir.SetText(store.Config.LogsDir)
logsDir.OnChanged = func(string) { updateSaveState() }
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir) chooseFolder(w, logsDir)
}) })
maxLogFiles := widget.NewEntry() maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogFiles.OnChanged = func(string) { updateSaveState() }
maxLogAgeDays := widget.NewEntry() maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
// Autostart status sits on its own row beneath the checkbox (rather than
// beside it) so the Application section fits within a half-width column.
// Truncating keeps a long status message from forcing the column wider.
autostartStatus.Wrapping = fyne.TextTruncate
settingsStatus := widget.NewLabel("") settingsStatus := widget.NewLabel("")
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
@@ -121,27 +140,57 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
} }
refreshAutostartStatus() refreshAutostartStatus()
settingsStatus.SetText("Saved") settingsStatus.SetText("Saved")
// The form now matches the persisted config, so disable Save again.
updateSaveState()
}) })
// The form is grouped into sections in an outer VBox. The outer box keeps the // Save stays disabled until a field differs from the saved config, so the
// theme's normal padding, so the separators and the editable Storage fields // button only invites a click when there is something to persist. The numeric
// get proper breathing room; only the label-only sections are condensed with // fields compare against their canonical string form; any unparsable text
// the tight settingsSection spacing. Wrapping the whole thing in a vertical // counts as a change so the user can click Save and see the validation error.
// scroll keeps its minimum height small so it does not dictate the window's updateSaveState = func() {
// minimum height (AppTabs sizes to the tallest tab) and it scrolls on short c := store.Config
// 720p screens. changed := startOnLogin.Checked != c.StartOnLogin ||
return container.NewVScroll(container.NewPadded(container.NewVBox( minimizeToTray.Checked != c.KeepRunningInTray ||
notifications.Checked != c.NotifyOnFailure ||
executionModeSelect.Selected != string(c.ExecutionMode) ||
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays)
if changed {
saveSettings.Enable()
} else {
saveSettings.Disable()
}
}
updateSaveState()
// The form is split into two columns so a wide window uses its horizontal
// space instead of stretching into one tall strip. The left column holds the
// toggles (Application, Queue); the right holds the editable Storage fields and
// the read-only About block. Save spans the full width below both columns.
leftColumn := container.NewVBox(
settingsSection("Application", settingsSection("Application",
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus), settingsRow("Autostart", container.New(minWidthLayout{width: settingsControlWidth}, startOnLogin)),
// Autostart status sits on its own row, aligned under the checkbox via an
// empty caption, so the Application section fits in a half-width column.
settingsRow("", autostartStatus),
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
), ),
widget.NewSeparator(), widget.NewSeparator(),
settingsSection("Queue", // Queue holds the execution mode and overlap policy comboboxes. Like
// Storage, it uses the default VBox spacing (not the condensed section
// layout) so the comboboxes keep a visible gap between them.
container.NewVBox(
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
), ),
widget.NewSeparator(), )
rightColumn := container.NewVBox(
// Storage holds editable entry fields. It uses the default VBox spacing // Storage holds editable entry fields. It uses the default VBox spacing
// (not the condensed section layout) so the entry boxes keep a visible // (not the condensed section layout) so the entry boxes keep a visible
// gap between them instead of merging into one block. // gap between them instead of merging into one block.
@@ -153,8 +202,6 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("Max log files", maxLogFiles), settingsRow("Max log files", maxLogFiles),
settingsRow("Max log age days", maxLogAgeDays), settingsRow("Max log age days", maxLogAgeDays),
), ),
saveSettings,
settingsStatus,
widget.NewSeparator(), widget.NewSeparator(),
settingsSection("About", settingsSection("About",
settingsRow("GoSentry", widget.NewLabel(app.Version)), settingsRow("GoSentry", widget.NewLabel(app.Version)),
@@ -162,6 +209,19 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("Fyne", widget.NewLabel(fyneVersion())), settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))), settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
), ),
)
// The two columns sit in a top-aligned grid; Save spans the full width below.
// Wrapping the whole thing in a vertical scroll keeps its minimum height small
// so it does not dictate the window's minimum height (AppTabs sizes to the
// tallest tab) and it scrolls on short 720p screens.
return container.NewVScroll(container.NewPadded(container.NewVBox(
container.NewGridWithColumns(2, leftColumn, rightColumn),
widget.NewSeparator(),
// Save button and its status share one row so an empty status (the common
// case) does not leave a blank line above the separator. The status appears
// beside the button once a save reports a result.
container.NewHBox(saveSettings, settingsStatus),
))) )))
} }
@@ -234,8 +294,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewBorder(nil, nil, captionBox, nil, value) return container.NewBorder(nil, nil, captionBox, nil, value)
} }
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
}