feat(ui): content-measured History columns, shared caption widths, settings_view split

Stage 5: generalize logColumnWidth into textColumnWidth so History's
Trigger/Job/State/Detail/Log columns size from measured text instead of
pixel constants that clipped at larger text sizes (F6, F14).

Stage 6: captionColumnWidth replaces detailCaptionWidth and
settingsLabelWidth with one theme-derived helper; jobs_view_details.go
now builds its metadata rows and their width from a single
metadataRows() list instead of two hand-kept ones (F10); the Settings
button row drops its transparent-rectangle spacers for a
CustomPaddedLayout (F8); the remaining eight fyne.TextTruncate call
sites move to the non-deprecated Truncation field (N1).

Stage 7: settings_view.go split into settings_view.go (field
construction/save/load/validate), settings_view_layout.go (the
two-column layout and settingsSection/settingsRow), and
settings_view_helpers.go (fyneVersion, dialogs, path helpers),
mirroring the jobs_view.go split. Along the way, Queue/Storage's inline
VBox and Application/About's settingsSection collapse into one
settingsSection(title, spacing, rows...) constructor, and
chooseFile/chooseJSONFile merge into one function with a filter
argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-27 16:26:13 +03:00
parent 57e6fe410e
commit cebd41a5ac
12 changed files with 621 additions and 269 deletions
+11
View File
@@ -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) |
+80 -32
View File
@@ -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
+74
View File
@@ -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 {
+1 -1
View File
@@ -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()
+51 -31
View File
@@ -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
}
+25 -2
View File
@@ -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
+18
View File
@@ -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{}
+24
View File
@@ -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)
}
}
+35 -202
View File
@@ -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)
}
+107
View File
@@ -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)
}
}
+138
View File
@@ -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)
}
+57 -1
View File
@@ -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)
}
}
}