From 21d78ea010ab2019cfcf7c5fa5742f92b8ca6d29 Mon Sep 17 00:00:00 2001 From: mixeme Date: Thu, 25 Jun 2026 01:34:47 +0300 Subject: [PATCH] feat(ui): condense panels and let the window shrink to 720p Make the job details and settings views more compact and remove the tall minimum that prevented resizing the window shorter: - Lay job metadata out in two columns and stack rows with a negative-gap compactVBoxLayout, roughly halving the block height. - Wrap the settings form in a vertical scroll so it no longer dictates the window minimum (AppTabs sizes to the tallest tab), and tighten its rows with the same compact layout. - Shrink the command-output scroll minimum height so the details pane can get shorter; long output still scrolls. - Size the "Selected job activity" panel to exactly maxJobActivityRows using widget.List's own content-height formula, so all three rows show without a scrollbar regardless of theme or DPI. Together these drop the minimum window height from ~891px to ~570px. Co-Authored-By: Claude Opus 4.8 --- src/ui/jobs_view.go | 8 +++--- src/ui/jobs_view_details.go | 54 ++++++++++++++++++++++++++----------- src/ui/layout.go | 42 +++++++++++++++++++++++++++++ src/ui/settings_view.go | 13 +++++++-- 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index 9ae9cd9..a8dc6a3 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -23,10 +23,10 @@ const minJobsSidebarWidth float32 = 400 // view; this panel is a quick at-a-glance summary anchored below the output. const maxJobActivityRows = 3 -// jobActivityHeight is the fixed height reserved for the activity panel at the -// bottom of the details pane, sized for maxJobActivityRows rows so the command -// output above it can claim the remaining vertical space. -const jobActivityHeight float32 = 120 +// detailRowSpacing is the (negative) gap applied between metadata rows in the +// details panel. Pulling rows together overlaps the labels' built-in vertical +// padding, tightening the block so it fits comfortably on 720p screens. +const detailRowSpacing float32 = -8 // newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar. // It returns the assembled panel and a refresh function the caller invokes diff --git a/src/ui/jobs_view_details.go b/src/ui/jobs_view_details.go index f7ec491..407c126 100644 --- a/src/ui/jobs_view_details.go +++ b/src/ui/jobs_view_details.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -53,7 +54,10 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do // Command output can contain long lines and preserved whitespace. TextGrid is // used instead of Label so stdout/stderr remains readable and does not vanish // against the theme when it is placed inside a scroll container. - d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 120)) + // The height here is only a floor: the scroll grows to fill whatever space the + // border layout gives it, so keep the minimum small so the whole window can be + // shrunk on short (720p) screens. Long output stays reachable by scrolling. + d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 70)) d.logs = widget.NewList( func() int { return len(d.selectedLogs) }, func() fyne.CanvasObject { @@ -104,30 +108,51 @@ func (d *detailsPanel) clear() { // 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. + rows := container.New(compactVBoxLayout{spacing: detailRowSpacing}, + detailRowPair("Folder", d.folder, "Schedule", d.schedule), + detailRowPair("Command", d.command, "Arguments", d.arguments), + detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy), + detailRowPair("Last run", d.lastRun, "Next run", d.nextRun), + detailRowPair("State", d.state, "Statistics", d.stats), + ) top := container.NewVBox( d.title, widget.NewSeparator(), - detailRow("Folder", d.folder), - detailRow("Schedule", d.schedule), - detailRow("Command", d.command), - detailRow("Arguments", d.arguments), - detailRow("Run mode", d.runMode), - detailRow("Overlap policy", d.overlapPolicy), - detailRow("Last run", d.lastRun), - detailRow("Next run", d.nextRun), - detailRow("State", d.state), - detailRow("Statistics", d.stats), + rows, widget.NewSeparator(), widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), ) activity := container.NewVBox( widget.NewSeparator(), widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - container.New(fixedHeightLayout{height: jobActivityHeight}, d.logs), + container.New(fixedHeightLayout{height: activityRowsHeight(maxJobActivityRows)}, d.logs), ) return container.NewBorder(top, activity, nil, nil, d.commandOutputScroll) } +// activityRowsHeight returns the fixed height needed to show the given number of +// activity-list rows without scrolling. It mirrors widget.List's own content +// height — (itemHeight + padding) per row, less one separator — using the same +// label template the list builds its rows from, so it tracks the theme's text +// size and DPI instead of relying on a hand-tuned constant. The trailing pixel +// 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 + itemHeight := sample.MinSize().Height + padding := theme.Padding() + return (itemHeight+padding)*float32(rows) - padding + 1 +} + +// 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(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject { + return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2)) +} + func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption.Wrapping = fyne.TextTruncate @@ -136,9 +161,6 @@ func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { func newJobDetailLabel(text string) *widget.Label { label := widget.NewLabel(text) - // Job names, commands, and paths can be much wider than the details panel. - // Breaking long runs of text keeps Label.MinSize stable when the selection - // changes, so the right panel does not force the whole window to resize. - label.Wrapping = fyne.TextWrapBreak + label.Wrapping = fyne.TextTruncate return label } diff --git a/src/ui/layout.go b/src/ui/layout.go index c6dcf0c..ccb6be2 100644 --- a/src/ui/layout.go +++ b/src/ui/layout.go @@ -36,6 +36,48 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { } } +// compactVBoxLayout stacks children vertically with a configurable gap between +// them, producing tighter rows than container.NewVBox (which inserts +// theme.Padding() between every child). A negative spacing pulls neighbouring +// rows together so they overlap the labels' built-in vertical padding, which is +// how the details metadata is condensed to fit 720p screens. +type compactVBoxLayout struct { + spacing float32 +} + +func (l compactVBoxLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + var w, h float32 + var visible int + for _, o := range objects { + if !o.Visible() { + continue + } + min := o.MinSize() + if min.Width > w { + w = min.Width + } + h += min.Height + visible++ + } + if visible > 1 { + h += l.spacing * float32(visible-1) + } + return fyne.NewSize(w, h) +} + +func (l compactVBoxLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + var y float32 + for _, o := range objects { + if !o.Visible() { + continue + } + h := o.MinSize().Height + o.Move(fyne.NewPos(0, y)) + o.Resize(fyne.NewSize(size.Width, h)) + y += h + l.spacing + } +} + // fixedHeightLayout forces its contents to a fixed height while leaving the // width to the parent container. It is used to reserve a stable amount of space // for the activity panel so a neighbouring widget can absorb the rest. diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 9a50b5a..f9089d9 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -22,6 +22,11 @@ const settingsControlWidth float32 = 330 const settingsStatusWidth float32 = 280 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() startOnLogin := widget.NewCheck("Start on login", nil) @@ -118,7 +123,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsStatus.SetText("Saved") }) - return container.NewPadded(container.NewVBox( + // The settings form is a tall fixed-height column. Wrapping it in a vertical + // scroll keeps its minimum height small so it does not dictate the whole + // window's minimum height (AppTabs sizes to the tallest tab); on short 720p + // screens the window can shrink and the form scrolls instead. + return container.NewVScroll(container.NewPadded(container.New(compactVBoxLayout{spacing: settingsRowSpacing}, widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), settingsRowWithStatus("Autostart", startOnLogin, autostartStatus), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), @@ -142,7 +151,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsRow("Go", widget.NewLabel(runtime.Version())), settingsRow("Fyne", widget.NewLabel(fyneVersion())), settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))), - )) + ))) } func fyneVersion() string {