package ui import ( "net/url" "runtime" "runtime/debug" "strconv" "strings" "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "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 settingsControlWidth float32 = 330 const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" // settingsRowSpacing is the (negative) gap between rows of the settings form, // overlapping each control's built-in vertical padding so the column is tighter // and more compact, matching the condensed job details panel. const settingsRowSpacing float32 = -6 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 // when something differs. It is defined below (once Save and every field // exist) but declared here so the field change handlers can reference it. var updateSaveState func() // loadFields populates every form control from the given config. It backs // both the initial load and the Cancel/Defaults buttons below. var loadFields func(domain.Config) startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin.SetChecked(store.Config.StartOnLogin) autostartStatus := widget.NewLabel("") refreshAutostartStatus := func() { ok, message := svc.AutostartStatus() if ok { autostartStatus.SetText("OK: " + message) return } autostartStatus.SetText("Problem: " + message) } startOnLogin.OnChanged = func(bool) { if startOnLogin.Checked != store.Config.StartOnLogin { autostartStatus.SetText("Pending: save settings to apply") } else { refreshAutostartStatus() } updateSaveState() } refreshAutostartStatus() minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray.SetChecked(store.Config.KeepRunningInTray) minimizeToTray.OnChanged = func(bool) { updateSaveState() } notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications.SetChecked(store.Config.NotifyOnFailure) notifications.OnChanged = func(bool) { updateSaveState() } themeSelect := widget.NewSelect([]string{themeLabelDefault, themeLabelGoSentry}, nil) themeSelect.SetSelected(themeLabel(store.Config.Theme)) // Preview the theme the moment it is picked so the choice is visible before // saving; Save persists it. Reverting the selection reverts the preview, and // closing without saving falls back to the stored theme on next launch. themeSelect.OnChanged = func(string) { applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected)) updateSaveState() } executionModeSelect := widget.NewSelect( []string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)}, nil, ) executionModeSelect.SetSelected(string(store.Config.ExecutionMode)) executionModeSelect.OnChanged = func(string) { updateSaveState() } overlapPolicySelect := widget.NewSelect( []string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)}, nil, ) overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy)) overlapPolicySelect.OnChanged = func(string) { updateSaveState() } defaultTimeout := widget.NewEntry() defaultTimeout.SetPlaceHolder("0 = no timeout") defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds)) defaultTimeout.OnChanged = func(string) { updateSaveState() } jobsDir := widget.NewEntry() jobsDir.SetText(store.Config.JobsDir) jobsDir.OnChanged = func(string) { updateSaveState() } jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { chooseFolder(w, jobsDir) }) logsDir := widget.NewEntry() logsDir.SetText(store.Config.LogsDir) logsDir.OnChanged = func(string) { updateSaveState() } logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { chooseFolder(w, logsDir) }) maxLogFiles := widget.NewEntry() maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogFiles.OnChanged = func(string) { updateSaveState() } maxLogAgeDays := widget.NewEntry() maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) maxLogAgeDays.OnChanged = func(string) { updateSaveState() } // Autostart status sits on its own row beneath the checkbox (rather than // beside it) so the Application section fits within a half-width column. // Truncating keeps a long status message from forcing the column wider. autostartStatus.Wrapping = fyne.TextTruncate settingsStatus := widget.NewLabel("") saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text)) if err != nil || files <= 0 { settingsStatus.SetText("Max log files must be a positive number") return } days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text)) if err != nil || days <= 0 { settingsStatus.SetText("Max log age days must be a positive number") return } if strings.TrimSpace(jobsDir.Text) == "" { settingsStatus.SetText("Jobs directory is required") return } if strings.TrimSpace(logsDir.Text) == "" { settingsStatus.SetText("Logs directory is required") return } timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text)) if err != nil || timeout < 0 { settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)") return } // Build the new config from the form and hand it to the Service, which // validates it, persists config and jobs to the (possibly new) directory, // and runs log cleanup so tightened retention limits take effect at once. config := store.Config config.JobsDir = strings.TrimSpace(jobsDir.Text) config.LogsDir = strings.TrimSpace(logsDir.Text) config.MaxLogFiles = files config.MaxLogAgeDays = days config.StartOnLogin = startOnLogin.Checked config.KeepRunningInTray = minimizeToTray.Checked config.NotifyOnFailure = notifications.Checked config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected) config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected) config.DefaultTimeoutSeconds = timeout config.Theme = themeFromLabel(themeSelect.Selected) if err := svc.UpdateSettings(config); err != nil { settingsStatus.SetText("Save failed: " + err.Error()) return } if err := svc.ApplyAutostart(); err != nil { refreshAutostartStatus() settingsStatus.SetText("Saved, autostart failed: " + err.Error()) return } refreshAutostartStatus() settingsStatus.SetText("Saved") // The form now matches the persisted config, so disable Save again. updateSaveState() }) // Save stays disabled until a field differs from the saved config, so the // button only invites a click when there is something to persist. The numeric // fields compare against their canonical string form; any unparsable text // counts as a change so the user can click Save and see the validation error. updateSaveState = func() { c := store.Config changed := startOnLogin.Checked != c.StartOnLogin || minimizeToTray.Checked != c.KeepRunningInTray || notifications.Checked != c.NotifyOnFailure || executionModeSelect.Selected != string(c.ExecutionMode) || overlapPolicySelect.Selected != string(c.OverlapPolicy) || strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) || strings.TrimSpace(jobsDir.Text) != c.JobsDir || strings.TrimSpace(logsDir.Text) != c.LogsDir || strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) || strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) || themeSelect.Selected != themeLabel(c.Theme) if changed { saveSettings.Enable() } else { saveSettings.Disable() } } updateSaveState() // loadFields populates every form control from a config without saving it, // backing both the Cancel button (reload the saved config, discarding edits) // and the Defaults button (load the built-in defaults for review before // Save is clicked). loadFields = func(c domain.Config) { startOnLogin.SetChecked(c.StartOnLogin) minimizeToTray.SetChecked(c.KeepRunningInTray) notifications.SetChecked(c.NotifyOnFailure) themeSelect.SetSelected(themeLabel(c.Theme)) applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected)) executionModeSelect.SetSelected(string(c.ExecutionMode)) overlapPolicySelect.SetSelected(string(c.OverlapPolicy)) defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds)) jobsDir.SetText(c.JobsDir) logsDir.SetText(c.LogsDir) maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles)) maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays)) if startOnLogin.Checked != store.Config.StartOnLogin { autostartStatus.SetText("Pending: save settings to apply") } else { refreshAutostartStatus() } settingsStatus.SetText("") updateSaveState() } cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { loadFields(store.Config) }) restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() { 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", container.New(minWidthLayout{width: settingsControlWidth}, startOnLogin)), // Autostart status sits on its own row, aligned under the checkbox via an // empty caption, so the Application section fits in a half-width column. settingsRow("", autostartStatus), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), settingsRow("Theme", container.New(minWidthLayout{width: settingsControlWidth}, 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", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)), ), ) 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", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)), settingsRow("Logs directory", container.NewBorder(nil, nil, nil, 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. return container.NewVScroll(container.NewPadded(container.NewVBox( container.NewGridWithColumns(2, leftColumn, rightColumn), widget.NewSeparator(), // 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(saveSettings, cancelSettings, restoreDefaults, settingsStatus), ))) } // settingsSection groups a bold header above its rows using the tight // settingsRowSpacing 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(compactVBoxLayout{spacing: settingsRowSpacing}, 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() } 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() } // Theme dropdown labels. These are the human-facing captions; themeLabel and // themeFromLabel translate between them and the stored domain.Theme values so the // select never leaks the on-disk "default"/"gosentry" strings to the user. const ( themeLabelDefault = "Default" themeLabelGoSentry = "GoSentry" ) func themeLabel(choice domain.Theme) string { if choice == domain.ThemeGoSentry { return themeLabelGoSentry } return themeLabelDefault } func themeFromLabel(label string) domain.Theme { if label == themeLabelGoSentry { return domain.ThemeGoSentry } 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) }