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:
37 files changed
+2835
-31
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user