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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user