feat: add compact instance actions

This commit is contained in:
QiuSW
2026-07-22 16:37:34 +08:00
parent 7fcc9752a1
commit 1e5565e440
2 changed files with 103 additions and 16 deletions
+68 -12
View File
@@ -10,6 +10,7 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"golang.org/x/exp/shiny/materialdesign/icons"
) )
type page uint8 type page uint8
@@ -21,11 +22,16 @@ const (
) )
const ( const (
instanceNameColumnWeight = 25 instanceNameColumnWeight = 24
instanceBrowserColumnWeight = 12 instanceBrowserColumnWeight = 12
instanceDirectoryColumnWeight = 39 instanceDirectoryColumnWeight = 38
instanceStatusColumnWeight = 14 instanceStatusColumnWeight = 12
instanceStartColumnWeight = 10 instanceActionColumnWeight = 14
)
var (
instanceStartIcon = mustIcon(icons.AVPlayArrow)
instanceDeleteIcon = mustIcon(icons.ActionDelete)
) )
type PathField string type PathField string
@@ -123,6 +129,7 @@ type Shell struct {
list widget.List list widget.List
rows []InstanceRow rows []InstanceRow
startClicks map[string]*widget.Clickable startClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
nextInstance uint64 nextInstance uint64
instanceFeedback string instanceFeedback string
onSave func(SettingsState) onSave func(SettingsState)
@@ -146,7 +153,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {}, PathEdgeExecutable: {},
PathDefaultUserData: {}, PathDefaultUserData: {},
PathLogDirectory: {}, PathLogDirectory: {},
}, startClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} }, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
@@ -222,6 +229,11 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.requestStart(row.ID) s.requestStart(row.ID)
} }
} }
for _, row := range append([]InstanceRow(nil), s.rows...) {
for s.deleteClickFor(row.ID).Clicked(gtx) {
s.deleteInstance(row.ID)
}
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar), layout.Rigid(s.sidebar),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -279,7 +291,7 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout), layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)), layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)), layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
layout.Flexed(instanceStartColumnWeight, s.instanceStartButton(s.startClickFor(row.ID))), layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
) )
}) })
}) })
@@ -296,8 +308,8 @@ func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout), layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout), layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout), layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
layout.Flexed(instanceStartColumnWeight, func(gtx layout.Context) layout.Dimensions { layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, material.Caption(s.theme, "启动").Layout) return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
}), }),
) )
}) })
@@ -309,16 +321,29 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
return style.Layout return style.Layout
} }
func (s *Shell) instanceStartButton(click *widget.Clickable) layout.Widget { func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, click, "启动") return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
style.Inset = layout.Inset{Top: unit.Dp(6), Right: unit.Dp(10), Bottom: unit.Dp(6), Left: unit.Dp(10)} layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)),
return style.Layout(gtx) layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
)
}) })
} }
} }
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
style := material.IconButton(s.theme, click, icon, description)
style.Size = unit.Dp(18)
style.Inset = layout.UniformInset(unit.Dp(7))
style.Background = color.NRGBA{}
style.Color = iconColor
return style.Layout(gtx)
}
}
func statusLabel(theme *material.Theme, status string) layout.Widget { func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status) style := material.Label(theme, unit.Sp(13), status)
style.Color = map[string]color.NRGBA{ style.Color = map[string]color.NRGBA{
@@ -574,6 +599,15 @@ func (s *Shell) startClickFor(id string) *widget.Clickable {
return click return click
} }
func (s *Shell) deleteClickFor(id string) *widget.Clickable {
if click := s.deleteClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.deleteClicks[id] = click
return click
}
func (s *Shell) requestStart(id string) { func (s *Shell) requestStart(id string) {
for _, row := range s.rows { for _, row := range s.rows {
if row.ID != id { if row.ID != id {
@@ -585,6 +619,28 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = "找不到要启动的实例。" s.instanceFeedback = "找不到要启动的实例。"
} }
func (s *Shell) deleteInstance(id string) {
for i, row := range s.rows {
if row.ID != id {
continue
}
s.rows = append(s.rows[:i], s.rows[i+1:]...)
delete(s.startClicks, id)
delete(s.deleteClicks, id)
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
return
}
s.instanceFeedback = "找不到要删除的实例。"
}
func mustIcon(data []byte) *widget.Icon {
icon, err := widget.NewIcon(data)
if err != nil {
panic(err)
}
return icon
}
func (s *Shell) chooseInstanceDirectory() { func (s *Shell) chooseInstanceDirectory() {
if s.directoryPick.running { if s.directoryPick.running {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。" s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
+35 -4
View File
@@ -21,7 +21,7 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) {
} }
} }
func TestShellInstanceRowsContainRequiredColumnsAndIndependentStartControls(t *testing.T) { func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
if len(shell.rows) < 2 { if len(shell.rows) < 2 {
t.Fatal("expected fixture rows") t.Fatal("expected fixture rows")
@@ -36,18 +36,49 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentStartControls(t *t
if first == second { if first == second {
t.Fatal("rows share a start button state") t.Fatal("rows share a start button state")
} }
if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) {
t.Fatal("rows share a delete button state")
}
} }
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) { func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceStatusColumnWeight + instanceStartColumnWeight got := instanceNameColumnWeight + instanceBrowserColumnWeight + instanceDirectoryColumnWeight + instanceStatusColumnWeight + instanceActionColumnWeight
if got != 100 { if got != 100 {
t.Fatalf("instance column weights = %d, want 100", got) t.Fatalf("instance column weights = %d, want 100", got)
} }
if instanceDirectoryColumnWeight <= instanceNameColumnWeight { if instanceDirectoryColumnWeight <= instanceNameColumnWeight {
t.Fatal("user data directory must have the widest instance-list column") t.Fatal("user data directory must have the widest instance-list column")
} }
if instanceStartColumnWeight >= instanceStatusColumnWeight { if instanceActionColumnWeight <= instanceStatusColumnWeight {
t.Fatal("start action column must remain compact") t.Fatal("actions column must fit both compact icon commands")
}
}
func TestShellDeletesOnlyTheRequestedInstance(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[1]
remaining := shell.rows[2].ID
shell.startClickFor(target.ID)
shell.deleteClickFor(target.ID)
shell.deleteInstance(target.ID)
if len(shell.rows) != 3 {
t.Fatalf("instance count = %d, want 3", len(shell.rows))
}
for _, row := range shell.rows {
if row.ID == target.ID {
t.Fatalf("deleted instance %q remains in the list", target.ID)
}
}
if _, exists := shell.startClicks[target.ID]; exists {
t.Fatal("deleted instance start control was not released")
}
if _, exists := shell.deleteClicks[target.ID]; exists {
t.Fatal("deleted instance delete control was not released")
}
if shell.rows[1].ID != remaining {
t.Fatalf("remaining instance id = %q, want %q", shell.rows[1].ID, remaining)
} }
} }