diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1062a2d..bceb818 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -213,3 +213,14 @@ size guideline: | `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout | | `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` | | `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` | + +### `settings_view.go` file structure + +`src/ui/settings_view.go` is split across three files the same way, once its +own size passed the ~250-line guideline: + +| File | Contents | +|------|----------| +| `settings_view.go` | `settingsView` — field construction, save, load, validate; the Theme label translation helpers | +| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row | +| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) | diff --git a/src/ui/history_view.go b/src/ui/history_view.go index 78a0b2e..d47d777 100644 --- a/src/ui/history_view.go +++ b/src/ui/history_view.go @@ -42,37 +42,85 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event { return events } -// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column. -// The minimum keeps the column readable when names are short or absent; the -// maximum stops a single very long file name from dominating the table (the -// table still scrolls horizontally past it). -const ( - logColumnMinWidth = 240 - logColumnMaxWidth = 520 - logColumnPadding = 24 -) +// textWidth measures how wide s renders at the theme's current body text size. +func textWidth(s string) float32 { + return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width +} -// logColumnWidth measures the widest Log cell value so the column can be sized -// to fit its content. Fyne tables do not auto-size columns, so without this the -// fixed width clips file names like "20260601-100000_SomeJobName.log". -func logColumnWidth(events []event) float32 { - width := float32(logColumnMinWidth) - for _, current := range events { - text := logFileName(current.LogFile) +// cellPadding is the horizontal space a table cell reserves around its text. +// It replaces a hand-tuned pixel constant with the theme's own inner padding +// doubled (one side each), so it follows text size and DPI. +func cellPadding() float32 { return 2 * theme.InnerPadding() } + +// textColumnMinWidth/textColumnMaxWidth bound every content-measured History +// column: the minimum keeps a column readable when its values are short or +// absent, the maximum stops one very long value from dominating the table +// (the table still scrolls horizontally past it). Expressed as measured text +// rather than raw pixels so both follow the theme instead of drifting from it. +func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() } +func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() } + +// textColumnWidth measures the widest of samples so a table column can be +// sized to fit its content, clamped to [min, max]. Fyne tables do not +// auto-size columns, so without this a fixed width clips values like +// "20260601-100000_SomeJobName.log" in the Log column. +func textColumnWidth(samples []string, min, max float32) float32 { + width := min + for _, text := range samples { if text == "" { continue } - w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding - if w > width { + if w := textWidth(text) + cellPadding(); w > width { width = w } } - if width > logColumnMaxWidth { - width = logColumnMaxWidth + if width > max { + width = max } return width } +// historyTriggerSamples is the closed set of Trigger values History ever +// shows (see newEvent and app.operations.go/app.run.go, which produce "UI", +// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new +// trigger here too if one is introduced there, or the column may clip it. +var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"} + +// historyStateSamples is the closed set of State values History ever shows: +// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly); +// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go. +// Add a new state here too if one is introduced in either place. +var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"} + +// historyTimeSample is the rendered form of the timestamp layout every event +// uses (see newEvent), so the Time column needs no content scan: its width is +// fixed by the format string. +const historyTimeSample = "2026-01-02 15:04:05" + +// historyColumnWidths computes every column's width from the current sorted +// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job, +// Detail and Log are free text, so their width tracks the values actually +// present, bounded the same way the Log column always was. +func historyColumnWidths(rows []event) [6]float32 { + jobNames := make([]string, 0, len(rows)) + details := make([]string, 0, len(rows)) + logNames := make([]string, 0, len(rows)) + for _, current := range rows { + jobNames = append(jobNames, current.JobName) + details = append(details, current.Detail) + logNames = append(logNames, logFileName(current.LogFile)) + } + min, max := textColumnMinWidth(), textColumnMaxWidth() + return [6]float32{ + textWidth(historyTimeSample) + cellPadding(), + textColumnWidth(historyTriggerSamples, min, max), + textColumnWidth(jobNames, min, max), + textColumnWidth(historyStateSamples, min, max), + textColumnWidth(details, min, max), + textColumnWidth(logNames, min, max), + } +} + // historyHeader is a bold tappable label used in the History table header row. // In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort // toggle is wired through the Tappable interface instead. @@ -85,7 +133,7 @@ type historyHeader struct { func newHistoryHeader() *historyHeader { h := &historyHeader{label: widget.NewLabel("")} h.label.TextStyle = fyne.TextStyle{Bold: true} - h.label.Wrapping = fyne.TextTruncate + h.label.Truncation = fyne.TextTruncateClip h.ExtendBaseWidget(h) return h } @@ -147,7 +195,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) { }, func() fyne.CanvasObject { label := widget.NewLabel("") - label.Wrapping = fyne.TextTruncate + label.Truncation = fyne.TextTruncateClip return label }, func(id widget.TableCellID, item fyne.CanvasObject) { @@ -175,20 +223,20 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) { table.OnSelected = func(id widget.TableCellID) { table.Unselect(id) } - table.SetColumnWidth(0, 150) - table.SetColumnWidth(1, 90) - table.SetColumnWidth(2, 170) - table.SetColumnWidth(3, 90) - table.SetColumnWidth(4, 260) - table.SetColumnWidth(5, logColumnWidth(*events)) + setColumnWidths := func() { + for col, width := range historyColumnWidths(rows) { + table.SetColumnWidth(col, width) + } + } + setColumnWidths() // refresh re-reads the event list into the sorted snapshot and recomputes - // the content-fit Log column width before redrawing, so newly recorded - // events appear in the current sort order and longer file names widen the - // column instead of being truncated. + // every content-fit column width before redrawing, so newly recorded events + // appear in the current sort order and longer values widen their column + // instead of being truncated. refresh := func() { resort() - table.SetColumnWidth(5, logColumnWidth(*events)) + setColumnWidths() table.Refresh() } return container.NewPadded(table), refresh diff --git a/src/ui/history_view_test.go b/src/ui/history_view_test.go index debd488..67f20b3 100644 --- a/src/ui/history_view_test.go +++ b/src/ui/history_view_test.go @@ -1,6 +1,7 @@ package ui import ( + "strings" "testing" "time" @@ -218,6 +219,79 @@ func TestHistoryCellTemplateIsPlainText(t *testing.T) { } } +// TestTextColumnWidthClamps covers the three shapes textColumnWidth has to +// handle: a sample narrower than min, one that lands between the bounds, and +// one wide enough to hit the max cap. +func TestTextColumnWidthClamps(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + min, max := float32(50), float32(120) + if got := textColumnWidth([]string{"x"}, min, max); got != min { + t.Errorf("below-min sample: got %v, want the floor %v", got, min) + } + inRange := textWidth("mid-sized value") + cellPadding() + if inRange <= min || inRange >= max { + t.Skip("fixture sample no longer lands strictly between the bounds under this theme") + } + if got := textColumnWidth([]string{"mid-sized value"}, min, max); got != inRange { + t.Errorf("in-range sample: got %v, want %v", got, inRange) + } + if got := textColumnWidth([]string{strings.Repeat("0", 200)}, min, max); got != max { + t.Errorf("above-max sample: got %v, want the cap %v", got, max) + } + if got := textColumnWidth(nil, min, max); got != min { + t.Errorf("no samples: got %v, want the floor %v", got, min) + } +} + +// TestHistoryColumnsFitTheirContent guards F6/F14: every column must be at +// least as wide as its widest known or actually-present value, at the default +// theme and at a scaled one, so nothing that used to be a pixel constant +// clips again. +func TestHistoryColumnsFitTheirContent(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + rows := []event{ + { + Time: "2026-06-01 12:00:00", + Trigger: "Schedule", + JobName: "A moderately long job name for width testing", + State: "Jobs loaded", + Detail: "A somewhat longer detail message describing what happened", + LogFile: `/logs/20260601-120000_SomeJobName.log`, + }, + } + + check := func(when string) { + t.Helper() + widths := historyColumnWidths(rows) + samples := [][]string{ + {historyTimeSample}, + historyTriggerSamples, + {rows[0].JobName}, + historyStateSamples, + {rows[0].Detail}, + {logFileName(rows[0].LogFile)}, + } + min, max := textColumnMinWidth(), textColumnMaxWidth() + for col, colSamples := range samples { + want := textColumnWidth(colSamples, min, max) + if col == 0 { + want = textWidth(historyTimeSample) + cellPadding() + } + if widths[col] < want { + t.Errorf("%s: column %d width = %v, want at least %v", when, col, widths[col], want) + } + } + } + + check("default theme") + testApp.Settings().SetTheme(test.NewTheme()) + check("scaled theme") +} + func TestNewEventUsesConsistentTimestampShape(t *testing.T) { ev := newEvent(1, "Job", "OK", "detail") if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil { diff --git a/src/ui/job_dialog.go b/src/ui/job_dialog.go index eea1bdf..25d85d8 100644 --- a/src/ui/job_dialog.go +++ b/src/ui/job_dialog.go @@ -35,7 +35,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`) commandEntry.SetText(current.Command) commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { - chooseFile(w, commandEntry) + chooseFile(w, commandEntry, nil) }) commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry) argumentsEntry := widget.NewMultiLineEntry() diff --git a/src/ui/jobs_view_details.go b/src/ui/jobs_view_details.go index 6b5b70a..a475642 100644 --- a/src/ui/jobs_view_details.go +++ b/src/ui/jobs_view_details.go @@ -65,7 +65,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do func() int { return len(d.selectedLogs) }, func() fyne.CanvasObject { l := widget.NewLabel("log") - l.Wrapping = fyne.TextTruncate + l.Truncation = fyne.TextTruncateClip return l }, func(id widget.ListItemID, item fyne.CanvasObject) { @@ -115,21 +115,59 @@ func (d *detailsPanel) clear() { d.logs.Refresh() } +// detailRowSpec pairs a metadata caption with the widget that shows its value. +// metadataRows and container derive both the caption column width and the row +// layout from this single list, so a row added to one is never forgotten in +// the other. +type detailRowSpec struct { + caption string + value fyne.CanvasObject +} + +// metadataRows lists the details pane's metadata rows in display order. It is +// the single source both the caption width measurement and the row layout in +// container() read, so a twelfth row added here cannot silently go unmeasured +// or unlaid-out the way two separately maintained lists could. +func (d *detailsPanel) metadataRows() []detailRowSpec { + return []detailRowSpec{ + {"Folder", d.folder}, + {"Schedule", d.schedule}, + {"Command", d.command}, + {"Arguments", d.arguments}, + {"Run mode", d.runMode}, + {"Overlap policy", d.overlapPolicy}, + {"Timeout", d.timeout}, + {"State", d.state}, + {"Last run", d.lastRun}, + {"Next run", d.nextRun}, + {"Statistics", d.stats}, + } +} + // container assembles the details pane layout: metadata rows pin to the top, // the activity panel pins to the bottom, and command output fills the remainder. 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(layout.NewCustomPaddedVBoxLayout(rowOverlap()), - 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, "Timeout", d.timeout, "State", d.state), - detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun), - detailRow(capW, "Statistics", d.stats), - ) + specs := d.metadataRows() + captions := make([]string, len(specs)) + for i, spec := range specs { + captions[i] = spec.caption + } + capW := captionColumnWidth(captions...) + rowObjects := make([]fyne.CanvasObject, 0, (len(specs)+1)/2) + for i := 0; i+1 < len(specs); i += 2 { + rowObjects = append(rowObjects, detailRowPair(capW, specs[i].caption, specs[i].value, specs[i+1].caption, specs[i+1].value)) + } + // An odd row count leaves one caption without a partner (Statistics, today); + // it falls through to a single-column row rather than being paired with + // nothing. + if len(specs)%2 == 1 { + last := specs[len(specs)-1] + rowObjects = append(rowObjects, detailRow(capW, last.caption, last.value)) + } + rows := container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), rowObjects...) top := container.NewVBox( d.title, widget.NewSeparator(), @@ -153,30 +191,12 @@ func (d *detailsPanel) container() fyne.CanvasObject { // absorbs sub-pixel rounding so the last row is never clipped behind a scrollbar. func activityRowsHeight(rows int) float32 { sample := widget.NewLabel("log") - sample.Wrapping = fyne.TextTruncate + sample.Truncation = fyne.TextTruncateClip itemHeight := sample.MinSize().Height padding := theme.Padding() return (itemHeight+padding)*float32(rows) - padding + 1 } -// 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", "Timeout", "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 -} - // 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 { @@ -185,7 +205,7 @@ func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 str 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 + caption.Truncation = fyne.TextTruncateClip // 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) @@ -193,6 +213,6 @@ func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne func newJobDetailLabel(text string) *widget.Label { label := widget.NewLabel(text) - label.Wrapping = fyne.TextTruncate + label.Truncation = fyne.TextTruncateClip return label } diff --git a/src/ui/jobs_view_test.go b/src/ui/jobs_view_test.go index 9a2307f..5495359 100644 --- a/src/ui/jobs_view_test.go +++ b/src/ui/jobs_view_test.go @@ -35,8 +35,8 @@ func TestFolderOptionsAlwaysIncludesSentinels(t *testing.T) { func TestFolderOptionsAppendsUniqueFolders(t *testing.T) { jobs := []domain.Job{ {Folder: "Maintenance"}, - {Folder: ""}, // no folder → not a named folder - {Folder: " Backups "}, // trimmed to "Backups" + {Folder: ""}, // no folder → not a named folder + {Folder: " Backups "}, // trimmed to "Backups" {Folder: "Maintenance"}, // duplicate → not added again } opts := folderOptions(jobs) @@ -412,6 +412,29 @@ func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) { } } +// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single +// metadataRows list self-enforcing (F10): every caption it returns must +// measure no wider than captionColumnWidth's result for that same list, or a +// row added to metadataRows without updating the width measurement would +// silently truncate. +func TestDetailCaptionWidthCoversEveryCaption(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + d := newDetailsPanel(job{}, &domain.JobRuntime{}, domain.OverlapPolicySkip, 0) + specs := d.metadataRows() + captions := make([]string, len(specs)) + for i, spec := range specs { + captions[i] = spec.caption + } + capW := captionColumnWidth(captions...) + for _, c := range captions { + if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > capW { + t.Errorf("caption %q measures %v, wider than captionColumnWidth's %v", c, w, capW) + } + } +} + func TestViewToggleTextNamesTheAction(t *testing.T) { cases := []struct { current domain.JobListView diff --git a/src/ui/layout.go b/src/ui/layout.go index 375b962..7261740 100644 --- a/src/ui/layout.go +++ b/src/ui/layout.go @@ -3,8 +3,22 @@ package ui import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" ) +// captionColumnWidth returns the width to reserve for a column of bold +// captions: the widest of them, measured under the current theme so it tracks +// text size and DPI instead of a hand-tuned constant. +func captionColumnWidth(captions ...string) float32 { + var width float32 + for _, caption := range captions { + if w := widget.NewLabelWithStyle(caption, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width { + width = w + } + } + return width +} + type minWidthLayout struct { width float32 } @@ -84,6 +98,10 @@ type captionValueLayout struct { captionWidth float32 } +// MinSize and Layout both return silently when given anything but two +// objects. That is acceptable here because the type is package-private with a +// single constructor (detailRow), which always supplies exactly a caption and +// a value — there is no external caller that could pass the wrong count. func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { if len(objects) != 2 { return fyne.Size{} diff --git a/src/ui/layout_test.go b/src/ui/layout_test.go index b6929ee..32f21c5 100644 --- a/src/ui/layout_test.go +++ b/src/ui/layout_test.go @@ -26,3 +26,27 @@ func TestRowOverlapMatchesInnerPadding(t *testing.T) { t.Errorf("under a different theme, rowOverlap() = %v, want %v", got, want) } } + +// TestCaptionColumnWidth covers the shapes F10's shared helper has to handle: +// no captions, one, and several of varying length at two text sizes. +func TestCaptionColumnWidth(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + if got := captionColumnWidth(); got != 0 { + t.Errorf("no captions: got %v, want 0", got) + } + solo := captionColumnWidth("Solo") + if solo <= 0 { + t.Errorf("one caption: got %v, want > 0", solo) + } + widest := captionColumnWidth("Short", "A Much Longer Caption") + if widest <= solo { + t.Errorf("widest of several captions = %v, want it wider than a single short one (%v)", widest, solo) + } + + testApp.Settings().SetTheme(test.NewTheme()) + if got := captionColumnWidth("Short", "A Much Longer Caption"); got <= 0 { + t.Errorf("under a different theme: got %v, want > 0", got) + } +} diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index dadd41e..caa40cf 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -1,35 +1,31 @@ package ui import ( - "errors" - "image/color" - "net/url" - "runtime" - "runtime/debug" "strconv" "strings" "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" - "gitea.mixdep.ru/mix/gosentry/src/platform/filemanager" - "gitea.mixdep.ru/mix/gosentry/src/storage" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - fynestorage "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) -// 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 projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" +// settingsCaptions lists every settingsRow caption in the tab, in no +// particular order. settingsView measures this once with captionColumnWidth +// so every row's value column starts at the same x; a caption added to a row +// below without being added here is the one way a row would silently fall out +// of alignment. +var settingsCaptions = []string{ + "Autostart", "Tray", "Notifications", "Theme", + "Execution mode", "Default overlap policy", "Default timeout (s)", + "Config JSON", "Jobs file", "Logs directory", "Max log files", "Max log age days", + "GoSentry", "Go", "Fyne", "Repository", +} + func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { store := svc.Store() // updateSaveState compares the form to the saved config and enables Save only @@ -120,7 +116,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { // 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 + autostartStatus.Truncation = fyne.TextTruncateClip settingsStatus := widget.NewLabel("") saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { @@ -234,184 +230,28 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { loadFields(domain.DefaultConfig()) }) - // 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", - settingsRow("Autostart", 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", minimizeToTray), - settingsRow("Notifications", notifications), - settingsRow("Theme", themeSelect), - ), - widget.NewSeparator(), - // 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", executionModeSelect), - settingsRow("Default overlap policy", overlapPolicySelect), - settingsRow("Default timeout (s)", defaultTimeout), - ), - ) - // Truncating keeps a long config path from forcing the Settings tab's - // minimum width to track the path length instead of the layout itself. - configPathLabel := widget.NewLabel(store.Paths.ConfigPath) - configPathLabel.Truncation = fyne.TextTruncateClip - 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. - container.NewVBox( - widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - settingsRow("Config JSON", configPathLabel), - settingsRow("Jobs file", container.NewBorder(nil, nil, nil, jobsFileBrowse, jobsFile)), - // Browse stays rightmost so it lines up with the Jobs file row - // above it; Open sits between it and the path it opens. - settingsRow("Logs directory", container.NewBorder(nil, nil, nil, container.NewHBox(logsDirOpen, logsDirBrowse), logsDir)), - settingsRow("Max log files", maxLogFiles), - settingsRow("Max log age days", maxLogAgeDays), - ), - widget.NewSeparator(), - settingsSection("About", - settingsRow("GoSentry", widget.NewLabel(app.Version)), - settingsRow("Go", widget.NewLabel(runtime.Version())), - 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. - // The button row sits right below the separator's hairline, which reads as - // tighter than the other vertical gaps in the tab (those separate whole - // sections, not a single thin line from a row of buttons). A spacer the - // height of the default padding closes that gap up to match, and a matching - // width spacer indents the buttons from the left edge the same amount. - buttonRowSpacer := canvas.NewRectangle(color.Transparent) - buttonRowSpacer.SetMinSize(fyne.NewSize(0, theme.Padding())) - buttonRowLeftInset := canvas.NewRectangle(color.Transparent) - buttonRowLeftInset.SetMinSize(fyne.NewSize(theme.Padding(), 0)) - - return container.NewVScroll(container.NewPadded(container.NewVBox( - container.NewGridWithColumns(2, leftColumn, rightColumn), - widget.NewSeparator(), - buttonRowSpacer, - // Save/Cancel/Defaults share one row with the status so an empty status - // (the common case) does not leave a blank line above the separator. The - // status appears beside the buttons once a save reports a result. - container.NewHBox(buttonRowLeftInset, saveSettings, cancelSettings, restoreDefaults, settingsStatus), - ))) -} - -// settingsSection groups a bold header above its rows using the tight -// rowOverlap spacing so a block of label rows reads as one compact unit. The -// caller keeps separators and entry-heavy sections in the surrounding VBox so -// they retain the theme's normal spacing. -func settingsSection(title string, rows ...fyne.CanvasObject) fyne.CanvasObject { - children := make([]fyne.CanvasObject, 0, len(rows)+1) - children = append(children, widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) - children = append(children, rows...) - return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), children...) -} - -func fyneVersion() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return "unknown" - } - for _, dependency := range info.Deps { - if dependency.Path == "fyne.io/fyne/v2" { - if dependency.Replace != nil && dependency.Replace.Version != "" { - return dependency.Replace.Version - } - if dependency.Version != "" { - return dependency.Version - } - return "local" - } - } - return "unknown" -} - -func mustParseURL(raw string) *url.URL { - parsed, err := url.Parse(raw) - if err != nil { - return &url.URL{} - } - return parsed -} - -func chooseFile(w fyne.Window, target *widget.Entry) { - fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) { - if err != nil || uri == nil { - return - } - target.SetText(uri.URI().Path()) - }, w) - fileDialog.Resize(fyne.NewSize(900, 640)) - fileDialog.Show() -} - -// chooseJSONFile is chooseFile restricted to .json files, used for the jobs -// file so the picker does not list every file in the folder. The entry stays -// editable, which is how a path to a file that does not exist yet is entered. -func chooseJSONFile(w fyne.Window, target *widget.Entry) { - fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) { - if err != nil || uri == nil { - return - } - target.SetText(uri.URI().Path()) - }, w) - fileDialog.SetFilter(fynestorage.NewExtensionFileFilter([]string{".json"})) - fileDialog.Resize(fyne.NewSize(900, 640)) - fileDialog.Show() -} - -func chooseFolder(w fyne.Window, target *widget.Entry) { - folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { - if err != nil || uri == nil { - return - } - target.SetText(uri.Path()) - }, w) - // The default folder picker can be cramped on Windows. A larger size makes - // long paths readable and avoids forcing the user to resize it every time. - folderDialog.Resize(fyne.NewSize(900, 640)) - folderDialog.Show() -} - -// settingsFolderPath resolves what a directory field currently points at, -// applying the same relative-path rule the store uses when it loads the config -// so the folder that opens is the one the setting would use. Blank text has no -// folder to open and yields an empty path. -func settingsFolderPath(appDir string, text string) string { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return "" - } - return storage.ResolveConfiguredPath(appDir, trimmed) -} - -// openFolder reveals dir in the desktop file manager. A folder that is not set -// or cannot be opened (most often: it does not exist yet, because the logs -// directory is created on the first run) is reported in a dialog rather than -// leaving the button looking dead. -func openFolder(w fyne.Window, dir string) { - if dir == "" { - dialog.ShowError(errors.New("no folder is set"), w) - return - } - if err := filemanager.Open(dir); err != nil { - dialog.ShowError(err, w) - } + return newSettingsLayout(settingsFormFields{ + startOnLogin: startOnLogin, + autostartStatus: autostartStatus, + minimizeToTray: minimizeToTray, + notifications: notifications, + themeSelect: themeSelect, + executionModeSelect: executionModeSelect, + overlapPolicySelect: overlapPolicySelect, + defaultTimeout: defaultTimeout, + configPath: store.Paths.ConfigPath, + jobsFile: jobsFile, + jobsFileBrowse: jobsFileBrowse, + logsDir: logsDir, + logsDirOpen: logsDirOpen, + logsDirBrowse: logsDirBrowse, + maxLogFiles: maxLogFiles, + maxLogAgeDays: maxLogAgeDays, + saveSettings: saveSettings, + cancelSettings: cancelSettings, + restoreDefaults: restoreDefaults, + settingsStatus: settingsStatus, + }) } // Theme dropdown labels. These are the human-facing captions; themeLabel and @@ -435,10 +275,3 @@ func themeFromLabel(label string) domain.Theme { } return domain.ThemeDefault } - -func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { - caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - caption.Wrapping = fyne.TextTruncate - captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) - return container.NewBorder(nil, nil, captionBox, nil, value) -} diff --git a/src/ui/settings_view_helpers.go b/src/ui/settings_view_helpers.go new file mode 100644 index 0000000..76b2801 --- /dev/null +++ b/src/ui/settings_view_helpers.go @@ -0,0 +1,107 @@ +package ui + +import ( + "errors" + "net/url" + "runtime/debug" + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/platform/filemanager" + "gitea.mixdep.ru/mix/gosentry/src/storage" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/dialog" + fynestorage "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/widget" +) + +func fyneVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + for _, dependency := range info.Deps { + if dependency.Path == "fyne.io/fyne/v2" { + if dependency.Replace != nil && dependency.Replace.Version != "" { + return dependency.Replace.Version + } + if dependency.Version != "" { + return dependency.Version + } + return "local" + } + } + return "unknown" +} + +func mustParseURL(raw string) *url.URL { + parsed, err := url.Parse(raw) + if err != nil { + return &url.URL{} + } + return parsed +} + +// chooseFile opens a file picker that writes the chosen path into target. A +// nil filter offers every file, as job_dialog.go's command browser wants; +// chooseJSONFile passed an extension filter here before the two were merged, +// since they differed only by that one call. +func chooseFile(w fyne.Window, target *widget.Entry, filter fynestorage.FileFilter) { + fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) { + if err != nil || uri == nil { + return + } + target.SetText(uri.URI().Path()) + }, w) + if filter != nil { + fileDialog.SetFilter(filter) + } + fileDialog.Resize(fyne.NewSize(900, 640)) + fileDialog.Show() +} + +// chooseJSONFile is chooseFile restricted to .json files, used for the jobs +// file so the picker does not list every file in the folder. The entry stays +// editable, which is how a path to a file that does not exist yet is entered. +func chooseJSONFile(w fyne.Window, target *widget.Entry) { + chooseFile(w, target, fynestorage.NewExtensionFileFilter([]string{".json"})) +} + +func chooseFolder(w fyne.Window, target *widget.Entry) { + folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil || uri == nil { + return + } + target.SetText(uri.Path()) + }, w) + // The default folder picker can be cramped on Windows. A larger size makes + // long paths readable and avoids forcing the user to resize it every time. + folderDialog.Resize(fyne.NewSize(900, 640)) + folderDialog.Show() +} + +// settingsFolderPath resolves what a directory field currently points at, +// applying the same relative-path rule the store uses when it loads the config +// so the folder that opens is the one the setting would use. Blank text has no +// folder to open and yields an empty path. +func settingsFolderPath(appDir string, text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "" + } + return storage.ResolveConfiguredPath(appDir, trimmed) +} + +// openFolder reveals dir in the desktop file manager. A folder that is not set +// or cannot be opened (most often: it does not exist yet, because the logs +// directory is created on the first run) is reported in a dialog rather than +// leaving the button looking dead. +func openFolder(w fyne.Window, dir string) { + if dir == "" { + dialog.ShowError(errors.New("no folder is set"), w) + return + } + if err := filemanager.Open(dir); err != nil { + dialog.ShowError(err, w) + } +} diff --git a/src/ui/settings_view_layout.go b/src/ui/settings_view_layout.go new file mode 100644 index 0000000..a4928bf --- /dev/null +++ b/src/ui/settings_view_layout.go @@ -0,0 +1,138 @@ +package ui + +import ( + "runtime" + + "gitea.mixdep.ru/mix/gosentry/src/app" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// settingsFormFields groups every widget the Settings tab's two-column layout +// arranges. It exists so newSettingsLayout takes one argument instead of +// twenty, and so a widget added in settingsView is added to exactly one +// struct literal rather than threaded through a long parameter list. +type settingsFormFields struct { + startOnLogin *widget.Check + autostartStatus *widget.Label + minimizeToTray *widget.Check + notifications *widget.Check + themeSelect *widget.Select + executionModeSelect *widget.Select + overlapPolicySelect *widget.Select + defaultTimeout *widget.Entry + configPath string + jobsFile *widget.Entry + jobsFileBrowse *widget.Button + logsDir *widget.Entry + logsDirOpen *widget.Button + logsDirBrowse *widget.Button + maxLogFiles *widget.Entry + maxLogAgeDays *widget.Entry + saveSettings *widget.Button + cancelSettings *widget.Button + restoreDefaults *widget.Button + settingsStatus *widget.Label +} + +// newSettingsLayout assembles the Settings tab from its fields: a two-column +// grid (Application/Queue on the left, Storage/About on the right) with the +// Save/Cancel/Defaults row below. +func newSettingsLayout(f settingsFormFields) fyne.CanvasObject { + // capW is measured once from every caption in the tab so all rows — + // Application, Queue, Storage, About — share one value column start, + // instead of each settingsRow call re-measuring its own single caption. + capW := captionColumnWidth(settingsCaptions...) + + // 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", rowOverlap(), + settingsRow(capW, "Autostart", f.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(capW, "", f.autostartStatus), + settingsRow(capW, "Tray", f.minimizeToTray), + settingsRow(capW, "Notifications", f.notifications), + settingsRow(capW, "Theme", f.themeSelect), + ), + widget.NewSeparator(), + // Queue used to inline its own container.NewVBox at the theme's default + // spacing; settingsSection now takes that spacing explicitly so both + // idioms for "a titled block of rows" collapse into one constructor. + settingsSection("Queue", theme.Padding(), + settingsRow(capW, "Execution mode", f.executionModeSelect), + settingsRow(capW, "Default overlap policy", f.overlapPolicySelect), + settingsRow(capW, "Default timeout (s)", f.defaultTimeout), + ), + ) + // Truncating keeps a long config path from forcing the Settings tab's + // minimum width to track the path length instead of the layout itself. + configPathLabel := widget.NewLabel(f.configPath) + configPathLabel.Truncation = fyne.TextTruncateClip + rightColumn := container.NewVBox( + settingsSection("Storage", theme.Padding(), + settingsRow(capW, "Config JSON", configPathLabel), + settingsRow(capW, "Jobs file", container.NewBorder(nil, nil, nil, f.jobsFileBrowse, f.jobsFile)), + // Browse stays rightmost so it lines up with the Jobs file row + // above it; Open sits between it and the path it opens. + settingsRow(capW, "Logs directory", container.NewBorder(nil, nil, nil, container.NewHBox(f.logsDirOpen, f.logsDirBrowse), f.logsDir)), + settingsRow(capW, "Max log files", f.maxLogFiles), + settingsRow(capW, "Max log age days", f.maxLogAgeDays), + ), + widget.NewSeparator(), + settingsSection("About", rowOverlap(), + settingsRow(capW, "GoSentry", widget.NewLabel(app.Version)), + settingsRow(capW, "Go", widget.NewLabel(runtime.Version())), + settingsRow(capW, "Fyne", widget.NewLabel(fyneVersion())), + settingsRow(capW, "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. + // The button row sits right below the separator's hairline, which reads as + // tighter than the other vertical gaps in the tab (those separate whole + // sections, not a single thin line from a row of buttons). A top pad the + // height of the default padding closes that gap up to match, and a left pad + // indents the buttons 4px from the edge the layout promises. + return container.NewVScroll(container.NewPadded(container.NewVBox( + container.NewGridWithColumns(2, leftColumn, rightColumn), + widget.NewSeparator(), + container.New( + layout.NewCustomPaddedLayout(2*theme.Padding(), 0, theme.Padding(), 0), + // Save/Cancel/Defaults share one row with the status so an empty status + // (the common case) does not leave a blank line above the separator. The + // status appears beside the buttons once a save reports a result. + container.NewHBox(f.saveSettings, f.cancelSettings, f.restoreDefaults, f.settingsStatus), + ), + ))) +} + +// settingsSection groups a bold header above its rows, using the given +// vertical spacing between them. Application and About pass rowOverlap() so +// the block reads as one compact unit; Queue and Storage pass theme.Padding() +// (the same spacing container.NewVBox would use) so their entry-heavy rows +// keep a visible gap. One constructor for "a titled block of rows" rather +// than two spellings of it. +func settingsSection(title string, spacing float32, rows ...fyne.CanvasObject) fyne.CanvasObject { + children := make([]fyne.CanvasObject, 0, len(rows)+1) + children = append(children, widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})) + children = append(children, rows...) + return container.New(layout.NewCustomPaddedVBoxLayout(spacing), children...) +} + +func settingsRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject { + caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + caption.Truncation = fyne.TextTruncateClip + captionBox := container.New(minWidthLayout{width: captionWidth}, caption) + return container.NewBorder(nil, nil, captionBox, nil, value) +} diff --git a/src/ui/settings_view_test.go b/src/ui/settings_view_test.go index 6b37a7f..2cbb124 100644 --- a/src/ui/settings_view_test.go +++ b/src/ui/settings_view_test.go @@ -5,6 +5,8 @@ import ( "testing" "fyne.io/fyne/v2" + fynestorage "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/widget" ) @@ -43,7 +45,7 @@ func TestSettingsFolderPath(t *testing.T) { // wrapping it in a fixed-width layout was redundant. func TestSettingsRowStretchesItsControl(t *testing.T) { entry := widget.NewEntry() - row := settingsRow("Label", entry) + row := settingsRow(captionColumnWidth("Label"), "Label", entry) baseWidth := entry.Size().Width wide := fyne.NewSize(row.MinSize().Width+200, row.MinSize().Height) @@ -57,3 +59,57 @@ func TestSettingsRowStretchesItsControl(t *testing.T) { t.Errorf("control did not stretch to fill the row: entry width = %v, want at least %v", got, baseWidth+150) } } + +// TestChooseFileAppliesFilter is the coverage for the deduplicated file picker +// (chooseFile absorbed chooseJSONFile's SetFilter call behind a nil-means-none +// filter argument): both a nil filter (job_dialog.go's command browser) and a +// concrete one (chooseJSONFile) must open a dialog without panicking, and the +// dialog must actually appear as a canvas overlay either way. +func TestChooseFileAppliesFilter(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + w := testApp.NewWindow("test") + defer w.Close() + + target := widget.NewEntry() + + chooseFile(w, target, nil) + if w.Canvas().Overlays().Top() == nil { + t.Fatal("chooseFile(nil filter) did not open a dialog") + } + w.Canvas().Overlays().Top().Hide() + + chooseJSONFile(w, target) + if w.Canvas().Overlays().Top() == nil { + t.Fatal("chooseJSONFile did not open a dialog") + } + w.Canvas().Overlays().Top().Hide() + + // Exercising a concrete filter directly through chooseFile as well, so the + // filter parameter itself (not just chooseJSONFile's use of it) is covered. + chooseFile(w, target, fynestorage.NewExtensionFileFilter([]string{".json"})) + if w.Canvas().Overlays().Top() == nil { + t.Fatal("chooseFile(non-nil filter) did not open a dialog") + } + w.Canvas().Overlays().Top().Hide() +} + +// TestSettingsCaptionsCoverEveryRow is the settings-tab analog of F10's +// jobs-details guard: every caption settingsView actually uses in a row must +// be present in settingsCaptions and measure no wider than +// captionColumnWidth's result for that list, or a caption added to a row +// without adding it to settingsCaptions would silently misalign that column. +func TestSettingsCaptionsCoverEveryRow(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + capW := captionColumnWidth(settingsCaptions...) + for _, c := range settingsCaptions { + if c == "" { + continue + } + if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > capW { + t.Errorf("caption %q measures %v, wider than captionColumnWidth's %v", c, w, capW) + } + } +}