diff --git a/src/app/operations_test.go b/src/app/operations_test.go index 0615fe7..877419d 100644 --- a/src/app/operations_test.go +++ b/src/app/operations_test.go @@ -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}}) - var ran bool + done := make(chan struct{}, 1) svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { - ran = true - return domain.RunRecord{} + select { + case done <- struct{}{}: + default: + } + return domain.RunRecord{State: "Success"} } if err := svc.SetGlobalPause(true); err != nil { t.Fatalf("SetGlobalPause: %v", err) } - if err := svc.RunNow(1); err == nil { - t.Error("expected RunNow to be refused while paused") + // Pause stops only scheduled runs; an explicit manual run is still allowed. + if err := svc.RunNow(1); err != nil { + t.Fatalf("RunNow should be allowed while paused: %v", err) } - if ran { - t.Error("runner must not be invoked while paused") + select { + 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()) var ran int32 + runStarted := make(chan struct{}, 1) svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord { atomic.AddInt32(&ran, 1) + select { + case runStarted <- struct{}{}: + default: + } 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)) time.Sleep(50 * time.Millisecond) if atomic.LoadInt32(&ran) != 0 { t.Error("RunDue ran a job on a service rebuilt from a paused store") } - // RunNow must be refused. - if err := svc2.RunNow(1); err == nil { - t.Error("RunNow should be refused on a service rebuilt from a paused store") + // A manual RunNow is still allowed while paused — pause only stops the + // scheduler, not the user's explicit action. + if err := svc2.RunNow(1); err != nil { + t.Errorf("RunNow should be allowed while paused: %v", err) } - if atomic.LoadInt32(&ran) != 0 { - t.Error("runner was invoked despite global pause") + select { + case <-runStarted: + case <-time.After(2 * time.Second): + t.Error("manual run was not started on a service rebuilt from a paused store") } } diff --git a/src/app/run.go b/src/app/run.go index a43d8c8..8080f73 100644 --- a/src/app/run.go +++ b/src/app/run.go @@ -11,8 +11,9 @@ import ( "gitea.mixdep.ru/mix/gosentry/src/runner" ) -// RunNow starts a manual run of a job. It refuses to run while globally paused — -// the pause is an emergency stop for all execution — and will not start a job +// RunNow starts a manual run of a job. Global pause stops only the scheduler's +// 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 // 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 @@ -21,10 +22,6 @@ import ( // "Running" status), not the run's own outcome. func (s *Service) RunNow(id int) error { s.mu.Lock() - if s.paused { - s.mu.Unlock() - return errors.New("scheduler is paused") - } job := s.findByIDLocked(id) if job == nil { s.mu.Unlock() diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index a8dc6a3..a272c6e 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -28,6 +28,12 @@ const maxJobActivityRows = 3 // padding, tightening the block so it fits comfortably on 720p screens. 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. // 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 @@ -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}) meta := widget.NewLabel("schedule") 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) { 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) { return } - if schedulerPaused { - // The global pause is treated as an emergency stop for all execution, - // including manual "Run now", so the user has one reliable switch. - dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) - 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. + // A manual run is allowed even while the scheduler is paused: pause only + // stops automatic scheduled runs, not the user's explicit "Run now". + // RunNow still 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 { return } diff --git a/src/ui/jobs_view_details.go b/src/ui/jobs_view_details.go index 3b0567b..51f707f 100644 --- a/src/ui/jobs_view_details.go +++ b/src/ui/jobs_view_details.go @@ -116,12 +116,13 @@ func (d *detailsPanel) container() fyne.CanvasObject { // 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 // ten rows pushes the minimum window height past the available space. + capW := detailCaptionWidth() rows := container.New(compactVBoxLayout{spacing: detailRowSpacing}, - detailRowPair("Folder", d.folder, "Schedule", d.schedule), - detailRowPair("Command", d.command, "Arguments", d.arguments), - detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy), - detailRowPair("Last run", d.lastRun, "Next run", d.nextRun), - detailRowPair("State", d.state, "Statistics", d.stats), + detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule), + detailRowPair(capW, "Command", d.command, "Arguments", d.arguments), + detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy), + detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun), + detailRowPair(capW, "State", d.state, "Statistics", d.stats), ) top := container.NewVBox( d.title, @@ -152,16 +153,36 @@ func activityRowsHeight(rows int) float32 { return (itemHeight+padding)*float32(rows) - padding + 1 } -// 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(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject { - return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2)) +// detailCaptionWidth returns the width reserved for every metadata caption, +// derived from the widest caption label so the value columns all start at the +// same x and no caption truncates. Measuring a real label keeps it DPI- and +// 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.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 { diff --git a/src/ui/layout.go b/src/ui/layout.go index ccb6be2..805c79f 100644 --- a/src/ui/layout.go +++ b/src/ui/layout.go @@ -2,6 +2,7 @@ package ui import ( "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" ) 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)) } } + +// 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)) +} diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 7c98878..57457bd 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -19,7 +19,6 @@ import ( const settingsLabelWidth float32 = 140 const settingsControlWidth float32 = 330 -const settingsStatusWidth float32 = 280 const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" // settingsRowSpacing is the (negative) gap between rows of the settings form, @@ -76,6 +75,10 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogAgeDays := widget.NewEntry() maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) + // 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("") saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { @@ -123,16 +126,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsStatus.SetText("Saved") }) - // The form is grouped into sections in an outer VBox. The outer box keeps the - // theme's normal padding, so the separators and the editable Storage fields - // get proper breathing room; only the label-only sections are condensed with - // the tight settingsSection spacing. 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( + // 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", - 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("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), ), @@ -141,7 +144,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), 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 // (not the condensed section layout) so the entry boxes keep a visible // gap between them instead of merging into one block. @@ -153,8 +157,6 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsRow("Max log files", maxLogFiles), settingsRow("Max log age days", maxLogAgeDays), ), - saveSettings, - settingsStatus, widget.NewSeparator(), settingsSection("About", settingsRow("GoSentry", widget.NewLabel(app.Version)), @@ -162,6 +164,19 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsRow("Fyne", widget.NewLabel(fyneVersion())), 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 +249,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { 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)) -}