diff --git a/AGENTS.md b/AGENTS.md index 8a476f3..e58fc2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,12 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. - `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`. - Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`. -- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time. +- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time. - Containers take content as a final `func()`; interactive results are `NameResult`. -- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time. +- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`, a drag's release) is read at paint time, or set on the element after its children. - Never call `Selected`. The pixel theme keeps MyGo's row highlights clear, since scrolled rows sit between dots: draw chosen rows yourself. - A disabled or inert scroll view must not move: cover it, so the wheel stops above it. -- A press focuses the nearest focusable element under it, or nothing; a closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`. +- A press focuses the nearest focusable element under it, or nothing, and a focusable element keeps the click: clicks never reach its ancestors. A control that acts on a press is that focusable element. A `DialogBase` moves the focus inside as it opens and keeps Tab there, but only onto something focusable: make its panel focusable, ring off, and build its children in `pixel.Harbor(c, panel, …)` so controls turning off park their focus there and get it back. A closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`. - Enter and Space click a focused clickable element unless a `Shortcut` takes them. Take them wherever a click reads the pointer's position. - Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert. - Elements are known by their place among siblings. Wrap one whose earlier siblings come and go in `ui.Box(c).Key(k)`, or it loses focus and state. diff --git a/feedback/achievement.go b/feedback/achievement.go index 159fd3f..fd847f9 100644 --- a/feedback/achievement.go +++ b/feedback/achievement.go @@ -20,7 +20,7 @@ func Achievement(c *ui.Context, title string, o AchievementOptions) *ui.Element w := pixel.Or("Achievement Width", o.Width, 200) e := ui.Box(c) // Ten steps a second; in by 5, gone by 40. - step := k.Since(c, cue(e, k, o.Cue), 10, 40) + step := k.Since(c, k.Cue(e, o.Cue), 10, 40) if step >= 40 { return e } diff --git a/feedback/feedback.go b/feedback/feedback.go index 9ff3ddf..ed2475a 100644 --- a/feedback/feedback.go +++ b/feedback/feedback.go @@ -3,9 +3,6 @@ package feedback import ( "strconv" - "time" - - "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -35,20 +32,3 @@ func level(v, warn, bad float64) pixel.Slot { // fmtNum prints a number without trailing zeros. func fmtNum(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) } - -type cueKey struct{} - -// cued is a cue's last value and when it changed. -type cued struct { - n int - at time.Time -} - -// cue is when n last changed; zero until it does. -func cue(e *ui.Element, k pixel.Kit, n int) time.Time { - s := ui.Local(e, cueKey{}, func() cued { return cued{n: n} }) - if s.n != n { - s.n, s.at = n, k.Now() - } - return s.at -} diff --git a/feedback/shake.go b/feedback/shake.go index 9f5bdd4..2600cd3 100644 --- a/feedback/shake.go +++ b/feedback/shake.go @@ -19,7 +19,7 @@ func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element { k := pixel.K(c) e := ui.Box(c) dx := 0 - if step := k.Since(c, cue(e, k, o.Cue), 20, len(shakes)-1); !k.Reduce() { + if step := k.Since(c, k.Cue(e, o.Cue), 20, len(shakes)-1); !k.Reduce() { dx = shakes[step] } return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content) diff --git a/gallery/app_test.go b/gallery/app_test.go index 93eb3c5..44530c6 100644 --- a/gallery/app_test.go +++ b/gallery/app_test.go @@ -292,3 +292,35 @@ func TestDataDemosLogTheirEvents(t *testing.T) { } } } + +func TestOverlayDemosOpenClean(t *testing.T) { + for slug, act := range map[string]func(tt *ui.Tester){ + "176-dialog": func(tt *ui.Tester) { click(t, tt, "OPEN QUEST") }, + "177-alertdialog": func(tt *ui.Tester) { click(t, tt, "FIGHT BOSS") }, + "178-confirmdialog": func(tt *ui.Tester) { click(t, tt, "DROP SWORD") }, + "179-popover": func(tt *ui.Tester) { click(t, tt, "MAP") }, + "181-drawer": func(tt *ui.Tester) { click(t, tt, "RIGHT ▶") }, + "182-sheet": func(tt *ui.Tester) { click(t, tt, "OPTIONS") }, + "184-lightbox": func(tt *ui.Tester) { click(t, tt, "Cave") }, + "186-choiceprompt": func(tt *ui.Tester) { click(t, tt, "A SLIME APPEARS") }, + "188-spotlight": func(tt *ui.Tester) { click(t, tt, "SPOTLIGHT") }, + "189-notification": func(tt *ui.Tester) { click(t, tt, "NEW MAIL") }, + "191-pausemenu": func(tt *ui.Tester) { click(t, tt, "PAUSE") }, + "192-promptdialog": func(tt *ui.Tester) { click(t, tt, "NEW GAME") }, + "193-progressdialog": func(tt *ui.Tester) { click(t, tt, "SAVE") }, + } { + a, tt := open("/c/" + slug) + act(tt) + tt.Frame() + if len(a.logs[slug]) == 0 { + t.Errorf("%s logged nothing", slug) + } + keepsStyle(t, slug+" open", tt.Image(), colors(pixel.Palettes...)) + } + _, tt := open("/c/190-popconfirm") + click(t, tt, "SELL CROWN") + if !tt.HasText("Sell it for 5 gold?") { + t.Fatal("the popconfirm did not open") + } + keepsStyle(t, "popconfirm open", tt.Image(), colors(pixel.Palettes...)) +} diff --git a/gallery/demos_overlay.go b/gallery/demos_overlay.go new file mode 100644 index 0000000..15a850a --- /dev/null +++ b/gallery/demos_overlay.go @@ -0,0 +1,224 @@ +package gallery + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +var overlayEntries = []Entry{ + {176, "Dialog", "对话框", "overlay", "A titled window over a checkered backdrop. Escape or a click outside closes it.", demoDialog}, + {177, "AlertDialog", "警告框", "overlay", "Icon, title, message, buttons. Enter presses the highlighted one.", demoAlertDialog}, + {178, "ConfirmDialog", "确认框", "overlay", "ARE YOU SURE? The hand starts on NO, to be safe.", demoConfirmDialog}, + {179, "Popover", "弹出层", "overlay", "A panel under its button, notched toward it. It flips above near the bottom.", demoPopover}, + {180, "Tooltip", "提示气泡", "overlay", "Rest the pointer half a second, or Tab in, and a bubble says what it does.", demoTooltip}, + {181, "Drawer", "抽屉", "overlay", "A panel that slides in from an edge, four steps at a time.", demoDrawer}, + {182, "Sheet", "底部面板", "overlay", "Rises from the bottom. Drag its bar down to put it away.", demoSheet}, + {183, "HoverCard", "悬停卡片", "overlay", "Point at a name to see the card; the card stays while you read it.", demoHoverCard}, + {184, "Lightbox", "灯箱", "overlay", "Pictures over a dark veil. ◀ ▶ or the arrows turn them.", demoLightbox}, + {185, "DialogueBox", "剧情对话", "overlay", "An RPG text box. A press shows the line, the next turns the page.", demoDialogueBox}, +} + +type overlayDemo struct { + dialog, alert, confirm, popover, sheet, lightbox, choice, pause, prompt, saving bool + drawer display.Side + drawerOpen bool + picture, page, step, cue int + name string + notes []overlay.Notification + spot bool + since time.Time +} + +func overlayState(c *ui.Context) *overlayDemo { + return ui.Local(c.Root(), demoKey("overlay"), func() overlayDemo { return overlayDemo{step: 1} }) +} + +func demoDialog(c *ui.Context, log func(string)) { + s := overlayState(c) + if input.Button(c, "OPEN QUEST", input.ButtonOptions{}).Clicked { + s.dialog = true + log("open") + } + r := overlay.Dialog(c, "Quest", &s.dialog, overlay.DialogOptions{Actions: []string{"DECLINE", "ACCEPT"}}, func() { + text.Paragraph(c, "Bring three slime jellies to the baker. Reward: one warm pie.", text.ParagraphOptions{}) + }) + if r.Action >= 0 { + log([]string{"declined", "accepted"}[r.Action]) + } + if r.Closed { + log("closed") + } +} + +func demoAlertDialog(c *ui.Context, log func(string)) { + s := overlayState(c) + if input.Button(c, "FIGHT BOSS", input.ButtonOptions{Variant: input.Danger}).Clicked { + s.alert = true + log("open") + } + buttons := []string{"NO", "HEAL"} + r := overlay.AlertDialog(c, "Low HP", "You have 3 HP. Heal before the boss?", &s.alert, overlay.AlertDialogOptions{Buttons: buttons, Default: 1}) + if r.Pressed >= 0 { + log(buttons[r.Pressed]) + } + if r.Closed { + log("closed") + } +} + +func demoConfirmDialog(c *ui.Context, log func(string)) { + s := overlayState(c) + if input.Button(c, "DROP SWORD", input.ButtonOptions{}).Clicked { + s.confirm = true + log("open") + } + r := overlay.ConfirmDialog(c, "Drop the legendary sword in the lava?", &s.confirm, overlay.ConfirmDialogOptions{}) + if r.Confirmed { + log("dropped. it is gone.") + } + if r.Canceled { + log("kept the sword") + } +} + +func demoPopover(c *ui.Context, log func(string)) { + s := overlayState(c) + b := input.Button(c, "MAP", input.ButtonOptions{Icon: icon.Map}) + if b.Clicked { + s.popover = !s.popover + log(fmt.Sprintf("open %v", s.popover)) + } + overlay.Popover(c, "Map", b.Element, &s.popover, overlay.PopoverOptions{}, func() { + pixel.Text(c, "You are here: Town.", pixel.TextOptions{}) + pixel.Text(c, "Cave: 2 screens west.", pixel.TextOptions{Slot: pixel.Faint}) + }) +} + +func demoTooltip(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).Children(func() { + for _, t := range []struct { + art *pixel.Sprite + name string + tip string + }{{icon.Sword, "Attack", "Swing for 12 damage."}, {icon.Shield, "Defend", "Halve damage this turn."}, {icon.Flame, "Magic", "Costs 4 MP."}} { + overlay.Tooltip(c, t.tip, overlay.TooltipOptions{}, func() { + if input.IconButton(c, t.art, t.name, input.IconButtonOptions{}).Clicked { + log(t.name) + } + }) + } + }) +} + +func demoDrawer(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + ui.Row(c).Gap(k.D(6)).Children(func() { + if input.Button(c, "◀ LEFT", input.ButtonOptions{}).Clicked { + s.drawer, s.drawerOpen = display.Left, true + log("open left") + } + if input.Button(c, "RIGHT ▶", input.ButtonOptions{}).Clicked { + s.drawer, s.drawerOpen = display.Right, true + log("open right") + } + }) + if overlay.Drawer(c, "Party", &s.drawerOpen, overlay.DrawerOptions{Side: s.drawer, Size: 120}, func() { + pixel.Text(c, "PARTY", pixel.TextOptions{Slot: pixel.Faint}) + for _, h := range []string{"Aria LV 7", "Brom LV 5", "Cyra LV 9"} { + pixel.Text(c, h, pixel.TextOptions{}) + } + }).Closed { + log("drawer closed") + } +} + +func demoSheet(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + if input.Button(c, "OPTIONS", input.ButtonOptions{Icon: icon.Settings}).Clicked { + s.sheet = true + log("open") + } + if overlay.Sheet(c, "Options", &s.sheet, overlay.SheetOptions{}, func() { + ui.Column(c).Gap(k.D(4)).Children(func() { + pixel.Text(c, "Music: ON", pixel.TextOptions{}) + pixel.Text(c, "Text speed: FAST", pixel.TextOptions{}) + pixel.Text(c, "Drag the bar down to close.", pixel.TextOptions{Slot: pixel.Faint}) + }) + }).Closed { + log("sheet closed") + } +} + +func demoHoverCard(c *ui.Context, log func(string)) { + k := pixel.K(c) + overlay.HoverCard(c, "Aria", overlay.HoverCardOptions{}, func() { + pixel.Text(c, "@aria", pixel.TextOptions{Slot: pixel.Info}) + }, func() { + ui.Row(c).Gap(k.D(6)).Children(func() { + display.Avatar(c, "Aria", display.AvatarOptions{}) + ui.Column(c).Children(func() { + pixel.Text(c, "Aria", pixel.TextOptions{}) + pixel.Text(c, "Knight · LV 7", pixel.TextOptions{Slot: pixel.Faint}) + }) + }) + }) +} + +var scenes = []struct { + art *pixel.Sprite + name string +}{{icon.Home, "Town"}, {icon.Map, "Field"}, {icon.Skull, "Cave"}} + +func demoLightbox(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + for i, sc := range scenes { + if input.IconButton(c, sc.art, sc.name, input.IconButtonOptions{Size: input.Large}).Clicked { + s.picture, s.lightbox = i, true + log("open " + sc.name) + } + } + }) + r := overlay.Lightbox(c, "Postcards", len(scenes), &s.picture, &s.lightbox, overlay.LightboxOptions{}, func(i int) { + ui.Column(c).AlignItems(ui.Center).Padding(k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Well) + }).Children(func() { + display.Icon(c, scenes[i].art, display.IconOptions{Scale: 4, Label: scenes[i].name}) + pixel.Text(c, scenes[i].name, pixel.TextOptions{}) + }) + }) + if r.Changed { + log("showing " + scenes[s.picture].name) + } + if r.Closed { + log("closed") + } +} + +func demoDialogueBox(c *ui.Context, log func(string)) { + s := overlayState(c) + pages := []string{"It is dangerous to go alone!", "Take this. It is a stick.", "Mostly harmless. Good luck."} + r := overlay.DialogueBox(c, pages, &s.page, overlay.DialogueBoxOptions{Name: "OLD MAN", Portrait: icon.User}) + r.Element.Width(pixel.K(c).D(280)) + if r.Changed { + log(fmt.Sprintf("page %d", s.page+1)) + } + if r.Done { + s.page = 0 + log("the old man starts over") + } +} diff --git a/gallery/demos_popups.go b/gallery/demos_popups.go new file mode 100644 index 0000000..f5a164c --- /dev/null +++ b/gallery/demos_popups.go @@ -0,0 +1,189 @@ +package gallery + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var popupEntries = []Entry{ + {186, "ChoicePrompt", "选项提示", "overlay", "A question and a hand. Arrows and Enter, or press 1–9.", demoChoicePrompt}, + {187, "Coachmark", "新手引导", "overlay", "A guided tour: a bubble points at each button in turn.", demoCoachmark}, + {188, "Spotlight", "聚光灯", "overlay", "Everything but one button sinks into dither.", demoSpotlight}, + {189, "Notification", "通知", "overlay", "Cards stack at the top right and leave on their own. × sends one away.", demoNotification}, + {190, "Popconfirm", "气泡确认", "overlay", "A small YES / NO bubble before the button acts.", demoPopconfirm}, + {191, "PauseMenu", "暂停菜单", "overlay", "PAUSED blinks over the game. Escape resumes.", demoPauseMenu}, + {192, "PromptDialog", "输入对话框", "overlay", "ENTER YOUR NAME, eight letters at most.", demoPromptDialog}, + {193, "ProgressDialog", "进度对话框", "overlay", "Saving, with a way out.", demoProgressDialog}, + {194, "KeyHints", "按键提示", "overlay", "Pad buttons and keycaps beside what they do.", demoKeyHints}, + {195, "TitleCard", "关卡标题", "overlay", "WORLD 1-1 dithers in, waits, and dithers out.", demoTitleCard}, +} + +func demoChoicePrompt(c *ui.Context, log func(string)) { + s := overlayState(c) + choices := []string{"Fight", "Talk", "Flee"} + if input.Button(c, "A SLIME APPEARS", input.ButtonOptions{}).Clicked { + s.choice = true + log("open") + } + r := overlay.ChoicePrompt(c, "A wild slime blocks the road. It wobbles.", choices, &s.choice, overlay.ChoicePromptOptions{}) + if r.Chosen >= 0 { + log(choices[r.Chosen]) + } + if r.Canceled { + log("stared at the slime") + } +} + +func demoCoachmark(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + tips := []string{"Your bag. Potions live here.", "The map. You are the red dot.", "Save often. Slimes are tricky."} + var targets [3]*ui.Element + ui.Row(c).Gap(k.D(6)).Children(func() { + for i, b := range []struct { + art *pixel.Sprite + name string + }{{icon.Chest, "Bag"}, {icon.Map, "Map"}, {icon.Save, "Save"}} { + targets[i] = input.Button(c, b.name, input.ButtonOptions{Icon: b.art}).Element + } + }) + if s.step > len(tips) { + if input.Button(c, "TOUR AGAIN", input.ButtonOptions{Size: input.Small}).Clicked { + s.step = 1 + log("tour again") + } + return + } + if overlay.Coachmark(c, targets[s.step-1], tips[s.step-1], s.step, len(tips), overlay.CoachmarkOptions{}).Next { + s.step++ + log(fmt.Sprintf("step %d", s.step)) + } +} + +func demoSpotlight(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + var shop *ui.Element + ui.Row(c).Gap(k.D(6)).Children(func() { + input.Button(c, "INN", input.ButtonOptions{}) + shop = input.Button(c, "SHOP", input.ButtonOptions{Icon: icon.Cart}).Element + input.Button(c, "GUILD", input.ButtonOptions{}) + }) + if input.Toggle(c, "SPOTLIGHT", &s.spot, input.ToggleOptions{Size: input.Small}).Changed { + log(fmt.Sprintf("spotlight %v", s.spot)) + } + if s.spot { + overlay.Spotlight(c, shop, "Potions are on sale!", overlay.SpotlightOptions{}) + } +} + +func demoNotification(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "NEW MAIL", input.ButtonOptions{Size: input.Small}).Clicked { + s.notes = append(s.notes, overlay.Notification{Icon: icon.Mail, Title: "Letter from Mom", Text: "Eat your vegetables."}) + log("mail") + } + if input.Button(c, "LEVEL UP", input.ButtonOptions{Size: input.Small}).Clicked { + s.notes = append(s.notes, overlay.Notification{Icon: icon.Star, Title: "Level 8!", Text: "STR +2, LUCK +1.", For: 8 * time.Second}) + log("level up") + } + }) + if r := overlay.Notifications(c, &s.notes, overlay.NotificationsOptions{}); r.Dismissed != nil { + log("dismissed " + r.Dismissed.Title) + } +} + +func demoPopconfirm(c *ui.Context, log func(string)) { + r := overlay.Popconfirm(c, "SELL CROWN", "Sell it for 5 gold?", overlay.PopconfirmOptions{}) + if r.Confirmed { + log("sold. what a deal.") + } + if r.Canceled { + log("kept the crown") + } +} + +func demoPauseMenu(c *ui.Context, log func(string)) { + s := overlayState(c) + if input.Button(c, "PAUSE", input.ButtonOptions{Icon: icon.Pause}).Clicked { + s.pause = true + log("open") + } + if r := overlay.PauseMenu(c, &s.pause, overlay.PauseMenuOptions{}); r.Chosen >= 0 { + log([]string{"resume", "options", "quit"}[r.Chosen]) + } +} + +func demoPromptDialog(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + if input.Button(c, "NEW GAME", input.ButtonOptions{}).Clicked { + s.prompt = true + log("open") + } + if s.name != "" { + pixel.Text(c, "Hero: "+s.name, pixel.TextOptions{}) + } + }) + r := overlay.PromptDialog(c, "ENTER YOUR NAME", &s.name, &s.prompt, overlay.PromptDialogOptions{Placeholder: "LINK", MaxLength: 8}) + if r.Submitted { + log("named " + s.name) + } + if r.Canceled { + log("canceled") + } +} + +func demoProgressDialog(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + if input.Button(c, "SAVE", input.ButtonOptions{Icon: icon.Save}).Clicked { + s.saving, s.since = true, k.Now() + log("saving") + } + // The pretend save takes three seconds. + done := 0 + if s.saving { + done = k.Since(c, s.since, 10, 30) + } + if done == 30 { + s.saving = false + log("saved") + } + if overlay.ProgressDialog(c, "Saving", float64(done)/30, &s.saving, overlay.ProgressDialogOptions{}).Canceled { + log(fmt.Sprintf("canceled at %d%%", done*100/30)) + } +} + +func demoKeyHints(c *ui.Context, log func(string)) { + overlay.KeyHints(c, []overlay.KeyHint{{Key: "A", Action: "CONFIRM"}, {Key: "B", Action: "BACK"}, {Key: "START", Action: "MENU"}}, overlay.KeyHintsOptions{}) +} + +func demoTitleCard(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := overlayState(c) + layout.Layers(c, func() { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 4) + g.Rect(0, g.H-12, g.W, 12, pixel.Good) + }) + }, func() { + overlay.TitleCard(c, "WORLD 1-1", overlay.TitleCardOptions{Cue: s.cue}) + }).Size(k.D(200), k.D(90)) + if input.Button(c, "ENTER WORLD", input.ButtonOptions{Size: input.Small}).Clicked { + s.cue++ + log("world 1-1") + } +} diff --git a/gallery/registry.go b/gallery/registry.go index 48c9569..6adb0cc 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries) func Entries() []Entry { return entries } diff --git a/overlay/alertdialog.go b/overlay/alertdialog.go new file mode 100644 index 0000000..c394ed2 --- /dev/null +++ b/overlay/alertdialog.go @@ -0,0 +1,84 @@ +package overlay + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type AlertDialogOptions struct { + // Icon heads the message; nil means a warning sign. + Icon *pixel.Sprite + // Buttons label the answers; nil means OK. + Buttons []string + // Default is the button Enter presses. + Default int + Disabled bool +} + +type AlertDialogResult struct { + Element *ui.Element + // Pressed is the button pressed this frame, or -1. + Pressed int + Closed bool +} + +// AlertDialog asks for an answer; Enter presses the default. +func AlertDialog(c *ui.Context, title, message string, open *bool, o AlertDialogOptions) AlertDialogResult { + k := pixel.K(c) + buttons, art := o.Buttons, o.Icon + if buttons == nil { + buttons = []string{k.Msg("ok")} + } + if art == nil { + art = icon.Warning + } + if o.Default < 0 || o.Default >= len(buttons) { + panic(fmt.Sprintf("overlay: AlertDialog Default %d of %d buttons", o.Default, len(buttons))) + } + was := *open + res := AlertDialogResult{Pressed: -1} + press := func(i int) { res.Pressed, *open = i, false } + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Width(k.D(220)).Role(ui.RoleAlertDialog).Label(title).Description(message).Focusable().FocusRing(false) + if panel.Shortcut(0, ui.KeyEnter) && !o.Disabled && !pixel.Current(c).Inert { + press(o.Default) + } + panel.Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{Title: title}, func() { + ui.Row(c).Gap(k.D(8)).Children(func() { + ui.Box(c).Size(k.D(art.W*2), k.D(art.H*2)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, art, 0, pixel.Warn, 2) + }) + ui.Column(c).Grow(1).Children(func() { text.Paragraph(c, message, text.ParagraphOptions{}) }) + }) + ui.Row(c).Gap(k.D(4)).Justify(ui.End).Padding(k.D(6), 0, 0, 0).Children(func() { + for i, b := range buttons { + v := input.Normal + if i == o.Default { + v = input.Primary + } + if input.Button(c, b, input.ButtonOptions{Size: input.Small, Variant: v, Disabled: o.Disabled}).Clicked { + press(i) + } + } + }) + }) + }) + }) + }) + res.Closed = was && !*open && res.Pressed < 0 + if res.Pressed >= 0 || res.Closed { + slog.Debug("8bit: alert", "title", title, "pressed", res.Pressed, "closed", res.Closed) + } + return res +} diff --git a/overlay/bubbles_test.go b/overlay/bubbles_test.go new file mode 100644 index 0000000..150f2d7 --- /dev/null +++ b/overlay/bubbles_test.go @@ -0,0 +1,196 @@ +package overlay_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestPopoverOpensBelowWithANotch(t *testing.T) { + open := false + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(40).Children(func() { + b := input.Button(c, "MAP", input.ButtonOptions{}) + if b.Clicked { + open = !open + } + overlay.Popover(c, "Map", b.Element, &open, overlay.PopoverOptions{}, func() { + pixel.Text(c, "You are here.", pixel.TextOptions{}) + }) + }) + }) + click(t, tt, "MAP") + a, p := find(t, tt, "MAP"), find(t, tt, "You are here.") + if p.Y <= a.Y+a.H { + t.Fatalf("panel text at %v, anchor %v", p, a) + } + // The notch tip sits under the anchor's middle. + if tip := slot(t, tt.Image(), int(a.X+a.W/2), int(a.Y+a.H)+1); tip != pixel.Edge && tip != pixel.Face { + t.Fatalf("no notch under the anchor: %v", tip) + } + keeps(t, tt.Image()) + tt.Key(0, ui.KeyEscape) + if open || shows(tt, "You are here.") { + t.Fatal("Escape did not close it") + } +} + +func TestTooltipWaitsForRestOrKeyboard(t *testing.T) { + clk := newClock() + tt := view(640, 400, clk, false, func(c *ui.Context) { + ui.Column(c).Padding(80).Gap(20).Children(func() { + input.Button(c, "FIRST", input.ButtonOptions{}) + overlay.Tooltip(c, "Casts fire", overlay.TooltipOptions{}, func() { + input.Button(c, "FIRE", input.ButtonOptions{}) + }) + }) + }) + r := find(t, tt, "FIRE") + tt.Move(r.X+4, r.Y+4) + clk.t = clk.t.Add(300 * time.Millisecond) + tt.Frame() + if shows(tt, "Casts fire") { + t.Fatal("the tip showed before 500 ms") + } + clk.t = clk.t.Add(300 * time.Millisecond) + tt.Frame() + if !shows(tt, "Casts fire") { + t.Fatal("the tip did not show after 600 ms") + } + tt.ClickAt(r.X+4, r.Y+4) + tt.Move(600, 380) + if shows(tt, "Casts fire") { + t.Fatal("focus from a click kept the tip") + } + click(t, tt, "FIRST") + tt.Move(600, 380) + tt.Key(0, ui.KeyTab) + if !tt.Focused("FIRE") || !shows(tt, "Casts fire") { + t.Fatal("keyboard focus did not show the tip") + } + keeps(t, tt.Image()) +} + +func TestHoverCardStaysWhileThePointerMovesOntoIt(t *testing.T) { + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(40).Children(func() { + overlay.HoverCard(c, "Aria", overlay.HoverCardOptions{}, func() { + pixel.Text(c, "@aria", pixel.TextOptions{}) + }, func() { + pixel.Text(c, "Knight, LV 7", pixel.TextOptions{}) + }) + }) + }) + if shows(tt, "Knight, LV 7") { + t.Fatal("the card showed unhovered") + } + r := find(t, tt, "@aria") + tt.Move(r.X+4, r.Y+4) + tt.Frame() + card := find(t, tt, "Knight, LV 7") + tt.Move(card.X+4, card.Y+4) + tt.Frame() + if !shows(tt, "Knight, LV 7") { + t.Fatal("moving onto the card hid it") + } + tt.Move(600, 380) + tt.Frame() + if shows(tt, "Knight, LV 7") { + t.Fatal("the card stayed after the pointer left") + } +} + +func TestPopconfirmAsksFirst(t *testing.T) { + yes, no := 0, 0 + disabled := false + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(40).Children(func() { + r := overlay.Popconfirm(c, "SELL", "Sell the crown?", overlay.PopconfirmOptions{Disabled: disabled}) + if r.Confirmed { + yes++ + } + if r.Canceled { + no++ + } + }) + }) + click(t, tt, "SELL") + click(t, tt, "YES") + click(t, tt, "SELL") + click(t, tt, "NO") + click(t, tt, "SELL") + tt.Key(0, ui.KeyEscape) + if yes != 1 || no != 2 || shows(tt, "Sell the crown?") { + t.Fatalf("yes %d no %d", yes, no) + } + click(t, tt, "SELL") + disabled = true + tt.Frame() + if shows(tt, "Sell the crown?") { + t.Fatal("the bubble stayed open as its button turned off") + } + click(t, tt, "SELL") + if shows(tt, "Sell the crown?") { + t.Fatal("a disabled popconfirm opened") + } +} + +func TestCoachmarkCountsAndGoesOn(t *testing.T) { + next := 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(40).Children(func() { + b := input.Button(c, "INVENTORY", input.ButtonOptions{}) + if overlay.Coachmark(c, b.Element, "Your items live here.", 2, 3, overlay.CoachmarkOptions{}).Next { + next++ + } + }) + }) + if !shows(tt, "2/3") || find(t, tt, "Your items live here.").Y <= find(t, tt, "INVENTORY").Y { + t.Fatalf("texts %q", tt.Texts()) + } + click(t, tt, "NEXT ▶") + if next != 1 { + t.Fatal("NEXT did not report") + } + keeps(t, tt.Image()) +} + +func TestSpotlightDimsAllButTheTarget(t *testing.T) { + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(60).Children(func() { + b := input.Button(c, "SHOP", input.ButtonOptions{}) + overlay.Spotlight(c, b.Element, "Buy potions here.", overlay.SpotlightOptions{}) + }) + }) + tt.Frame() + img, r := tt.Image(), find(t, tt, "SHOP") + if in := count(t, img, r, pixel.Ink); in > int(r.W*r.H)/16 { + t.Fatalf("%d Ink dots veil the target", in) + } + far := ui.Rect{X: 400, Y: 300, W: 100, H: 60} + if n := count(t, img, far, pixel.Ink); n < int(far.W*far.H)/8 { + t.Fatalf("only %d Ink dots far from the target", n) + } + if !shows(tt, "Buy potions here.") { + t.Fatal("no caption") + } + keeps(t, img) +} + +func TestPopoverRefusesNegativeWidth(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("a negative width did not panic") + } + }() + open := false + view(200, 100, newClock(), false, func(c *ui.Context) { + b := input.Button(c, "X", input.ButtonOptions{}) + overlay.Popover(c, "X", b.Element, &open, overlay.PopoverOptions{Width: -1}, func() {}) + }) +} diff --git a/overlay/choiceprompt.go b/overlay/choiceprompt.go new file mode 100644 index 0000000..4d68781 --- /dev/null +++ b/overlay/choiceprompt.go @@ -0,0 +1,59 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type ChoicePromptOptions struct { + Disabled bool +} + +type ChoicePromptResult struct { + Element *ui.Element + // Chosen is the choice taken this frame, or -1. + Chosen int + // Canceled is Escape or a click outside. + Canceled bool +} + +type choiceKey struct{} + +// ChoicePrompt asks a question; a hand picks, or keys 1–9. +func ChoicePrompt(c *ui.Context, question string, choices []string, open *bool, o ChoicePromptOptions) ChoicePromptResult { + k := pixel.K(c) + if len(choices) == 0 { + panic("overlay: ChoicePrompt has no choices") + } + home := ui.Box(c) + cur := ui.Local(home, choiceKey{}, func() int { return 0 }) + was := *open + res := ChoicePromptResult{Chosen: -1} + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 4) + panel.Width(k.D(180)).Label(question).Focusable().FocusRing(false).Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{}, func() { + text.Paragraph(c, question, text.ParagraphOptions{}) + ui.Box(c).Height(k.D(6)) + if i := choose(c, question, choices, cur, true, o.Disabled); i >= 0 { + res.Chosen, *open = i, false + } + }) + }) + }) + }) + res.Canceled = was && !*open && res.Chosen < 0 + if !*open { + *cur = 0 + } + if res.Chosen >= 0 || res.Canceled { + slog.Debug("8bit: choice", "question", question, "chosen", res.Chosen) + } + return res +} diff --git a/overlay/coachmark.go b/overlay/coachmark.go new file mode 100644 index 0000000..9a19929 --- /dev/null +++ b/overlay/coachmark.go @@ -0,0 +1,47 @@ +package overlay + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type CoachmarkOptions struct { + Disabled bool +} + +type CoachmarkResult struct { + Element *ui.Element + Next bool +} + +// coachWidth is the bubble's text width in dots. +const coachWidth = 140 + +// Coachmark points a bubble at target: step count, NEXT ▶. +func Coachmark(c *ui.Context, target *ui.Element, message string, step, steps int, o CoachmarkOptions) CoachmarkResult { + k := pixel.K(c) + if step < 1 || step > steps { + panic(fmt.Sprintf("overlay: Coachmark step %d of %d", step, steps)) + } + var res CoachmarkResult + res.Element = bubble(c, target.Bounds(), func() { + ui.Column(c).Width(k.D(coachWidth)).Gap(k.D(4)).Role(ui.RoleGroup).Label(message).Children(func() { + text.Paragraph(c, message, text.ParagraphOptions{}) + ui.Row(c).AlignItems(ui.Center).Children(func() { + text.MicroText(c, fmt.Sprintf("%d/%d", step, steps), text.MicroTextOptions{Slot: pixel.Faint}) + ui.Box(c).Grow(1) + res.Next = input.Button(c, k.Msg("next_step"), input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled}).Clicked + }) + }) + }, coachWidth+16) + if res.Next { + slog.Debug("8bit: coachmark next", "step", step, "steps", steps) + } + return res +} diff --git a/overlay/confirmdialog.go b/overlay/confirmdialog.go new file mode 100644 index 0000000..09f2462 --- /dev/null +++ b/overlay/confirmdialog.go @@ -0,0 +1,57 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type ConfirmDialogOptions struct { + Disabled bool +} + +type ConfirmDialogResult struct { + Element *ui.Element + // Canceled is NO, Escape or a click outside. + Confirmed, Canceled bool +} + +type confirmKey struct{} + +// ConfirmDialog asks ARE YOU SURE? with YES / NO. +func ConfirmDialog(c *ui.Context, message string, open *bool, o ConfirmDialogOptions) ConfirmDialogResult { + k := pixel.K(c) + home := ui.Box(c) + cur := ui.Local(home, confirmKey{}, func() int { return 1 }) + was := *open + var res ConfirmDialogResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Width(k.D(180)).Label(k.Msg("sure")).Description(message).Focusable().FocusRing(false).Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{Title: k.Msg("sure")}, func() { + text.Paragraph(c, message, text.ParagraphOptions{}) + ui.Box(c).Height(k.D(6)) + switch choose(c, k.Msg("sure"), []string{k.Msg("yes"), k.Msg("no")}, cur, false, o.Disabled) { + case 0: + res.Confirmed, *open = true, false + case 1: + *open = false + } + }) + }) + }) + }) + res.Canceled = was && !*open && !res.Confirmed + if !*open { + *cur = 1 + } + if res.Confirmed || res.Canceled { + slog.Debug("8bit: confirm", "message", message, "yes", res.Confirmed) + } + return res +} diff --git a/overlay/dialog.go b/overlay/dialog.go new file mode 100644 index 0000000..cf0fecd --- /dev/null +++ b/overlay/dialog.go @@ -0,0 +1,69 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DialogOptions struct { + // Actions label buttons under the content; any one closes. + Actions []string + // Width is the window's in dots; zero means 220. + Width int + Disabled bool +} + +type DialogResult struct { + // Element is the window, nil while closed. + Element *ui.Element + // Action is the button clicked this frame, or -1. + Action int + // Closed is Escape or a click outside. + Closed bool +} + +// Dialog is a titled window over a checkered backdrop. +func Dialog(c *ui.Context, title string, open *bool, o DialogOptions, content func()) DialogResult { + k := pixel.K(c) + w := pixel.Or("Dialog Width", o.Width, 220) + was := *open + res := DialogResult{Action: -1} + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + // The panel holds the focus when nothing inside can. + panel.Width(k.D(w)).Label(title).Focusable().FocusRing(false).Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{Title: title}, func() { + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() { + body(c, o.Disabled, content) + actions(c, o.Actions, o.Disabled, func(i int) { res.Action, *open = i, false }) + }) + }) + }) + }) + }) + res.Closed = was && !*open && res.Action < 0 + if res.Action >= 0 || res.Closed { + slog.Debug("8bit: dialog", "title", title, "action", res.Action, "closed", res.Closed) + } + return res +} + +// actions lines buttons up at the right; act takes clicks. +func actions(c *ui.Context, labels []string, disabled bool, act func(i int)) { + if len(labels) == 0 { + return + } + ui.Row(c).Gap(pixel.K(c).D(4)).Justify(ui.End).Children(func() { + for i, l := range labels { + if input.Button(c, l, input.ButtonOptions{Size: input.Small, Disabled: disabled}).Clicked { + act(i) + } + } + }) +} diff --git a/overlay/dialogs_test.go b/overlay/dialogs_test.go new file mode 100644 index 0000000..9a9e173 --- /dev/null +++ b/overlay/dialogs_test.go @@ -0,0 +1,335 @@ +package overlay_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestDialogActsClosesAndHandsFocusBack(t *testing.T) { + open, action, closed := false, -1, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if input.Button(c, "OPEN", input.ButtonOptions{}).Clicked { + open = true + } + r := overlay.Dialog(c, "Save game?", &open, overlay.DialogOptions{Actions: []string{"LATER", "SAVE"}}, func() { + pixel.Text(c, "Slot 1 is free.", pixel.TextOptions{}) + }) + if r.Action >= 0 { + action = r.Action + } + if r.Closed { + closed++ + } + }) + click(t, tt, "OPEN") + if !shows(tt, "Slot 1 is free.") { + t.Fatal("the dialog did not open") + } + click(t, tt, "SAVE") + if open || action != 1 || shows(tt, "Slot 1 is free.") { + t.Fatalf("SAVE left open %v, action %d", open, action) + } + if !tt.Focused("OPEN") { + t.Fatal("closing did not hand the focus back") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEscape) + if open || closed != 1 { + t.Fatalf("Escape left open %v, %d closes", open, closed) + } + keeps(t, tt.Image()) +} + +func TestAlertDialogEnterPressesDefault(t *testing.T) { + open, pressed := true, -1 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if r := overlay.AlertDialog(c, "Low HP", "Heal before the boss?", &open, overlay.AlertDialogOptions{Buttons: []string{"NO", "HEAL"}, Default: 1}); r.Pressed >= 0 { + pressed = r.Pressed + } + }) + tt.Key(0, ui.KeyEnter) + if open || pressed != 1 { + t.Fatalf("Enter pressed %d, open %v", pressed, open) + } + open = true + tt.Frame() + click(t, tt, "NO") + if open || pressed != 0 { + t.Fatalf("NO pressed %d", pressed) + } + defer func() { + if recover() == nil { + t.Fatal("a Default past the buttons did not panic") + } + }() + view(100, 100, newClock(), false, func(c *ui.Context) { + overlay.AlertDialog(c, "x", "y", &open, overlay.AlertDialogOptions{Default: 1}) + }) +} + +func TestConfirmDialogStartsOnNo(t *testing.T) { + open, yes, no := true, 0, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + r := overlay.ConfirmDialog(c, "Drop the sword?", &open, overlay.ConfirmDialogOptions{}) + if r.Confirmed { + yes++ + } + if r.Canceled { + no++ + } + }) + if !shows(tt, "ARE YOU SURE?") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if open || yes != 0 || no != 1 { + t.Fatalf("Enter on the start gave yes %d no %d", yes, no) + } + open = true + tt.Frame() + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if open || yes != 1 { + t.Fatalf("Up, Enter gave yes %d", yes) + } + open = true + tt.Frame() + click(t, tt, "YES") + tt.Frame() + open = true + tt.Frame() + tt.Key(0, ui.KeyEscape) + if yes != 2 || no != 2 { + t.Fatalf("click YES then Escape gave yes %d no %d", yes, no) + } + keeps(t, tt.Image()) +} + +func TestChoicePromptTakesDigitsArrowsAndClicks(t *testing.T) { + open, chosen, canceled := true, -1, 0 + choices := []string{"Fight", "Talk", "Flee"} + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + r := overlay.ChoicePrompt(c, "A slime appears!", choices, &open, overlay.ChoicePromptOptions{}) + if r.Chosen >= 0 { + chosen = r.Chosen + } + if r.Canceled { + canceled++ + } + }) + tt.Key(0, ui.Key3) + if open || chosen != 2 { + t.Fatalf("3 chose %d", chosen) + } + open = true + tt.Frame() + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeySpace) + if chosen != 1 { + t.Fatalf("Down, Space chose %d", chosen) + } + open = true + tt.Frame() + click(t, tt, "Flee") + open = true + tt.Frame() + tt.Key(0, ui.KeyEscape) + if chosen != 2 || canceled != 1 || open { + t.Fatalf("click Flee, Escape: chosen %d, %d cancels", chosen, canceled) + } +} + +func TestPauseMenuResumesAndChooses(t *testing.T) { + paused, chosen := true, -1 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + if !shows(tt, "PAUSED") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if !paused || chosen != 1 { + t.Fatalf("OPTIONS left paused %v, chosen %d", paused, chosen) + } + tt.Key(0, ui.KeyEscape) + if paused || chosen != 0 { + t.Fatalf("Escape left paused %v, chosen %d", paused, chosen) + } + keeps(t, tt.Image()) +} + +func TestPromptDialogSubmitsAndCancels(t *testing.T) { + open, name, sent, canceled := true, "", 0, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + r := overlay.PromptDialog(c, "ENTER YOUR NAME", &name, &open, overlay.PromptDialogOptions{MaxLength: 8}) + if r.Submitted { + sent++ + } + if r.Canceled { + canceled++ + } + }) + tt.Type("LINK") + tt.Key(0, ui.KeyEnter) + if open || name != "LINK" || sent != 1 { + t.Fatalf("typing, Enter left %q, open %v, %d sent", name, open, sent) + } + open = true + tt.Frame() + click(t, tt, "CANCEL") + if open || canceled != 1 { + t.Fatalf("CANCEL left open %v, %d cancels", open, canceled) + } +} + +func TestProgressDialogCancels(t *testing.T) { + open, canceled := true, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if overlay.ProgressDialog(c, "Saving", 0.5, &open, overlay.ProgressDialogOptions{}).Canceled { + canceled++ + } + }) + if !shows(tt, "Saving") { + t.Fatalf("texts %q", tt.Texts()) + } + click(t, tt, "CANCEL") + if open || canceled != 1 { + t.Fatalf("CANCEL left open %v", open) + } + keeps(t, tt.Image()) +} + +func TestOffDialogsTakeNoAnswers(t *testing.T) { + for _, inert := range []bool{false, true} { + open, action := true, -1 + tt := view(640, 400, newClock(), inert, func(c *ui.Context) { + if r := overlay.Dialog(c, "Quest", &open, overlay.DialogOptions{Actions: []string{"ACCEPT"}, Disabled: !inert}, func() {}); r.Action >= 0 { + action = r.Action + } + }) + click(t, tt, "ACCEPT") + if action >= 0 || !open { + t.Fatalf("inert %v: a dialog took action %d", inert, action) + } + alert, pressed := true, -1 + tt = view(640, 400, newClock(), inert, func(c *ui.Context) { + if r := overlay.AlertDialog(c, "Warn", "Careful.", &alert, overlay.AlertDialogOptions{Disabled: !inert}); r.Pressed >= 0 { + pressed = r.Pressed + } + }) + tt.Key(0, ui.KeyEnter) + click(t, tt, "OK") + if pressed >= 0 || !alert { + t.Fatalf("inert %v: an alert took button %d", inert, pressed) + } + } +} + +func TestInertMenusTakeNoPicks(t *testing.T) { + open, paused, chosen, picked := true, true, -1, -1 + tt := view(640, 400, newClock(), true, func(c *ui.Context) { + if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &open, overlay.ChoicePromptOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + tt.Key(0, ui.Key1) + click(t, tt, "No") + tt2 := view(640, 400, newClock(), true, func(c *ui.Context) { + if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}); r.Chosen > 0 { + picked = r.Chosen + } + }) + click(t, tt2, "QUIT") + if chosen >= 0 || picked >= 0 || !open { + t.Fatalf("inert menus picked %d and %d", chosen, picked) + } +} + +func TestDisabledMenusAndPromptsTakeNoAnswers(t *testing.T) { + open, chosen, sure, paused, picked, prompt, name, sent := true, -1, true, true, -1, true, "", 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &open, overlay.ChoicePromptOptions{Disabled: true}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + tt.Key(0, ui.Key1) + tt2 := view(640, 400, newClock(), false, func(c *ui.Context) { + if overlay.ConfirmDialog(c, "Sure?", &sure, overlay.ConfirmDialogOptions{Disabled: true}).Confirmed { + chosen = 99 + } + }) + click(t, tt2, "YES") + tt3 := view(640, 400, newClock(), false, func(c *ui.Context) { + if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{Disabled: true}); r.Chosen > 0 { + picked = r.Chosen + } + }) + click(t, tt3, "QUIT") + tt4 := view(640, 400, newClock(), false, func(c *ui.Context) { + if overlay.PromptDialog(c, "NAME", &name, &prompt, overlay.PromptDialogOptions{Disabled: true}).Submitted { + sent++ + } + }) + tt4.Type("AB") + click(t, tt4, "OK") + if chosen >= 0 || picked >= 0 || sent != 0 || name != "" || !open || !sure || !prompt { + t.Fatalf("disabled answers: chosen %d picked %d sent %d name %q", chosen, picked, sent, name) + } +} + +func TestFocusComesBackAfterAnOffSpellInADialog(t *testing.T) { + inert, disabled := false, false + open, action := true, -1 + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert}) + if r := overlay.Dialog(c, "Quest", &open, overlay.DialogOptions{Actions: []string{"ACCEPT"}}, func() {}); r.Action >= 0 { + action = r.Action + } + }, 640, 400) + tt.Key(0, ui.KeyTab) + if !tt.Focused("ACCEPT") { + t.Fatal("Tab did not reach ACCEPT") + } + inert = true + tt.Frame() + inert = false + tt.Frame() + tt.Key(0, ui.KeyEnter) + if action != 0 { + t.Fatalf("Enter after the veil gave action %d", action) + } + chosen, choice := -1, true + tt = view(640, 400, newClock(), false, func(c *ui.Context) { + if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &choice, overlay.ChoicePromptOptions{Disabled: disabled}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + disabled = true + tt.Frame() + disabled = false + tt.Frame() + tt.Key(0, ui.KeyEnter) + if chosen != 0 { + t.Fatalf("Enter after a disabled spell chose %d", chosen) + } + name, prompt := "", true + tt = view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.PromptDialog(c, "NAME", &name, &prompt, overlay.PromptDialogOptions{Disabled: disabled}) + }) + disabled = true + tt.Frame() + disabled = false + tt.Frame() + tt.Type("AB") + if name != "AB" { + t.Fatalf("typing after a disabled spell gave %q", name) + } +} diff --git a/overlay/dialoguebox.go b/overlay/dialoguebox.go new file mode 100644 index 0000000..8e76a45 --- /dev/null +++ b/overlay/dialoguebox.go @@ -0,0 +1,126 @@ +package overlay + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var more = pixel.MustSprite(` + ##### + .###. + ..#.. +`) + +type DialogueBoxOptions struct { + // Name labels the speaker's tab. + Name string + // Portrait draws the speaker; nil shows none. + Portrait *pixel.Sprite + // CPS is the typing speed; zero means 30. + CPS int + Disabled bool +} + +type DialogueBoxResult struct { + Element *ui.Element + // Changed turns a page; Done passes the last one. + Changed, Done bool +} + +type dialogueKey struct{} + +// typing is the page, when it began, and if out. +type typing struct { + page int + since time.Time + out bool +} + +// DialogueBox types pages; a press shows all, then turns. +func DialogueBox(c *ui.Context, pages []string, page *int, o DialogueBoxOptions) DialogueBoxResult { + k := pixel.K(c) + if *page < 0 || *page >= len(pages) { + panic(fmt.Sprintf("overlay: DialogueBox page %d of %d", *page, len(pages))) + } + cps := pixel.Or("DialogueBox CPS", o.CPS, 30) + off := o.Disabled || pixel.Current(c).Inert + box := ui.Box(c).Role(ui.RoleText).Label(strings.TrimSpace(o.Name + " " + pages[*page])).FocusRing(false) + if !off { + box.Focusable() + } + pixel.Park(c, box, off) + st := ui.Local(box, dialogueKey{}, func() typing { return typing{page: -1} }) + if st.page != *page { + *st = typing{page: *page, since: k.Now()} + } + res := DialogueBoxResult{Element: box} + // A press shows the whole page, then turns it. + if !off && box.Clicked() { + switch { + case !st.out: + st.out = true + case *page < len(pages)-1: + *page, res.Changed = *page+1, true + *st = typing{page: *page, since: k.Now()} + default: + res.Done = true + } + } + focus := box.FocusVisible() + box.DrawOver(func(p *ui.Painter, r ui.Rect) { + if focus { + g := k.Canvas(p, r) + g.FocusMark(0, 0, g.W, g.H) + } + }).Children(func() { + layout.Window(c, layout.WindowOptions{Title: o.Name}, func() { + ui.Row(c).Gap(k.D(8)).Children(func() { + if sp := o.Portrait; sp != nil { + ui.Box(c).Size(k.D(sp.W*2+6), k.D(sp.H*2+6)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + g.Sprite(3, 3, sp, 0, pixel.Ink, 2) + }) + } + words := ui.Box(c).Grow(1) + w, check := pixel.WrapWidth(c, words) + lines := pixel.Wrap(pages[*page], max(0, w-8), 0) + total := 0 + for _, l := range lines { + total += len([]rune(l)) + } + shown := total + if !st.out && !k.Reduce() { + shown = k.Since(c, st.since, cps, total) + } + st.out = shown == total + words.Height(k.D(max(2, len(lines)) * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + check(r) + g := k.Canvas(p, r) + left, end := shown, 0 + for i, l := range lines { + lr := []rune(l) + n := min(len(lr), max(0, left)) + g.Text(0, i*pixel.LineHeight, string(lr[:n]), pixel.Ink, 1) + left -= len(lr) + end = pixel.Measure(string(lr[:n])) + } + if shown == total && g.Step(2)%2 == 0 { + g.Sprite(end+3, len(lines)*pixel.LineHeight-6, more, 0, pixel.Accent, 1) + } + }) + }) + }) + }) + if res.Changed || res.Done { + slog.Debug("8bit: dialogue", "name", o.Name, "page", *page, "done", res.Done) + } + return res +} diff --git a/overlay/drawer.go b/overlay/drawer.go new file mode 100644 index 0000000..6cb2c33 --- /dev/null +++ b/overlay/drawer.go @@ -0,0 +1,63 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DrawerOptions struct { + // Side is the edge it slides from. + Side display.Side + // Size is its depth in dots; zero means 160. + Size int + // Disabled makes the content inert. + Disabled bool +} + +type DrawerResult struct { + Element *ui.Element + // Closed is Escape or a click outside. + Closed bool +} + +// Drawer slides a panel in from an edge in steps. +func Drawer(c *ui.Context, text string, open *bool, o DrawerOptions, content func()) DrawerResult { + k := pixel.K(c) + if o.Side > display.Right { + panic("overlay: unknown Drawer Side") + } + size := pixel.Or("Drawer Size", o.Size, 160) + home := ui.Box(c) + off := k.D(arriving(c, home, *open) * size / 4) + was := *open + var res DrawerResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Absolute().Label(text).Padding(k.D(8)).Focusable().FocusRing(false) + switch o.Side { + case display.Left: + panel.Top(0).Bottom(0).Left(-off).Width(k.D(size)) + case display.Right: + panel.Top(0).Bottom(0).Right(-off).Width(k.D(size)) + case display.Top: + panel.Left(0).Right(0).Top(-off).Height(k.D(size)) + case display.Bottom: + panel.Left(0).Right(0).Bottom(-off).Height(k.D(size)) + } + panel.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + pixel.Harbor(c, panel, func() { body(c, o.Disabled, content) }) + }) + }) + res.Closed = was && !*open + if res.Closed { + slog.Debug("8bit: drawer closed", "label", text) + } + return res +} diff --git a/overlay/hovercard.go b/overlay/hovercard.go new file mode 100644 index 0000000..14c9306 --- /dev/null +++ b/overlay/hovercard.go @@ -0,0 +1,41 @@ +package overlay + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HoverCardOptions struct { + // Width is the card's in dots; zero means 160. + Width int +} + +type hoverKey struct{} + +// HoverCard shows card below trigger while either is hovered. +func HoverCard(c *ui.Context, text string, o HoverCardOptions, trigger func(), card func()) *ui.Element { + k := pixel.K(c) + w := pixel.Or("HoverCard Width", o.Width, 160) + wrap := ui.Box(c) + last := ui.Local(wrap, hoverKey{}, func() ui.Rect { return ui.Rect{} }) + b := wrap.Bounds() + px, py, _ := wrap.PointerPosition() + show := hint(c, wrap, 0) || last.Contains(b.X+px, b.Y+py) + wrap.Children(trigger) + *last = ui.Rect{} + if !show { + return wrap + } + ww, _ := c.Size() + ui.Overlay(c, func() { + e := ui.Column(c).Absolute().Left(max(0, min(b.X, ww-k.D(w)))).Top(b.Y + b.H).Width(k.D(w)). + Padding(k.D(6)).Role(ui.RoleGroup).Label(text) + *last = e.Bounds() + e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(card) + }) + return wrap +} diff --git a/overlay/keyhints.go b/overlay/keyhints.go new file mode 100644 index 0000000..3c05cb7 --- /dev/null +++ b/overlay/keyhints.go @@ -0,0 +1,39 @@ +package overlay + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +// KeyHint pairs a control with what it does. +type KeyHint struct { + Key, Action string +} + +type KeyHintsOptions struct{} + +// KeyHints shows controls, as Ⓐ CONFIRM Ⓑ BACK. +func KeyHints(c *ui.Context, hints []KeyHint, o KeyHintsOptions) *ui.Element { + k := pixel.K(c) + return ui.Row(c).Gap(k.D(10)).Padding(k.D(3), k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Face) + }).Children(func() { + for _, h := range hints { + ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleText).Label(h.Key + " " + h.Action).Children(func() { + if len([]rune(h.Key)) == 1 && pixel.MicroOK(h.Key) { + ui.Box(c).Size(k.D(11), k.D(11)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Disc(5, 5, 5, pixel.Ink) + g.Micro((11-pixel.MicroWidth(h.Key))/2, (11-pixel.MicroHeight)/2, h.Key, pixel.Back, 1) + }) + } else { + text.Kbd(c, h.Key, text.KbdOptions{}) + } + pixel.Text(c, h.Action, pixel.TextOptions{}) + }) + } + }) +} diff --git a/overlay/lightbox.go b/overlay/lightbox.go new file mode 100644 index 0000000..bdb8233 --- /dev/null +++ b/overlay/lightbox.go @@ -0,0 +1,70 @@ +package overlay + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LightboxOptions struct { + Disabled bool +} + +type LightboxResult struct { + Element *ui.Element + // Changed turns the picture; Closed is ×, Escape or outside. + Changed, Closed bool +} + +// Lightbox shows pictures over a dark veil, with ◀ ▶. +func Lightbox(c *ui.Context, text string, n int, at *int, open *bool, o LightboxOptions, picture func(i int)) LightboxResult { + k := pixel.K(c) + if n < 1 || *at < 0 || *at >= n { + panic(fmt.Sprintf("overlay: Lightbox picture %d of %d", *at, n)) + } + was, old := *open, *at + off := o.Disabled || pixel.Current(c).Inert + turn := func(d int) { *at = (*at + d + n) % n } + var res LightboxResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + deep, light := dark(k) + dim(k, back, deep, 13) + left, right := panel.Shortcut(0, ui.KeyLeft), panel.Shortcut(0, ui.KeyRight) + switch { + case off: + case left: + turn(-1) + case right: + turn(1) + } + panel.Label(text).Focusable().FocusRing(false).AlignItems(ui.Center).Gap(k.D(6)).Children(func() { + pixel.Harbor(c, panel, func() { + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked { + turn(-1) + } + ui.Box(c).Key(*at).Children(func() { picture(*at) }) + if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked { + turn(1) + } + }) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + pixel.Text(c, fmt.Sprintf("%d / %d", *at+1, n), pixel.TextOptions{Slot: light}) + if input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked { + *open = false + } + }) + }) + }) + }) + res.Changed, res.Closed = *at != old, was && !*open + if res.Changed || res.Closed { + slog.Debug("8bit: lightbox", "label", text, "at", *at, "closed", res.Closed) + } + return res +} diff --git a/overlay/notification.go b/overlay/notification.go new file mode 100644 index 0000000..51cf301 --- /dev/null +++ b/overlay/notification.go @@ -0,0 +1,113 @@ +package overlay + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +// Notification is a card in the queue Notifications shows. +type Notification struct { + Icon *pixel.Sprite + Title, Text string + // For is how long it shows; zero means five seconds. + For time.Duration + // id and shown are set as it first shows. + id uint64 + shown time.Time +} + +type NotificationsOptions struct { + // Width is each card's in dots; zero means 180. + Width int + Disabled bool +} + +type NotificationsResult struct { + // Element holds the cards on screen. + Element *ui.Element + // Dismissed is the card × closed this frame, or nil. + Dismissed *Notification +} + +type notesKey struct{} + +// Notifications stacks timed cards at the top right; × dismisses. +func Notifications(c *ui.Context, queue *[]Notification, o NotificationsOptions) NotificationsResult { + k := pixel.K(c) + w := pixel.Or("Notifications Width", o.Width, 180) + now := k.Now() + kept := (*queue)[:0] + for _, n := range *queue { + if n.For < 0 { + panic("overlay: Notification For is negative") + } + life := n.For + if life == 0 { + life = 5 * time.Second + } + if n.shown.IsZero() || now.Sub(n.shown) < life { + kept = append(kept, n) + } + } + *queue = kept + shown := min(4, len(*queue)) + last := ui.Local(c.Root(), notesKey{}, func() uint64 { return 0 }) + for i := range shown { + if (*queue)[i].id == 0 { + *last++ + (*queue)[i].id, (*queue)[i].shown = *last, now + } + } + if shown > 0 { + k.Tick(c, 4) + } + var res NotificationsResult + ui.Overlay(c, func() { + res.Element = ui.Column(c).Absolute().Right(k.D(8)).Top(k.D(8)).Width(k.D(w)).Gap(k.D(4)).AlignItems(ui.Stretch).Children(func() { + for i := range shown { + n := (*queue)[i] + ui.Row(c).Key(n.id).Padding(k.D(5), k.D(6)).Gap(k.D(6)).Role(ui.RoleStatus).Label(n.Title).Description(n.Text).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + art := n.Icon + if art == nil { + art = icon.Bell + } + ui.Box(c).Size(k.D(art.W), k.D(art.H)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, art, 0, pixel.Accent, 1) + }) + ui.Column(c).Grow(1).Children(func() { + pixel.Text(c, n.Title, pixel.TextOptions{}) + text.Paragraph(c, n.Text, text.ParagraphOptions{Slot: pixel.Faint}) + }) + if input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Variant: input.Ghost, Disabled: o.Disabled}).Clicked { + res.Dismissed = &n + } + }) + } + }) + }) + if res.Dismissed != nil { + *queue = dropNote(*queue, res.Dismissed.id) + slog.Debug("8bit: notification dismissed", "title", res.Dismissed.Title) + } + return res +} + +// dropNote removes the card with id from q. +func dropNote(q []Notification, id uint64) []Notification { + for i := range q { + if q[i].id == id { + return append(q[:i], q[i+1:]...) + } + } + return q +} diff --git a/overlay/overlay.go b/overlay/overlay.go new file mode 100644 index 0000000..b94b235 --- /dev/null +++ b/overlay/overlay.go @@ -0,0 +1,135 @@ +// Package overlay floats windows, bubbles and veils over the view. +package overlay + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// dim checkers the backdrop of a dialog. +func dim(k pixel.Kit, back *ui.Element, s pixel.Slot, level int) { + back.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Veil(0, 0, g.W, g.H, s, level) + }) +} + +// dark puts the deeper of Ink and Back first. +func dark(k pixel.Kit) (dark, light pixel.Slot) { + if k.Palette().Night { + return pixel.Back, pixel.Ink + } + return pixel.Ink, pixel.Back +} + +// choose lists items under a hand; digits pick 1–9. +func choose(c *ui.Context, text string, items []string, cur *int, digits, off bool) int { + k := pixel.K(c) + n := len(items) + *cur = max(0, min(*cur, n-1)) + off = off || pixel.Current(c).Inert + box := ui.Column(c).FocusRing(false).Role(ui.RoleList).Label(text) + if !off { + box.Focusable().AutoFocus() + } + pixel.Park(c, box, off) + picked := -1 + if off { + box.Description(k.Msg("disabled")) + } + if box.Shortcut(0, ui.KeyDown) { + *cur = (*cur + 1) % n + } + if box.Shortcut(0, ui.KeyUp) { + *cur = (*cur + n - 1) % n + } + enter, space := box.Shortcut(0, ui.KeyEnter), box.Shortcut(0, ui.KeySpace) + if enter || space { + picked = *cur + } + for i := range min(9, n) { + if digits && box.Shortcut(0, ui.Key1+ui.Key(i)) { + *cur, picked = i, i + } + } + box.Children(func() { + for i, it := range items { + row := ui.Row(c).Height(k.D(pixel.LineHeight)).MinWidth(k.D(pixel.Measure(it) + 16)).Role(ui.RoleListItem).Label(it) + if !off && row.Clicked() { + *cur, picked = i, i + } + row.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if i == *cur { + g.Sprite(g.Step(4)%2, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1) + } + g.Text(14, 0, it, pixel.Ink, 1) + }) + } + }) + if !off { + box.Description(items[*cur]) + } + return picked +} + +// body builds content, inert while off. +func body(c *ui.Context, off bool, content func()) { + if !off { + content() + return + } + s := pixel.Current(c) + s.Inert = true + pixel.Scope(c, s, content) +} + +type hintKey struct{} + +// hinting is when hover began, and a press-given focus. +type hinting struct { + since time.Time + pressed bool +} + +// hint holds after resting wait, or on keyboard focus. +func hint(c *ui.Context, e *ui.Element, wait time.Duration) bool { + k := pixel.K(c) + s := ui.Local(e, hintKey{}, func() hinting { return hinting{} }) + _, _, over := e.PointerPosition() + hovered, focus := e.Hovered(), e.FocusWithin() + switch { + case over && !hovered: + // A press inside: its focus is not the keyboard's. + s.pressed = true + case !focus: + s.pressed = false + } + rested := false + if hovered { + if s.since.IsZero() { + s.since = k.Now() + } + steps := int(wait / (100 * time.Millisecond)) + rested = k.Since(c, s.since, 10, steps) >= steps + } else { + s.since = time.Time{} + } + return rested || focus && !s.pressed +} + +// arriving counts the steps, of four, a panel still slides. +func arriving(c *ui.Context, home *ui.Element, open bool) int { + k := pixel.K(c) + on := 0 + if open { + on = 1 + } + if k.Reduce() { + return 0 + } + return 4 - k.Since(c, k.Cue(home, on), 15, 4) +} diff --git a/overlay/overlay_test.go b/overlay/overlay_test.go new file mode 100644 index 0000000..01e38d8 --- /dev/null +++ b/overlay/overlay_test.go @@ -0,0 +1,88 @@ +package overlay_test + +import ( + "image" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} } + +// view builds on a fake clock; inert veils every control. +func view(w, h int, clk *clock, inert bool, build func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now, Inert: inert}) + build(c) + }, w, h) +} + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +func click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +func shows(tt *ui.Tester, label string) bool { + _, ok := tt.Find(label) + return ok +} + +// slot is the palette slot of pixel x, y. +func slot(t *testing.T, img *image.RGBA, x, y int) pixel.Slot { + t.Helper() + c := img.RGBAAt(x, y) + for s, pc := range pixel.SpringDay.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + return pixel.Slot(s) + } + } + t.Fatalf("pixel %d,%d is %v, off the palette", x, y, c) + return 0 +} + +// keeps fails on off-palette colors or split dots. +func keeps(t *testing.T, img *image.RGBA) { + t.Helper() + b := img.Bounds() + for y := 0; y+1 < b.Dy(); y += 2 { + for x := 0; x+1 < b.Dx(); x += 2 { + slot(t, img, x, y) + c := img.RGBAAt(x, y) + if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c { + t.Fatalf("dot at %d,%d is split", x/2, y/2) + } + } + } +} + +// count tallies dots of slot s in r. +func count(t *testing.T, img *image.RGBA, r ui.Rect, s pixel.Slot) int { + t.Helper() + n := 0 + for y := int(r.Y); y < int(r.Y+r.H); y += 2 { + for x := int(r.X); x < int(r.X+r.W); x += 2 { + if slot(t, img, x, y) == s { + n++ + } + } + } + return n +} diff --git a/overlay/panels_test.go b/overlay/panels_test.go new file mode 100644 index 0000000..717e976 --- /dev/null +++ b/overlay/panels_test.go @@ -0,0 +1,331 @@ +package overlay_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestDrawerSlidesInInSteps(t *testing.T) { + clk, open, closed := newClock(), false, 0 + side := display.Right + tt := view(640, 400, clk, false, func(c *ui.Context) { + if input.Button(c, "MENU", input.ButtonOptions{}).Clicked { + open = true + } + if overlay.Drawer(c, "Menu", &open, overlay.DrawerOptions{Side: side, Size: 100}, func() { + pixel.Text(c, "Items", pixel.TextOptions{}) + }).Closed { + closed++ + } + }) + click(t, tt, "MENU") + var xs []float32 + for range 5 { + xs = append(xs, find(t, tt, "Items").X) + clk.t = clk.t.Add(70 * time.Millisecond) + tt.Frame() + } + // Four 50-pixel steps, then rest at 640-200+16. + want := []float32{456 + 200, 456 + 150, 456 + 100, 456 + 50, 456} + for i := range want { + if xs[i] != want[i] { + t.Fatalf("x by step %v, want %v", xs, want) + } + } + keeps(t, tt.Image()) + tt.Key(0, ui.KeyEscape) + if open || closed != 1 { + t.Fatal("Escape left it open") + } + side, open = display.Left, true + tt.Frame() + clk.t = clk.t.Add(time.Second) + tt.Frame() + if x := find(t, tt, "Items").X; x != 16 { + t.Fatalf("a left drawer opened at %v", x) + } +} + +func TestSheetClosesOnALongPull(t *testing.T) { + open, closed := true, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() { + pixel.Text(c, "Sound: ON", pixel.TextOptions{}) + }).Closed { + closed++ + } + }) + // The bar tops the sheet, which is 240 pixels. + top := float32(400 - 240) + tt.Press(320, top+8) + tt.Move(320, top+40) + tt.Release(320, top+40) + if !open || find(t, tt, "Sound: ON").Y > top+40 { + t.Fatal("a short pull closed or moved the sheet") + } + tt.Press(320, top+8) + tt.Move(320, top+60) + tt.Move(320, top+120) + tt.Release(320, top+120) + if open || closed != 1 { + t.Fatalf("a long pull left open %v", open) + } +} + +func TestLightboxTurnsWrapsAndCloses(t *testing.T) { + open, at, closed := true, 0, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{}, func(i int) { + pixel.Text(c, []string{"Castle", "Cave", "Coast"}[i], pixel.TextOptions{}) + }).Closed { + closed++ + } + }) + tt.Key(0, ui.KeyLeft) + if at != 2 || !shows(tt, "Coast") || !shows(tt, "3 / 3") { + t.Fatalf("Left from the first went to %d", at) + } + click(t, tt, "NEXT") + tt.Key(0, ui.KeyRight) + if at != 1 || !shows(tt, "Cave") { + t.Fatalf("▶, Right went to %d", at) + } + keeps(t, tt.Image()) + click(t, tt, "CLOSE") + if open || closed != 1 { + t.Fatal("× did not close it") + } +} + +func TestDialogueBoxTypesThenTurns(t *testing.T) { + clk, page, done := newClock(), 0, 0 + pages := []string{"It is dangerous to go alone!", "Take this."} + tt := view(640, 400, clk, false, func(c *ui.Context) { + if overlay.DialogueBox(c, pages, &page, overlay.DialogueBoxOptions{Name: "OLD MAN", Portrait: icon.User}).Done { + done++ + } + }) + r := find(t, tt, "OLD MAN "+pages[0]) + tt.ClickAt(r.X+4, r.Y+4) + if page != 0 { + t.Fatal("the first press turned the page before the text was out") + } + tt.Key(0, ui.KeyEnter) + if page != 1 || !shows(tt, "OLD MAN Take this.") { + t.Fatalf("Enter after the text left page %d", page) + } + clk.t = clk.t.Add(2 * time.Second) + tt.Frame() + tt.Key(0, ui.KeyEnter) + if done != 1 || !shows(tt, "OLD MAN") { + t.Fatalf("Enter on the last page: done %d", done) + } + keeps(t, tt.Image()) +} + +func TestDialogueBoxUnderReduceMotionTurnsAtOnce(t *testing.T) { + page := 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.DialogueBox(c, []string{"One.", "Two.", "Three."}, &page, overlay.DialogueBoxOptions{}) + }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + click(t, tt, "One.") + tt.Key(0, ui.KeyEnter) + if page != 2 { + t.Fatalf("two presses under Reduce Motion reached page %d", page) + } +} + +func TestNotificationsExpireAndDismiss(t *testing.T) { + clk := newClock() + queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}, {Icon: icon.Mail, Title: "Letter", Text: "From mom.", For: 10 * time.Second}} + var dismissed string + tt := view(640, 400, clk, false, func(c *ui.Context) { + if r := overlay.Notifications(c, &queue, overlay.NotificationsOptions{}); r.Dismissed != nil { + dismissed = r.Dismissed.Title + } + }) + a, b := find(t, tt, "Saved"), find(t, tt, "Letter") + if a.X+a.W != 640-16 || b.Y <= a.Y { + t.Fatalf("cards at %v and %v", a, b) + } + clk.t = clk.t.Add(6 * time.Second) + tt.Frame() + if shows(tt, "Saved") || len(queue) != 1 { + t.Fatalf("the first card outlived five seconds: %d left", len(queue)) + } + click(t, tt, "CLOSE") + if dismissed != "Letter" || len(queue) != 0 { + t.Fatalf("× dismissed %q", dismissed) + } +} + +func TestKeyHintsDrawPadButtonsAndKeycaps(t *testing.T) { + tt := view(640, 100, newClock(), false, func(c *ui.Context) { + overlay.KeyHints(c, []overlay.KeyHint{{Key: "A", Action: "CONFIRM"}, {Key: "START", Action: "MENU"}}, overlay.KeyHintsOptions{}) + }) + r := find(t, tt, "A CONFIRM") + if s := slot(t, tt.Image(), int(r.X)+18, int(r.Y)+r2i(r.H/2)); s != pixel.Ink { + t.Fatalf("pad button is %v", s) + } + if !shows(tt, "START") || !shows(tt, "MENU") { + t.Fatalf("texts %q", tt.Texts()) + } + keeps(t, tt.Image()) +} + +func r2i(f float32) int { return int(f) } + +func TestTitleCardDithersInHoldsAndGoes(t *testing.T) { + clk, cue := newClock(), 0 + tt := view(320, 200, clk, false, func(c *ui.Context) { + ui.Box(c).Size(300, 160).Children(func() { + overlay.TitleCard(c, "WORLD 1-1", overlay.TitleCardOptions{Cue: cue, Hold: time.Second}) + }) + }) + area := ui.Rect{X: 0, Y: 0, W: 300, H: 160} + if n := count(t, tt.Image(), area, pixel.Ink); n != 0 || shows(tt, "WORLD 1-1") { + t.Fatal("the card showed before its cue") + } + cue = 1 + tt.Frame() + var inks []int + for range 4 { + inks = append(inks, count(t, tt.Image(), area, pixel.Ink)) + clk.t = clk.t.Add(time.Second / 8) + tt.Frame() + } + if !(inks[0] < inks[1] && inks[1] < inks[2] && inks[2] < inks[3]) || !shows(tt, "WORLD 1-1") { + t.Fatalf("ink by step %v", inks) + } + clk.t = clk.t.Add(2 * time.Second) + tt.Frame() + if shows(tt, "WORLD 1-1") || count(t, tt.Image(), area, pixel.Ink) != 0 { + t.Fatal("the card stayed past its hold") + } +} + +func TestOffDialogueBoxStaysPut(t *testing.T) { + for _, inert := range []bool{false, true} { + page := 0 + tt := view(640, 400, newClock(), inert, func(c *ui.Context) { + overlay.DialogueBox(c, []string{"One.", "Two."}, &page, overlay.DialogueBoxOptions{Disabled: !inert}) + }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + click(t, tt, "One.") + click(t, tt, "One.") + tt.Key(0, ui.KeyEnter) + if page != 0 { + t.Fatalf("inert %v: an off box turned to page %d", inert, page) + } + } +} + +func TestSheetVanishesOnTheReleaseThatClosesIt(t *testing.T) { + open := true + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() { + pixel.Text(c, "Sound: ON", pixel.TextOptions{}) + }) + }) + top := float32(400 - 240) + tt.Press(320, top+8) + tt.Move(320, top+120) + tt.Release(320, top+120) + if open || shows(tt, "Sound: ON") { + t.Fatal("the sheet stayed on screen after the pull closed it") + } +} + +func TestPanelsWithoutControlsTakeTheFocus(t *testing.T) { + for _, kind := range []string{"drawer", "sheet", "dialog", "alert", "confirm", "choice", "pause", "prompt", "progress"} { + open, opened := false, 0 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + if input.Button(c, "OPEN", input.ButtonOptions{}).Clicked { + open = true + opened++ + } + body := func() { pixel.Text(c, "Just words.", pixel.TextOptions{}) } + name := "Menu" + switch kind { + case "drawer": + overlay.Drawer(c, name, &open, overlay.DrawerOptions{Side: display.Left}, body) + case "sheet": + overlay.Sheet(c, name, &open, overlay.SheetOptions{}, body) + case "dialog": + overlay.Dialog(c, name, &open, overlay.DialogOptions{}, body) + case "alert": + overlay.AlertDialog(c, name, "Hi.", &open, overlay.AlertDialogOptions{Disabled: true}) + case "confirm": + overlay.ConfirmDialog(c, "Hi.", &open, overlay.ConfirmDialogOptions{Disabled: true}) + case "choice": + overlay.ChoicePrompt(c, name, []string{"A"}, &open, overlay.ChoicePromptOptions{Disabled: true}) + case "pause": + overlay.PauseMenu(c, &open, overlay.PauseMenuOptions{Disabled: true}) + case "prompt": + s := "" + overlay.PromptDialog(c, name, &s, &open, overlay.PromptDialogOptions{Disabled: true}) + case "progress": + overlay.ProgressDialog(c, name, 0.5, &open, overlay.ProgressDialogOptions{Disabled: true}) + } + }) + click(t, tt, "OPEN") + tt.Key(0, ui.KeyEnter) + if opened != 1 || tt.Focused("OPEN") { + t.Fatalf("%s: Enter reached the opener behind it (%d opens)", kind, opened) + } + } +} + +func TestOffSheetTakesNoPull(t *testing.T) { + for _, inert := range []bool{false, true} { + open := true + tt := view(640, 400, newClock(), inert, func(c *ui.Context) { + overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120, Disabled: !inert}, func() {}) + }) + top := float32(400 - 240) + tt.Press(320, top+8) + tt.Move(320, top+120) + tt.Release(320, top+120) + if !open { + t.Fatalf("inert %v: a pull closed an off sheet", inert) + } + } +} + +func TestDisabledPanelsTakeNoClicks(t *testing.T) { + open, at, saving, next := true, 0, true, 0 + queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}} + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{Disabled: true}, func(int) {}) + }) + tt.Key(0, ui.KeyRight) + click(t, tt, "NEXT") + click(t, tt, "CLOSE") + tt = view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.ProgressDialog(c, "Saving", 0.2, &saving, overlay.ProgressDialogOptions{Disabled: true}) + }) + click(t, tt, "CANCEL") + tt = view(640, 400, newClock(), false, func(c *ui.Context) { + ui.Column(c).Padding(40).Children(func() { + b := input.Button(c, "BAG", input.ButtonOptions{}) + if overlay.Coachmark(c, b.Element, "Bag.", 1, 2, overlay.CoachmarkOptions{Disabled: true}).Next { + next++ + } + }) + overlay.Notifications(c, &queue, overlay.NotificationsOptions{Disabled: true}) + }) + click(t, tt, "NEXT ▶") + click(t, tt, "CLOSE") + if at != 0 || !open || !saving || next != 0 || len(queue) != 1 { + t.Fatalf("disabled: at %d open %v saving %v next %d queue %d", at, open, saving, next, len(queue)) + } +} diff --git a/overlay/pausemenu.go b/overlay/pausemenu.go new file mode 100644 index 0000000..ad6c24f --- /dev/null +++ b/overlay/pausemenu.go @@ -0,0 +1,65 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PauseMenuOptions struct { + Disabled bool +} + +type PauseMenuResult struct { + Element *ui.Element + // Chosen is RESUME 0, OPTIONS 1, QUIT 2, or -1. + Chosen int +} + +type pauseKey struct{} + +// PauseMenu veils the game under PAUSED; Escape resumes. +func PauseMenu(c *ui.Context, paused *bool, o PauseMenuOptions) PauseMenuResult { + k := pixel.K(c) + home := ui.Box(c) + cur := ui.Local(home, pauseKey{}, func() int { return 0 }) + was := *paused + res := PauseMenuResult{Chosen: -1} + items := []string{k.Msg("resume"), k.Msg("options"), k.Msg("quit")} + res.Element = ui.DialogBase(c, paused, func(back, panel *ui.Element) { + deep, light := dark(k) + dim(k, back, deep, 10) + title := k.Msg("paused") + panel.Label(title).Focusable().FocusRing(false).AlignItems(ui.Center).Gap(k.D(10)).Children(func() { + pixel.Harbor(c, panel, func() { + ui.Box(c).Size(k.D(pixel.Measure(title)*3), k.D(pixel.LineHeight*3)).Role(ui.RoleText).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if g.Step(2)%2 == 0 || k.Reduce() { + g.Text(0, 0, title, light, 3) + } + }) + ui.Box(c).Padding(k.D(6), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + if i := choose(c, title, items, cur, false, o.Disabled); i >= 0 { + res.Chosen = i + *paused = i != 0 + } + }) + }) + }) + }) + if was && !*paused && res.Chosen < 0 { + res.Chosen = 0 + } + if !*paused { + *cur = 0 + } + if res.Chosen >= 0 { + slog.Debug("8bit: pause menu", "chosen", items[res.Chosen]) + } + return res +} diff --git a/overlay/popconfirm.go b/overlay/popconfirm.go new file mode 100644 index 0000000..c7bb641 --- /dev/null +++ b/overlay/popconfirm.go @@ -0,0 +1,58 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PopconfirmOptions struct { + Disabled bool +} + +type PopconfirmResult struct { + Element *ui.Element + // Canceled is NO, Escape or a click outside. + Confirmed, Canceled bool +} + +type popKey struct{} + +// Popconfirm asks question in a bubble before its button acts. +func Popconfirm(c *ui.Context, text, question string, o PopconfirmOptions) PopconfirmResult { + k := pixel.K(c) + wrap := ui.Box(c) + open := ui.Local(wrap, popKey{}, func() bool { return false }) + var b input.ButtonResult + wrap.Children(func() { b = input.Button(c, text, input.ButtonOptions{Disabled: o.Disabled}) }) + if b.Clicked { + *open = true + } + // A popup above a veil closes with its control. + if o.Disabled || pixel.Current(c).Inert { + *open = false + } + was := *open + res := PopconfirmResult{Element: wrap} + Popover(c, question, b.Element, open, PopoverOptions{}, func() { + ui.Column(c).Gap(k.D(4)).Children(func() { + pixel.Text(c, question, pixel.TextOptions{}) + ui.Row(c).Gap(k.D(4)).Justify(ui.End).Children(func() { + if input.Button(c, k.Msg("no"), input.ButtonOptions{Size: input.Small}).Clicked { + *open = false + } + if input.Button(c, k.Msg("yes"), input.ButtonOptions{Size: input.Small, Variant: input.Primary}).Clicked { + res.Confirmed, *open = true, false + } + }) + }) + }) + res.Canceled = was && !*open && !res.Confirmed + if res.Confirmed || res.Canceled { + slog.Debug("8bit: popconfirm", "label", text, "yes", res.Confirmed) + } + return res +} diff --git a/overlay/popover.go b/overlay/popover.go new file mode 100644 index 0000000..a6fb87e --- /dev/null +++ b/overlay/popover.go @@ -0,0 +1,52 @@ +package overlay + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PopoverOptions struct { + // Width is the panel's in dots; zero fits the content. + Width int +} + +// notch is the pointer's height in dots. +const notch = 4 + +// Popover opens a panel below anchor, notched toward it. +func Popover(c *ui.Context, text string, anchor *ui.Element, open *bool, o PopoverOptions, content func()) *ui.Element { + k := pixel.K(c) + w := pixel.Or("Popover Width", o.Width, 0) + ab := anchor.Bounds() + return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + if w > 0 { + panel.Width(k.D(w)) + } + panel.Label(text).Padding(k.D(notch+5), k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + // Flipped above its anchor, the notch points down. + up := r.Y >= ab.Y + top := notch + if !up { + top = 0 + } + g.Frame(0, top, g.W, g.H-notch, pixel.Window, pixel.Face) + x := max(notch+1, min(g.W-notch-2, int((ab.X+ab.W/2-r.X)/k.Dot()))) + for j := range notch { + y := j + if !up { + y = g.H - 1 - j + } + g.HLine(x-j, y, 2*j+1, pixel.Face) + g.Dot(x-j, y, pixel.Edge) + g.Dot(x+j, y, pixel.Edge) + } + edge := notch + if !up { + edge = g.H - notch - 1 + } + g.HLine(x-notch+1, edge, 2*notch-1, pixel.Face) + }).Children(content) + }) +} diff --git a/overlay/progressdialog.go b/overlay/progressdialog.go new file mode 100644 index 0000000..e1cdd60 --- /dev/null +++ b/overlay/progressdialog.go @@ -0,0 +1,46 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ProgressDialogOptions struct { + Disabled bool +} + +type ProgressDialogResult struct { + Element *ui.Element + // Canceled is CANCEL, Escape or a click outside. + Canceled bool +} + +// ProgressDialog holds the view on a progress bar with CANCEL. +func ProgressDialog(c *ui.Context, title string, value float64, open *bool, o ProgressDialogOptions) ProgressDialogResult { + k := pixel.K(c) + was := *open + var res ProgressDialogResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Width(k.D(200)).Label(title).Focusable().FocusRing(false).Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{Title: title}, func() { + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() { + feedback.Progress(c, title, value, feedback.ProgressOptions{}) + actions(c, []string{k.Msg("cancel")}, o.Disabled, func(int) { *open = false }) + }) + }) + }) + }) + }) + res.Canceled = was && !*open + if res.Canceled { + slog.Debug("8bit: progress canceled", "title", title, "value", value) + } + return res +} diff --git a/overlay/promptdialog.go b/overlay/promptdialog.go new file mode 100644 index 0000000..52dbed0 --- /dev/null +++ b/overlay/promptdialog.go @@ -0,0 +1,55 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PromptDialogOptions struct { + Placeholder string + MaxLength int + Disabled bool +} + +type PromptDialogResult struct { + Element *ui.Element + // Canceled is CANCEL, Escape or a click outside. + Submitted, Canceled bool +} + +// PromptDialog asks for a line, as ENTER YOUR NAME. +func PromptDialog(c *ui.Context, title string, value *string, open *bool, o PromptDialogOptions) PromptDialogResult { + k := pixel.K(c) + was := *open + var res PromptDialogResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Width(k.D(220)).Label(title).Focusable().FocusRing(false).Children(func() { + pixel.Harbor(c, panel, func() { + layout.Window(c, layout.WindowOptions{Title: title}, func() { + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() { + f := input.TextInput(c, title, value, input.TextInputOptions{Placeholder: o.Placeholder, MaxLength: o.MaxLength, Width: 196, Disabled: o.Disabled}) + f.Input.AutoFocus() + // A Shortcut, not Submitted: the frame builds again closed. + if f.Element.Shortcut(0, ui.KeyEnter) && !o.Disabled { + res.Submitted, *open = true, false + } + actions(c, []string{k.Msg("cancel"), k.Msg("ok")}, o.Disabled, func(i int) { + res.Submitted, *open = i == 1, false + }) + }) + }) + }) + }) + }) + res.Canceled = was && !*open && !res.Submitted + if res.Submitted || res.Canceled { + slog.Debug("8bit: prompt", "title", title, "submitted", res.Submitted) + } + return res +} diff --git a/overlay/sheet.go b/overlay/sheet.go new file mode 100644 index 0000000..bf3fe31 --- /dev/null +++ b/overlay/sheet.go @@ -0,0 +1,76 @@ +package overlay + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SheetOptions struct { + // Height is in dots; zero means 120. + Height int + Disabled bool +} + +type SheetResult struct { + Element *ui.Element + // Closed is a drag down, Escape or a click outside. + Closed bool +} + +type sheetKey struct{} + +// Sheet rises from the bottom; drag its bar down. +func Sheet(c *ui.Context, text string, open *bool, o SheetOptions, content func()) SheetResult { + k := pixel.K(c) + h := pixel.Or("Sheet Height", o.Height, 120) + off := o.Disabled || pixel.Current(c).Inert + home := ui.Box(c) + pulled := ui.Local(home, sheetKey{}, func() float32 { return 0 }) + rise := k.D(arriving(c, home, *open) * h / 4) + was := *open + var res SheetResult + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + dim(k, back, pixel.Back, 8) + panel.Label(text).Focusable().FocusRing(false).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + pixel.Harbor(c, panel, func() { + bar := ui.Box(c).FillWidth().Height(k.D(10)).Role(ui.RoleNone) + _, dy, held := bar.Dragged() + switch { + case off: + *pulled = 0 + case held: + *pulled = max(0, *pulled+dy) + case *pulled > 0: + if *pulled > k.D(h/3) { + // A release is no drag: build again, closed. + *open = false + c.Invalidate() + } + *pulled = 0 + } + bar.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect((g.W-24)/2, 4, 24, 2, pixel.Shade) + }) + ui.Column(c).Grow(1).Padding(0, k.D(8), k.D(8), k.D(8)).Children(func() { body(c, o.Disabled, content) }) + }) + }) + // Placed after the bar, so a release lands at once. + drop := float32(int(*pulled/k.Dot())) * k.Dot() + panel.Absolute().Left(0).Right(0).Bottom(-rise - drop).Height(k.D(h)) + }) + res.Closed = was && !*open + if !*open { + *pulled = 0 + } + if res.Closed { + slog.Debug("8bit: sheet closed", "label", text) + } + return res +} diff --git a/overlay/spotlight.go b/overlay/spotlight.go new file mode 100644 index 0000000..9016db8 --- /dev/null +++ b/overlay/spotlight.go @@ -0,0 +1,36 @@ +package overlay + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SpotlightOptions struct{} + +// Spotlight dithers all but target and captions it. +func Spotlight(c *ui.Context, target *ui.Element, caption string, o SpotlightOptions) *ui.Element { + k := pixel.K(c) + b := target.Bounds() + deep, _ := dark(k) + var veil *ui.Element + ui.Overlay(c, func() { + veil = ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).PassThrough().Role(ui.RoleStatus).Label(caption) + veil.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + // The hole is the target, two dots wider all round. + x, y := int(b.X/k.Dot())-2, int(b.Y/k.Dot())-2 + w, h := int(b.W/k.Dot())+4, int(b.H/k.Dot())+4 + g.Veil(0, 0, g.W, y, deep, 12) + g.Veil(0, y+h, g.W, g.H-y-h, deep, 12) + g.Veil(0, y, x, h, deep, 12) + g.Veil(x+w, y, g.W-x-w, h, deep, 12) + g.FocusMark(x+2, y+2, w-4, h-4) + }) + }) + w := pixel.Measure(caption) + 16 + bubble(c, ui.Rect{X: b.X, Y: b.Y - k.D(2), W: b.W, H: b.H + k.D(4)}, func() { + pixel.Text(c, caption, pixel.TextOptions{}) + }, w) + return veil +} diff --git a/overlay/titlecard.go b/overlay/titlecard.go new file mode 100644 index 0000000..fac35a4 --- /dev/null +++ b/overlay/titlecard.go @@ -0,0 +1,51 @@ +package overlay + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TitleCardOptions struct { + // Cue shows the card each time it changes. + Cue int + // Hold keeps the title up; zero means two seconds. + Hold time.Duration +} + +// TitleCard dithers in, holds title, dithers out; fills its parent. +func TitleCard(c *ui.Context, title string, o TitleCardOptions) *ui.Element { + k := pixel.K(c) + if o.Hold < 0 { + panic("overlay: TitleCard Hold is negative") + } + hold := o.Hold + if hold == 0 { + hold = 2 * time.Second + } + e := ui.Box(c).Fill() + // Eight steps a second: four in, the hold, four out. + held := int(hold * 8 / time.Second) + step := k.Since(c, k.Cue(e, o.Cue), 8, held+8) + if step >= held+8 { + return e + } + level := 16 + switch { + case k.Reduce(): + case step < 4: + level = 4 * (step + 1) + case step >= held+4: + level = 4 * (held + 7 - step) + } + deep, light := dark(k) + return e.Role(ui.RoleStatus).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Veil(0, 0, g.W, g.H, deep, level) + if level == 16 { + g.Text((g.W-pixel.Measure(title)*2)/2, (g.H-pixel.LineHeight*2)/2, title, light, 2) + } + }) +} diff --git a/overlay/tooltip.go b/overlay/tooltip.go new file mode 100644 index 0000000..a97cf96 --- /dev/null +++ b/overlay/tooltip.go @@ -0,0 +1,53 @@ +package overlay + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TooltipOptions struct{} + +// tipWidth caps a tip's text in dots. +const tipWidth = 160 + +// Tooltip bubbles text after 500 ms, or on keyboard focus. +func Tooltip(c *ui.Context, text string, o TooltipOptions, trigger func()) *ui.Element { + k := pixel.K(c) + wrap := ui.Box(c) + show := hint(c, wrap, 500*time.Millisecond) + wrap.Children(trigger) + if show { + bubble(c, wrap.Bounds(), func() { + tip := ui.Box(c).Role(ui.RoleTooltip).Label(text) + tip.Children(func() { + pixel.Text(c, text, pixel.TextOptions{Wrap: true}).Width(k.D(min(pixel.Measure(text), tipWidth))) + }) + }, min(pixel.Measure(text), tipWidth)+16) + } + return wrap +} + +// bubble floats content by b; w keeps it inside. +func bubble(c *ui.Context, b ui.Rect, content func(), w int) *ui.Element { + k := pixel.K(c) + ww, wh := c.Size() + x := max(0, min(b.X+b.W/2-k.D(8), ww-k.D(w))) + at := max(3, int((b.X+b.W/2-x)/k.Dot())) + var e *ui.Element + ui.Overlay(c, func() { + e = ui.Box(c).Absolute().Left(x).PassThrough() + tail := display.Bottom + if b.Y > k.D(40) { + e.Bottom(wh - b.Y) + } else { + e.Top(b.Y + b.H) + tail = display.Top + } + e.Children(func() { display.Bubble(c, display.BubbleOptions{Tail: tail, At: at}, content) }) + }) + return e +} diff --git a/pixel/clock.go b/pixel/clock.go index d2e40e9..1cd9814 100644 --- a/pixel/clock.go +++ b/pixel/clock.go @@ -67,6 +67,23 @@ func (k Kit) Since(c *ui.Context, t time.Time, fps, done int) int { return min(n, done) } +type cueKey struct{} + +// cued is a cue's last value and when it changed. +type cued struct { + n int + at time.Time +} + +// Cue is when n last changed on e, zero before. +func (k Kit) Cue(e *ui.Element, n int) time.Time { + s := ui.Local(e, cueKey{}, func() cued { return cued{n: n} }) + if s.n != n { + s.n, s.at = n, k.now + } + return s.at +} + // frameAt is the frame at t and the next wait. func frameAt(t time.Time, fps int) (int, time.Duration) { checkFPS(fps) diff --git a/pixel/element.go b/pixel/element.go index 2db49b7..1f9cd7c 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -107,19 +107,32 @@ type parkKey struct{} // Park moves focus off e while off, back once on. func Park(c *ui.Context, e *ui.Element, off bool) { parked := ui.Local(e, parkKey{}, func() bool { return false }) + spot := c.Root() + if h := current(c).harbor; h != nil { + spot = h + } switch { case off && e.Focused(): - c.Root().Focus() + spot.Focus() *parked = true case !off && *parked: // Focus comes back unless something else took it. - if root := c.Root(); root.Focused() || !root.FocusWithin() { + if spot.Focused() || !c.Root().FocusWithin() { e.Focus() } *parked = false } } +// Harbor keeps focus in e while controls in fn rest. +func Harbor(c *ui.Context, e *ui.Element, fn func()) { + f := current(c) + prev := f.harbor + f.harbor = e + fn() + f.harbor = prev +} + // Hand is the pointing cursor of focus and menus. var Hand = MustSprite(` .eee...... diff --git a/pixel/messages.go b/pixel/messages.go index 1b879c1..41ead22 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -17,7 +17,8 @@ var messages = map[Locale]map[string]string{ "combo": "COMBO!", "saving": "SAVING", "saved": "SAVED", "go": "GO!", "wait": "PLEASE WAIT", "prev": "PREVIOUS", "next": "NEXT", "back": "BACK", "forward": "FORWARD", "history": "HISTORY", "on": "ON", "off": "OFF", "clear": "CLEAR", "per_page": "PER PAGE", "range": "%d–%d OF %d", "found": "FOUND: %d", - "unknown": "???", + "unknown": "???", "sure": "ARE YOU SURE?", "paused": "PAUSED", "resume": "RESUME", "options": "OPTIONS", "quit": "QUIT", + "next_step": "NEXT ▶", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -33,7 +34,8 @@ var messages = map[Locale]map[string]string{ "combo": "连击!", "saving": "存档中", "saved": "已存档", "go": "开始!", "wait": "请稍候", "prev": "上一页", "next": "下一页", "back": "后退", "forward": "前进", "history": "历史", "on": "开", "off": "关", "clear": "清除", "per_page": "每页", "range": "%d–%d / 共 %d", "found": "找到:%d", - "unknown": "???", + "unknown": "???", "sure": "确定吗?", "paused": "暂停", "resume": "继续游戏", "options": "设置", "quit": "退出", + "next_step": "下一步 ▶", }, } diff --git a/pixel/settings.go b/pixel/settings.go index 2e392a9..ba48826 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -48,6 +48,8 @@ type frame struct { cur Settings at time.Time typing bool + // harbor holds the focus of controls turning off. + harbor *ui.Element } type frameKey struct{} @@ -59,7 +61,7 @@ func Use(c *ui.Context, s Settings) { if f.cur.Palette != s.Palette { slog.Debug("8bit: palette", "name", s.Palette.Name) } - f.cur, f.at, f.typing = s, c.Now(), false + f.cur, f.at, f.typing, f.harbor = s, c.Now(), false, nil c.SetTheme(theme(s.Palette)) }