Gallery: composite demos
This commit is contained in:
7 files changed
+970
-1
No files matched your search
+12
-1
@@ -33,13 +33,18 @@ type gallery struct {
|
||||
events []event
|
||||
scrolls map[string]*ui.ScrollState
|
||||
overture overtureState
|
||||
// Composition state lives here so it survives page switches.
|
||||
cSettings settingsDemo
|
||||
cFiles filesDemo
|
||||
cForm formSubmitDemo
|
||||
}
|
||||
|
||||
// densities maps the density control's segments to theme densities.
|
||||
var densities = []theme.Density{theme.Compact, theme.Default, theme.Comfortable}
|
||||
|
||||
func newGallery() *gallery {
|
||||
g := &gallery{pages: catalog(), byID: map[string]int{}, selected: overtureID, density: 1, scrolls: map[string]*ui.ScrollState{}}
|
||||
g := &gallery{pages: catalog(), byID: map[string]int{}, selected: overtureID, density: 1, scrolls: map[string]*ui.ScrollState{},
|
||||
cSettings: newSettingsDemo(), cFiles: newFilesDemo(), cForm: newFormSubmitDemo()}
|
||||
for i, p := range g.pages {
|
||||
if _, dup := g.byID[p.ID]; dup {
|
||||
panic("gallery: duplicate page " + p.ID)
|
||||
@@ -138,6 +143,12 @@ func (g *gallery) content(c *ui.Context) {
|
||||
switch g.selected {
|
||||
case overtureID:
|
||||
g.overturePage(c)
|
||||
case settingsID:
|
||||
g.compositePage(c, settingsID, g.settingsView)
|
||||
case filesID:
|
||||
g.compositePage(c, filesID, g.filesView)
|
||||
case formID:
|
||||
g.compositePage(c, formID, g.formView)
|
||||
default:
|
||||
i, ok := g.byID[g.selected]
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
const filesID = "c-files"
|
||||
|
||||
type fileEntry struct {
|
||||
ID int
|
||||
Name string
|
||||
Size int64
|
||||
Modified time.Time
|
||||
}
|
||||
|
||||
type filesDemo struct {
|
||||
entries []fileEntry
|
||||
next int
|
||||
version int
|
||||
query string
|
||||
table mujica.DataTableState[int]
|
||||
dropped []mujica.DroppedFile
|
||||
picked []string
|
||||
renaming bool
|
||||
renameID int
|
||||
newName string
|
||||
deleting bool
|
||||
doomed []int
|
||||
}
|
||||
|
||||
func filesSample() []fileEntry {
|
||||
at := func(m time.Month, d, h int) time.Time { return time.Date(2026, m, d, h, 0, 0, 0, time.UTC) }
|
||||
return []fileEntry{
|
||||
{1, "setlist-masquerade.pdf", 182_400, at(9, 28, 21)},
|
||||
{2, "rehearsal-0412.wav", 48_211_968, at(4, 12, 19)},
|
||||
{3, "stage-plot.png", 2_406_400, at(8, 3, 14)},
|
||||
{4, "lyrics-draft.md", 6_912, at(10, 1, 23)},
|
||||
{5, "venue-contract.docx", 96_256, at(7, 19, 10)},
|
||||
{6, "overture-score.musicxml", 731_136, at(9, 30, 2)},
|
||||
{7, "press-kit.zip", 18_874_368, at(6, 6, 16)},
|
||||
{8, "budget-2026.xlsx", 41_984, at(5, 22, 9)},
|
||||
}
|
||||
}
|
||||
|
||||
func newFilesDemo() filesDemo { return filesDemo{entries: filesSample(), next: 9} }
|
||||
|
||||
// fileSize writes a byte count the way a file manager does.
|
||||
func fileSize(n int64) string {
|
||||
switch {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gallery) fileColumns(c *ui.Context) []mujica.DataColumn[fileEntry] {
|
||||
return []mujica.DataColumn[fileEntry]{
|
||||
{ID: "name", Title: g.t("Name", "名称"), MinWidth: 200, Sortable: true,
|
||||
Text: func(f fileEntry) string { return f.Name },
|
||||
Compare: func(a, b fileEntry) int { return cmp.Compare(a.Name, b.Name) },
|
||||
Cell: func(f fileEntry) {
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).MinWidth(0).Children(func() {
|
||||
ui.Icon(c, icons.Must("file")).Size(16, 16).TextColor(mujica.Tokens(c).TextMuted)
|
||||
ui.Text(c, f.Name).SingleLine()
|
||||
})
|
||||
}},
|
||||
{ID: "size", Title: g.t("Size", "大小"), Width: 110, Align: ui.End, Sortable: true,
|
||||
Text: func(f fileEntry) string { return fileSize(f.Size) },
|
||||
Compare: func(a, b fileEntry) int { return cmp.Compare(a.Size, b.Size) }},
|
||||
{ID: "modified", Title: g.t("Modified", "修改时间"), Width: 170, Sortable: true,
|
||||
Text: func(f fileEntry) string { return f.Modified.Format("2006-01-02 15:04") },
|
||||
Compare: func(a, b fileEntry) int { return a.Modified.Compare(b.Modified) }},
|
||||
}
|
||||
}
|
||||
|
||||
// targets are the checked rows, or else the focused row.
|
||||
func (s *filesDemo) targets() []int {
|
||||
if s.table.Selection.Len() > 0 {
|
||||
return slices.Sorted(s.table.Selection.All())
|
||||
}
|
||||
if id, ok := s.table.Selected(); ok {
|
||||
return []int{id}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *filesDemo) entry(id int) *fileEntry {
|
||||
i := slices.IndexFunc(s.entries, func(f fileEntry) bool { return f.ID == id })
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("gallery: no file %d", id))
|
||||
}
|
||||
return &s.entries[i]
|
||||
}
|
||||
|
||||
// add appends a file unless one with its name is listed; it reports whether it did.
|
||||
func (s *filesDemo) add(path string, size int64, at time.Time) bool {
|
||||
name := filepath.Base(path)
|
||||
if slices.ContainsFunc(s.entries, func(f fileEntry) bool { return f.Name == name }) {
|
||||
return false
|
||||
}
|
||||
s.entries = append(s.entries, fileEntry{s.next, name, size, at})
|
||||
s.next++
|
||||
s.version++
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *gallery) filesView(c *ui.Context, log func(string)) {
|
||||
s := &g.cFiles
|
||||
k := mujica.Tokens(c)
|
||||
cols := g.fileColumns(c)
|
||||
targets := s.targets()
|
||||
ui.Row(c).FillWidth().Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Toolbar(c, mujica.ToolbarOptions{Label: g.t("File actions", "文件操作")}, func() {
|
||||
g.fileActions(c, log, targets, func(m *mujica.PopupMenu) { g.fileMenu(m, log, targets) })
|
||||
}).Grow(1)
|
||||
before := s.query
|
||||
mujica.SearchInput(c, &s.query, mujica.SearchInputOptions{Placeholder: g.t("Filter files", "筛选文件")}).Element.Width(240)
|
||||
if s.query != before {
|
||||
log(fmt.Sprintf("filter %q", s.query))
|
||||
}
|
||||
})
|
||||
s.table.Filter = s.query
|
||||
shown := len(mujica.DataTableRows(s.entries, cols, s.table.Sort, s.query))
|
||||
switch {
|
||||
case len(s.entries) == 0:
|
||||
if mujica.EmptyState(c, mujica.EmptyStateOptions{Kind: mujica.EmptyNoData, Title: g.t("No files", "没有文件"),
|
||||
Description: g.t("Drop files below or choose some to add them.", "将文件拖到下方,或选取文件以添加。"),
|
||||
Action: g.t("Restore samples", "恢复示例")}).Action {
|
||||
g.restoreFiles(log)
|
||||
}
|
||||
case shown == 0:
|
||||
if mujica.EmptyState(c, mujica.EmptyStateOptions{Kind: mujica.EmptyNoResults, Query: s.query, Action: g.t("Clear filter", "清除筛选")}).Action {
|
||||
s.query = ""
|
||||
log("filter cleared")
|
||||
}
|
||||
default:
|
||||
v := mujica.DataTable(c, &s.table, s.entries, mujica.DataTableOptions[fileEntry, int]{
|
||||
Columns: cols, Key: func(f fileEntry) int { return f.ID }, Multiple: true, Version: s.version,
|
||||
})
|
||||
v.FillWidth().Height(430)
|
||||
mujica.ContextMenu(c, v.Element, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) { g.fileMenu(m, log, targets) })
|
||||
if v.Changed() {
|
||||
log(fmt.Sprintf("selection %d checked", s.table.Selection.Len()))
|
||||
}
|
||||
if v.SortChanged() {
|
||||
log(fmt.Sprintf("sort %s descending=%v", s.table.Sort.Column, s.table.Sort.Descending))
|
||||
}
|
||||
if v.Submitted() && len(targets) == 1 {
|
||||
g.startRename(log, targets[0])
|
||||
}
|
||||
}
|
||||
var size int64
|
||||
for _, f := range s.entries {
|
||||
size += f.Size
|
||||
}
|
||||
mujica.StatusBar(c, mujica.StatusBarOptions{
|
||||
Left: []mujica.StatusItem{
|
||||
{ID: "count", Icon: icons.Must("folder"), Text: fmt.Sprintf(g.t("%d files · %s", "%d 个文件 · %s"), len(s.entries), fileSize(size))},
|
||||
{ID: "shown", Text: fmt.Sprintf(g.t("%d shown", "显示 %d 个"), shown), Secondary: true},
|
||||
},
|
||||
Right: []mujica.StatusItem{{ID: "selected", Text: fmt.Sprintf(g.t("%d selected", "已选 %d 个"), s.table.Selection.Len())}},
|
||||
}).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border)
|
||||
g.fileAdders(c, log)
|
||||
g.renameDialog(c, log)
|
||||
g.deleteDialog(c, log)
|
||||
}
|
||||
|
||||
// fileActions fills the toolbar; menu fills the More dropdown, shared with the context menu.
|
||||
func (g *gallery) fileActions(c *ui.Context, log func(string), targets []int, menu func(m *mujica.PopupMenu)) {
|
||||
mujica.ToolbarGroup(c, func() {
|
||||
if mujica.ToolbarButton(c, g.t("Rename", "重命名"), mujica.ToolbarButtonOptions{Icon: icons.Must("file-text"), ShowLabel: true, Disabled: len(targets) != 1}).Clicked() {
|
||||
g.startRename(log, targets[0])
|
||||
}
|
||||
if mujica.ToolbarButton(c, g.t("Delete", "删除"), mujica.ToolbarButtonOptions{Icon: icons.Must("trash-2"), ShowLabel: true, Disabled: len(targets) == 0}).Clicked() {
|
||||
g.startDelete(log, targets)
|
||||
}
|
||||
})
|
||||
mujica.ToolbarSeparator(c)
|
||||
mujica.DropdownMenu(c, g.t("Actions", "操作"), mujica.DropdownMenuOptions{Icon: icons.Must("ellipsis")}, menu)
|
||||
}
|
||||
|
||||
// fileMenu holds every file action; the dropdown and the context menu share it.
|
||||
func (g *gallery) fileMenu(m *mujica.PopupMenu, log func(string), targets []int) {
|
||||
s := &g.cFiles
|
||||
if m.Item(g.t("Rename…", "重命名…"), mujica.PopupMenuItemOptions{Disabled: len(targets) != 1}) {
|
||||
g.startRename(log, targets[0])
|
||||
}
|
||||
if m.Item(g.t("Delete…", "删除…"), mujica.PopupMenuItemOptions{Disabled: len(targets) == 0}) {
|
||||
g.startDelete(log, targets)
|
||||
}
|
||||
if m.Item(g.t("Select all", "全选"), mujica.PopupMenuItemOptions{Disabled: len(s.entries) == 0}) {
|
||||
for _, f := range s.entries {
|
||||
s.table.Selection.Add(f.ID)
|
||||
}
|
||||
log(fmt.Sprintf("selected all %d", len(s.entries)))
|
||||
}
|
||||
if m.Item(g.t("Clear selection", "清除选择"), mujica.PopupMenuItemOptions{Disabled: s.table.Selection.Len() == 0}) {
|
||||
s.table.Selection.Clear()
|
||||
log("selection cleared")
|
||||
}
|
||||
if m.Item(g.t("Restore samples", "恢复示例"), mujica.PopupMenuItemOptions{}) {
|
||||
g.restoreFiles(log)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gallery) restoreFiles(log func(string)) {
|
||||
s := &g.cFiles
|
||||
s.entries, s.next, s.query = filesSample(), 9, ""
|
||||
s.table.Selection.Clear()
|
||||
s.version++
|
||||
log("samples restored")
|
||||
}
|
||||
|
||||
// fileAdders are the drop zone and the picker; both only list files, nothing is uploaded.
|
||||
func (g *gallery) fileAdders(c *ui.Context, log func(string)) {
|
||||
s := &g.cFiles
|
||||
mujica.Card(c, mujica.CardOptions{Title: g.t("Add files", "添加文件")}, func() {
|
||||
ui.Text(c, g.t("Files are checked and listed, never read or uploaded.", "文件只做校验并列出,不会读取或上传。")).TextColor(mujica.Tokens(c).TextMuted)
|
||||
if mujica.FileDropZone(c, &s.dropped, mujica.FileDropZoneOptions{MaxFiles: 8, MaxSize: 50 << 20,
|
||||
Hint: g.t("Drop files here to list them", "将文件拖到此处以列出")}).Changed() {
|
||||
for _, f := range s.dropped {
|
||||
g.addFile(c, log, f.Path, f.Size, time.Now())
|
||||
}
|
||||
s.dropped = nil
|
||||
}
|
||||
p := mujica.FilePicker(c, &s.picked, mujica.FilePickerOptions{Multiple: true, Label: g.t("Choose files…", "选取文件…")})
|
||||
if p.Canceled() {
|
||||
log("picker canceled")
|
||||
}
|
||||
if p.Changed() {
|
||||
for _, path := range s.picked {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
mujica.ShowToast(c, mujica.ToastMessage{Severity: mujica.SeverityError, Message: err.Error()})
|
||||
log("stat failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
g.addFile(c, log, path, info.Size(), info.ModTime())
|
||||
}
|
||||
s.picked = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) addFile(c *ui.Context, log func(string), path string, size int64, at time.Time) {
|
||||
if g.cFiles.add(path, size, at) {
|
||||
log("added " + filepath.Base(path))
|
||||
return
|
||||
}
|
||||
log("skipped duplicate " + filepath.Base(path))
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "dup-" + path, Severity: mujica.SeverityWarning,
|
||||
Message: fmt.Sprintf(g.t("%s is already listed", "%s 已在列表中"), filepath.Base(path))})
|
||||
}
|
||||
|
||||
func (g *gallery) startRename(log func(string), id int) {
|
||||
s := &g.cFiles
|
||||
s.renaming, s.renameID, s.newName = true, id, s.entry(id).Name
|
||||
log("rename " + s.newName)
|
||||
}
|
||||
|
||||
// renameError checks a new name against the others in the list.
|
||||
func (g *gallery) renameError() string {
|
||||
s := &g.cFiles
|
||||
name := strings.TrimSpace(s.newName)
|
||||
switch {
|
||||
case name == "":
|
||||
return g.t("Enter a name.", "请输入名称。")
|
||||
case strings.ContainsAny(name, `/\`):
|
||||
return g.t("A name cannot contain / or \\.", "名称不能包含 / 或 \\。")
|
||||
case slices.ContainsFunc(s.entries, func(f fileEntry) bool { return f.Name == name && f.ID != s.renameID }):
|
||||
return g.t("Another file has this name.", "已有同名文件。")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *gallery) renameDialog(c *ui.Context, log func(string)) {
|
||||
s := &g.cFiles
|
||||
if !s.renaming {
|
||||
return
|
||||
}
|
||||
bad := g.renameError()
|
||||
commit := func() {
|
||||
if bad != "" {
|
||||
return
|
||||
}
|
||||
f := s.entry(s.renameID)
|
||||
log(fmt.Sprintf("renamed %s to %s", f.Name, strings.TrimSpace(s.newName)))
|
||||
f.Name = strings.TrimSpace(s.newName)
|
||||
s.version++
|
||||
s.renaming = false
|
||||
}
|
||||
mujica.Dialog(c, &s.renaming, mujica.DialogOptions{Title: g.t("Rename file", "重命名文件"), Actions: func() {
|
||||
if mujica.Button(c, g.t("Cancel", "取消"), mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.renaming = false
|
||||
log("rename canceled")
|
||||
}
|
||||
if mujica.Button(c, g.t("Rename", "重命名"), mujica.ButtonOptions{Disabled: bad != ""}).Clicked() {
|
||||
commit()
|
||||
}
|
||||
}}, func() {
|
||||
mujica.FormField(c, g.t("New name", "新名称"), mujica.FormFieldOptions{Error: bad}, func() *ui.Element {
|
||||
r := mujica.TextInput(c, &s.newName, mujica.TextInputOptions{Error: bad})
|
||||
if r.Submitted() {
|
||||
commit()
|
||||
}
|
||||
return r.Input.AutoFocus()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) startDelete(log func(string), ids []int) {
|
||||
s := &g.cFiles
|
||||
s.deleting, s.doomed = true, ids
|
||||
log(fmt.Sprintf("delete asked for %d", len(ids)))
|
||||
}
|
||||
|
||||
func (g *gallery) deleteDialog(c *ui.Context, log func(string)) {
|
||||
s := &g.cFiles
|
||||
if !s.deleting {
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf(g.t("%d files will be removed from the list.", "将从列表中移除 %d 个文件。"), len(s.doomed))
|
||||
if len(s.doomed) == 1 {
|
||||
desc = fmt.Sprintf(g.t("“%s” will be removed from the list.", "将从列表中移除“%s”。"), s.entry(s.doomed[0]).Name)
|
||||
}
|
||||
r := mujica.AlertDialog(c, &s.deleting, mujica.AlertDialogOptions{Title: g.t("Delete files?", "删除文件?"), Description: desc,
|
||||
ConfirmLabel: g.t("Delete permanently", "永久删除")})
|
||||
switch {
|
||||
case r.Confirmed:
|
||||
s.entries = slices.DeleteFunc(slices.Clone(s.entries), func(f fileEntry) bool { return slices.Contains(s.doomed, f.ID) })
|
||||
for _, id := range s.doomed {
|
||||
s.table.Selection.Remove(id)
|
||||
}
|
||||
s.version++
|
||||
log(fmt.Sprintf("deleted %d", len(s.doomed)))
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "files-deleted", Severity: mujica.SeveritySuccess,
|
||||
Message: fmt.Sprintf(g.t("Deleted %d files", "已删除 %d 个文件"), len(s.doomed))})
|
||||
s.doomed = nil
|
||||
case r.Cancelled:
|
||||
s.doomed = nil
|
||||
log("delete canceled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const formID = "c-form"
|
||||
|
||||
// Simulated latencies of the name check and the server.
|
||||
const (
|
||||
formCheckDelay = 400 * time.Millisecond
|
||||
formSubmitDelay = 900 * time.Millisecond
|
||||
)
|
||||
|
||||
// formPhase is what the form composite shows.
|
||||
type formPhase int
|
||||
|
||||
const (
|
||||
formEditing formPhase = iota
|
||||
formSucceeded
|
||||
formFailed
|
||||
)
|
||||
|
||||
type formSubmitDemo struct {
|
||||
st mujica.FormState
|
||||
name string
|
||||
password string
|
||||
debut mujica.Date
|
||||
tags []string
|
||||
terms bool
|
||||
failNext bool
|
||||
phase formPhase
|
||||
failure string
|
||||
checks chan formAsync
|
||||
done chan string
|
||||
}
|
||||
|
||||
func newFormSubmitDemo() formSubmitDemo {
|
||||
return formSubmitDemo{checks: make(chan formAsync, 8), done: make(chan string, 1)}
|
||||
}
|
||||
|
||||
// formTaken are the names the simulated archive already holds.
|
||||
var formTaken = []string{"admin", "oblivionis"}
|
||||
|
||||
func (g *gallery) formView(c *ui.Context, log func(string)) {
|
||||
s := &g.cForm
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case r := <-s.checks:
|
||||
log(fmt.Sprintf("name check v%d: %q, kept %v", r.version, r.err, s.st.Resolve("name", r.version, r.err)))
|
||||
case e := <-s.done:
|
||||
s.st.EndSubmit("")
|
||||
s.phase, s.failure = formSucceeded, e
|
||||
if e != "" {
|
||||
s.phase = formFailed
|
||||
}
|
||||
log(fmt.Sprintf("server answered, error %q", e))
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
mujica.Card(c, mujica.CardOptions{Title: g.t("Audition entry", "试演报名")}, func() {
|
||||
switch s.phase {
|
||||
case formSucceeded:
|
||||
g.formSuccess(c, log)
|
||||
case formFailed:
|
||||
g.formFailure(c, log)
|
||||
default:
|
||||
ui.Column(c).FillWidth().MaxWidth(640).Gap(16).Children(func() { g.formEdit(c, log) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) formEdit(c *ui.Context, log func(string)) {
|
||||
s := &g.cForm
|
||||
if mujica.Switch(c, &s.failNext, g.t("The server rejects the next submit", "服务器拒绝下一次提交"), mujica.SwitchOptions{Disabled: s.st.Submitting()}).Changed() {
|
||||
log(fmt.Sprintf("fail next submit %v", s.failNext))
|
||||
}
|
||||
r := mujica.LoadingOverlay(c, s.st.Submitting(), mujica.LoadingOverlayOptions{Label: g.t("Submitting…", "正在提交…")}, func() {
|
||||
res := mujica.Form(c, &s.st, mujica.FormOptions{Horizontal: true, SubmitLabel: g.t("Submit entry", "提交报名")}, func(f *mujica.FormScope) {
|
||||
g.formFields(c, f, log)
|
||||
})
|
||||
res.Element.FillWidth()
|
||||
if res.Reset() {
|
||||
log("reset to initial values")
|
||||
}
|
||||
if res.Submitted() {
|
||||
fail := ""
|
||||
if s.failNext {
|
||||
s.failNext = false
|
||||
fail = g.t("The archive is closed for the night. Your entry was not sent.", "档案馆今夜已闭馆,报名未送达。")
|
||||
}
|
||||
// The password is never logged, not even its length.
|
||||
log(fmt.Sprintf("submitted %q, debut %s, roles %q", s.name, s.debut.Format("2006-01-02"), s.tags))
|
||||
go func() {
|
||||
time.Sleep(formSubmitDelay)
|
||||
s.done <- fail
|
||||
c.Invalidate()
|
||||
}()
|
||||
}
|
||||
})
|
||||
r.Element.FillWidth()
|
||||
}
|
||||
|
||||
func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string)) {
|
||||
s := &g.cForm
|
||||
mujica.FormItem(f, "name", &s.name, mujica.FormItemOptions[string]{Label: g.t("Stage name", "艺名"), Required: true,
|
||||
Description: g.t("Checked against the archive; “Oblivionis” is taken.", "将与档案核对;“Oblivionis”已被占用。"),
|
||||
Validate: func(v string) string {
|
||||
if len([]rune(strings.TrimSpace(v))) < 2 {
|
||||
return g.t("Use at least 2 characters.", "至少 2 个字符。")
|
||||
}
|
||||
return ""
|
||||
},
|
||||
Async: func(v string, version uint64) {
|
||||
log(fmt.Sprintf("name check started v%d", version))
|
||||
go func() {
|
||||
time.Sleep(formCheckDelay)
|
||||
msg := ""
|
||||
for _, t := range formTaken {
|
||||
if strings.EqualFold(strings.TrimSpace(v), t) {
|
||||
msg = g.t("That name is taken.", "该名称已被占用。")
|
||||
}
|
||||
}
|
||||
s.checks <- formAsync{version, msg}
|
||||
c.Invalidate()
|
||||
}()
|
||||
}}, func() *ui.Element {
|
||||
return mujica.TextInput(c, &s.name, mujica.TextInputOptions{}).Input
|
||||
})
|
||||
mujica.FormItem(f, "password", &s.password, mujica.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true,
|
||||
Validate: func(v string) string {
|
||||
if len([]rune(v)) < 8 {
|
||||
return g.t("Use at least 8 characters.", "至少 8 个字符。")
|
||||
}
|
||||
return ""
|
||||
}}, func() *ui.Element {
|
||||
r := mujica.PasswordInput(c, &s.password, mujica.PasswordInputOptions{})
|
||||
if r.Submitted() {
|
||||
s.st.Submit()
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormItem(f, "debut", &s.debut, mujica.FormItemOptions[mujica.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element {
|
||||
r := mujica.DatePicker(c, &s.debut, mujica.DatePickerOptions{Clearable: true})
|
||||
if r.Changed() {
|
||||
log("debut " + s.debut.Format("2006-01-02"))
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
mujica.FormItem(f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true,
|
||||
Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() *ui.Element {
|
||||
r := mujica.TagsInput(c, &s.tags, mujica.TagsInputOptions{Max: 4, Invalid: s.st.Error("roles") != "", Placeholder: g.t("keyboard, bass…", "键盘、贝斯…")})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("roles %q", s.tags))
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormItem(f, "terms", &s.terms, mujica.FormItemOptions[bool]{Label: g.t("Terms", "条款"),
|
||||
Validate: func(v bool) string {
|
||||
if !v {
|
||||
return g.t("Accept the terms to continue.", "请先同意条款。")
|
||||
}
|
||||
return ""
|
||||
}}, func() *ui.Element {
|
||||
e := mujica.Checkbox(c, &s.terms, g.t("I accept the covenant of the masquerade", "我接受假面舞会的盟约"), mujica.CheckboxOptions{})
|
||||
if e.Changed() {
|
||||
log(fmt.Sprintf("terms %v", s.terms))
|
||||
}
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) formSuccess(c *ui.Context, log func(string)) {
|
||||
s := &g.cForm
|
||||
r := mujica.Result(c, mujica.ResultOptions{Kind: mujica.ResultSuccess, Title: g.t("Entry received", "报名已收到"),
|
||||
Description: fmt.Sprintf(g.t("%s, your debut on %s is on the ledger.", "%s,你在 %s 的首演已登记。"), s.name, s.debut.Format("2006-01-02")),
|
||||
Actions: []string{g.t("New entry", "新的报名")}})
|
||||
if r.Action == 0 {
|
||||
*s = newFormSubmitDemo()
|
||||
log("new entry")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gallery) formFailure(c *ui.Context, log func(string)) {
|
||||
s := &g.cForm
|
||||
r := mujica.Result(c, mujica.ResultOptions{Kind: mujica.ResultFailure, Title: g.t("Entry not sent", "报名未送达"), Description: s.failure,
|
||||
Actions: []string{g.t("Retry", "重试"), g.t("Back to the form", "返回表单")}})
|
||||
switch r.Action {
|
||||
case 0:
|
||||
s.phase = formEditing
|
||||
s.st.Submit()
|
||||
log("retry")
|
||||
case 1:
|
||||
s.phase = formEditing
|
||||
log("back to the form")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
const settingsID = "c-settings"
|
||||
|
||||
// settingsValues is one full set of preferences; drafts compare to saved ones with ==.
|
||||
type settingsValues struct {
|
||||
workspace string
|
||||
language string
|
||||
startPage string
|
||||
launch bool
|
||||
theme string
|
||||
fontSize float64
|
||||
sidebar float64
|
||||
reduce bool
|
||||
telemetry bool
|
||||
crashes bool
|
||||
history float64
|
||||
lock string
|
||||
}
|
||||
|
||||
var settingsInitial = settingsValues{
|
||||
workspace: "Mujica", language: "en", startPage: "last", launch: true,
|
||||
theme: "system", fontSize: 14, sidebar: 240,
|
||||
crashes: true, history: 30, lock: "15",
|
||||
}
|
||||
|
||||
type settingsDemo struct {
|
||||
tab int
|
||||
saved, draft settingsValues
|
||||
}
|
||||
|
||||
func newSettingsDemo() settingsDemo {
|
||||
return settingsDemo{saved: settingsInitial, draft: settingsInitial}
|
||||
}
|
||||
|
||||
func (g *gallery) settingsView(c *ui.Context, log func(string)) {
|
||||
s := &g.cSettings
|
||||
mujica.Card(c, mujica.CardOptions{Title: g.t("Preferences", "偏好设置"), Footer: func() { g.settingsFooter(c, log) }}, func() {
|
||||
tabs := []mujica.Tab{
|
||||
{Label: g.t("General", "通用"), Icon: icons.Must("settings"), Panel: func() { g.settingsGeneral(c, log) }},
|
||||
{Label: g.t("Appearance", "外观"), Icon: icons.Must("palette"), Panel: func() { g.settingsAppearance(c, log) }},
|
||||
{Label: g.t("Privacy", "隐私"), Icon: icons.Must("lock"), Panel: func() { g.settingsPrivacy(c, log) }},
|
||||
}
|
||||
if mujica.Tabs(c, &s.tab, tabs, mujica.TabsOptions{Label: g.t("Preference groups", "设置分组")}).Changed() {
|
||||
log("tab " + tabs[s.tab].Label)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// settingsPanel lays one tab's fields in a readable column.
|
||||
func settingsPanel(c *ui.Context, fields func()) {
|
||||
ui.Column(c).FillWidth().MaxWidth(560).Gap(20).Padding(16, 0, 4, 0).Children(fields)
|
||||
}
|
||||
|
||||
// workspaceError checks the one free-text preference.
|
||||
func (g *gallery) workspaceError(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return g.t("Name the workspace.", "请为工作区命名。")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *gallery) settingsGeneral(c *ui.Context, log func(string)) {
|
||||
d := &g.cSettings.draft
|
||||
settingsPanel(c, func() {
|
||||
bad := g.workspaceError(d.workspace)
|
||||
mujica.FormField(c, g.t("Workspace name", "工作区名称"), mujica.FormFieldOptions{Required: true, Error: bad,
|
||||
Description: g.t("Shown in the title bar.", "显示在标题栏中。")}, func() *ui.Element {
|
||||
r := mujica.TextInput(c, &d.workspace, mujica.TextInputOptions{Error: bad})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("workspace %q", d.workspace))
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormField(c, g.t("Language", "语言"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
r := mujica.Select(c, &d.language, []mujica.SelectOption[string]{
|
||||
{Value: "en", Label: "English"}, {Value: "zh", Label: "简体中文"}, {Value: "ja", Label: "日本語"},
|
||||
}, mujica.SelectOptions{})
|
||||
if r.Changed() {
|
||||
log("language " + d.language)
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
mujica.FormField(c, g.t("On startup", "启动时"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
r := mujica.RadioGroup(c, &d.startPage, []mujica.RadioItem[string]{
|
||||
{Value: "blank", Label: g.t("Open a blank page", "打开空白页")},
|
||||
{Value: "last", Label: g.t("Reopen the last session", "恢复上次会话")},
|
||||
{Value: "home", Label: g.t("Open the home page", "打开主页")},
|
||||
}, mujica.RadioGroupOptions{})
|
||||
if r.Changed() {
|
||||
log("startup " + d.startPage)
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
mujica.FormField(c, g.t("Login item", "登录项"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
e := mujica.Switch(c, &d.launch, g.t("Launch at login", "登录时启动"), mujica.SwitchOptions{})
|
||||
if e.Changed() {
|
||||
log(fmt.Sprintf("launch at login %v", d.launch))
|
||||
}
|
||||
return e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) settingsAppearance(c *ui.Context, log func(string)) {
|
||||
d := &g.cSettings.draft
|
||||
settingsPanel(c, func() {
|
||||
mujica.FormField(c, g.t("Theme", "主题"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
r := mujica.RadioGroup(c, &d.theme, []mujica.RadioItem[string]{
|
||||
{Value: "system", Label: g.t("System", "跟随系统")}, {Value: "light", Label: g.t("Light", "浅色")}, {Value: "dark", Label: g.t("Dark", "深色")},
|
||||
}, mujica.RadioGroupOptions{Horizontal: true})
|
||||
if r.Changed() {
|
||||
log("theme " + d.theme)
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
mujica.FormField(c, g.t("Text size", "文字大小"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
r := mujica.Slider(c, &d.fontSize, mujica.SliderOptions{Min: 12, Max: 20, Step: 1, ShowValue: true,
|
||||
Marks: []mujica.SliderMark{{Value: 12, Label: "12"}, {Value: 16, Label: "16"}, {Value: 20, Label: "20"}},
|
||||
Format: func(v float64) string { return fmt.Sprintf("%.0f pt", v) }})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("text size %.0f", d.fontSize))
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
mujica.FormField(c, g.t("Sidebar width", "侧栏宽度"), mujica.FormFieldOptions{Description: g.t("In DIPs, from 180 to 360.", "单位 DIP,180 至 360。")}, func() *ui.Element {
|
||||
r := mujica.NumberInput(c, &d.sidebar, mujica.NumberInputOptions{Min: 180, Max: 360, Step: 10})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("sidebar width %.0f", d.sidebar))
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormField(c, g.t("Motion", "动效"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
e := mujica.Switch(c, &d.reduce, g.t("Reduce motion", "减少动效"), mujica.SwitchOptions{})
|
||||
if e.Changed() {
|
||||
log(fmt.Sprintf("reduce motion %v", d.reduce))
|
||||
}
|
||||
return e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gallery) settingsPrivacy(c *ui.Context, log func(string)) {
|
||||
d := &g.cSettings.draft
|
||||
settingsPanel(c, func() {
|
||||
mujica.FormField(c, g.t("Usage data", "使用数据"), mujica.FormFieldOptions{Description: g.t("Nothing leaves this demo.", "本示例不会发送任何数据。")}, func() *ui.Element {
|
||||
e := mujica.Switch(c, &d.telemetry, g.t("Share anonymous usage data", "共享匿名使用数据"), mujica.SwitchOptions{})
|
||||
if e.Changed() {
|
||||
log(fmt.Sprintf("usage data %v", d.telemetry))
|
||||
}
|
||||
return e
|
||||
})
|
||||
mujica.FormField(c, g.t("Crash reports", "崩溃报告"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
e := mujica.Switch(c, &d.crashes, g.t("Send crash reports", "发送崩溃报告"), mujica.SwitchOptions{})
|
||||
if e.Changed() {
|
||||
log(fmt.Sprintf("crash reports %v", d.crashes))
|
||||
}
|
||||
return e
|
||||
})
|
||||
mujica.FormField(c, g.t("Keep history", "保留历史"), mujica.FormFieldOptions{Description: g.t("Days, from 0 to 365; 0 keeps none.", "天数,0 至 365;0 表示不保留。")}, func() *ui.Element {
|
||||
r := mujica.NumberInput(c, &d.history, mujica.NumberInputOptions{Min: 0, Max: 365, Step: 1})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("history %.0f days", d.history))
|
||||
}
|
||||
return r.Input
|
||||
})
|
||||
mujica.FormField(c, g.t("Lock after", "自动锁定"), mujica.FormFieldOptions{}, func() *ui.Element {
|
||||
r := mujica.Select(c, &d.lock, []mujica.SelectOption[string]{
|
||||
{Value: "never", Label: g.t("Never", "从不")}, {Value: "5", Label: g.t("5 minutes", "5 分钟")},
|
||||
{Value: "15", Label: g.t("15 minutes", "15 分钟")}, {Value: "60", Label: g.t("1 hour", "1 小时")},
|
||||
}, mujica.SelectOptions{})
|
||||
if r.Changed() {
|
||||
log("lock after " + d.lock)
|
||||
}
|
||||
return r.Element
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// settingsFooter flags unsaved changes and holds Reset and Save.
|
||||
func (g *gallery) settingsFooter(c *ui.Context, log func(string)) {
|
||||
s := &g.cSettings
|
||||
dirty := s.draft != s.saved
|
||||
invalid := g.workspaceError(s.draft.workspace) != ""
|
||||
ui.Row(c).FillWidth().AlignItems(ui.Center).Gap(12).Children(func() {
|
||||
if dirty {
|
||||
mujica.StatusIndicator(c, mujica.StatusWarning, mujica.StatusIndicatorOptions{Label: g.t("Unsaved changes", "有未保存的更改")})
|
||||
} else {
|
||||
mujica.StatusIndicator(c, mujica.StatusSuccess, mujica.StatusIndicatorOptions{Label: g.t("All changes saved", "更改均已保存")})
|
||||
}
|
||||
ui.Box(c).Grow(1)
|
||||
if mujica.Button(c, g.t("Reset", "重置"), mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.draft == settingsInitial}).Clicked() {
|
||||
s.draft = settingsInitial
|
||||
log("reset to initial values")
|
||||
}
|
||||
if mujica.Button(c, g.t("Save", "保存"), mujica.ButtonOptions{Disabled: !dirty || invalid}).Clicked() {
|
||||
s.saved = s.draft
|
||||
log("saved")
|
||||
mujica.ShowToast(c, mujica.ToastMessage{ID: "settings-saved", Severity: mujica.SeveritySuccess, Message: g.t("Settings saved", "设置已保存")})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// compositeTester opens the gallery on one composition at 1200×800.
|
||||
func compositeTester(id string) (*gallery, *ui.Tester) {
|
||||
g := newGallery()
|
||||
g.selected = id
|
||||
return g, ui.NewTester(g.view, 1200, 800)
|
||||
}
|
||||
|
||||
func click(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
if err := tt.Click(label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// await renders frames until done holds or 5 seconds pass.
|
||||
func await(tt *ui.Tester, done func() bool) {
|
||||
for deadline := time.Now().Add(5 * time.Second); !done() && time.Now().Before(deadline); {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeSettingsSaveAndReset(t *testing.T) {
|
||||
g, tt := compositeTester(settingsID)
|
||||
s := &g.cSettings
|
||||
click(t, tt, "Appearance")
|
||||
click(t, tt, "Reduce motion")
|
||||
if !s.draft.reduce || s.saved.reduce {
|
||||
t.Fatalf("draft reduce %v, saved reduce %v", s.draft.reduce, s.saved.reduce)
|
||||
}
|
||||
if !tt.HasText("Unsaved changes") {
|
||||
t.Fatalf("no unsaved flag in %q", tt.Texts())
|
||||
}
|
||||
click(t, tt, "Save")
|
||||
if s.saved != s.draft || !s.saved.reduce {
|
||||
t.Fatalf("saved %+v, draft %+v", s.saved, s.draft)
|
||||
}
|
||||
if !tt.HasText("Settings saved") || !tt.HasText("All changes saved") {
|
||||
t.Fatalf("no saved toast in %q", tt.Texts())
|
||||
}
|
||||
click(t, tt, "Reset")
|
||||
if s.draft != settingsInitial || !s.saved.reduce {
|
||||
t.Fatalf("reset draft %+v, saved %+v", s.draft, s.saved)
|
||||
}
|
||||
if !tt.HasText("Unsaved changes") {
|
||||
t.Fatal("reset away from the saved values is not flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeFilesFilterSelectDelete(t *testing.T) {
|
||||
g, tt := compositeTester(filesID)
|
||||
s := &g.cFiles
|
||||
click(t, tt, "Filter files")
|
||||
tt.Type("rehearsal")
|
||||
if s.query != "rehearsal" || s.table.Rows() != 1 {
|
||||
t.Fatalf("query %q shows %d rows", s.query, s.table.Rows())
|
||||
}
|
||||
click(t, tt, "Select row")
|
||||
if s.table.Selection.Len() != 1 || !s.table.Selection.Has(2) {
|
||||
t.Fatalf("selection %v", slices.Collect(s.table.Selection.All()))
|
||||
}
|
||||
click(t, tt, "Delete")
|
||||
if !s.deleting || !slices.Equal(s.doomed, []int{2}) {
|
||||
t.Fatalf("deleting %v, doomed %v", s.deleting, s.doomed)
|
||||
}
|
||||
click(t, tt, "Delete permanently")
|
||||
if len(s.entries) != 7 || slices.ContainsFunc(s.entries, func(f fileEntry) bool { return f.ID == 2 }) {
|
||||
t.Fatalf("entries after delete %+v", s.entries)
|
||||
}
|
||||
if s.deleting || s.table.Selection.Len() != 0 {
|
||||
t.Fatalf("deleting %v, selection %d", s.deleting, s.table.Selection.Len())
|
||||
}
|
||||
if !tt.HasText("Nothing matches “rehearsal”.") {
|
||||
t.Fatalf("no empty search state in %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeFormInvalidThenValid(t *testing.T) {
|
||||
g, tt := compositeTester(formID)
|
||||
s := &g.cForm
|
||||
click(t, tt, "Submit entry")
|
||||
if got := s.st.Error("name"); got != "Required" {
|
||||
t.Fatalf("name error %q", got)
|
||||
}
|
||||
if got := s.st.Error("terms"); got != "Accept the terms to continue." {
|
||||
t.Fatalf("terms error %q", got)
|
||||
}
|
||||
if !tt.Focused("Stage name") {
|
||||
t.Fatal("the first invalid field has no focus")
|
||||
}
|
||||
tt.Type("Mortis")
|
||||
click(t, tt, "Password")
|
||||
tt.Type("hunter22secret")
|
||||
click(t, tt, "Select date") // the date field itself; its FormItem label focuses the picker's column
|
||||
tt.Type("2026-12-24")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
click(t, tt, "Roles")
|
||||
tt.Type("guitar")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
click(t, tt, "I accept the covenant of the masquerade")
|
||||
if s.name != "Mortis" || s.password != "hunter22secret" || s.debut.Format("2006-01-02") != "2026-12-24" ||
|
||||
!slices.Equal(s.tags, []string{"guitar"}) || !s.terms {
|
||||
t.Fatalf("values %q %q %v %q %v", s.name, s.password, s.debut, s.tags, s.terms)
|
||||
}
|
||||
click(t, tt, "Submit entry")
|
||||
await(tt, s.st.Submitting) // the name check finishes first
|
||||
if !s.st.Submitting() || !tt.HasText("Submitting…") {
|
||||
t.Fatalf("no loading overlay in %q", tt.Texts())
|
||||
}
|
||||
click(t, tt, "Submit entry") // the overlay blocks a second submit
|
||||
await(tt, func() bool { return s.phase == formSucceeded })
|
||||
if s.phase != formSucceeded || !tt.HasText("Entry received") {
|
||||
t.Fatalf("phase %d, texts %q", s.phase, tt.Texts())
|
||||
}
|
||||
submits := 0
|
||||
for _, e := range g.events {
|
||||
if strings.Contains(e.text, "hunter22secret") {
|
||||
t.Fatalf("password logged: %q", e.text)
|
||||
}
|
||||
if e.page == formID && strings.HasPrefix(e.text, "submitted ") {
|
||||
submits++
|
||||
}
|
||||
}
|
||||
if submits != 1 {
|
||||
t.Fatalf("%d submits, want 1", submits)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,26 @@ import (
|
||||
|
||||
const overtureID = "overture"
|
||||
|
||||
// composition is one PRD §8 demo that combines components.
|
||||
type composition struct {
|
||||
ID, Name, ZhName, About, ZhAbout, Icon string
|
||||
}
|
||||
|
||||
var compositions = []composition{
|
||||
{settingsID, "Settings", "设置页", "Tabs of labelled controls with save, reset and an unsaved-changes flag.", "分页的带标签控件,可保存、重置,并标出未保存的更改。", "settings"},
|
||||
{filesID, "File list", "文件列表", "Filter, select, rename and delete files; drop or pick files to add them.", "筛选、选择、重命名与删除文件;拖入或选取文件以添加。", "folder"},
|
||||
{formID, "Form submission", "表单提交", "Validated fields, an async name check, a guarded submit and its result.", "带校验的字段、异步名称检查、防重复提交及其结果。", "file-text"},
|
||||
}
|
||||
|
||||
func compositionOf(id string) composition {
|
||||
for _, cp := range compositions {
|
||||
if cp.ID == id {
|
||||
return cp
|
||||
}
|
||||
}
|
||||
panic("gallery: no composition " + id)
|
||||
}
|
||||
|
||||
// categoryZh names each PRD group in Chinese.
|
||||
var categoryZh = map[string]string{
|
||||
"Layout": "布局与桌面", "Display": "基础展示", "Inputs": "基础输入", "Choices": "选择控件",
|
||||
@@ -47,6 +67,16 @@ func (g *gallery) sections() []mujica.SidebarSection {
|
||||
out := []mujica.SidebarSection{{Title: g.t("Overture", "序章"), Items: []mujica.SidebarItem{
|
||||
{ID: overtureID, Label: g.t("Overture", "序章"), Icon: icons.Must("house")},
|
||||
}}}
|
||||
var comps []mujica.SidebarItem
|
||||
q := strings.ToLower(strings.TrimSpace(g.query))
|
||||
for _, cp := range compositions {
|
||||
if q == "" || strings.Contains(strings.ToLower(cp.ID+" "+cp.Name+" "+cp.ZhName), q) {
|
||||
comps = append(comps, mujica.SidebarItem{ID: cp.ID, Label: g.t(cp.Name, cp.ZhName), Icon: icons.Must(cp.Icon)})
|
||||
}
|
||||
}
|
||||
if len(comps) > 0 {
|
||||
out = append(out, mujica.SidebarSection{Title: g.t("Compositions", "组合示例"), Items: comps})
|
||||
}
|
||||
index := map[string]int{}
|
||||
for _, p := range g.pages {
|
||||
if !matches(p, g.query) {
|
||||
@@ -87,6 +117,10 @@ func (g *gallery) commandPalette(c *ui.Context) {
|
||||
{ID: "density", Label: g.t("Cycle density", "切换密度"), Group: g.t("Window", "窗口"), Keywords: []string{"compact", "紧凑"}},
|
||||
{ID: "locale", Label: g.t("Switch to Chinese", "切换到英文"), Group: g.t("Window", "窗口"), Keywords: []string{"language", "语言"}},
|
||||
}
|
||||
for _, cp := range compositions {
|
||||
cmds = append(cmds, mujica.Command{ID: "page:" + cp.ID, Label: g.t(cp.Name, cp.ZhName), Group: g.t("Compositions", "组合示例"),
|
||||
Keywords: []string{cp.Name, cp.ZhName, "composition", "组合"}})
|
||||
}
|
||||
for _, p := range g.pages {
|
||||
cmds = append(cmds, mujica.Command{ID: "page:" + p.ID, Label: p.ID + " " + g.pageName(p), Group: g.categoryName(p.Category),
|
||||
Keywords: []string{p.Name, p.ZhName, p.Category, categoryZh[p.Category]}})
|
||||
|
||||
@@ -60,3 +60,15 @@ func (g *gallery) eventCard(c *ui.Context, id string) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// compositePage shows one composition demo above its events.
|
||||
func (g *gallery) compositePage(c *ui.Context, id string, body func(c *ui.Context, log func(string))) {
|
||||
cp := compositionOf(id)
|
||||
mujica.Container(c, mujica.ContainerOptions{MaxWidth: 1080, Center: true}, func() {
|
||||
ui.Column(c).FillWidth().Gap(24).Children(func() {
|
||||
mujica.Heading(c, g.t(cp.Name, cp.ZhName), mujica.HeadingOptions{Subtitle: g.t(cp.About, cp.ZhAbout), Divider: true})
|
||||
ui.Column(c).FillWidth().Gap(16).Children(func() { body(c, g.logFor(id)) })
|
||||
g.eventCard(c, id)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user