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

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

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package overlay
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type AlertDialogOptions struct {
// Icon heads the message; nil means a warning sign.
Icon *pixel.Sprite
// Buttons label the answers; nil means OK.
Buttons []string
// Default is the button Enter presses.
Default int
Disabled bool
}
type AlertDialogResult struct {
Element *ui.Element
// Pressed is the button pressed this frame, or -1.
Pressed int
Closed bool
}
// AlertDialog asks for an answer; Enter presses the default.
func AlertDialog(c *ui.Context, title, message string, open *bool, o AlertDialogOptions) AlertDialogResult {
k := pixel.K(c)
buttons, art := o.Buttons, o.Icon
if buttons == nil {
buttons = []string{k.Msg("ok")}
}
if art == nil {
art = icon.Warning
}
if o.Default < 0 || o.Default >= len(buttons) {
panic(fmt.Sprintf("overlay: AlertDialog Default %d of %d buttons", o.Default, len(buttons)))
}
was := *open
res := AlertDialogResult{Pressed: -1}
press := func(i int) { res.Pressed, *open = i, false }
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Width(k.D(220)).Role(ui.RoleAlertDialog).Label(title).Description(message).Focusable().FocusRing(false)
if panel.Shortcut(0, ui.KeyEnter) && !o.Disabled && !pixel.Current(c).Inert {
press(o.Default)
}
panel.Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{Title: title}, func() {
ui.Row(c).Gap(k.D(8)).Children(func() {
ui.Box(c).Size(k.D(art.W*2), k.D(art.H*2)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, art, 0, pixel.Warn, 2)
})
ui.Column(c).Grow(1).Children(func() { text.Paragraph(c, message, text.ParagraphOptions{}) })
})
ui.Row(c).Gap(k.D(4)).Justify(ui.End).Padding(k.D(6), 0, 0, 0).Children(func() {
for i, b := range buttons {
v := input.Normal
if i == o.Default {
v = input.Primary
}
if input.Button(c, b, input.ButtonOptions{Size: input.Small, Variant: v, Disabled: o.Disabled}).Clicked {
press(i)
}
}
})
})
})
})
})
res.Closed = was && !*open && res.Pressed < 0
if res.Pressed >= 0 || res.Closed {
slog.Debug("8bit: alert", "title", title, "pressed", res.Pressed, "closed", res.Closed)
}
return res
}
+196
View File
@@ -0,0 +1,196 @@
package overlay_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestPopoverOpensBelowWithANotch(t *testing.T) {
open := false
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
b := input.Button(c, "MAP", input.ButtonOptions{})
if b.Clicked {
open = !open
}
overlay.Popover(c, "Map", b.Element, &open, overlay.PopoverOptions{}, func() {
pixel.Text(c, "You are here.", pixel.TextOptions{})
})
})
})
click(t, tt, "MAP")
a, p := find(t, tt, "MAP"), find(t, tt, "You are here.")
if p.Y <= a.Y+a.H {
t.Fatalf("panel text at %v, anchor %v", p, a)
}
// The notch tip sits under the anchor's middle.
if tip := slot(t, tt.Image(), int(a.X+a.W/2), int(a.Y+a.H)+1); tip != pixel.Edge && tip != pixel.Face {
t.Fatalf("no notch under the anchor: %v", tip)
}
keeps(t, tt.Image())
tt.Key(0, ui.KeyEscape)
if open || shows(tt, "You are here.") {
t.Fatal("Escape did not close it")
}
}
func TestTooltipWaitsForRestOrKeyboard(t *testing.T) {
clk := newClock()
tt := view(640, 400, clk, false, func(c *ui.Context) {
ui.Column(c).Padding(80).Gap(20).Children(func() {
input.Button(c, "FIRST", input.ButtonOptions{})
overlay.Tooltip(c, "Casts fire", overlay.TooltipOptions{}, func() {
input.Button(c, "FIRE", input.ButtonOptions{})
})
})
})
r := find(t, tt, "FIRE")
tt.Move(r.X+4, r.Y+4)
clk.t = clk.t.Add(300 * time.Millisecond)
tt.Frame()
if shows(tt, "Casts fire") {
t.Fatal("the tip showed before 500 ms")
}
clk.t = clk.t.Add(300 * time.Millisecond)
tt.Frame()
if !shows(tt, "Casts fire") {
t.Fatal("the tip did not show after 600 ms")
}
tt.ClickAt(r.X+4, r.Y+4)
tt.Move(600, 380)
if shows(tt, "Casts fire") {
t.Fatal("focus from a click kept the tip")
}
click(t, tt, "FIRST")
tt.Move(600, 380)
tt.Key(0, ui.KeyTab)
if !tt.Focused("FIRE") || !shows(tt, "Casts fire") {
t.Fatal("keyboard focus did not show the tip")
}
keeps(t, tt.Image())
}
func TestHoverCardStaysWhileThePointerMovesOntoIt(t *testing.T) {
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
overlay.HoverCard(c, "Aria", overlay.HoverCardOptions{}, func() {
pixel.Text(c, "@aria", pixel.TextOptions{})
}, func() {
pixel.Text(c, "Knight, LV 7", pixel.TextOptions{})
})
})
})
if shows(tt, "Knight, LV 7") {
t.Fatal("the card showed unhovered")
}
r := find(t, tt, "@aria")
tt.Move(r.X+4, r.Y+4)
tt.Frame()
card := find(t, tt, "Knight, LV 7")
tt.Move(card.X+4, card.Y+4)
tt.Frame()
if !shows(tt, "Knight, LV 7") {
t.Fatal("moving onto the card hid it")
}
tt.Move(600, 380)
tt.Frame()
if shows(tt, "Knight, LV 7") {
t.Fatal("the card stayed after the pointer left")
}
}
func TestPopconfirmAsksFirst(t *testing.T) {
yes, no := 0, 0
disabled := false
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
r := overlay.Popconfirm(c, "SELL", "Sell the crown?", overlay.PopconfirmOptions{Disabled: disabled})
if r.Confirmed {
yes++
}
if r.Canceled {
no++
}
})
})
click(t, tt, "SELL")
click(t, tt, "YES")
click(t, tt, "SELL")
click(t, tt, "NO")
click(t, tt, "SELL")
tt.Key(0, ui.KeyEscape)
if yes != 1 || no != 2 || shows(tt, "Sell the crown?") {
t.Fatalf("yes %d no %d", yes, no)
}
click(t, tt, "SELL")
disabled = true
tt.Frame()
if shows(tt, "Sell the crown?") {
t.Fatal("the bubble stayed open as its button turned off")
}
click(t, tt, "SELL")
if shows(tt, "Sell the crown?") {
t.Fatal("a disabled popconfirm opened")
}
}
func TestCoachmarkCountsAndGoesOn(t *testing.T) {
next := 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
b := input.Button(c, "INVENTORY", input.ButtonOptions{})
if overlay.Coachmark(c, b.Element, "Your items live here.", 2, 3, overlay.CoachmarkOptions{}).Next {
next++
}
})
})
if !shows(tt, "2/3") || find(t, tt, "Your items live here.").Y <= find(t, tt, "INVENTORY").Y {
t.Fatalf("texts %q", tt.Texts())
}
click(t, tt, "NEXT ▶")
if next != 1 {
t.Fatal("NEXT did not report")
}
keeps(t, tt.Image())
}
func TestSpotlightDimsAllButTheTarget(t *testing.T) {
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(60).Children(func() {
b := input.Button(c, "SHOP", input.ButtonOptions{})
overlay.Spotlight(c, b.Element, "Buy potions here.", overlay.SpotlightOptions{})
})
})
tt.Frame()
img, r := tt.Image(), find(t, tt, "SHOP")
if in := count(t, img, r, pixel.Ink); in > int(r.W*r.H)/16 {
t.Fatalf("%d Ink dots veil the target", in)
}
far := ui.Rect{X: 400, Y: 300, W: 100, H: 60}
if n := count(t, img, far, pixel.Ink); n < int(far.W*far.H)/8 {
t.Fatalf("only %d Ink dots far from the target", n)
}
if !shows(tt, "Buy potions here.") {
t.Fatal("no caption")
}
keeps(t, img)
}
func TestPopoverRefusesNegativeWidth(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("a negative width did not panic")
}
}()
open := false
view(200, 100, newClock(), false, func(c *ui.Context) {
b := input.Button(c, "X", input.ButtonOptions{})
overlay.Popover(c, "X", b.Element, &open, overlay.PopoverOptions{Width: -1}, func() {})
})
}
+59
View File
@@ -0,0 +1,59 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type ChoicePromptOptions struct {
Disabled bool
}
type ChoicePromptResult struct {
Element *ui.Element
// Chosen is the choice taken this frame, or -1.
Chosen int
// Canceled is Escape or a click outside.
Canceled bool
}
type choiceKey struct{}
// ChoicePrompt asks a question; a hand picks, or keys 1–9.
func ChoicePrompt(c *ui.Context, question string, choices []string, open *bool, o ChoicePromptOptions) ChoicePromptResult {
k := pixel.K(c)
if len(choices) == 0 {
panic("overlay: ChoicePrompt has no choices")
}
home := ui.Box(c)
cur := ui.Local(home, choiceKey{}, func() int { return 0 })
was := *open
res := ChoicePromptResult{Chosen: -1}
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 4)
panel.Width(k.D(180)).Label(question).Focusable().FocusRing(false).Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{}, func() {
text.Paragraph(c, question, text.ParagraphOptions{})
ui.Box(c).Height(k.D(6))
if i := choose(c, question, choices, cur, true, o.Disabled); i >= 0 {
res.Chosen, *open = i, false
}
})
})
})
})
res.Canceled = was && !*open && res.Chosen < 0
if !*open {
*cur = 0
}
if res.Chosen >= 0 || res.Canceled {
slog.Debug("8bit: choice", "question", question, "chosen", res.Chosen)
}
return res
}
+47
View File
@@ -0,0 +1,47 @@
package overlay
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type CoachmarkOptions struct {
Disabled bool
}
type CoachmarkResult struct {
Element *ui.Element
Next bool
}
// coachWidth is the bubble's text width in dots.
const coachWidth = 140
// Coachmark points a bubble at target: step count, NEXT ▶.
func Coachmark(c *ui.Context, target *ui.Element, message string, step, steps int, o CoachmarkOptions) CoachmarkResult {
k := pixel.K(c)
if step < 1 || step > steps {
panic(fmt.Sprintf("overlay: Coachmark step %d of %d", step, steps))
}
var res CoachmarkResult
res.Element = bubble(c, target.Bounds(), func() {
ui.Column(c).Width(k.D(coachWidth)).Gap(k.D(4)).Role(ui.RoleGroup).Label(message).Children(func() {
text.Paragraph(c, message, text.ParagraphOptions{})
ui.Row(c).AlignItems(ui.Center).Children(func() {
text.MicroText(c, fmt.Sprintf("%d/%d", step, steps), text.MicroTextOptions{Slot: pixel.Faint})
ui.Box(c).Grow(1)
res.Next = input.Button(c, k.Msg("next_step"), input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled}).Clicked
})
})
}, coachWidth+16)
if res.Next {
slog.Debug("8bit: coachmark next", "step", step, "steps", steps)
}
return res
}
+57
View File
@@ -0,0 +1,57 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type ConfirmDialogOptions struct {
Disabled bool
}
type ConfirmDialogResult struct {
Element *ui.Element
// Canceled is NO, Escape or a click outside.
Confirmed, Canceled bool
}
type confirmKey struct{}
// ConfirmDialog asks ARE YOU SURE? with YES / NO.
func ConfirmDialog(c *ui.Context, message string, open *bool, o ConfirmDialogOptions) ConfirmDialogResult {
k := pixel.K(c)
home := ui.Box(c)
cur := ui.Local(home, confirmKey{}, func() int { return 1 })
was := *open
var res ConfirmDialogResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Width(k.D(180)).Label(k.Msg("sure")).Description(message).Focusable().FocusRing(false).Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{Title: k.Msg("sure")}, func() {
text.Paragraph(c, message, text.ParagraphOptions{})
ui.Box(c).Height(k.D(6))
switch choose(c, k.Msg("sure"), []string{k.Msg("yes"), k.Msg("no")}, cur, false, o.Disabled) {
case 0:
res.Confirmed, *open = true, false
case 1:
*open = false
}
})
})
})
})
res.Canceled = was && !*open && !res.Confirmed
if !*open {
*cur = 1
}
if res.Confirmed || res.Canceled {
slog.Debug("8bit: confirm", "message", message, "yes", res.Confirmed)
}
return res
}
+69
View File
@@ -0,0 +1,69 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DialogOptions struct {
// Actions label buttons under the content; any one closes.
Actions []string
// Width is the window's in dots; zero means 220.
Width int
Disabled bool
}
type DialogResult struct {
// Element is the window, nil while closed.
Element *ui.Element
// Action is the button clicked this frame, or -1.
Action int
// Closed is Escape or a click outside.
Closed bool
}
// Dialog is a titled window over a checkered backdrop.
func Dialog(c *ui.Context, title string, open *bool, o DialogOptions, content func()) DialogResult {
k := pixel.K(c)
w := pixel.Or("Dialog Width", o.Width, 220)
was := *open
res := DialogResult{Action: -1}
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
// The panel holds the focus when nothing inside can.
panel.Width(k.D(w)).Label(title).Focusable().FocusRing(false).Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{Title: title}, func() {
ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() {
body(c, o.Disabled, content)
actions(c, o.Actions, o.Disabled, func(i int) { res.Action, *open = i, false })
})
})
})
})
})
res.Closed = was && !*open && res.Action < 0
if res.Action >= 0 || res.Closed {
slog.Debug("8bit: dialog", "title", title, "action", res.Action, "closed", res.Closed)
}
return res
}
// actions lines buttons up at the right; act takes clicks.
func actions(c *ui.Context, labels []string, disabled bool, act func(i int)) {
if len(labels) == 0 {
return
}
ui.Row(c).Gap(pixel.K(c).D(4)).Justify(ui.End).Children(func() {
for i, l := range labels {
if input.Button(c, l, input.ButtonOptions{Size: input.Small, Disabled: disabled}).Clicked {
act(i)
}
}
})
}
+335
View File
@@ -0,0 +1,335 @@
package overlay_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDialogActsClosesAndHandsFocusBack(t *testing.T) {
open, action, closed := false, -1, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if input.Button(c, "OPEN", input.ButtonOptions{}).Clicked {
open = true
}
r := overlay.Dialog(c, "Save game?", &open, overlay.DialogOptions{Actions: []string{"LATER", "SAVE"}}, func() {
pixel.Text(c, "Slot 1 is free.", pixel.TextOptions{})
})
if r.Action >= 0 {
action = r.Action
}
if r.Closed {
closed++
}
})
click(t, tt, "OPEN")
if !shows(tt, "Slot 1 is free.") {
t.Fatal("the dialog did not open")
}
click(t, tt, "SAVE")
if open || action != 1 || shows(tt, "Slot 1 is free.") {
t.Fatalf("SAVE left open %v, action %d", open, action)
}
if !tt.Focused("OPEN") {
t.Fatal("closing did not hand the focus back")
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEscape)
if open || closed != 1 {
t.Fatalf("Escape left open %v, %d closes", open, closed)
}
keeps(t, tt.Image())
}
func TestAlertDialogEnterPressesDefault(t *testing.T) {
open, pressed := true, -1
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if r := overlay.AlertDialog(c, "Low HP", "Heal before the boss?", &open, overlay.AlertDialogOptions{Buttons: []string{"NO", "HEAL"}, Default: 1}); r.Pressed >= 0 {
pressed = r.Pressed
}
})
tt.Key(0, ui.KeyEnter)
if open || pressed != 1 {
t.Fatalf("Enter pressed %d, open %v", pressed, open)
}
open = true
tt.Frame()
click(t, tt, "NO")
if open || pressed != 0 {
t.Fatalf("NO pressed %d", pressed)
}
defer func() {
if recover() == nil {
t.Fatal("a Default past the buttons did not panic")
}
}()
view(100, 100, newClock(), false, func(c *ui.Context) {
overlay.AlertDialog(c, "x", "y", &open, overlay.AlertDialogOptions{Default: 1})
})
}
func TestConfirmDialogStartsOnNo(t *testing.T) {
open, yes, no := true, 0, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
r := overlay.ConfirmDialog(c, "Drop the sword?", &open, overlay.ConfirmDialogOptions{})
if r.Confirmed {
yes++
}
if r.Canceled {
no++
}
})
if !shows(tt, "ARE YOU SURE?") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if open || yes != 0 || no != 1 {
t.Fatalf("Enter on the start gave yes %d no %d", yes, no)
}
open = true
tt.Frame()
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if open || yes != 1 {
t.Fatalf("Up, Enter gave yes %d", yes)
}
open = true
tt.Frame()
click(t, tt, "YES")
tt.Frame()
open = true
tt.Frame()
tt.Key(0, ui.KeyEscape)
if yes != 2 || no != 2 {
t.Fatalf("click YES then Escape gave yes %d no %d", yes, no)
}
keeps(t, tt.Image())
}
func TestChoicePromptTakesDigitsArrowsAndClicks(t *testing.T) {
open, chosen, canceled := true, -1, 0
choices := []string{"Fight", "Talk", "Flee"}
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
r := overlay.ChoicePrompt(c, "A slime appears!", choices, &open, overlay.ChoicePromptOptions{})
if r.Chosen >= 0 {
chosen = r.Chosen
}
if r.Canceled {
canceled++
}
})
tt.Key(0, ui.Key3)
if open || chosen != 2 {
t.Fatalf("3 chose %d", chosen)
}
open = true
tt.Frame()
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
if chosen != 1 {
t.Fatalf("Down, Space chose %d", chosen)
}
open = true
tt.Frame()
click(t, tt, "Flee")
open = true
tt.Frame()
tt.Key(0, ui.KeyEscape)
if chosen != 2 || canceled != 1 || open {
t.Fatalf("click Flee, Escape: chosen %d, %d cancels", chosen, canceled)
}
}
func TestPauseMenuResumesAndChooses(t *testing.T) {
paused, chosen := true, -1
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
if !shows(tt, "PAUSED") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !paused || chosen != 1 {
t.Fatalf("OPTIONS left paused %v, chosen %d", paused, chosen)
}
tt.Key(0, ui.KeyEscape)
if paused || chosen != 0 {
t.Fatalf("Escape left paused %v, chosen %d", paused, chosen)
}
keeps(t, tt.Image())
}
func TestPromptDialogSubmitsAndCancels(t *testing.T) {
open, name, sent, canceled := true, "", 0, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
r := overlay.PromptDialog(c, "ENTER YOUR NAME", &name, &open, overlay.PromptDialogOptions{MaxLength: 8})
if r.Submitted {
sent++
}
if r.Canceled {
canceled++
}
})
tt.Type("LINK")
tt.Key(0, ui.KeyEnter)
if open || name != "LINK" || sent != 1 {
t.Fatalf("typing, Enter left %q, open %v, %d sent", name, open, sent)
}
open = true
tt.Frame()
click(t, tt, "CANCEL")
if open || canceled != 1 {
t.Fatalf("CANCEL left open %v, %d cancels", open, canceled)
}
}
func TestProgressDialogCancels(t *testing.T) {
open, canceled := true, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.ProgressDialog(c, "Saving", 0.5, &open, overlay.ProgressDialogOptions{}).Canceled {
canceled++
}
})
if !shows(tt, "Saving") {
t.Fatalf("texts %q", tt.Texts())
}
click(t, tt, "CANCEL")
if open || canceled != 1 {
t.Fatalf("CANCEL left open %v", open)
}
keeps(t, tt.Image())
}
func TestOffDialogsTakeNoAnswers(t *testing.T) {
for _, inert := range []bool{false, true} {
open, action := true, -1
tt := view(640, 400, newClock(), inert, func(c *ui.Context) {
if r := overlay.Dialog(c, "Quest", &open, overlay.DialogOptions{Actions: []string{"ACCEPT"}, Disabled: !inert}, func() {}); r.Action >= 0 {
action = r.Action
}
})
click(t, tt, "ACCEPT")
if action >= 0 || !open {
t.Fatalf("inert %v: a dialog took action %d", inert, action)
}
alert, pressed := true, -1
tt = view(640, 400, newClock(), inert, func(c *ui.Context) {
if r := overlay.AlertDialog(c, "Warn", "Careful.", &alert, overlay.AlertDialogOptions{Disabled: !inert}); r.Pressed >= 0 {
pressed = r.Pressed
}
})
tt.Key(0, ui.KeyEnter)
click(t, tt, "OK")
if pressed >= 0 || !alert {
t.Fatalf("inert %v: an alert took button %d", inert, pressed)
}
}
}
func TestInertMenusTakeNoPicks(t *testing.T) {
open, paused, chosen, picked := true, true, -1, -1
tt := view(640, 400, newClock(), true, func(c *ui.Context) {
if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &open, overlay.ChoicePromptOptions{}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
tt.Key(0, ui.Key1)
click(t, tt, "No")
tt2 := view(640, 400, newClock(), true, func(c *ui.Context) {
if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}); r.Chosen > 0 {
picked = r.Chosen
}
})
click(t, tt2, "QUIT")
if chosen >= 0 || picked >= 0 || !open {
t.Fatalf("inert menus picked %d and %d", chosen, picked)
}
}
func TestDisabledMenusAndPromptsTakeNoAnswers(t *testing.T) {
open, chosen, sure, paused, picked, prompt, name, sent := true, -1, true, true, -1, true, "", 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &open, overlay.ChoicePromptOptions{Disabled: true}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
tt.Key(0, ui.Key1)
tt2 := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.ConfirmDialog(c, "Sure?", &sure, overlay.ConfirmDialogOptions{Disabled: true}).Confirmed {
chosen = 99
}
})
click(t, tt2, "YES")
tt3 := view(640, 400, newClock(), false, func(c *ui.Context) {
if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{Disabled: true}); r.Chosen > 0 {
picked = r.Chosen
}
})
click(t, tt3, "QUIT")
tt4 := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.PromptDialog(c, "NAME", &name, &prompt, overlay.PromptDialogOptions{Disabled: true}).Submitted {
sent++
}
})
tt4.Type("AB")
click(t, tt4, "OK")
if chosen >= 0 || picked >= 0 || sent != 0 || name != "" || !open || !sure || !prompt {
t.Fatalf("disabled answers: chosen %d picked %d sent %d name %q", chosen, picked, sent, name)
}
}
func TestFocusComesBackAfterAnOffSpellInADialog(t *testing.T) {
inert, disabled := false, false
open, action := true, -1
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
if r := overlay.Dialog(c, "Quest", &open, overlay.DialogOptions{Actions: []string{"ACCEPT"}}, func() {}); r.Action >= 0 {
action = r.Action
}
}, 640, 400)
tt.Key(0, ui.KeyTab)
if !tt.Focused("ACCEPT") {
t.Fatal("Tab did not reach ACCEPT")
}
inert = true
tt.Frame()
inert = false
tt.Frame()
tt.Key(0, ui.KeyEnter)
if action != 0 {
t.Fatalf("Enter after the veil gave action %d", action)
}
chosen, choice := -1, true
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
if r := overlay.ChoicePrompt(c, "Go?", []string{"Yes", "No"}, &choice, overlay.ChoicePromptOptions{Disabled: disabled}); r.Chosen >= 0 {
chosen = r.Chosen
}
})
disabled = true
tt.Frame()
disabled = false
tt.Frame()
tt.Key(0, ui.KeyEnter)
if chosen != 0 {
t.Fatalf("Enter after a disabled spell chose %d", chosen)
}
name, prompt := "", true
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.PromptDialog(c, "NAME", &name, &prompt, overlay.PromptDialogOptions{Disabled: disabled})
})
disabled = true
tt.Frame()
disabled = false
tt.Frame()
tt.Type("AB")
if name != "AB" {
t.Fatalf("typing after a disabled spell gave %q", name)
}
}
+126
View File
@@ -0,0 +1,126 @@
package overlay
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var more = pixel.MustSprite(`
#####
.###.
..#..
`)
type DialogueBoxOptions struct {
// Name labels the speaker's tab.
Name string
// Portrait draws the speaker; nil shows none.
Portrait *pixel.Sprite
// CPS is the typing speed; zero means 30.
CPS int
Disabled bool
}
type DialogueBoxResult struct {
Element *ui.Element
// Changed turns a page; Done passes the last one.
Changed, Done bool
}
type dialogueKey struct{}
// typing is the page, when it began, and if out.
type typing struct {
page int
since time.Time
out bool
}
// DialogueBox types pages; a press shows all, then turns.
func DialogueBox(c *ui.Context, pages []string, page *int, o DialogueBoxOptions) DialogueBoxResult {
k := pixel.K(c)
if *page < 0 || *page >= len(pages) {
panic(fmt.Sprintf("overlay: DialogueBox page %d of %d", *page, len(pages)))
}
cps := pixel.Or("DialogueBox CPS", o.CPS, 30)
off := o.Disabled || pixel.Current(c).Inert
box := ui.Box(c).Role(ui.RoleText).Label(strings.TrimSpace(o.Name + " " + pages[*page])).FocusRing(false)
if !off {
box.Focusable()
}
pixel.Park(c, box, off)
st := ui.Local(box, dialogueKey{}, func() typing { return typing{page: -1} })
if st.page != *page {
*st = typing{page: *page, since: k.Now()}
}
res := DialogueBoxResult{Element: box}
// A press shows the whole page, then turns it.
if !off && box.Clicked() {
switch {
case !st.out:
st.out = true
case *page < len(pages)-1:
*page, res.Changed = *page+1, true
*st = typing{page: *page, since: k.Now()}
default:
res.Done = true
}
}
focus := box.FocusVisible()
box.DrawOver(func(p *ui.Painter, r ui.Rect) {
if focus {
g := k.Canvas(p, r)
g.FocusMark(0, 0, g.W, g.H)
}
}).Children(func() {
layout.Window(c, layout.WindowOptions{Title: o.Name}, func() {
ui.Row(c).Gap(k.D(8)).Children(func() {
if sp := o.Portrait; sp != nil {
ui.Box(c).Size(k.D(sp.W*2+6), k.D(sp.H*2+6)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.Sprite(3, 3, sp, 0, pixel.Ink, 2)
})
}
words := ui.Box(c).Grow(1)
w, check := pixel.WrapWidth(c, words)
lines := pixel.Wrap(pages[*page], max(0, w-8), 0)
total := 0
for _, l := range lines {
total += len([]rune(l))
}
shown := total
if !st.out && !k.Reduce() {
shown = k.Since(c, st.since, cps, total)
}
st.out = shown == total
words.Height(k.D(max(2, len(lines)) * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
left, end := shown, 0
for i, l := range lines {
lr := []rune(l)
n := min(len(lr), max(0, left))
g.Text(0, i*pixel.LineHeight, string(lr[:n]), pixel.Ink, 1)
left -= len(lr)
end = pixel.Measure(string(lr[:n]))
}
if shown == total && g.Step(2)%2 == 0 {
g.Sprite(end+3, len(lines)*pixel.LineHeight-6, more, 0, pixel.Accent, 1)
}
})
})
})
})
if res.Changed || res.Done {
slog.Debug("8bit: dialogue", "name", o.Name, "page", *page, "done", res.Done)
}
return res
}
+63
View File
@@ -0,0 +1,63 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DrawerOptions struct {
// Side is the edge it slides from.
Side display.Side
// Size is its depth in dots; zero means 160.
Size int
// Disabled makes the content inert.
Disabled bool
}
type DrawerResult struct {
Element *ui.Element
// Closed is Escape or a click outside.
Closed bool
}
// Drawer slides a panel in from an edge in steps.
func Drawer(c *ui.Context, text string, open *bool, o DrawerOptions, content func()) DrawerResult {
k := pixel.K(c)
if o.Side > display.Right {
panic("overlay: unknown Drawer Side")
}
size := pixel.Or("Drawer Size", o.Size, 160)
home := ui.Box(c)
off := k.D(arriving(c, home, *open) * size / 4)
was := *open
var res DrawerResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Absolute().Label(text).Padding(k.D(8)).Focusable().FocusRing(false)
switch o.Side {
case display.Left:
panel.Top(0).Bottom(0).Left(-off).Width(k.D(size))
case display.Right:
panel.Top(0).Bottom(0).Right(-off).Width(k.D(size))
case display.Top:
panel.Left(0).Right(0).Top(-off).Height(k.D(size))
case display.Bottom:
panel.Left(0).Right(0).Bottom(-off).Height(k.D(size))
}
panel.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
pixel.Harbor(c, panel, func() { body(c, o.Disabled, content) })
})
})
res.Closed = was && !*open
if res.Closed {
slog.Debug("8bit: drawer closed", "label", text)
}
return res
}
+41
View File
@@ -0,0 +1,41 @@
package overlay
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HoverCardOptions struct {
// Width is the card's in dots; zero means 160.
Width int
}
type hoverKey struct{}
// HoverCard shows card below trigger while either is hovered.
func HoverCard(c *ui.Context, text string, o HoverCardOptions, trigger func(), card func()) *ui.Element {
k := pixel.K(c)
w := pixel.Or("HoverCard Width", o.Width, 160)
wrap := ui.Box(c)
last := ui.Local(wrap, hoverKey{}, func() ui.Rect { return ui.Rect{} })
b := wrap.Bounds()
px, py, _ := wrap.PointerPosition()
show := hint(c, wrap, 0) || last.Contains(b.X+px, b.Y+py)
wrap.Children(trigger)
*last = ui.Rect{}
if !show {
return wrap
}
ww, _ := c.Size()
ui.Overlay(c, func() {
e := ui.Column(c).Absolute().Left(max(0, min(b.X, ww-k.D(w)))).Top(b.Y + b.H).Width(k.D(w)).
Padding(k.D(6)).Role(ui.RoleGroup).Label(text)
*last = e.Bounds()
e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(card)
})
return wrap
}
+39
View File
@@ -0,0 +1,39 @@
package overlay
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// KeyHint pairs a control with what it does.
type KeyHint struct {
Key, Action string
}
type KeyHintsOptions struct{}
// KeyHints shows controls, as Ⓐ CONFIRM Ⓑ BACK.
func KeyHints(c *ui.Context, hints []KeyHint, o KeyHintsOptions) *ui.Element {
k := pixel.K(c)
return ui.Row(c).Gap(k.D(10)).Padding(k.D(3), k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Flat, pixel.Face)
}).Children(func() {
for _, h := range hints {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleText).Label(h.Key + " " + h.Action).Children(func() {
if len([]rune(h.Key)) == 1 && pixel.MicroOK(h.Key) {
ui.Box(c).Size(k.D(11), k.D(11)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Disc(5, 5, 5, pixel.Ink)
g.Micro((11-pixel.MicroWidth(h.Key))/2, (11-pixel.MicroHeight)/2, h.Key, pixel.Back, 1)
})
} else {
text.Kbd(c, h.Key, text.KbdOptions{})
}
pixel.Text(c, h.Action, pixel.TextOptions{})
})
}
})
}
+70
View File
@@ -0,0 +1,70 @@
package overlay
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LightboxOptions struct {
Disabled bool
}
type LightboxResult struct {
Element *ui.Element
// Changed turns the picture; Closed is ×, Escape or outside.
Changed, Closed bool
}
// Lightbox shows pictures over a dark veil, with ◀ ▶.
func Lightbox(c *ui.Context, text string, n int, at *int, open *bool, o LightboxOptions, picture func(i int)) LightboxResult {
k := pixel.K(c)
if n < 1 || *at < 0 || *at >= n {
panic(fmt.Sprintf("overlay: Lightbox picture %d of %d", *at, n))
}
was, old := *open, *at
off := o.Disabled || pixel.Current(c).Inert
turn := func(d int) { *at = (*at + d + n) % n }
var res LightboxResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
deep, light := dark(k)
dim(k, back, deep, 13)
left, right := panel.Shortcut(0, ui.KeyLeft), panel.Shortcut(0, ui.KeyRight)
switch {
case off:
case left:
turn(-1)
case right:
turn(1)
}
panel.Label(text).Focusable().FocusRing(false).AlignItems(ui.Center).Gap(k.D(6)).Children(func() {
pixel.Harbor(c, panel, func() {
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked {
turn(-1)
}
ui.Box(c).Key(*at).Children(func() { picture(*at) })
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked {
turn(1)
}
})
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
pixel.Text(c, fmt.Sprintf("%d / %d", *at+1, n), pixel.TextOptions{Slot: light})
if input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
*open = false
}
})
})
})
})
res.Changed, res.Closed = *at != old, was && !*open
if res.Changed || res.Closed {
slog.Debug("8bit: lightbox", "label", text, "at", *at, "closed", res.Closed)
}
return res
}
+113
View File
@@ -0,0 +1,113 @@
package overlay
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Notification is a card in the queue Notifications shows.
type Notification struct {
Icon *pixel.Sprite
Title, Text string
// For is how long it shows; zero means five seconds.
For time.Duration
// id and shown are set as it first shows.
id uint64
shown time.Time
}
type NotificationsOptions struct {
// Width is each card's in dots; zero means 180.
Width int
Disabled bool
}
type NotificationsResult struct {
// Element holds the cards on screen.
Element *ui.Element
// Dismissed is the card × closed this frame, or nil.
Dismissed *Notification
}
type notesKey struct{}
// Notifications stacks timed cards at the top right; × dismisses.
func Notifications(c *ui.Context, queue *[]Notification, o NotificationsOptions) NotificationsResult {
k := pixel.K(c)
w := pixel.Or("Notifications Width", o.Width, 180)
now := k.Now()
kept := (*queue)[:0]
for _, n := range *queue {
if n.For < 0 {
panic("overlay: Notification For is negative")
}
life := n.For
if life == 0 {
life = 5 * time.Second
}
if n.shown.IsZero() || now.Sub(n.shown) < life {
kept = append(kept, n)
}
}
*queue = kept
shown := min(4, len(*queue))
last := ui.Local(c.Root(), notesKey{}, func() uint64 { return 0 })
for i := range shown {
if (*queue)[i].id == 0 {
*last++
(*queue)[i].id, (*queue)[i].shown = *last, now
}
}
if shown > 0 {
k.Tick(c, 4)
}
var res NotificationsResult
ui.Overlay(c, func() {
res.Element = ui.Column(c).Absolute().Right(k.D(8)).Top(k.D(8)).Width(k.D(w)).Gap(k.D(4)).AlignItems(ui.Stretch).Children(func() {
for i := range shown {
n := (*queue)[i]
ui.Row(c).Key(n.id).Padding(k.D(5), k.D(6)).Gap(k.D(6)).Role(ui.RoleStatus).Label(n.Title).Description(n.Text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
art := n.Icon
if art == nil {
art = icon.Bell
}
ui.Box(c).Size(k.D(art.W), k.D(art.H)).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, art, 0, pixel.Accent, 1)
})
ui.Column(c).Grow(1).Children(func() {
pixel.Text(c, n.Title, pixel.TextOptions{})
text.Paragraph(c, n.Text, text.ParagraphOptions{Slot: pixel.Faint})
})
if input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Size: input.Small, Variant: input.Ghost, Disabled: o.Disabled}).Clicked {
res.Dismissed = &n
}
})
}
})
})
if res.Dismissed != nil {
*queue = dropNote(*queue, res.Dismissed.id)
slog.Debug("8bit: notification dismissed", "title", res.Dismissed.Title)
}
return res
}
// dropNote removes the card with id from q.
func dropNote(q []Notification, id uint64) []Notification {
for i := range q {
if q[i].id == id {
return append(q[:i], q[i+1:]...)
}
}
return q
}
+135
View File
@@ -0,0 +1,135 @@
// Package overlay floats windows, bubbles and veils over the view.
package overlay
import (
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// dim checkers the backdrop of a dialog.
func dim(k pixel.Kit, back *ui.Element, s pixel.Slot, level int) {
back.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Veil(0, 0, g.W, g.H, s, level)
})
}
// dark puts the deeper of Ink and Back first.
func dark(k pixel.Kit) (dark, light pixel.Slot) {
if k.Palette().Night {
return pixel.Back, pixel.Ink
}
return pixel.Ink, pixel.Back
}
// choose lists items under a hand; digits pick 1–9.
func choose(c *ui.Context, text string, items []string, cur *int, digits, off bool) int {
k := pixel.K(c)
n := len(items)
*cur = max(0, min(*cur, n-1))
off = off || pixel.Current(c).Inert
box := ui.Column(c).FocusRing(false).Role(ui.RoleList).Label(text)
if !off {
box.Focusable().AutoFocus()
}
pixel.Park(c, box, off)
picked := -1
if off {
box.Description(k.Msg("disabled"))
}
if box.Shortcut(0, ui.KeyDown) {
*cur = (*cur + 1) % n
}
if box.Shortcut(0, ui.KeyUp) {
*cur = (*cur + n - 1) % n
}
enter, space := box.Shortcut(0, ui.KeyEnter), box.Shortcut(0, ui.KeySpace)
if enter || space {
picked = *cur
}
for i := range min(9, n) {
if digits && box.Shortcut(0, ui.Key1+ui.Key(i)) {
*cur, picked = i, i
}
}
box.Children(func() {
for i, it := range items {
row := ui.Row(c).Height(k.D(pixel.LineHeight)).MinWidth(k.D(pixel.Measure(it) + 16)).Role(ui.RoleListItem).Label(it)
if !off && row.Clicked() {
*cur, picked = i, i
}
row.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if i == *cur {
g.Sprite(g.Step(4)%2, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1)
}
g.Text(14, 0, it, pixel.Ink, 1)
})
}
})
if !off {
box.Description(items[*cur])
}
return picked
}
// body builds content, inert while off.
func body(c *ui.Context, off bool, content func()) {
if !off {
content()
return
}
s := pixel.Current(c)
s.Inert = true
pixel.Scope(c, s, content)
}
type hintKey struct{}
// hinting is when hover began, and a press-given focus.
type hinting struct {
since time.Time
pressed bool
}
// hint holds after resting wait, or on keyboard focus.
func hint(c *ui.Context, e *ui.Element, wait time.Duration) bool {
k := pixel.K(c)
s := ui.Local(e, hintKey{}, func() hinting { return hinting{} })
_, _, over := e.PointerPosition()
hovered, focus := e.Hovered(), e.FocusWithin()
switch {
case over && !hovered:
// A press inside: its focus is not the keyboard's.
s.pressed = true
case !focus:
s.pressed = false
}
rested := false
if hovered {
if s.since.IsZero() {
s.since = k.Now()
}
steps := int(wait / (100 * time.Millisecond))
rested = k.Since(c, s.since, 10, steps) >= steps
} else {
s.since = time.Time{}
}
return rested || focus && !s.pressed
}
// arriving counts the steps, of four, a panel still slides.
func arriving(c *ui.Context, home *ui.Element, open bool) int {
k := pixel.K(c)
on := 0
if open {
on = 1
}
if k.Reduce() {
return 0
}
return 4 - k.Since(c, k.Cue(home, on), 15, 4)
}
+88
View File
@@ -0,0 +1,88 @@
package overlay_test
import (
"image"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} }
// view builds on a fake clock; inert veils every control.
func view(w, h int, clk *clock, inert bool, build func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now, Inert: inert})
build(c)
}, w, h)
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func shows(tt *ui.Tester, label string) bool {
_, ok := tt.Find(label)
return ok
}
// slot is the palette slot of pixel x, y.
func slot(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
t.Helper()
c := img.RGBAAt(x, y)
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
return pixel.Slot(s)
}
}
t.Fatalf("pixel %d,%d is %v, off the palette", x, y, c)
return 0
}
// keeps fails on off-palette colors or split dots.
func keeps(t *testing.T, img *image.RGBA) {
t.Helper()
b := img.Bounds()
for y := 0; y+1 < b.Dy(); y += 2 {
for x := 0; x+1 < b.Dx(); x += 2 {
slot(t, img, x, y)
c := img.RGBAAt(x, y)
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
t.Fatalf("dot at %d,%d is split", x/2, y/2)
}
}
}
}
// count tallies dots of slot s in r.
func count(t *testing.T, img *image.RGBA, r ui.Rect, s pixel.Slot) int {
t.Helper()
n := 0
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
for x := int(r.X); x < int(r.X+r.W); x += 2 {
if slot(t, img, x, y) == s {
n++
}
}
}
return n
}
+331
View File
@@ -0,0 +1,331 @@
package overlay_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDrawerSlidesInInSteps(t *testing.T) {
clk, open, closed := newClock(), false, 0
side := display.Right
tt := view(640, 400, clk, false, func(c *ui.Context) {
if input.Button(c, "MENU", input.ButtonOptions{}).Clicked {
open = true
}
if overlay.Drawer(c, "Menu", &open, overlay.DrawerOptions{Side: side, Size: 100}, func() {
pixel.Text(c, "Items", pixel.TextOptions{})
}).Closed {
closed++
}
})
click(t, tt, "MENU")
var xs []float32
for range 5 {
xs = append(xs, find(t, tt, "Items").X)
clk.t = clk.t.Add(70 * time.Millisecond)
tt.Frame()
}
// Four 50-pixel steps, then rest at 640-200+16.
want := []float32{456 + 200, 456 + 150, 456 + 100, 456 + 50, 456}
for i := range want {
if xs[i] != want[i] {
t.Fatalf("x by step %v, want %v", xs, want)
}
}
keeps(t, tt.Image())
tt.Key(0, ui.KeyEscape)
if open || closed != 1 {
t.Fatal("Escape left it open")
}
side, open = display.Left, true
tt.Frame()
clk.t = clk.t.Add(time.Second)
tt.Frame()
if x := find(t, tt, "Items").X; x != 16 {
t.Fatalf("a left drawer opened at %v", x)
}
}
func TestSheetClosesOnALongPull(t *testing.T) {
open, closed := true, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() {
pixel.Text(c, "Sound: ON", pixel.TextOptions{})
}).Closed {
closed++
}
})
// The bar tops the sheet, which is 240 pixels.
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+40)
tt.Release(320, top+40)
if !open || find(t, tt, "Sound: ON").Y > top+40 {
t.Fatal("a short pull closed or moved the sheet")
}
tt.Press(320, top+8)
tt.Move(320, top+60)
tt.Move(320, top+120)
tt.Release(320, top+120)
if open || closed != 1 {
t.Fatalf("a long pull left open %v", open)
}
}
func TestLightboxTurnsWrapsAndCloses(t *testing.T) {
open, at, closed := true, 0, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{}, func(i int) {
pixel.Text(c, []string{"Castle", "Cave", "Coast"}[i], pixel.TextOptions{})
}).Closed {
closed++
}
})
tt.Key(0, ui.KeyLeft)
if at != 2 || !shows(tt, "Coast") || !shows(tt, "3 / 3") {
t.Fatalf("Left from the first went to %d", at)
}
click(t, tt, "NEXT")
tt.Key(0, ui.KeyRight)
if at != 1 || !shows(tt, "Cave") {
t.Fatalf("▶, Right went to %d", at)
}
keeps(t, tt.Image())
click(t, tt, "CLOSE")
if open || closed != 1 {
t.Fatal("× did not close it")
}
}
func TestDialogueBoxTypesThenTurns(t *testing.T) {
clk, page, done := newClock(), 0, 0
pages := []string{"It is dangerous to go alone!", "Take this."}
tt := view(640, 400, clk, false, func(c *ui.Context) {
if overlay.DialogueBox(c, pages, &page, overlay.DialogueBoxOptions{Name: "OLD MAN", Portrait: icon.User}).Done {
done++
}
})
r := find(t, tt, "OLD MAN "+pages[0])
tt.ClickAt(r.X+4, r.Y+4)
if page != 0 {
t.Fatal("the first press turned the page before the text was out")
}
tt.Key(0, ui.KeyEnter)
if page != 1 || !shows(tt, "OLD MAN Take this.") {
t.Fatalf("Enter after the text left page %d", page)
}
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
tt.Key(0, ui.KeyEnter)
if done != 1 || !shows(tt, "OLD MAN") {
t.Fatalf("Enter on the last page: done %d", done)
}
keeps(t, tt.Image())
}
func TestDialogueBoxUnderReduceMotionTurnsAtOnce(t *testing.T) {
page := 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.DialogueBox(c, []string{"One.", "Two.", "Three."}, &page, overlay.DialogueBoxOptions{})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
click(t, tt, "One.")
tt.Key(0, ui.KeyEnter)
if page != 2 {
t.Fatalf("two presses under Reduce Motion reached page %d", page)
}
}
func TestNotificationsExpireAndDismiss(t *testing.T) {
clk := newClock()
queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}, {Icon: icon.Mail, Title: "Letter", Text: "From mom.", For: 10 * time.Second}}
var dismissed string
tt := view(640, 400, clk, false, func(c *ui.Context) {
if r := overlay.Notifications(c, &queue, overlay.NotificationsOptions{}); r.Dismissed != nil {
dismissed = r.Dismissed.Title
}
})
a, b := find(t, tt, "Saved"), find(t, tt, "Letter")
if a.X+a.W != 640-16 || b.Y <= a.Y {
t.Fatalf("cards at %v and %v", a, b)
}
clk.t = clk.t.Add(6 * time.Second)
tt.Frame()
if shows(tt, "Saved") || len(queue) != 1 {
t.Fatalf("the first card outlived five seconds: %d left", len(queue))
}
click(t, tt, "CLOSE")
if dismissed != "Letter" || len(queue) != 0 {
t.Fatalf("× dismissed %q", dismissed)
}
}
func TestKeyHintsDrawPadButtonsAndKeycaps(t *testing.T) {
tt := view(640, 100, newClock(), false, func(c *ui.Context) {
overlay.KeyHints(c, []overlay.KeyHint{{Key: "A", Action: "CONFIRM"}, {Key: "START", Action: "MENU"}}, overlay.KeyHintsOptions{})
})
r := find(t, tt, "A CONFIRM")
if s := slot(t, tt.Image(), int(r.X)+18, int(r.Y)+r2i(r.H/2)); s != pixel.Ink {
t.Fatalf("pad button is %v", s)
}
if !shows(tt, "START") || !shows(tt, "MENU") {
t.Fatalf("texts %q", tt.Texts())
}
keeps(t, tt.Image())
}
func r2i(f float32) int { return int(f) }
func TestTitleCardDithersInHoldsAndGoes(t *testing.T) {
clk, cue := newClock(), 0
tt := view(320, 200, clk, false, func(c *ui.Context) {
ui.Box(c).Size(300, 160).Children(func() {
overlay.TitleCard(c, "WORLD 1-1", overlay.TitleCardOptions{Cue: cue, Hold: time.Second})
})
})
area := ui.Rect{X: 0, Y: 0, W: 300, H: 160}
if n := count(t, tt.Image(), area, pixel.Ink); n != 0 || shows(tt, "WORLD 1-1") {
t.Fatal("the card showed before its cue")
}
cue = 1
tt.Frame()
var inks []int
for range 4 {
inks = append(inks, count(t, tt.Image(), area, pixel.Ink))
clk.t = clk.t.Add(time.Second / 8)
tt.Frame()
}
if !(inks[0] < inks[1] && inks[1] < inks[2] && inks[2] < inks[3]) || !shows(tt, "WORLD 1-1") {
t.Fatalf("ink by step %v", inks)
}
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
if shows(tt, "WORLD 1-1") || count(t, tt.Image(), area, pixel.Ink) != 0 {
t.Fatal("the card stayed past its hold")
}
}
func TestOffDialogueBoxStaysPut(t *testing.T) {
for _, inert := range []bool{false, true} {
page := 0
tt := view(640, 400, newClock(), inert, func(c *ui.Context) {
overlay.DialogueBox(c, []string{"One.", "Two."}, &page, overlay.DialogueBoxOptions{Disabled: !inert})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
click(t, tt, "One.")
click(t, tt, "One.")
tt.Key(0, ui.KeyEnter)
if page != 0 {
t.Fatalf("inert %v: an off box turned to page %d", inert, page)
}
}
}
func TestSheetVanishesOnTheReleaseThatClosesIt(t *testing.T) {
open := true
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120}, func() {
pixel.Text(c, "Sound: ON", pixel.TextOptions{})
})
})
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+120)
tt.Release(320, top+120)
if open || shows(tt, "Sound: ON") {
t.Fatal("the sheet stayed on screen after the pull closed it")
}
}
func TestPanelsWithoutControlsTakeTheFocus(t *testing.T) {
for _, kind := range []string{"drawer", "sheet", "dialog", "alert", "confirm", "choice", "pause", "prompt", "progress"} {
open, opened := false, 0
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
if input.Button(c, "OPEN", input.ButtonOptions{}).Clicked {
open = true
opened++
}
body := func() { pixel.Text(c, "Just words.", pixel.TextOptions{}) }
name := "Menu"
switch kind {
case "drawer":
overlay.Drawer(c, name, &open, overlay.DrawerOptions{Side: display.Left}, body)
case "sheet":
overlay.Sheet(c, name, &open, overlay.SheetOptions{}, body)
case "dialog":
overlay.Dialog(c, name, &open, overlay.DialogOptions{}, body)
case "alert":
overlay.AlertDialog(c, name, "Hi.", &open, overlay.AlertDialogOptions{Disabled: true})
case "confirm":
overlay.ConfirmDialog(c, "Hi.", &open, overlay.ConfirmDialogOptions{Disabled: true})
case "choice":
overlay.ChoicePrompt(c, name, []string{"A"}, &open, overlay.ChoicePromptOptions{Disabled: true})
case "pause":
overlay.PauseMenu(c, &open, overlay.PauseMenuOptions{Disabled: true})
case "prompt":
s := ""
overlay.PromptDialog(c, name, &s, &open, overlay.PromptDialogOptions{Disabled: true})
case "progress":
overlay.ProgressDialog(c, name, 0.5, &open, overlay.ProgressDialogOptions{Disabled: true})
}
})
click(t, tt, "OPEN")
tt.Key(0, ui.KeyEnter)
if opened != 1 || tt.Focused("OPEN") {
t.Fatalf("%s: Enter reached the opener behind it (%d opens)", kind, opened)
}
}
}
func TestOffSheetTakesNoPull(t *testing.T) {
for _, inert := range []bool{false, true} {
open := true
tt := view(640, 400, newClock(), inert, func(c *ui.Context) {
overlay.Sheet(c, "Options", &open, overlay.SheetOptions{Height: 120, Disabled: !inert}, func() {})
})
top := float32(400 - 240)
tt.Press(320, top+8)
tt.Move(320, top+120)
tt.Release(320, top+120)
if !open {
t.Fatalf("inert %v: a pull closed an off sheet", inert)
}
}
}
func TestDisabledPanelsTakeNoClicks(t *testing.T) {
open, at, saving, next := true, 0, true, 0
queue := []overlay.Notification{{Title: "Saved", Text: "Slot 1."}}
tt := view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.Lightbox(c, "Gallery", 3, &at, &open, overlay.LightboxOptions{Disabled: true}, func(int) {})
})
tt.Key(0, ui.KeyRight)
click(t, tt, "NEXT")
click(t, tt, "CLOSE")
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
overlay.ProgressDialog(c, "Saving", 0.2, &saving, overlay.ProgressDialogOptions{Disabled: true})
})
click(t, tt, "CANCEL")
tt = view(640, 400, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(40).Children(func() {
b := input.Button(c, "BAG", input.ButtonOptions{})
if overlay.Coachmark(c, b.Element, "Bag.", 1, 2, overlay.CoachmarkOptions{Disabled: true}).Next {
next++
}
})
overlay.Notifications(c, &queue, overlay.NotificationsOptions{Disabled: true})
})
click(t, tt, "NEXT ▶")
click(t, tt, "CLOSE")
if at != 0 || !open || !saving || next != 0 || len(queue) != 1 {
t.Fatalf("disabled: at %d open %v saving %v next %d queue %d", at, open, saving, next, len(queue))
}
}
+65
View File
@@ -0,0 +1,65 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PauseMenuOptions struct {
Disabled bool
}
type PauseMenuResult struct {
Element *ui.Element
// Chosen is RESUME 0, OPTIONS 1, QUIT 2, or -1.
Chosen int
}
type pauseKey struct{}
// PauseMenu veils the game under PAUSED; Escape resumes.
func PauseMenu(c *ui.Context, paused *bool, o PauseMenuOptions) PauseMenuResult {
k := pixel.K(c)
home := ui.Box(c)
cur := ui.Local(home, pauseKey{}, func() int { return 0 })
was := *paused
res := PauseMenuResult{Chosen: -1}
items := []string{k.Msg("resume"), k.Msg("options"), k.Msg("quit")}
res.Element = ui.DialogBase(c, paused, func(back, panel *ui.Element) {
deep, light := dark(k)
dim(k, back, deep, 10)
title := k.Msg("paused")
panel.Label(title).Focusable().FocusRing(false).AlignItems(ui.Center).Gap(k.D(10)).Children(func() {
pixel.Harbor(c, panel, func() {
ui.Box(c).Size(k.D(pixel.Measure(title)*3), k.D(pixel.LineHeight*3)).Role(ui.RoleText).Label(title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if g.Step(2)%2 == 0 || k.Reduce() {
g.Text(0, 0, title, light, 3)
}
})
ui.Box(c).Padding(k.D(6), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
if i := choose(c, title, items, cur, false, o.Disabled); i >= 0 {
res.Chosen = i
*paused = i != 0
}
})
})
})
})
if was && !*paused && res.Chosen < 0 {
res.Chosen = 0
}
if !*paused {
*cur = 0
}
if res.Chosen >= 0 {
slog.Debug("8bit: pause menu", "chosen", items[res.Chosen])
}
return res
}
+58
View File
@@ -0,0 +1,58 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PopconfirmOptions struct {
Disabled bool
}
type PopconfirmResult struct {
Element *ui.Element
// Canceled is NO, Escape or a click outside.
Confirmed, Canceled bool
}
type popKey struct{}
// Popconfirm asks question in a bubble before its button acts.
func Popconfirm(c *ui.Context, text, question string, o PopconfirmOptions) PopconfirmResult {
k := pixel.K(c)
wrap := ui.Box(c)
open := ui.Local(wrap, popKey{}, func() bool { return false })
var b input.ButtonResult
wrap.Children(func() { b = input.Button(c, text, input.ButtonOptions{Disabled: o.Disabled}) })
if b.Clicked {
*open = true
}
// A popup above a veil closes with its control.
if o.Disabled || pixel.Current(c).Inert {
*open = false
}
was := *open
res := PopconfirmResult{Element: wrap}
Popover(c, question, b.Element, open, PopoverOptions{}, func() {
ui.Column(c).Gap(k.D(4)).Children(func() {
pixel.Text(c, question, pixel.TextOptions{})
ui.Row(c).Gap(k.D(4)).Justify(ui.End).Children(func() {
if input.Button(c, k.Msg("no"), input.ButtonOptions{Size: input.Small}).Clicked {
*open = false
}
if input.Button(c, k.Msg("yes"), input.ButtonOptions{Size: input.Small, Variant: input.Primary}).Clicked {
res.Confirmed, *open = true, false
}
})
})
})
res.Canceled = was && !*open && !res.Confirmed
if res.Confirmed || res.Canceled {
slog.Debug("8bit: popconfirm", "label", text, "yes", res.Confirmed)
}
return res
}
+52
View File
@@ -0,0 +1,52 @@
package overlay
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PopoverOptions struct {
// Width is the panel's in dots; zero fits the content.
Width int
}
// notch is the pointer's height in dots.
const notch = 4
// Popover opens a panel below anchor, notched toward it.
func Popover(c *ui.Context, text string, anchor *ui.Element, open *bool, o PopoverOptions, content func()) *ui.Element {
k := pixel.K(c)
w := pixel.Or("Popover Width", o.Width, 0)
ab := anchor.Bounds()
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
if w > 0 {
panel.Width(k.D(w))
}
panel.Label(text).Padding(k.D(notch+5), k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Flipped above its anchor, the notch points down.
up := r.Y >= ab.Y
top := notch
if !up {
top = 0
}
g.Frame(0, top, g.W, g.H-notch, pixel.Window, pixel.Face)
x := max(notch+1, min(g.W-notch-2, int((ab.X+ab.W/2-r.X)/k.Dot())))
for j := range notch {
y := j
if !up {
y = g.H - 1 - j
}
g.HLine(x-j, y, 2*j+1, pixel.Face)
g.Dot(x-j, y, pixel.Edge)
g.Dot(x+j, y, pixel.Edge)
}
edge := notch
if !up {
edge = g.H - notch - 1
}
g.HLine(x-notch+1, edge, 2*notch-1, pixel.Face)
}).Children(content)
})
}
+46
View File
@@ -0,0 +1,46 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ProgressDialogOptions struct {
Disabled bool
}
type ProgressDialogResult struct {
Element *ui.Element
// Canceled is CANCEL, Escape or a click outside.
Canceled bool
}
// ProgressDialog holds the view on a progress bar with CANCEL.
func ProgressDialog(c *ui.Context, title string, value float64, open *bool, o ProgressDialogOptions) ProgressDialogResult {
k := pixel.K(c)
was := *open
var res ProgressDialogResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Width(k.D(200)).Label(title).Focusable().FocusRing(false).Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{Title: title}, func() {
ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() {
feedback.Progress(c, title, value, feedback.ProgressOptions{})
actions(c, []string{k.Msg("cancel")}, o.Disabled, func(int) { *open = false })
})
})
})
})
})
res.Canceled = was && !*open
if res.Canceled {
slog.Debug("8bit: progress canceled", "title", title, "value", value)
}
return res
}
+55
View File
@@ -0,0 +1,55 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PromptDialogOptions struct {
Placeholder string
MaxLength int
Disabled bool
}
type PromptDialogResult struct {
Element *ui.Element
// Canceled is CANCEL, Escape or a click outside.
Submitted, Canceled bool
}
// PromptDialog asks for a line, as ENTER YOUR NAME.
func PromptDialog(c *ui.Context, title string, value *string, open *bool, o PromptDialogOptions) PromptDialogResult {
k := pixel.K(c)
was := *open
var res PromptDialogResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Width(k.D(220)).Label(title).Focusable().FocusRing(false).Children(func() {
pixel.Harbor(c, panel, func() {
layout.Window(c, layout.WindowOptions{Title: title}, func() {
ui.Column(c).Gap(k.D(6)).AlignItems(ui.Stretch).Children(func() {
f := input.TextInput(c, title, value, input.TextInputOptions{Placeholder: o.Placeholder, MaxLength: o.MaxLength, Width: 196, Disabled: o.Disabled})
f.Input.AutoFocus()
// A Shortcut, not Submitted: the frame builds again closed.
if f.Element.Shortcut(0, ui.KeyEnter) && !o.Disabled {
res.Submitted, *open = true, false
}
actions(c, []string{k.Msg("cancel"), k.Msg("ok")}, o.Disabled, func(i int) {
res.Submitted, *open = i == 1, false
})
})
})
})
})
})
res.Canceled = was && !*open && !res.Submitted
if res.Submitted || res.Canceled {
slog.Debug("8bit: prompt", "title", title, "submitted", res.Submitted)
}
return res
}
+76
View File
@@ -0,0 +1,76 @@
package overlay
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SheetOptions struct {
// Height is in dots; zero means 120.
Height int
Disabled bool
}
type SheetResult struct {
Element *ui.Element
// Closed is a drag down, Escape or a click outside.
Closed bool
}
type sheetKey struct{}
// Sheet rises from the bottom; drag its bar down.
func Sheet(c *ui.Context, text string, open *bool, o SheetOptions, content func()) SheetResult {
k := pixel.K(c)
h := pixel.Or("Sheet Height", o.Height, 120)
off := o.Disabled || pixel.Current(c).Inert
home := ui.Box(c)
pulled := ui.Local(home, sheetKey{}, func() float32 { return 0 })
rise := k.D(arriving(c, home, *open) * h / 4)
was := *open
var res SheetResult
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
dim(k, back, pixel.Back, 8)
panel.Label(text).Focusable().FocusRing(false).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
pixel.Harbor(c, panel, func() {
bar := ui.Box(c).FillWidth().Height(k.D(10)).Role(ui.RoleNone)
_, dy, held := bar.Dragged()
switch {
case off:
*pulled = 0
case held:
*pulled = max(0, *pulled+dy)
case *pulled > 0:
if *pulled > k.D(h/3) {
// A release is no drag: build again, closed.
*open = false
c.Invalidate()
}
*pulled = 0
}
bar.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect((g.W-24)/2, 4, 24, 2, pixel.Shade)
})
ui.Column(c).Grow(1).Padding(0, k.D(8), k.D(8), k.D(8)).Children(func() { body(c, o.Disabled, content) })
})
})
// Placed after the bar, so a release lands at once.
drop := float32(int(*pulled/k.Dot())) * k.Dot()
panel.Absolute().Left(0).Right(0).Bottom(-rise - drop).Height(k.D(h))
})
res.Closed = was && !*open
if !*open {
*pulled = 0
}
if res.Closed {
slog.Debug("8bit: sheet closed", "label", text)
}
return res
}
+36
View File
@@ -0,0 +1,36 @@
package overlay
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SpotlightOptions struct{}
// Spotlight dithers all but target and captions it.
func Spotlight(c *ui.Context, target *ui.Element, caption string, o SpotlightOptions) *ui.Element {
k := pixel.K(c)
b := target.Bounds()
deep, _ := dark(k)
var veil *ui.Element
ui.Overlay(c, func() {
veil = ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).PassThrough().Role(ui.RoleStatus).Label(caption)
veil.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// The hole is the target, two dots wider all round.
x, y := int(b.X/k.Dot())-2, int(b.Y/k.Dot())-2
w, h := int(b.W/k.Dot())+4, int(b.H/k.Dot())+4
g.Veil(0, 0, g.W, y, deep, 12)
g.Veil(0, y+h, g.W, g.H-y-h, deep, 12)
g.Veil(0, y, x, h, deep, 12)
g.Veil(x+w, y, g.W-x-w, h, deep, 12)
g.FocusMark(x+2, y+2, w-4, h-4)
})
})
w := pixel.Measure(caption) + 16
bubble(c, ui.Rect{X: b.X, Y: b.Y - k.D(2), W: b.W, H: b.H + k.D(4)}, func() {
pixel.Text(c, caption, pixel.TextOptions{})
}, w)
return veil
}
+51
View File
@@ -0,0 +1,51 @@
package overlay
import (
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TitleCardOptions struct {
// Cue shows the card each time it changes.
Cue int
// Hold keeps the title up; zero means two seconds.
Hold time.Duration
}
// TitleCard dithers in, holds title, dithers out; fills its parent.
func TitleCard(c *ui.Context, title string, o TitleCardOptions) *ui.Element {
k := pixel.K(c)
if o.Hold < 0 {
panic("overlay: TitleCard Hold is negative")
}
hold := o.Hold
if hold == 0 {
hold = 2 * time.Second
}
e := ui.Box(c).Fill()
// Eight steps a second: four in, the hold, four out.
held := int(hold * 8 / time.Second)
step := k.Since(c, k.Cue(e, o.Cue), 8, held+8)
if step >= held+8 {
return e
}
level := 16
switch {
case k.Reduce():
case step < 4:
level = 4 * (step + 1)
case step >= held+4:
level = 4 * (held + 7 - step)
}
deep, light := dark(k)
return e.Role(ui.RoleStatus).Label(title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Veil(0, 0, g.W, g.H, deep, level)
if level == 16 {
g.Text((g.W-pixel.Measure(title)*2)/2, (g.H-pixel.LineHeight*2)/2, title, light, 2)
}
})
}
+53
View File
@@ -0,0 +1,53 @@
package overlay
import (
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TooltipOptions struct{}
// tipWidth caps a tip's text in dots.
const tipWidth = 160
// Tooltip bubbles text after 500 ms, or on keyboard focus.
func Tooltip(c *ui.Context, text string, o TooltipOptions, trigger func()) *ui.Element {
k := pixel.K(c)
wrap := ui.Box(c)
show := hint(c, wrap, 500*time.Millisecond)
wrap.Children(trigger)
if show {
bubble(c, wrap.Bounds(), func() {
tip := ui.Box(c).Role(ui.RoleTooltip).Label(text)
tip.Children(func() {
pixel.Text(c, text, pixel.TextOptions{Wrap: true}).Width(k.D(min(pixel.Measure(text), tipWidth)))
})
}, min(pixel.Measure(text), tipWidth)+16)
}
return wrap
}
// bubble floats content by b; w keeps it inside.
func bubble(c *ui.Context, b ui.Rect, content func(), w int) *ui.Element {
k := pixel.K(c)
ww, wh := c.Size()
x := max(0, min(b.X+b.W/2-k.D(8), ww-k.D(w)))
at := max(3, int((b.X+b.W/2-x)/k.Dot()))
var e *ui.Element
ui.Overlay(c, func() {
e = ui.Box(c).Absolute().Left(x).PassThrough()
tail := display.Bottom
if b.Y > k.D(40) {
e.Bottom(wh - b.Y)
} else {
e.Top(b.Y + b.H)
tail = display.Top
}
e.Children(func() { display.Bubble(c, display.BubbleOptions{Tail: tail, At: at}, content) })
})
return e
}