Add overlay components 176-195; pixel Harbor and Kit.Cue

Dialogs on DialogBase with focusable panels that harbor parked focus,
popovers on PopoverBase, bubbles, drawers, sheets, a lightbox, an RPG
dialogue box, notifications, a pause menu and a dithered title card.
Kit.Cue moves from feedback to pixel.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 23:11:46 -04:00
1 parent 03680980da
commit 5c3c883b7d
37 files changed
+2835 -31

No files matched your search

+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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
}
-20
View File
@@ -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
}
+1 -1
View File
@@ -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)
+32
View File
@@ -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...))
}
+224
View File
@@ -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")
}
}
+189
View File
@@ -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")
}
}
+1 -1
View File
@@ -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 }
+84
View File
@@ -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
}
+196
View File
@@ -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() {})
})
}
+59
View File
@@ -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
}
+47
View File
@@ -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
}
+57
View File
@@ -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
}
+69
View File
@@ -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)
}
}
})
}
+335
View File
@@ -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)
}
}
+126
View File
@@ -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
}
+63
View File
@@ -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
}
+41
View File
@@ -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
}
+39
View File
@@ -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{})
})
}
})
}
+70
View File
@@ -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
}
+113
View File
@@ -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
}
+135
View File
@@ -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)
}
+88
View File
@@ -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
}
+331
View File
@@ -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))
}
}
+65
View File
@@ -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
}
+58
View File
@@ -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
}
+52
View File
@@ -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)
})
}
+46
View File
@@ -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
}
+55
View File
@@ -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
}
+76
View File
@@ -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
}
+36
View File
@@ -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
}
+51
View File
@@ -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)
}
})
}
+53
View File
@@ -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
}
+17
View File
@@ -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)
+15 -2
View File
@@ -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......
+4 -2
View File
@@ -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": "下一步 ▶",
},
}
+3 -1
View File
@@ -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))
}