From 9c1e2ad36d082c737629c8ebfa37eaf599fd2f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 13:00:36 -0400 Subject: [PATCH] Gallery shell: AppShell, search, sidebar, theme/density/locale, palette, code, keys, events, Overture --- examples/gallery/app.go | 152 ++++++++++++++++++++++++++++++---- examples/gallery/nav.go | 115 +++++++++++++++++++++++++ examples/gallery/overture.go | 116 ++++++++++++++++++++++++++ examples/gallery/page_view.go | 62 ++++++++++++++ 4 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 examples/gallery/nav.go create mode 100644 examples/gallery/overture.go create mode 100644 examples/gallery/page_view.go diff --git a/examples/gallery/app.go b/examples/gallery/app.go index e325589..29e8bda 100644 --- a/examples/gallery/app.go +++ b/examples/gallery/app.go @@ -1,35 +1,157 @@ package main import ( + "fmt" + "log/slog" + "time" + "github.com/egoist/mygo/ui" mujica "mujicaui" + "mujicaui/icons" + "mujicaui/theme" ) +// event is one line of the gallery's event log. +type event struct { + at time.Time + page string + text string +} + type gallery struct { pages []page - selected int - events []string + byID map[string]int + selected string + query string + mode int + density int + zh bool + folded bool + autoFold bool + palette bool + events []event + scrolls map[string]*ui.ScrollState + overture overtureState } -func newGallery() *gallery { return &gallery{pages: catalog()} } +// densities maps the density control's segments to theme densities. +var densities = []theme.Density{theme.Compact, theme.Default, theme.Comfortable} -func (g *gallery) log(s string) { g.events = append(g.events, s) } +func newGallery() *gallery { + g := &gallery{pages: catalog(), byID: map[string]int{}, selected: overtureID, density: 1, scrolls: map[string]*ui.ScrollState{}} + for i, p := range g.pages { + if _, dup := g.byID[p.ID]; dup { + panic("gallery: duplicate page " + p.ID) + } + g.byID[p.ID] = i + } + return g +} + +// logFor returns the event logger of one page. +func (g *gallery) logFor(id string) func(string) { + return func(s string) { + g.events = append(g.events, event{time.Now(), id, s}) + if len(g.events) > 200 { + g.events = g.events[len(g.events)-200:] + } + slog.Info("gallery: event", "page", id, "event", s) + } +} + +func (g *gallery) settings() mujica.Settings { + s := mujica.Settings{Mode: mujica.Mode(g.mode), Density: densities[g.density], Locale: mujica.EnUS} + if g.zh { + s.Locale = mujica.ZhCN + } + return s +} func (g *gallery) view(c *ui.Context) { - mujica.Use(c, mujica.Settings{}) - ui.Row(c).Fill().Children(func() { - ui.Scroll(c).Width(240).FillHeight().Children(func() { - for i, p := range g.pages { - if mujica.Button(c, p.ID+" "+p.Name, mujica.ButtonOptions{Variant: mujica.Ghost}).Key(p.ID).Clicked() { - g.selected = i - } + mujica.Use(c, g.settings()) + w, _ := c.Size() + if narrow := w < 900; narrow != g.autoFold { + g.autoFold, g.folded = narrow, narrow + } + scroll := g.scrolls[g.selected] + if scroll == nil { + scroll = &ui.ScrollState{} + g.scrolls[g.selected] = scroll + } + mujica.AppShell(c, &g.folded, mujica.AppShellOptions{ + TitleBar: func() { g.titleBar(c) }, + Sidebar: func() { g.sidebar(c) }, + Content: func() { g.content(c) }, + StatusBar: func() { g.statusBar(c) }, + ContentScroll: scroll, + }) + g.commandPalette(c) + mujica.Toaster(c) +} + +func (g *gallery) titleBar(c *ui.Context) { + mujica.TitleBar(c, "MujicaUI", mujica.TitleBarOptions{Trailing: func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + mode := g.mode + if mujica.SegmentedControl(c, &g.mode, []mujica.Segment{ + {Label: g.t("System", "跟随系统"), Icon: icons.Must("settings"), HideLabel: true}, + {Label: g.t("Light", "浅色"), Icon: icons.Must("sun"), HideLabel: true}, + {Label: g.t("Dark", "深色"), Icon: icons.Must("moon"), HideLabel: true}, + }, mujica.SegmentedControlOptions{Label: g.t("Theme", "主题")}).Changed() && mode != g.mode { + g.logFor("shell")("theme " + mujica.Mode(g.mode).String()) + g.overture.switches++ + } + density := g.density + if mujica.SegmentedControl(c, &g.density, []mujica.Segment{ + {Label: g.t("Compact", "紧凑")}, {Label: g.t("Default", "默认")}, {Label: g.t("Comfortable", "宽松")}, + }, mujica.SegmentedControlOptions{Label: g.t("Density", "密度")}).Changed() && density != g.density { + g.logFor("shell")("density " + densities[g.density].String()) + g.overture.switches++ + } + if mujica.Button(c, g.t("中文", "English"), mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + g.zh = !g.zh + g.logFor("shell")(fmt.Sprintf("locale %s", g.settings().Locale)) + } + if mujica.IconButton(c, icons.Must("command"), g.t("Command palette", "命令面板"), mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + g.palette = true } }) - ui.Scroll(c).Fill().Padding(24).Children(func() { - if len(g.pages) > 0 { - g.pages[g.selected].Demo(c, g.log) + }}) +} + +func (g *gallery) statusBar(c *ui.Context) { + left := []mujica.StatusItem{ + {ID: "count", Text: fmt.Sprintf(g.t("%d components", "%d 个组件"), len(g.pages)), Icon: icons.Must("layout-grid")}, + {ID: "look", Text: fmt.Sprintf("%s · %s", mujica.Mode(g.mode), densities[g.density]), Secondary: true}, + } + var right []mujica.StatusItem + if n := len(g.events); n > 0 { + e := g.events[n-1] + right = append(right, mujica.StatusItem{ID: "last", Text: e.page + " · " + e.text, Icon: icons.Must("bell"), Secondary: true}) + } + mujica.StatusBar(c, mujica.StatusBarOptions{Left: left, Right: right}) +} + +func (g *gallery) content(c *ui.Context) { + ui.Box(c).Key(g.selected).FillWidth().Children(func() { + switch g.selected { + case overtureID: + g.overturePage(c) + default: + i, ok := g.byID[g.selected] + if !ok { + panic("gallery: no page " + g.selected) } - }) + g.componentPage(c, g.pages[i]) + } }) } + +// t picks the English or Chinese copy for the gallery's own text. +func (g *gallery) t(en, zh string) string { + if g.zh { + return zh + } + return en +} diff --git a/examples/gallery/nav.go b/examples/gallery/nav.go new file mode 100644 index 0000000..649106b --- /dev/null +++ b/examples/gallery/nav.go @@ -0,0 +1,115 @@ +package main + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +const overtureID = "overture" + +// categoryZh names each PRD group in Chinese. +var categoryZh = map[string]string{ + "Layout": "布局与桌面", "Display": "基础展示", "Inputs": "基础输入", "Choices": "选择控件", + "Forms": "表单组合", "Date & Time": "日期与时间", "Navigation": "导航", "Data": "内容与数据", + "Overlays": "浮层与菜单", "Feedback": "反馈与状态", +} + +// matches reports whether p fits the search query in English or Chinese. +func matches(p page, q string) bool { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + return true + } + for _, s := range []string{p.ID, p.Name, p.ZhName, p.Category, categoryZh[p.Category]} { + if strings.Contains(strings.ToLower(s), q) { + return true + } + } + return false +} + +func (g *gallery) categoryName(cat string) string { + zh, ok := categoryZh[cat] + if !ok { + panic("gallery: unknown category " + cat) + } + return g.t(cat, zh) +} + +func (g *gallery) pageName(p page) string { return g.t(p.Name, p.ZhName) } + +// sections groups the pages that match the query by category, in PRD order. +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")}, + }}} + index := map[string]int{} + for _, p := range g.pages { + if !matches(p, g.query) { + continue + } + i, ok := index[p.Category] + if !ok { + i = len(out) + index[p.Category] = i + out = append(out, mujica.SidebarSection{Title: g.categoryName(p.Category)}) + } + out[i].Items = append(out[i].Items, mujica.SidebarItem{ID: p.ID, Label: p.ID + " " + g.pageName(p)}) + } + return out +} + +func (g *gallery) sidebar(c *ui.Context) { + ui.Column(c).FillWidth().Gap(8).Padding(8, 8, 0, 8).Children(func() { + before := g.query + mujica.SearchInput(c, &g.query, mujica.SearchInputOptions{Placeholder: g.t("Search · 搜索", "搜索 · Search")}) + if g.query != before { + g.logFor("shell")("search " + g.query) + } + }) + sections := g.sections() + if len(sections) == 1 && g.query != "" { + ui.Text(c, g.t("No component matches.", "没有匹配的组件。")).Padding(8, 16) + } + if mujica.Sidebar(c, &g.selected, sections, mujica.SidebarOptions{}).Changed() { + g.logFor("shell")("open " + g.selected) + } +} + +func (g *gallery) commandPalette(c *ui.Context) { + cmds := []mujica.Command{ + {ID: overtureID, Label: g.t("Overture", "序章"), Group: g.t("Go to", "前往")}, + {ID: "theme", Label: g.t("Cycle theme", "切换主题"), Group: g.t("Window", "窗口"), Keywords: []string{"dark", "light", "深色", "浅色"}}, + {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 _, 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]}}) + } + id, ok := mujica.CommandPalette(c, &g.palette, cmds, mujica.CommandPaletteOptions{}).Chosen() + if !ok { + return + } + g.logFor("shell")("command " + id) + switch { + case id == overtureID: + g.selected = overtureID + case id == "theme": + g.mode = (g.mode + 1) % 3 + g.overture.switches++ + case id == "density": + g.density = (g.density + 1) % len(densities) + g.overture.switches++ + case id == "locale": + g.zh = !g.zh + case strings.HasPrefix(id, "page:"): + g.selected = strings.TrimPrefix(id, "page:") + default: + panic("gallery: unknown command " + id) + } +} diff --git a/examples/gallery/overture.go b/examples/gallery/overture.go new file mode 100644 index 0000000..d928c2a --- /dev/null +++ b/examples/gallery/overture.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +// overtureState is the continuity proof: values that must survive theme changes. +type overtureState struct { + note string + tempo float64 + sealed bool + switches int +} + +func (g *gallery) overturePage(c *ui.Context) { + k := mujica.Tokens(c) + w, _ := c.Size() + mujica.Container(c, mujica.ContainerOptions{MaxWidth: 1080, Center: true}, func() { + ui.Column(c).FillWidth().Gap(32).Children(func() { + mujica.Heading(c, "MujicaUI", mujica.HeadingOptions{ + Subtitle: g.t("One hundred court-gothic components for MyGo native UI.", "为 MyGo 原生界面打造的一百件宫廷哥特组件。"), + Divider: true, + }) + ui.Text(c, g.t( + "Every pixel here is drawn by Go. No HTML, no WebView. Ivory and charcoal, a wine accent, old gold on the edges.", + "这里每一个像素都由 Go 绘制。没有 HTML,没有 WebView。象牙白与炭黑,酒红为主色,旧金只落在边缘。", + )).TextColor(k.TextMuted).FillWidth() + mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fr(1), ui.Fr(1), ui.Fr(1)}}, func() { + mujica.Statistic(c, float64(len(g.pages)), mujica.StatisticOptions{Title: g.t("Components", "组件")}) + mujica.Statistic(c, 2, mujica.StatisticOptions{Title: g.t("Themes", "主题")}) + mujica.Statistic(c, 3, mujica.StatisticOptions{Title: g.t("Densities", "密度")}) + }) + g.continuity(c) + g.categoryGrid(c, w) + }) + }) +} + +// continuity holds live state while the user flips theme and density. +func (g *gallery) continuity(c *ui.Context) { + s := &g.overture + k := mujica.Tokens(c) + mujica.Card(c, mujica.CardOptions{Title: g.t("Proof of continuity", "连续性证明"), Decorated: true}, func() { + ui.Column(c).FillWidth().Gap(16).Children(func() { + ui.Text(c, g.t( + "Type a line, move the slider, then switch theme or density above, or press ⌘K. Nothing resets: not the text, not the caret, not the focus.", + "写下一行字,拖动滑块,再切换上方的主题或密度,或按 ⌘K。什么都不会重置:文字、光标、焦点都留在原处。", + )).FillWidth() + if mujica.TextInput(c, &s.note, mujica.TextInputOptions{Placeholder: g.t("A line for the court ledger", "写给宫廷账簿的一行"), Label: g.t("Ledger line", "账簿")}).Changed() { + g.logFor(overtureID)("ledger line edited") + } + if mujica.Slider(c, &s.tempo, mujica.SliderOptions{Max: 100, Step: 1, ShowValue: true, Label: g.t("Tempo", "速度")}).Changed() { + g.logFor(overtureID)(fmt.Sprintf("tempo %.0f", s.tempo)) + } + if mujica.Checkbox(c, &s.sealed, g.t("Seal with wax", "以火漆封缄"), mujica.CheckboxOptions{}).Changed() { + g.logFor(overtureID)(fmt.Sprintf("sealed %v", s.sealed)) + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + if mujica.Button(c, g.t("Cycle theme", "切换主题"), mujica.ButtonOptions{Icon: icons.Must("moon")}).Clicked() { + g.mode = (g.mode + 1) % 3 + s.switches++ + g.logFor(overtureID)("theme " + mujica.Mode(g.mode).String()) + } + if mujica.Button(c, g.t("Cycle density", "切换密度"), mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + g.density = (g.density + 1) % len(densities) + s.switches++ + g.logFor(overtureID)("density " + densities[g.density].String()) + } + ui.Text(c, fmt.Sprintf(g.t("%d switches · %d characters kept", "已切换 %d 次 · 保留 %d 个字符"), s.switches, len([]rune(s.note)))). + TextColor(k.TextMuted) + }) + }) + }) +} + +// categoryGrid lists the ten PRD groups as doors into the gallery. +func (g *gallery) categoryGrid(c *ui.Context, width float32) { + cols := 3 + if width < 1000 { + cols = 2 + } + tracks := make([]ui.Track, cols) + for i := range tracks { + tracks[i] = ui.Fr(1) + } + var cats []string + first := map[string]page{} + count := map[string]int{} + for _, p := range g.pages { + if _, ok := first[p.Category]; !ok { + cats = append(cats, p.Category) + first[p.Category] = p + } + count[p.Category]++ + } + mujica.Grid(c, mujica.GridOptions{Columns: tracks}, func() { + for _, cat := range cats { + ui.Box(c).Key(cat).Children(func() { + mujica.Card(c, mujica.CardOptions{Title: g.categoryName(cat), Footer: func() { + if mujica.Button(c, g.t("Enter", "进入"), mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() { + g.selected = first[cat].ID + g.logFor(overtureID)("enter " + cat) + } + }}, func() { + ui.Text(c, fmt.Sprintf(g.t("%d components, from %s", "%d 个组件,始于 %s"), count[cat], g.pageName(first[cat]))). + TextColor(mujica.Tokens(c).TextMuted) + }) + }) + } + }) +} diff --git a/examples/gallery/page_view.go b/examples/gallery/page_view.go new file mode 100644 index 0000000..e9b1d04 --- /dev/null +++ b/examples/gallery/page_view.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" + "mujicaui/theme" +) + +// componentPage shows one component: demo, code, keys and its events. +func (g *gallery) componentPage(c *ui.Context, p page) { + k := mujica.Tokens(c) + mujica.Container(c, mujica.ContainerOptions{MaxWidth: 1080, Center: true}, func() { + ui.Column(c).FillWidth().Gap(24).Children(func() { + mujica.Heading(c, g.pageName(p), mujica.HeadingOptions{ + Subtitle: fmt.Sprintf("%s · %s · %s", p.ID, g.t(p.ZhName, p.Name), g.categoryName(p.Category)), + Divider: true, + }) + ui.Column(c).FillWidth().Gap(16).Children(func() { p.Demo(c, g.logFor(p.ID)) }) + mujica.Grid(c, mujica.GridOptions{Columns: []ui.Track{ui.Fr(3), ui.Fr(2)}, Gap: mujica.SpaceL}, func() { + mujica.Card(c, mujica.CardOptions{Title: g.t("Code", "代码"), Footer: func() { + if mujica.Button(c, g.t("Copy", "复制"), mujica.ButtonOptions{Variant: mujica.Secondary, Icon: icons.Must("copy")}).Clicked() { + c.WriteClipboard(p.Code) + g.logFor(p.ID)("code copied") + mujica.ShowToast(c, mujica.ToastMessage{ID: "copied", Severity: mujica.SeveritySuccess, Message: g.t("Code copied", "代码已复制")}) + } + }}, func() { + ui.Text(c, p.Code).Font(theme.MonoFont).FontSize(mujica.FontSize(c, theme.CaptionSize)).Selectable().FillWidth() + }) + mujica.Card(c, mujica.CardOptions{Title: g.t("Keyboard", "键盘")}, func() { + ui.Text(c, p.Keys).TextColor(k.TextMuted).FillWidth() + }) + }) + g.eventCard(c, p.ID) + }) + }) +} + +// eventCard lists the newest events of one page. +func (g *gallery) eventCard(c *ui.Context, id string) { + k := mujica.Tokens(c) + mujica.Card(c, mujica.CardOptions{Title: g.t("Events", "事件")}, func() { + shown := 0 + for i := len(g.events) - 1; i >= 0 && shown < 8; i-- { + e := g.events[i] + if e.page != id { + continue + } + shown++ + ui.Row(c).Gap(12).Children(func() { + ui.Text(c, e.at.Format("15:04:05.000")).Font(theme.MonoFont).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Text(c, e.text).Grow(1) + }) + } + if shown == 0 { + ui.Text(c, g.t("Interact with the demo; its events appear here.", "操作上方示例,事件会显示在这里。")).TextColor(k.TextMuted) + } + }) +}