feat: add compact instance actions
This commit is contained in:
+68
-12
@@ -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 = "目录选择器已打开,请在系统窗口中选择或取消。"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user