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>
This commit is contained in:
+29
-14
@@ -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
@@ -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()
|
||||||
|
|||||||
+11
-9
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-11
@@ -116,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,
|
||||||
@@ -152,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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
+28
-18
@@ -19,7 +19,6 @@ import (
|
|||||||
|
|
||||||
const settingsLabelWidth float32 = 140
|
const settingsLabelWidth float32 = 140
|
||||||
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,
|
||||||
@@ -76,6 +75,10 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||||
maxLogAgeDays := widget.NewEntry()
|
maxLogAgeDays := widget.NewEntry()
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
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("")
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
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")
|
settingsStatus.SetText("Saved")
|
||||||
})
|
})
|
||||||
|
|
||||||
// The form is grouped into sections in an outer VBox. The outer box keeps the
|
// The form is split into two columns so a wide window uses its horizontal
|
||||||
// theme's normal padding, so the separators and the editable Storage fields
|
// space instead of stretching into one tall strip. The left column holds the
|
||||||
// get proper breathing room; only the label-only sections are condensed with
|
// toggles (Application, Queue); the right holds the editable Storage fields and
|
||||||
// the tight settingsSection spacing. Wrapping the whole thing in a vertical
|
// the read-only About block. Save spans the full width below both columns.
|
||||||
// scroll keeps its minimum height small so it does not dictate the window's
|
leftColumn := container.NewVBox(
|
||||||
// minimum height (AppTabs sizes to the tallest tab) and it scrolls on short
|
|
||||||
// 720p screens.
|
|
||||||
return container.NewVScroll(container.NewPadded(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)),
|
||||||
),
|
),
|
||||||
@@ -141,7 +144,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
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 +157,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 +164,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 +249,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))
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user