bde9a2e33e
The 0.16.0 release was never tagged or pushed, so what it described ships as 1.0.0 instead: the changelog section is renamed rather than followed by an empty one, and there is no 0.16.0 for anyone to have seen. The last remainder of the layout review's F9 goes in with it. The value column in captionValueLayout has no minimum of its own — it takes whatever the container leaves after the caption — and what actually keeps it readable is the 460 px minimum on commandOutputScroll, a constant that exists for command output being legible. The dependency was invisible at both ends; both now state it, so lowering that width is a decision rather than an accident. The HSplit divider is the user's side of the same thing: it is how the value column can be widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.9 KiB
Go
225 lines
8.9 KiB
Go
package ui
|
|
|
|
import (
|
|
"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/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// detailsPanel holds all widgets in the job details pane and knows how to
|
|
// assemble, populate, and clear them. Extracting it here keeps newJobsView
|
|
// focused on list, toolbar, and layout wiring without embedding 100+ lines of
|
|
// widget construction and update logic.
|
|
type detailsPanel struct {
|
|
title *widget.Label
|
|
folder *widget.Label
|
|
schedule *widget.Label
|
|
command *widget.Label
|
|
arguments *widget.Label
|
|
runMode *widget.Label
|
|
overlapPolicy *widget.Label
|
|
timeout *widget.Label
|
|
lastRun *widget.Label
|
|
nextRun *widget.Label
|
|
state *widget.Label
|
|
stats *widget.Label
|
|
|
|
commandOutput *widget.TextGrid
|
|
commandOutputScroll *container.Scroll
|
|
|
|
logs *widget.List
|
|
selectedLogs []event
|
|
}
|
|
|
|
func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) *detailsPanel {
|
|
d := &detailsPanel{
|
|
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
folder: newJobDetailLabel(""),
|
|
schedule: newJobDetailLabel(""),
|
|
command: newJobDetailLabel(""),
|
|
arguments: newJobDetailLabel(""),
|
|
runMode: newJobDetailLabel(""),
|
|
overlapPolicy: newJobDetailLabel(""),
|
|
timeout: newJobDetailLabel(""),
|
|
lastRun: newJobDetailLabel(""),
|
|
nextRun: newJobDetailLabel(""),
|
|
state: newJobDetailLabel(""),
|
|
stats: newJobDetailLabel(""),
|
|
commandOutput: widget.NewTextGrid(),
|
|
}
|
|
d.title.Wrapping = fyne.TextWrapBreak
|
|
d.commandOutputScroll = container.NewScroll(d.commandOutput)
|
|
// 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.
|
|
// 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.
|
|
// The width, unlike the height, is load-bearing outside this widget: it is the
|
|
// details pane's widest minimum, so it is also what keeps the metadata value
|
|
// column non-empty — captionValueLayout hands the value whatever is left after
|
|
// the caption and has no floor of its own (see its comment in layout.go).
|
|
// Lowering it narrows those values with nothing to warn about it; the user can
|
|
// only widen them, by dragging the jobs split divider left.
|
|
d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 70))
|
|
d.logs = widget.NewList(
|
|
func() int { return len(d.selectedLogs) },
|
|
func() fyne.CanvasObject {
|
|
l := widget.NewLabel("log")
|
|
l.Truncation = fyne.TextTruncateClip
|
|
return l
|
|
},
|
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
|
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
|
|
},
|
|
)
|
|
d.update(firstJob, rt, globalOverlapPolicy, globalTimeout)
|
|
return d
|
|
}
|
|
|
|
func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) {
|
|
d.title.SetText(j.Name)
|
|
d.folder.SetText(app.DisplayFolder(j.Folder))
|
|
d.schedule.SetText(j.Schedule)
|
|
d.command.SetText(j.Command)
|
|
d.arguments.SetText(app.DisplayArguments(j.Arguments))
|
|
d.runMode.SetText(app.DisplayRunMode(j))
|
|
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
|
d.timeout.SetText(app.DisplayTimeout(j, globalTimeout))
|
|
d.lastRun.SetText(rt.LastRun)
|
|
d.nextRun.SetText(rt.NextRun)
|
|
d.state.SetText(rt.LastState)
|
|
d.stats.SetText(app.DisplayStats(rt))
|
|
d.commandOutput.SetText(rt.Output)
|
|
d.selectedLogs = lastJobLogs(rt.Logs)
|
|
// The activity list renders d.selectedLogs but, unlike the labels above whose
|
|
// SetText refreshes them, only its backing slice changed. Refresh it here so
|
|
// switching jobs immediately redraws the panel instead of keeping stale rows.
|
|
d.logs.Refresh()
|
|
}
|
|
|
|
func (d *detailsPanel) clear() {
|
|
d.title.SetText("No job selected")
|
|
d.folder.SetText("")
|
|
d.schedule.SetText("")
|
|
d.command.SetText("")
|
|
d.arguments.SetText("")
|
|
d.runMode.SetText("")
|
|
d.overlapPolicy.SetText("")
|
|
d.timeout.SetText("")
|
|
d.lastRun.SetText("")
|
|
d.nextRun.SetText("")
|
|
d.state.SetText("")
|
|
d.stats.SetText("")
|
|
d.commandOutput.SetText("")
|
|
d.selectedLogs = nil
|
|
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.
|
|
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(),
|
|
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: 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.Truncation = fyne.TextTruncateClip
|
|
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(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
|
return container.NewGridWithColumns(2, detailRow(captionWidth, l1, v1), detailRow(captionWidth, l2, v2))
|
|
}
|
|
|
|
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
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)
|
|
}
|
|
|
|
func newJobDetailLabel(text string) *widget.Label {
|
|
label := widget.NewLabel(text)
|
|
label.Truncation = fyne.TextTruncateClip
|
|
return label
|
|
}
|