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.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
||||||
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
||||||
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
| `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
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
|
// textWidth measures how wide s renders at the theme's current body text size.
|
||||||
// The minimum keeps the column readable when names are short or absent; the
|
func textWidth(s string) float32 {
|
||||||
// maximum stops a single very long file name from dominating the table (the
|
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||||
// table still scrolls horizontally past it).
|
}
|
||||||
const (
|
|
||||||
logColumnMinWidth = 240
|
|
||||||
logColumnMaxWidth = 520
|
|
||||||
logColumnPadding = 24
|
|
||||||
)
|
|
||||||
|
|
||||||
// logColumnWidth measures the widest Log cell value so the column can be sized
|
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||||
// to fit its content. Fyne tables do not auto-size columns, so without this the
|
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||||
// fixed width clips file names like "20260601-100000_SomeJobName.log".
|
// doubled (one side each), so it follows text size and DPI.
|
||||||
func logColumnWidth(events []event) float32 {
|
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||||
width := float32(logColumnMinWidth)
|
|
||||||
for _, current := range events {
|
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||||
text := logFileName(current.LogFile)
|
// 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 == "" {
|
if text == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
|
if w := textWidth(text) + cellPadding(); w > width {
|
||||||
if w > width {
|
|
||||||
width = w
|
width = w
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if width > logColumnMaxWidth {
|
if width > max {
|
||||||
width = logColumnMaxWidth
|
width = max
|
||||||
}
|
}
|
||||||
return width
|
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.
|
// 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
|
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
||||||
// toggle is wired through the Tappable interface instead.
|
// toggle is wired through the Tappable interface instead.
|
||||||
@@ -85,7 +133,7 @@ type historyHeader struct {
|
|||||||
func newHistoryHeader() *historyHeader {
|
func newHistoryHeader() *historyHeader {
|
||||||
h := &historyHeader{label: widget.NewLabel("")}
|
h := &historyHeader{label: widget.NewLabel("")}
|
||||||
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
h.label.Wrapping = fyne.TextTruncate
|
h.label.Truncation = fyne.TextTruncateClip
|
||||||
h.ExtendBaseWidget(h)
|
h.ExtendBaseWidget(h)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -147,7 +195,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
|||||||
},
|
},
|
||||||
func() fyne.CanvasObject {
|
func() fyne.CanvasObject {
|
||||||
label := widget.NewLabel("")
|
label := widget.NewLabel("")
|
||||||
label.Wrapping = fyne.TextTruncate
|
label.Truncation = fyne.TextTruncateClip
|
||||||
return label
|
return label
|
||||||
},
|
},
|
||||||
func(id widget.TableCellID, item fyne.CanvasObject) {
|
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.OnSelected = func(id widget.TableCellID) {
|
||||||
table.Unselect(id)
|
table.Unselect(id)
|
||||||
}
|
}
|
||||||
table.SetColumnWidth(0, 150)
|
setColumnWidths := func() {
|
||||||
table.SetColumnWidth(1, 90)
|
for col, width := range historyColumnWidths(rows) {
|
||||||
table.SetColumnWidth(2, 170)
|
table.SetColumnWidth(col, width)
|
||||||
table.SetColumnWidth(3, 90)
|
}
|
||||||
table.SetColumnWidth(4, 260)
|
}
|
||||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
setColumnWidths()
|
||||||
|
|
||||||
// refresh re-reads the event list into the sorted snapshot and recomputes
|
// refresh re-reads the event list into the sorted snapshot and recomputes
|
||||||
// the content-fit Log column width before redrawing, so newly recorded
|
// every content-fit column width before redrawing, so newly recorded events
|
||||||
// events appear in the current sort order and longer file names widen the
|
// appear in the current sort order and longer values widen their column
|
||||||
// column instead of being truncated.
|
// instead of being truncated.
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
resort()
|
resort()
|
||||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
setColumnWidths()
|
||||||
table.Refresh()
|
table.Refresh()
|
||||||
}
|
}
|
||||||
return container.NewPadded(table), refresh
|
return container.NewPadded(table), refresh
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
|
||||||
ev := newEvent(1, "Job", "OK", "detail")
|
ev := newEvent(1, "Job", "OK", "detail")
|
||||||
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
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.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
||||||
commandEntry.SetText(current.Command)
|
commandEntry.SetText(current.Command)
|
||||||
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFile(w, commandEntry)
|
chooseFile(w, commandEntry, nil)
|
||||||
})
|
})
|
||||||
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
|
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
|
||||||
argumentsEntry := widget.NewMultiLineEntry()
|
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() int { return len(d.selectedLogs) },
|
||||||
func() fyne.CanvasObject {
|
func() fyne.CanvasObject {
|
||||||
l := widget.NewLabel("log")
|
l := widget.NewLabel("log")
|
||||||
l.Wrapping = fyne.TextTruncate
|
l.Truncation = fyne.TextTruncateClip
|
||||||
return l
|
return l
|
||||||
},
|
},
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
@@ -115,21 +115,59 @@ func (d *detailsPanel) clear() {
|
|||||||
d.logs.Refresh()
|
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,
|
// 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.
|
// the activity panel pins to the bottom, and command output fills the remainder.
|
||||||
func (d *detailsPanel) container() fyne.CanvasObject {
|
func (d *detailsPanel) container() fyne.CanvasObject {
|
||||||
// Metadata is laid out in two columns so the block stays half as tall,
|
// Metadata is laid out in two columns so the block stays half as tall,
|
||||||
// keeping the details pane usable on 720p screens where a single column of
|
// keeping the details pane usable on 720p screens where a single column of
|
||||||
// ten rows pushes the minimum window height past the available space.
|
// ten rows pushes the minimum window height past the available space.
|
||||||
capW := detailCaptionWidth()
|
specs := d.metadataRows()
|
||||||
rows := container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()),
|
captions := make([]string, len(specs))
|
||||||
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
for i, spec := range specs {
|
||||||
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
captions[i] = spec.caption
|
||||||
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
}
|
||||||
detailRowPair(capW, "Timeout", d.timeout, "State", d.state),
|
capW := captionColumnWidth(captions...)
|
||||||
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
|
rowObjects := make([]fyne.CanvasObject, 0, (len(specs)+1)/2)
|
||||||
detailRow(capW, "Statistics", d.stats),
|
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(
|
top := container.NewVBox(
|
||||||
d.title,
|
d.title,
|
||||||
widget.NewSeparator(),
|
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.
|
// absorbs sub-pixel rounding so the last row is never clipped behind a scrollbar.
|
||||||
func activityRowsHeight(rows int) float32 {
|
func activityRowsHeight(rows int) float32 {
|
||||||
sample := widget.NewLabel("log")
|
sample := widget.NewLabel("log")
|
||||||
sample.Wrapping = fyne.TextTruncate
|
sample.Truncation = fyne.TextTruncateClip
|
||||||
itemHeight := sample.MinSize().Height
|
itemHeight := sample.MinSize().Height
|
||||||
padding := theme.Padding()
|
padding := theme.Padding()
|
||||||
return (itemHeight+padding)*float32(rows) - padding + 1
|
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
|
// detailRowPair places two label/value pairs side by side, producing the
|
||||||
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
// 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 {
|
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 {
|
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
caption.Wrapping = fyne.TextTruncate
|
caption.Truncation = fyne.TextTruncateClip
|
||||||
// A fixed caption width (rather than an even split) means widening the window
|
// A fixed caption width (rather than an even split) means widening the window
|
||||||
// feeds the extra space to the value, not the short caption.
|
// feeds the extra space to the value, not the short caption.
|
||||||
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
|
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 {
|
func newJobDetailLabel(text string) *widget.Label {
|
||||||
label := widget.NewLabel(text)
|
label := widget.NewLabel(text)
|
||||||
label.Wrapping = fyne.TextTruncate
|
label.Truncation = fyne.TextTruncateClip
|
||||||
return label
|
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) {
|
func TestViewToggleTextNamesTheAction(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
current domain.JobListView
|
current domain.JobListView
|
||||||
|
|||||||
@@ -3,8 +3,22 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/theme"
|
"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 {
|
type minWidthLayout struct {
|
||||||
width float32
|
width float32
|
||||||
}
|
}
|
||||||
@@ -84,6 +98,10 @@ type captionValueLayout struct {
|
|||||||
captionWidth float32
|
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 {
|
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
if len(objects) != 2 {
|
if len(objects) != 2 {
|
||||||
return fyne.Size{}
|
return fyne.Size{}
|
||||||
|
|||||||
@@ -26,3 +26,27 @@ func TestRowOverlapMatchesInnerPadding(t *testing.T) {
|
|||||||
t.Errorf("under a different theme, rowOverlap() = %v, want %v", got, want)
|
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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"image/color"
|
|
||||||
"net/url"
|
|
||||||
"runtime"
|
|
||||||
"runtime/debug"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"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"
|
||||||
"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/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"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"
|
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 {
|
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||||
store := svc.Store()
|
store := svc.Store()
|
||||||
// updateSaveState compares the form to the saved config and enables Save only
|
// 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
|
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||||
// beside it) so the Application section fits within a half-width column.
|
// beside it) so the Application section fits within a half-width column.
|
||||||
// Truncating keeps a long status message from forcing the column wider.
|
// Truncating keeps a long status message from forcing the column wider.
|
||||||
autostartStatus.Wrapping = fyne.TextTruncate
|
autostartStatus.Truncation = fyne.TextTruncateClip
|
||||||
settingsStatus := widget.NewLabel("")
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
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())
|
loadFields(domain.DefaultConfig())
|
||||||
})
|
})
|
||||||
|
|
||||||
// The form is split into two columns so a wide window uses its horizontal
|
return newSettingsLayout(settingsFormFields{
|
||||||
// space instead of stretching into one tall strip. The left column holds the
|
startOnLogin: startOnLogin,
|
||||||
// toggles (Application, Queue); the right holds the editable Storage fields and
|
autostartStatus: autostartStatus,
|
||||||
// the read-only About block. Save spans the full width below both columns.
|
minimizeToTray: minimizeToTray,
|
||||||
leftColumn := container.NewVBox(
|
notifications: notifications,
|
||||||
settingsSection("Application",
|
themeSelect: themeSelect,
|
||||||
settingsRow("Autostart", startOnLogin),
|
executionModeSelect: executionModeSelect,
|
||||||
// Autostart status sits on its own row, aligned under the checkbox via an
|
overlapPolicySelect: overlapPolicySelect,
|
||||||
// empty caption, so the Application section fits in a half-width column.
|
defaultTimeout: defaultTimeout,
|
||||||
settingsRow("", autostartStatus),
|
configPath: store.Paths.ConfigPath,
|
||||||
settingsRow("Tray", minimizeToTray),
|
jobsFile: jobsFile,
|
||||||
settingsRow("Notifications", notifications),
|
jobsFileBrowse: jobsFileBrowse,
|
||||||
settingsRow("Theme", themeSelect),
|
logsDir: logsDir,
|
||||||
),
|
logsDirOpen: logsDirOpen,
|
||||||
widget.NewSeparator(),
|
logsDirBrowse: logsDirBrowse,
|
||||||
// Queue holds the execution mode and overlap policy comboboxes. Like
|
maxLogFiles: maxLogFiles,
|
||||||
// Storage, it uses the default VBox spacing (not the condensed section
|
maxLogAgeDays: maxLogAgeDays,
|
||||||
// layout) so the comboboxes keep a visible gap between them.
|
saveSettings: saveSettings,
|
||||||
container.NewVBox(
|
cancelSettings: cancelSettings,
|
||||||
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
restoreDefaults: restoreDefaults,
|
||||||
settingsRow("Execution mode", executionModeSelect),
|
settingsStatus: settingsStatus,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||||
@@ -435,10 +275,3 @@ func themeFromLabel(label string) domain.Theme {
|
|||||||
}
|
}
|
||||||
return domain.ThemeDefault
|
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"
|
"testing"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
|
fynestorage "fyne.io/fyne/v2/storage"
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ func TestSettingsFolderPath(t *testing.T) {
|
|||||||
// wrapping it in a fixed-width layout was redundant.
|
// wrapping it in a fixed-width layout was redundant.
|
||||||
func TestSettingsRowStretchesItsControl(t *testing.T) {
|
func TestSettingsRowStretchesItsControl(t *testing.T) {
|
||||||
entry := widget.NewEntry()
|
entry := widget.NewEntry()
|
||||||
row := settingsRow("Label", entry)
|
row := settingsRow(captionColumnWidth("Label"), "Label", entry)
|
||||||
|
|
||||||
baseWidth := entry.Size().Width
|
baseWidth := entry.Size().Width
|
||||||
wide := fyne.NewSize(row.MinSize().Width+200, row.MinSize().Height)
|
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)
|
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