Overlays: components 081-090
No files matched your search
@@ -0,0 +1,110 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AlertDialogOptions configure an AlertDialog. Title is required.
|
||||
type AlertDialogOptions struct {
|
||||
Title string
|
||||
Description string
|
||||
// ConfirmLabel is "Delete" when empty; CancelLabel is "Cancel".
|
||||
ConfirmLabel string
|
||||
CancelLabel string
|
||||
// ConfirmText, when set, must be typed before Confirm unlocks.
|
||||
ConfirmText string
|
||||
}
|
||||
|
||||
// AlertDialogResult reports the choice made in this frame.
|
||||
type AlertDialogResult struct {
|
||||
Confirmed bool
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// alertDialogState is the typed confirmation, kept while the alert shows.
|
||||
type alertDialogState struct{ typed string }
|
||||
|
||||
// AlertDialog asks to confirm a destructive action while *open is true.
|
||||
// Cancel takes the focus, the backdrop ignores clicks, Escape cancels,
|
||||
// and Enter never confirms before ConfirmText is typed.
|
||||
func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: AlertDialog needs a Title")
|
||||
}
|
||||
var res AlertDialogResult
|
||||
if !*open {
|
||||
return res
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
confirmLabel, cancelLabel := opts.ConfirmLabel, opts.CancelLabel
|
||||
if confirmLabel == "" {
|
||||
confirmLabel = msg(c, "alert_dialog.confirm")
|
||||
}
|
||||
if cancelLabel == "" {
|
||||
cancelLabel = msg(c, "alert_dialog.cancel")
|
||||
}
|
||||
shown, backClicked := true, false
|
||||
ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
backClicked = back.Clicked()
|
||||
st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} })
|
||||
p := overlayEnter(c, panel)
|
||||
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
|
||||
dialogPanel(c, panel, 420, false, p)
|
||||
panel.Role(ui.RoleAlertDialog).Label(opts.Title).Description(opts.Description)
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(22, 22).TextColor(k.Danger).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u * 2).Grow(1).Children(func() {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
submitted := opts.ConfirmText != "" && alertDialogTyped(c, st, opts.ConfirmText)
|
||||
unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText
|
||||
res.Confirmed = submitted && unlocked
|
||||
dialogActions(c, func() {
|
||||
if Button(c, cancelLabel, ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
|
||||
res.Cancelled = true
|
||||
}
|
||||
if Button(c, confirmLabel, ButtonOptions{Variant: Danger, Disabled: !unlocked}).Clicked() {
|
||||
res.Confirmed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
if !shown && !backClicked {
|
||||
res.Cancelled = true // Escape
|
||||
}
|
||||
if res.Confirmed {
|
||||
res.Cancelled = false
|
||||
}
|
||||
if res.Confirmed || res.Cancelled {
|
||||
slog.Debug("mujica: alert_dialog close", "confirmed", res.Confirmed)
|
||||
*open = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// alertDialogTyped builds the confirmation field and reports Enter in it.
|
||||
func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
prompt := fmt.Sprintf(msg(c, "alert_dialog.type_to_unlock"), want)
|
||||
submitted := false
|
||||
ui.Column(c).Gap(d.Unit()).Shrink(0).Children(func() {
|
||||
ui.Text(c, prompt).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
in := ui.TextInputBase(c, &st.typed).Label(prompt).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
|
||||
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||
submitted = in.Submitted()
|
||||
})
|
||||
return submitted
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type alertApp struct {
|
||||
open bool
|
||||
opts AlertDialogOptions
|
||||
confirmed, cancelled int
|
||||
}
|
||||
|
||||
func (a *alertApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Remove", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
r := AlertDialog(c, &a.open, a.opts)
|
||||
if r.Confirmed {
|
||||
a.confirmed++
|
||||
}
|
||||
if r.Cancelled {
|
||||
a.cancelled++
|
||||
}
|
||||
}
|
||||
|
||||
func newAlertApp(opts AlertDialogOptions) (*alertApp, *ui.Tester) {
|
||||
opts.Title = "Delete the archive?"
|
||||
a := &alertApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
_ = tt.Click("Remove")
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestAlertDialogFocusesCancelAndEnterCancels(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{Description: "This cannot be undone."})
|
||||
if !a.open || !tt.Focused("Cancel") {
|
||||
t.Fatalf("open %v, Cancel focused %v", a.open, tt.Focused("Cancel"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || a.cancelled != 1 || a.open {
|
||||
t.Fatalf("Enter: confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
|
||||
}
|
||||
if !tt.Focused("Remove") {
|
||||
t.Fatal("focus not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogIgnoresBackdropEscapeCancels(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{})
|
||||
tt.ClickAt(8, 392)
|
||||
if !a.open || a.cancelled != 0 {
|
||||
t.Fatalf("backdrop click: open %v cancelled %d", a.open, a.cancelled)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open || a.cancelled != 1 || a.confirmed != 0 {
|
||||
t.Fatalf("Escape: open %v cancelled %d confirmed %d", a.open, a.cancelled, a.confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogConfirmOnce(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{ConfirmLabel: "Erase"})
|
||||
if err := tt.Click("Erase"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.confirmed != 1 || a.cancelled != 0 || a.open {
|
||||
t.Fatalf("confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogConfirmTextGatesEnter(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"})
|
||||
_ = tt.Click("Delete") // locked
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatal("locked Delete confirmed")
|
||||
}
|
||||
if err := tt.Click("Type “archive” to confirm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("archiv")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatalf("before the target matched: confirmed %d open %v", a.confirmed, a.open)
|
||||
}
|
||||
tt.Type("e")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 1 || a.open {
|
||||
t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) {
|
||||
open := true
|
||||
AlertDialog(c, &open, AlertDialogOptions{
|
||||
Title: "Burn the old letters?", Description: "Twelve letters will be destroyed. This cannot be undone.",
|
||||
ConfirmText: "letters",
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ContextMenuOptions configure a ContextMenu.
|
||||
type ContextMenuOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// contextMenuState is where the menu opened, relative to the target.
|
||||
type contextMenuState struct {
|
||||
open bool
|
||||
x, y float32
|
||||
}
|
||||
|
||||
// ContextMenu gives target an in-window menu that build fills. A secondary
|
||||
// click opens it at the pointer; the menu key or Shift+F10, while the focus
|
||||
// is in target, opens it below target. It flips to stay in the window, and
|
||||
// the focus returns on close. target must be a container: the menu adds an
|
||||
// anchor child to it, and takes its HandleInput. It returns target.
|
||||
func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element {
|
||||
if target == nil {
|
||||
panic("mujica: ContextMenu needs a target")
|
||||
}
|
||||
if build == nil {
|
||||
panic("mujica: ContextMenu needs a build function")
|
||||
}
|
||||
st := ui.Local(target, "mujica.contextmenu", func() contextMenuState { return contextMenuState{} })
|
||||
if opts.Disabled {
|
||||
st.open = false
|
||||
return target
|
||||
}
|
||||
target.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputPointerDown || ev.Button != 1 {
|
||||
return false
|
||||
}
|
||||
st.open, st.x, st.y = true, ev.X, ev.Y
|
||||
slog.Debug("mujica: context_menu open", "by", "pointer")
|
||||
return true
|
||||
})
|
||||
if target.Shortcut(0, ui.KeyContextMenu) || target.Shortcut(ui.Shift, ui.KeyF10) {
|
||||
st.open, st.x, st.y = true, 0, target.Bounds().H
|
||||
slog.Debug("mujica: context_menu open", "by", "keyboard")
|
||||
}
|
||||
if !st.open {
|
||||
return target
|
||||
}
|
||||
var anchor *ui.Element
|
||||
target.Children(func() {
|
||||
anchor = ui.Box(c).Key("mujica.contextmenu.anchor").Absolute().Left(st.x).Top(st.y).Size(1, 1).PassThrough()
|
||||
})
|
||||
popupMenuOpen(c, anchor, &st.open, build)
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func contextMenuTester(a *menuApp, disabled bool) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(20).Gap(8).Children(func() {
|
||||
row := ui.Row(c).Height(80).FillWidth().Padding(12).Background(Tokens(c).Surface)
|
||||
row.Children(func() {
|
||||
Button(c, "Letter row", ButtonOptions{Variant: Ghost})
|
||||
})
|
||||
ContextMenu(c, row, ContextMenuOptions{Disabled: disabled}, a.build)
|
||||
})
|
||||
}, 500, 320)
|
||||
}
|
||||
|
||||
func TestContextMenuOpensAtPointer(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
tt.RightClickAt(200, 60)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Rename")
|
||||
// The label sits past the menu padding and the check column.
|
||||
if !ok || r.X < 230 || r.X > 250 || r.Y < 60 || r.Y > 110 {
|
||||
t.Fatalf("menu item %+v not at the pointer (200, 60)", r)
|
||||
}
|
||||
if tt.Menu() != nil {
|
||||
t.Fatal("the system menu opened")
|
||||
}
|
||||
_ = tt.Click("Rename")
|
||||
if len(a.chosen) != 1 || tt.HasText("Rename") {
|
||||
t.Fatalf("chosen %q", a.chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuOnInnerButtonAndEdge(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
b, _ := tt.Find("Letter row")
|
||||
tt.RightClickAt(b.X+4, b.Y+4)
|
||||
if !tt.HasText("Rename") {
|
||||
t.Fatal("right click on an inner button did not open the menu")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.RightClickAt(480, 95)
|
||||
tt.Frame()
|
||||
last, _ := tt.Find("Burn")
|
||||
first, _ := tt.Find("Rename")
|
||||
if last.Y+last.H > 320 || first.Y > 95 || last.X+last.W > 500 {
|
||||
t.Fatalf("menu not kept in the window: first %+v last %+v", first, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuKeyboard(t *testing.T) {
|
||||
for _, key := range []struct {
|
||||
mods ui.Modifiers
|
||||
key ui.Key
|
||||
}{{0, ui.KeyContextMenu}, {ui.Shift, ui.KeyF10}} {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, false)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(key.mods, key.key)
|
||||
tt.Frame()
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatalf("%v: menu did not open with focus", key)
|
||||
}
|
||||
item, _ := tt.Find("Rename")
|
||||
if item.Y < 100 {
|
||||
t.Fatalf("%v: keyboard menu %+v not below the focused target", key, item)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Rename") || !tt.Focused("Letter row") {
|
||||
t.Fatalf("%v: Escape did not close and restore", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuDisabled(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := contextMenuTester(a, true)
|
||||
tt.RightClickAt(200, 60)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(ui.Shift, ui.KeyF10)
|
||||
if tt.HasText("Rename") {
|
||||
t.Fatal("disabled context menu opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "context_menu", 460, 320, func(c *ui.Context) {
|
||||
a := &menuApp{}
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection)
|
||||
row.Children(func() { ui.Text(c, "Letter to the Countess") })
|
||||
anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1)
|
||||
open := true
|
||||
popupMenuOpen(c, anchor, &open, a.build)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DialogOptions configure a Dialog. Title is required.
|
||||
type DialogOptions struct {
|
||||
Title string
|
||||
Description string
|
||||
// Width is 440 DIPs when zero; the window always bounds it.
|
||||
Width float32
|
||||
// Decorated draws the double border, corner ornaments and title rule.
|
||||
Decorated bool
|
||||
// Actions builds the footer, usually buttons, aligned to the end.
|
||||
Actions func()
|
||||
// KeepOnBackdrop ignores clicks on the backdrop.
|
||||
KeepOnBackdrop bool
|
||||
// KeepOnEscape ignores Escape.
|
||||
KeepOnEscape bool
|
||||
// HideClose removes the close button from the header.
|
||||
HideClose bool
|
||||
}
|
||||
|
||||
// Dialog shows a modal dialog while *open is true. It traps the focus,
|
||||
// makes the window inert and gives the focus back to the opener on close.
|
||||
func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Dialog needs a Title")
|
||||
}
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
shown, backClicked, closed := true, false, false
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
backClicked = back.Clicked()
|
||||
p := overlayEnter(c, panel)
|
||||
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
|
||||
dialogPanel(c, panel, opts.Width, opts.Decorated, p)
|
||||
panel.Label(opts.Title)
|
||||
if opts.Description != "" {
|
||||
panel.Description(opts.Description)
|
||||
}
|
||||
closed = dialogHeader(c, opts.Title, opts.Description, opts.Decorated, !opts.HideClose, "dialog.close")
|
||||
if content != nil {
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
}
|
||||
if opts.Actions != nil {
|
||||
dialogActions(c, opts.Actions)
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case closed:
|
||||
slog.Debug("mujica: dialog close", "by", "button")
|
||||
*open = false
|
||||
case !shown && backClicked && !opts.KeepOnBackdrop:
|
||||
slog.Debug("mujica: dialog close", "by", "backdrop")
|
||||
*open = false
|
||||
case !shown && !backClicked && !opts.KeepOnEscape:
|
||||
slog.Debug("mujica: dialog close", "by", "escape")
|
||||
*open = false
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
// dialogPanel styles a centered dialog panel at entrance progress p.
|
||||
func dialogPanel(c *ui.Context, panel *ui.Element, width float32, decorated bool, p float32) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
if width == 0 {
|
||||
width = 440
|
||||
}
|
||||
w, h := c.Size()
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Width(width).MaxWidth(w-32).MaxHeight(h-32).Padding(u*6).Gap(u*3).Margin(8*(1-p), 0, 0, 0)
|
||||
if decorated {
|
||||
panel.Border(theme.BorderWidth, k.Ornament).Padding(u*6 + 6).DrawOver(func(pt *ui.Painter, r ui.Rect) {
|
||||
dialogOrnaments(pt, r, k.Ornament, k.Surface)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dialogOrnaments draws the inner frame and the four corner diamonds.
|
||||
func dialogOrnaments(p *ui.Painter, r ui.Rect, ink, ground ui.Color) {
|
||||
const in = 5
|
||||
f := ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}
|
||||
p.Stroke(f, ink, 1, 1)
|
||||
for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} {
|
||||
x, y := internal.Snap(p, pt[0]), internal.Snap(p, pt[1])
|
||||
p.FillPath(internal.Diamond(x, y, 11), ground)
|
||||
p.FillPath(internal.Diamond(x, y, 7), ink)
|
||||
}
|
||||
}
|
||||
|
||||
// dialogHeader builds the title, description and close button; it reports a close click.
|
||||
func dialogHeader(c *ui.Context, title, description string, decorated, closable bool, closeKey string) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
clicked := false
|
||||
ui.Column(c).Gap(u).Shrink(0).Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1)
|
||||
if closable {
|
||||
clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked()
|
||||
}
|
||||
})
|
||||
if decorated {
|
||||
dialogRule(c, k.Ornament)
|
||||
}
|
||||
if description != "" {
|
||||
ui.Text(c, description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
return clicked
|
||||
}
|
||||
|
||||
// dialogRule is the ornament line under a decorated title.
|
||||
func dialogRule(c *ui.Context, ink ui.Color) {
|
||||
ui.Box(c).FillWidth().Height(9).Margin(2, 0, 2, 0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
y, cx := r.Y+r.H/2, r.X+r.W/2
|
||||
p.Line(r.X, y, cx-10, y, 1, ink)
|
||||
p.Line(cx+10, y, r.X+r.W, y, 1, ink)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, y), 7), ink)
|
||||
})
|
||||
}
|
||||
|
||||
// dialogCloseButton is the 28 DIP icon button closing a panel.
|
||||
func dialogCloseButton(c *ui.Context, label string) *ui.Element {
|
||||
k := Tokens(c)
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).Margin(-4, -4, 0, 0)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
b.TextColor(k.TextMuted)
|
||||
if b.Pressed() {
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must("x")).Size(16, 16) })
|
||||
return b
|
||||
}
|
||||
|
||||
// dialogActions lays out a dialog footer.
|
||||
func dialogActions(c *ui.Context, actions func()) {
|
||||
u := Density(c).Unit()
|
||||
ui.Row(c).Gap(u*2).Justify(ui.End).Wrap().Shrink(0).Margin(u*2, 0, 0, 0).Children(actions)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type dialogApp struct {
|
||||
open bool
|
||||
opts DialogOptions
|
||||
saved int
|
||||
name string
|
||||
densities theme.Density
|
||||
}
|
||||
|
||||
func (a *dialogApp) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light, Density: a.densities})
|
||||
if Button(c, "Rename", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
a.opts.Actions = func() {
|
||||
if Button(c, "Cancel", ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
a.open = false
|
||||
}
|
||||
if Button(c, "Save", ButtonOptions{}).Clicked() {
|
||||
a.saved++
|
||||
a.open = false
|
||||
}
|
||||
}
|
||||
Dialog(c, &a.open, a.opts, func() {
|
||||
ui.TextInput(c, &a.name)
|
||||
})
|
||||
}
|
||||
|
||||
func newDialogApp(opts DialogOptions) (*dialogApp, *ui.Tester) {
|
||||
opts.Title = "Rename"
|
||||
a := &dialogApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestDialogOpenFocusTrapRestore(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{Description: "Pick a new name."})
|
||||
if err := tt.Click("Rename"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !a.open || !tt.HasText("Pick a new name.") {
|
||||
t.Fatalf("open %v, texts %q", a.open, tt.Texts())
|
||||
}
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatalf("focus did not move into the dialog")
|
||||
}
|
||||
// Tab cycles Close, input, Cancel, Save and back to Close.
|
||||
for range 3 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("Save") {
|
||||
t.Fatalf("tab order left the dialog")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatalf("tab did not wrap inside the dialog")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatal("focus not restored to the opener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogActionsAndClose(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{})
|
||||
_ = tt.Click("Rename")
|
||||
if err := tt.Click("Save"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.saved != 1 || a.open {
|
||||
t.Fatalf("saved %d open %v", a.saved, a.open)
|
||||
}
|
||||
_ = tt.Click("Rename")
|
||||
if err := tt.Click("Close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.open || a.saved != 1 {
|
||||
t.Fatalf("close button: open %v saved %d", a.open, a.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogCloseRules(t *testing.T) {
|
||||
a, tt := newDialogApp(DialogOptions{})
|
||||
_ = tt.Click("Rename")
|
||||
tt.ClickAt(10, 390)
|
||||
if a.open {
|
||||
t.Fatal("backdrop click did not close")
|
||||
}
|
||||
a, tt = newDialogApp(DialogOptions{KeepOnBackdrop: true, KeepOnEscape: true, HideClose: true})
|
||||
_ = tt.Click("Rename")
|
||||
tt.ClickAt(10, 390)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if !a.open {
|
||||
t.Fatal("kept dialog closed")
|
||||
}
|
||||
if tt.HasText("Close") {
|
||||
t.Fatal("close button shown with HideClose")
|
||||
}
|
||||
if _, ok := tt.Find("Close"); ok {
|
||||
t.Fatal("close button present with HideClose")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogEscapeGoesToNestedPopoverFirst(t *testing.T) {
|
||||
open, inner := true, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{Title: "Outer"}, func() {
|
||||
b := Button(c, "More", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
inner = true
|
||||
}
|
||||
Popover(c, b, &inner, PopoverOptions{}, func() { ui.Text(c, "Inner") })
|
||||
})
|
||||
}, 600, 400)
|
||||
_ = tt.Click("More")
|
||||
if !inner {
|
||||
t.Fatal("popover did not open")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if inner || !open {
|
||||
t.Fatalf("after one Escape inner %v outer %v", inner, open)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if open {
|
||||
t.Fatal("second Escape did not close the dialog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogNeedsTitle(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{}, nil)
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
mode := Light
|
||||
if Tokens(c).Dark {
|
||||
mode = Dark
|
||||
}
|
||||
Use(c, Settings{Mode: mode, Density: d})
|
||||
open := true
|
||||
name := "Ophelia"
|
||||
ui.Column(c).Padding(24).Children(func() { ui.Text(c, "Behind the dialog") })
|
||||
Dialog(c, &open, DialogOptions{
|
||||
Title: "Rename the chamber", Description: "The new name shows in every hall.", Decorated: decorated,
|
||||
Actions: func() {
|
||||
Button(c, "Cancel", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Rename", ButtonOptions{})
|
||||
},
|
||||
}, func() { ui.TextInput(c, &name) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true))
|
||||
snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false))
|
||||
snapshot(t, "dialog-comfortable", 560, 400, dialogSnapshotView(theme.Comfortable, true))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# 081 Dialog 对话框
|
||||
|
||||
## 用途
|
||||
|
||||
在窗口上方显示模态对话框:标题、说明、内容与操作区。打开时限制焦点并屏蔽背景,关闭后把焦点还给打开它的控件。视觉回归基准组件之一。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
name := "Ophelia"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Dialog(c, &open, mujica.DialogOptions{
|
||||
Title: "Rename the chamber",
|
||||
Description: "The new name shows in every hall.",
|
||||
Decorated: true,
|
||||
Actions: func() {
|
||||
if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
},
|
||||
}, func() {
|
||||
ui.TextInput(c, &name)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element`,关闭时返回 nil。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 衬线标题,也是对话框的无障碍名称;为空时 panic。 |
|
||||
| `Description` | `string` | `""` | 标题下的说明文字,同时作为无障碍描述。 |
|
||||
| `Width` | `float32` | `440` | 面板宽度(DIP),始终不超过窗口宽度减 32。 |
|
||||
| `Decorated` | `bool` | `false` | 开启双线边框、四角菱形角饰和标题下的装饰线。 |
|
||||
| `Actions` | `func()` | `nil` | 页脚操作区,靠右排列,可换行。 |
|
||||
| `KeepOnBackdrop` | `bool` | `false` | 点击遮罩不关闭。 |
|
||||
| `KeepOnEscape` | `bool` | `false` | Escape 不关闭。 |
|
||||
| `HideClose` | `bool` | `false` | 不显示右上角关闭按钮。 |
|
||||
|
||||
`open` 为 false 时不构建任何元素。`content` 可为 nil;内容超出窗口高度时在内容区滚动。
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开:遮罩渐入,面板 160 ms 淡入并上移 8 DIP;减少动画时直接显示。
|
||||
- 关闭按钮:悬停、按下、键盘焦点环(2 DIP,留 2 DIP 间隔)。
|
||||
- 装饰:只出现在边缘与标题区,不占焦点,不影响点击区域。
|
||||
|
||||
## 事件
|
||||
|
||||
- 关闭按钮、遮罩点击、Escape 把 `*open` 设为 false(各自可关闭)。
|
||||
- 业务事件由 `Actions` 和 `content` 中的控件自己报告。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Tab / Shift+Tab | 在对话框内循环,不会离开。 |
|
||||
| Escape | 先交给输入法与正在编辑的控件,再关闭最上层浮层;嵌套 Popover 或菜单先关。 |
|
||||
| Enter / Space | 触发聚焦的按钮。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 关闭后焦点回到打开时的控件;若该控件已不存在,MyGo 不回退到父级(`ui/scope.go:137` restoreFocus 只恢复原控件),焦点为空,再按 Tab 从窗口开头开始。
|
||||
- 没有关闭过渡:MyGo 在 `*open` 变为 false 的下一帧即移除浮层元素,`Transition` 的 Exit 需要父元素保留,而浮层根只在有浮层时存在。
|
||||
@@ -0,0 +1,65 @@
|
||||
# 082 AlertDialog 警示对话框
|
||||
|
||||
## 用途
|
||||
|
||||
确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未确认目标前 Enter 不会执行。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{
|
||||
Title: "Delete the archive?",
|
||||
Description: "Twelve letters will be destroyed.",
|
||||
ConfirmText: "archive",
|
||||
})
|
||||
if r.Confirmed {
|
||||
fmt.Println("deleted")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult`。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 标题与无障碍名称;为空时 panic。 |
|
||||
| `Description` | `string` | `""` | 后果说明,同时作为无障碍描述。 |
|
||||
| `ConfirmLabel` | `string` | 内置“删除 / Delete” | 危险按钮文字。 |
|
||||
| `CancelLabel` | `string` | 内置“取消 / Cancel” | 取消按钮文字。 |
|
||||
| `ConfirmText` | `string` | `""` | 非空时显示输入框,输入内容与之完全相同后确认按钮才可用。 |
|
||||
|
||||
`AlertDialogResult{Confirmed, Cancelled bool}` 只在发生选择的那一帧为 true,之后对话框关闭。
|
||||
|
||||
## 状态
|
||||
|
||||
- 确认按钮使用 Danger 变体;`ConfirmText` 未匹配时禁用(MyGo 统一变暗)。
|
||||
- 输入框有边框与焦点环;内容保留在对话框打开期间,关闭后清空。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Confirmed`:点击确认,或在输入框内按 Enter 且目标已匹配。
|
||||
- `Cancelled`:点击取消、在取消上按 Enter / Space、或按 Escape。
|
||||
- 点击遮罩不产生事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| 打开时 | 焦点在“取消”。 |
|
||||
| Enter | 触发聚焦的按钮;在输入框中只有目标匹配时确认。 |
|
||||
| Escape | 取消并关闭。 |
|
||||
| Tab | 在对话框内循环。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(MyGo 只在键盘移动焦点后设置可见焦点,`ui/input.go:868`);按 Tab 后显示。
|
||||
- 不替代系统原生警示框;需要系统样式时用 MyGo 的桌面对话框。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 083 Drawer 抽屉
|
||||
|
||||
## 用途
|
||||
|
||||
从窗口四边之一滑出的面板,用于筛选、详情和设置。可选模态或非模态。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() {
|
||||
ui.Text(c, "Guests are placed by rank.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element`。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 标题与无障碍名称;为空时 panic。 |
|
||||
| `Side` | `DrawerSide` | `DrawerRight` | `DrawerRight`、`DrawerLeft`、`DrawerTop`、`DrawerBottom`;越界时 panic。 |
|
||||
| `Size` | `float32` | 左右 360,上下 280 | 宽度或高度;限制在 160 DIP 与窗口的 90% 之间;负数 panic。 |
|
||||
| `NonModal` | `bool` | `false` | 非模态:不加遮罩、不限制焦点,窗口其余部分仍可操作。 |
|
||||
| `Actions` | `func()` | `nil` | 页脚操作区。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开时从所在边滑入并淡入 160 ms;减少动画时直接出现。
|
||||
- 面板贴边,只在朝内一侧画 1 DIP 分隔线。
|
||||
- 内容超出时在内容区滚动。
|
||||
|
||||
## 事件
|
||||
|
||||
关闭按钮与 Escape 把 `*open` 设为 false;模态抽屉点击遮罩同样关闭。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Tab / Shift+Tab | 模态:在抽屉内循环。非模态:按窗口焦点顺序,抽屉内容排在窗口内容之后。 |
|
||||
| Escape | 关闭(先交给正在编辑的控件和更上层浮层)。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态抽屉不在打开时移动焦点,关闭时也不自动恢复焦点:MyGo 记录与恢复打开者的 `openOverlay` 不公开(`ui/scope.go:123`),也没有读取当前焦点的公开接口。
|
||||
- 非模态抽屉的 Escape 是窗口级快捷键,优先级最低;同时打开多个非模态浮层时由先构建的那个响应。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 084 Popover 弹出框
|
||||
|
||||
## 用途
|
||||
|
||||
在锚点下方显示可交互的面板;空间不足时翻到上方,并保持在窗口内。外部点击与 Escape 关闭。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = !open
|
||||
}
|
||||
mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() {
|
||||
ui.Text(c, "Twelve guests confirmed.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element`。`anchor` 必须是本帧已构建的元素,nil 时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | `""` | 面板顶部的衬线标题,同时命名面板。 |
|
||||
| `Width` | `float32` | 0 | 固定宽度;0 时随内容,至少与锚点同宽,最多 360。 |
|
||||
| `Label` | `string` | `""` | 无标题时的无障碍名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 6 DIP 圆角、Surface 底色、1 DIP 边框、160 ms 淡入。
|
||||
- 面板最高为锚点上下较大一侧的空间,超出时滚动。
|
||||
- 锚点首帧尚未布局时面板保持透明,避免闪到窗口左上角。
|
||||
|
||||
## 事件
|
||||
|
||||
外部点击与 Escape 把 `*open` 设为 false;内容内控件自行报告事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Tab | 从锚点进入弹层,弹层元素紧跟锚点排序。 |
|
||||
| Escape | 关闭并把焦点还给锚点。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持下方与上方两个位置:位置由 MyGo `PopoverBase` 决定(`ui/widgets.go:689` keepInWindow)。
|
||||
- 打开时外部点击只用于关闭,不会传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 085 Tooltip 文字提示
|
||||
|
||||
## 用途
|
||||
|
||||
指针停留或键盘聚焦一段时间后,在元素旁显示简短说明。提示不含交互内容,不拦截指针,并作为元素的无障碍描述。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Seal", mujica.ButtonOptions{})
|
||||
mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element`,返回 `anchor`。`anchor` 为 nil 时 panic;`text` 为空时什么也不做。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Delay` | `time.Duration` | 500 ms | 悬停或键盘聚焦持续多久后显示;负数 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 炭黑底(深色主题为象牙底)、2 DIP 圆角、12+1 DIP 文字,最宽 240 DIP 后换行。
|
||||
- 默认在元素下方居中,下方放不下时翻到上方,左右限制在窗口内。
|
||||
|
||||
## 事件
|
||||
|
||||
无业务事件。`text` 同时写入 `anchor` 的无障碍描述。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Tab 聚焦 | 键盘焦点停留 `Delay` 后显示。 |
|
||||
| Escape | 隐藏,直到指针离开或焦点移走。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发,避免点击后弹出提示。
|
||||
- 不使用 MyGo 的 `Element.Tooltip`:它只跟随指针、不响应焦点(`ui/widgets.go:549`)。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 086 HoverCard 悬停卡片
|
||||
|
||||
## 用途
|
||||
|
||||
悬停或聚焦锚点时预览更多信息,例如人物资料。指针可以移入卡片并操作其中的控件,键盘可以 Tab 进入卡片,离开后延迟关闭。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() {
|
||||
ui.Text(c, "Keyboardist of the court.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element`,返回卡片,未显示时为 nil。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OpenDelay` | `time.Duration` | 500 ms | 悬停或聚焦多久后打开;负数 panic。 |
|
||||
| `CloseDelay` | `time.Duration` | 300 ms | 指针与焦点都离开后多久关闭;负数 panic。 |
|
||||
| `Width` | `float32` | 280 | 卡片宽度。 |
|
||||
| `Label` | `string` | `""` | 卡片的无障碍名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开、关闭都按延迟进行;减少动画时取消淡入。
|
||||
- 指针在锚点或卡片上、或键盘焦点在锚点或卡片内时保持打开。
|
||||
- 下方空间不足时翻到上方,左右限制在窗口内。
|
||||
|
||||
## 事件
|
||||
|
||||
返回值由 nil 变为非 nil 即打开;卡片内控件自行报告事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Tab 聚焦锚点 | `OpenDelay` 后打开。 |
|
||||
| Tab | 从锚点进入卡片内的控件,再继续到后面的元素。 |
|
||||
| Escape | 立即关闭,焦点回到锚点,直到焦点离开前不再打开。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 键盘打开时卡片由 `PopoverBase` 承载,以便焦点顺序紧跟锚点;此时卡片外的点击只用于关闭(`ui/base.go:373`)。指针打开时卡片不拦截窗口其余部分。
|
||||
- 指针打开时卡片元素在窗口焦点顺序的末尾。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 087 DropdownMenu 下拉菜单
|
||||
|
||||
## 用途
|
||||
|
||||
按钮触发的应用内菜单,由 MujicaUI 自绘(不是系统菜单)。支持分组、子菜单、勾选项与禁用项;选择后按规则关闭。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
pinned := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Group("Letter", func() {
|
||||
if m.Item("Rename", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("rename")
|
||||
}
|
||||
m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true})
|
||||
})
|
||||
m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Item("Archive", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
m.Separator()
|
||||
m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回触发按钮。`build` 为 nil、或 `label` 与 `opts.Label` 都为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Icon` | `*ui.SVG` | `nil` | 标签前的图标。 |
|
||||
| `Disabled` | `bool` | `false` | 禁用触发按钮并关闭已打开的菜单。 |
|
||||
| `Label` | `string` | `""` | 仅图标按钮的无障碍名称。 |
|
||||
|
||||
`PopupMenu` 方法:
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `Item(label, opts) bool` | 普通项;本帧被选择时返回 true。 |
|
||||
| `Check(label, *bool, opts) bool` | 勾选项,切换 `*bool`;本帧变化时返回 true。 |
|
||||
| `Submenu(label, opts, build)` | 子菜单项;`build` 为 nil 时 panic。 |
|
||||
| `Group(label, fn)` | 带说明文字的分组,前面有内容时自动加分隔线。 |
|
||||
| `Separator()` | 分隔线。 |
|
||||
|
||||
`PopupMenuItemOptions`:`Disabled bool`(不可选、不进入方向键序列),`KeepOpen bool`(选择后不关闭菜单)。
|
||||
|
||||
## 状态
|
||||
|
||||
- 触发按钮:次按钮外观加下拉箭头,打开时保持按下底色。
|
||||
- 菜单项:悬停或键盘焦点时显示 Selection 底色和左侧 2 DIP 强调条;勾选项显示勾号;禁用项变暗。
|
||||
- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,下方放不下时上移。
|
||||
|
||||
## 事件
|
||||
|
||||
`Item` / `Check` 的返回值;每次选择只报告一次。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Enter / Space / ↓ | 在触发按钮上打开菜单,焦点在第一项。 |
|
||||
| ↑ / ↓ / Home / End | 在当前层移动,跳过禁用项,首尾循环。 |
|
||||
| → / Enter | 在子菜单项上打开子菜单并进入。 |
|
||||
| ← / Escape | 在子菜单中关闭子菜单,回到父项。 |
|
||||
| Escape / Tab | 关闭菜单,焦点回到触发按钮。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 没有 menu / menuitem 无障碍角色(`ui/access.go:22` Role 列表),菜单面板报告为 popup,普通项为 button,勾选项为 checkbox,触发按钮为 menu button。
|
||||
- 菜单打开期间外部点击只用于关闭(`ui/base.go:373`)。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 088 ContextMenu 右键菜单
|
||||
|
||||
## 用途
|
||||
|
||||
为一块区域提供应用内右键菜单,由 MujicaUI 自绘。右键在指针处打开,键盘在焦点区域下方打开,并避开窗口边缘。菜单项与 DropdownMenu 相同(`PopupMenu`)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
row := ui.Row(c).Padding(12).Children(func() {
|
||||
ui.Text(c, "Letter to the Countess")
|
||||
})
|
||||
mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
if m.Item("Reply", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("reply")
|
||||
}
|
||||
m.Item("Delete", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element`,返回 `target`。`target` 或 `build` 为 nil 时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Disabled` | `bool` | `false` | 不响应右键与菜单键,并关闭已打开的菜单。 |
|
||||
|
||||
`PopupMenu` 的方法与选项见 [087 DropdownMenu](087-dropdown-menu.md)。
|
||||
|
||||
## 状态
|
||||
|
||||
与 DropdownMenu 菜单面板相同。右下角附近打开时,菜单向左、向上翻转,保持在窗口内。
|
||||
|
||||
## 事件
|
||||
|
||||
`Item` / `Check` 的返回值;每次选择只报告一次。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Menu 键 / Shift+F10 | 焦点在 `target` 内时,在 `target` 左下方打开,焦点在第一项。 |
|
||||
| 菜单内按键 | 同 DropdownMenu。 |
|
||||
| Escape | 关闭,焦点回到打开前的控件。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- `target` 必须是容器:菜单会向它追加一个不可见的定位子元素,文字元素不能有非文字子元素(MyGo 会 panic)。
|
||||
- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(MyGo 每个元素只保存一个处理函数,`ui/handler.go:73`)。
|
||||
- 不使用 MyGo 的 `Element.ContextMenu`:那是系统菜单,PRD 不允许把它当作哥特样式菜单。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 089 Popconfirm 气泡确认
|
||||
|
||||
## 用途
|
||||
|
||||
在触发控件旁做轻量确认(归档、移出列表等可撤销操作)。说明与面板关联,确认与取消都可聚焦。高风险操作请用 AlertDialog。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = true
|
||||
}
|
||||
if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed {
|
||||
fmt.Println("archived")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult`。`anchor` 为 nil 或 `Title` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 问题,也是面板的无障碍名称。 |
|
||||
| `Description` | `string` | `""` | 补充说明,作为面板的无障碍描述。 |
|
||||
| `ConfirmLabel` | `string` | 内置“确定 / Confirm” | 确认按钮文字。 |
|
||||
| `CancelLabel` | `string` | 内置“取消 / Cancel” | 取消按钮文字。 |
|
||||
| `Danger` | `bool` | `false` | 确认按钮使用危险变体,图标改为 Danger 色。 |
|
||||
|
||||
`PopconfirmResult{Confirmed, Cancelled bool}` 只在发生选择的那一帧为 true。
|
||||
|
||||
## 状态
|
||||
|
||||
与 Popover 相同的面板;下方放不下时翻到上方。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Confirmed`:点击确认或在其上按 Enter / Space。
|
||||
- `Cancelled`:点击取消、Escape、外部点击。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| 打开时 | 焦点在确认按钮。 |
|
||||
| Enter | 触发聚焦的按钮。 |
|
||||
| Tab | 在取消与确认间移动,焦点顺序紧跟锚点。 |
|
||||
| Escape | 取消并把焦点还给锚点。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态:不屏蔽窗口,但外部点击只用于取消,不传给下方控件(`ui/base.go:373`)。
|
||||
- 位置只有下方与上方(`ui/widgets.go:689`)。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 090 Overlay 覆盖层
|
||||
|
||||
## 用途
|
||||
|
||||
在窗口内显示一层自定义内容:九个位置、可选遮罩、模态或非模态。模态时复用 MyGo `DialogBase` 的焦点限制与背景屏蔽,可与 MyGo 自带的 Select、Popover、菜单嵌套使用。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() {
|
||||
ui.Text(c, "Symbol I: △")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element`。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Place` | `OverlayPlace` | `OverlayCenter` | `OverlayCenter`、`Top`、`Bottom`、`Left`、`Right`、`TopLeft`、`TopRight`、`BottomLeft`、`BottomRight`;越界 panic。 |
|
||||
| `Inset` | `float32` | 16 | 面板与窗口边缘的距离。 |
|
||||
| `Modal` | `bool` | `false` | 限制焦点、屏蔽背景,点击背景关闭。 |
|
||||
| `Dim` | `bool` | `false` | 模态时加暗色遮罩;未开 `Modal` 时 panic。 |
|
||||
| `Label` | `string` | `""` | 覆盖层的无障碍名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
弹层外观(Surface 底、1 DIP 边框、6 DIP 圆角)与 160 ms 淡入;减少动画时直接显示。
|
||||
|
||||
## 事件
|
||||
|
||||
Escape 与(模态时)背景点击把 `*open` 设为 false。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| Escape | 关闭;先交给编辑中的控件和更上层浮层。 |
|
||||
| Tab | 模态时在覆盖层内循环;非模态时覆盖层元素排在窗口焦点顺序末尾。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态覆盖层的 Escape 是窗口级快捷键(MyGo 浮层专用快捷键 `overlayShortcut` 不公开,`ui/scope.go:109`),同时打开多个非模态浮层时由先构建的那个响应。
|
||||
- 非模态覆盖层关闭时不自动恢复焦点(`ui/scope.go:123` openOverlay 不公开)。
|
||||
- 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。
|
||||
@@ -0,0 +1,151 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DrawerSide is the window edge a Drawer comes from. The zero value is DrawerRight.
|
||||
type DrawerSide int
|
||||
|
||||
const (
|
||||
DrawerRight DrawerSide = iota
|
||||
DrawerLeft
|
||||
DrawerTop
|
||||
DrawerBottom
|
||||
)
|
||||
|
||||
// DrawerOptions configure a Drawer. Title is required.
|
||||
type DrawerOptions struct {
|
||||
Title string
|
||||
Side DrawerSide
|
||||
// Size is the width (left, right) or height (top, bottom): 360 or 280 DIPs when zero.
|
||||
// It is kept between 160 DIPs and 90% of the window.
|
||||
Size float32
|
||||
// NonModal leaves the window usable and the focus free.
|
||||
NonModal bool
|
||||
// Actions builds the footer, aligned to the end.
|
||||
Actions func()
|
||||
}
|
||||
|
||||
// Drawer shows a panel along a window edge while *open is true. A modal
|
||||
// drawer traps the focus and restores it on close; Escape closes either kind.
|
||||
func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Drawer needs a Title")
|
||||
}
|
||||
if opts.Side < DrawerRight || opts.Side > DrawerBottom {
|
||||
panic("mujica: Drawer Side out of range")
|
||||
}
|
||||
if opts.Size < 0 {
|
||||
panic("mujica: Drawer Size is negative")
|
||||
}
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
size := drawerSize(c, opts)
|
||||
closed := false
|
||||
body := func(panel *ui.Element, p float32) {
|
||||
drawerPanel(c, panel, opts.Side, size, p)
|
||||
panel.Label(opts.Title)
|
||||
closed = dialogHeader(c, opts.Title, "", false, true, "drawer.close")
|
||||
if content != nil {
|
||||
ui.Scroll(c).Grow(1).Shrink(1).Children(content)
|
||||
}
|
||||
if opts.Actions != nil {
|
||||
dialogActions(c, opts.Actions)
|
||||
}
|
||||
}
|
||||
var panel *ui.Element
|
||||
escaped := false
|
||||
if opts.NonModal {
|
||||
ui.Overlay(c, func() {
|
||||
panel = ui.Column(c).Absolute().Role(ui.RoleDialog)
|
||||
p := overlayEnter(c, panel)
|
||||
drawerPlace(c, panel, opts.Side, size*p)
|
||||
panel.Children(func() { body(panel, p) })
|
||||
})
|
||||
escaped = c.Shortcut(0, ui.KeyEscape)
|
||||
} else {
|
||||
shown := true
|
||||
panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
p := overlayEnter(c, panel)
|
||||
back.Background(overlayScrim(k).Alpha(p))
|
||||
drawerBackdrop(back, opts.Side)
|
||||
body(panel, p)
|
||||
})
|
||||
escaped = !shown
|
||||
}
|
||||
if closed || escaped {
|
||||
slog.Debug("mujica: drawer close", "escape", escaped)
|
||||
*open = false
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
func drawerSize(c *ui.Context, opts DrawerOptions) float32 {
|
||||
w, h := c.Size()
|
||||
extent, size := w, opts.Size
|
||||
if opts.Side == DrawerTop || opts.Side == DrawerBottom {
|
||||
extent = h
|
||||
if size == 0 {
|
||||
size = 280
|
||||
}
|
||||
} else if size == 0 {
|
||||
size = 360
|
||||
}
|
||||
return max(min(size, extent*0.9), min(160, extent))
|
||||
}
|
||||
|
||||
func drawerHorizontal(s DrawerSide) bool { return s == DrawerLeft || s == DrawerRight }
|
||||
|
||||
// drawerBackdrop lines the panel up against its edge inside a modal backdrop.
|
||||
func drawerBackdrop(back *ui.Element, s DrawerSide) {
|
||||
if drawerHorizontal(s) {
|
||||
back.Row()
|
||||
}
|
||||
back.AlignItems(ui.Stretch).Justify(ui.Start)
|
||||
if s == DrawerRight || s == DrawerBottom {
|
||||
back.Justify(ui.End)
|
||||
}
|
||||
}
|
||||
|
||||
// drawerPlace puts a non-modal panel against its edge, shown by size.
|
||||
func drawerPlace(c *ui.Context, panel *ui.Element, s DrawerSide, size float32) {
|
||||
w, h := c.Size()
|
||||
switch s {
|
||||
case DrawerRight:
|
||||
panel.Left(w - size).Top(0).Height(h)
|
||||
case DrawerLeft:
|
||||
panel.Left(0).Top(0).Height(h)
|
||||
case DrawerTop:
|
||||
panel.Left(0).Top(0).Width(w)
|
||||
case DrawerBottom:
|
||||
panel.Left(0).Top(h - size).Width(w)
|
||||
}
|
||||
}
|
||||
|
||||
// drawerPanel styles the panel at entrance progress p, sliding in from its edge.
|
||||
func drawerPanel(c *ui.Context, panel *ui.Element, s DrawerSide, size, p float32) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
off := -(1 - p) * size
|
||||
panel.Background(k.Surface).TextColor(k.Text).Opacity(p).Padding(u*6).Gap(u*3).
|
||||
Shadow(0, 0, 24, 0, ui.RGBA(0, 0, 0, 0.18*p))
|
||||
bw := theme.BorderWidth
|
||||
switch s {
|
||||
case DrawerRight:
|
||||
panel.Width(size).BorderWidth(0, 0, 0, bw).Margin(0, off, 0, 0)
|
||||
case DrawerLeft:
|
||||
panel.Width(size).BorderWidth(0, bw, 0, 0).Margin(0, 0, 0, off)
|
||||
case DrawerTop:
|
||||
panel.Height(size).BorderWidth(0, 0, bw, 0).Margin(off, 0, 0, 0)
|
||||
case DrawerBottom:
|
||||
panel.Height(size).BorderWidth(bw, 0, 0, 0).Margin(0, 0, off, 0)
|
||||
}
|
||||
panel.BorderColor(k.Border)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type drawerApp struct {
|
||||
open bool
|
||||
opts DrawerOptions
|
||||
clicked int
|
||||
panel ui.Rect
|
||||
}
|
||||
|
||||
func (a *drawerApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
if Button(c, "Filters", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
if Button(c, "Behind", ButtonOptions{}).Clicked() {
|
||||
a.clicked++
|
||||
}
|
||||
})
|
||||
if p := Drawer(c, &a.open, a.opts, func() {
|
||||
Button(c, "Apply", ButtonOptions{})
|
||||
}); p != nil {
|
||||
a.panel = p.Bounds()
|
||||
}
|
||||
}
|
||||
|
||||
func newDrawerApp(opts DrawerOptions) (*drawerApp, *ui.Tester) {
|
||||
opts.Title = "Filters panel"
|
||||
a := &drawerApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 800, 500)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
_ = tt.Click("Filters")
|
||||
tt.Frame()
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestDrawerSides(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
side DrawerSide
|
||||
x, y, w, h float32
|
||||
}{
|
||||
{DrawerRight, 440, 0, 360, 500},
|
||||
{DrawerLeft, 0, 0, 360, 500},
|
||||
{DrawerTop, 0, 0, 800, 280},
|
||||
{DrawerBottom, 0, 220, 800, 280},
|
||||
} {
|
||||
for _, nonModal := range []bool{false, true} {
|
||||
a, _ := newDrawerApp(DrawerOptions{Side: tc.side, NonModal: nonModal})
|
||||
got := a.panel
|
||||
if got.X != tc.x || got.Y != tc.y || got.W != tc.w || got.H != tc.h {
|
||||
t.Errorf("side %d nonModal %v: panel %+v", tc.side, nonModal, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawerSizeLimits(t *testing.T) {
|
||||
a, _ := newDrawerApp(DrawerOptions{Size: 5000})
|
||||
if a.panel.W != 720 {
|
||||
t.Errorf("oversize width %v, want 720 (90%%)", a.panel.W)
|
||||
}
|
||||
a, _ = newDrawerApp(DrawerOptions{Size: 40})
|
||||
if a.panel.W != 160 {
|
||||
t.Errorf("undersize width %v, want 160", a.panel.W)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawerModalTrapsAndRestores(t *testing.T) {
|
||||
a, tt := newDrawerApp(DrawerOptions{})
|
||||
_ = tt.Click("Behind")
|
||||
if a.clicked != 0 {
|
||||
t.Fatal("modal drawer let a click through")
|
||||
}
|
||||
a.open = true
|
||||
tt.Frame()
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatal("focus not in the drawer")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Close") {
|
||||
t.Fatal("tab left the modal drawer")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open || !tt.Focused("Filters") {
|
||||
t.Fatalf("Escape: open %v, opener focused %v", a.open, tt.Focused("Filters"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawerNonModalLeavesWindowUsable(t *testing.T) {
|
||||
a, tt := newDrawerApp(DrawerOptions{NonModal: true})
|
||||
if err := tt.Click("Behind"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.clicked != 1 || !a.open {
|
||||
t.Fatalf("clicked %d open %v", a.clicked, a.open)
|
||||
}
|
||||
if err := tt.Click("Close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.open {
|
||||
t.Fatal("close button did not close")
|
||||
}
|
||||
a.open = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open {
|
||||
t.Fatal("Escape did not close the non-modal drawer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawerSnapshot(t *testing.T) {
|
||||
snapshot(t, "drawer", 640, 360, func(c *ui.Context) {
|
||||
open := true
|
||||
ui.Column(c).Padding(24).Children(func() { ui.Text(c, "The gallery behind") })
|
||||
Drawer(c, &open, DrawerOptions{Title: "Seating chart", Size: 300, Actions: func() {
|
||||
Button(c, "Reset", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Apply", ButtonOptions{})
|
||||
}}, func() {
|
||||
ui.Text(c, "Guests are placed by rank, then by arrival.").TextColor(Tokens(c).TextMuted)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// DropdownMenuOptions configure a DropdownMenu.
|
||||
type DropdownMenuOptions struct {
|
||||
// Icon goes before the label.
|
||||
Icon *ui.SVG
|
||||
Disabled bool
|
||||
// Label names an icon-only trigger for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// DropdownMenu is a button opening an in-window menu that build fills.
|
||||
// Click, Enter, Space or Down opens it with the focus on the first item;
|
||||
// the arrows, Home and End move, Escape or Tab closes, and the focus
|
||||
// returns to the button. It returns the button.
|
||||
func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element {
|
||||
if build == nil {
|
||||
panic("mujica: DropdownMenu needs a build function")
|
||||
}
|
||||
if label == "" && opts.Label == "" {
|
||||
panic("mujica: DropdownMenu needs a label or Label")
|
||||
}
|
||||
b := ui.ButtonBase(c).Role(ui.RoleMenuButton).Disabled(opts.Disabled)
|
||||
if opts.Label != "" {
|
||||
b.Label(opts.Label)
|
||||
}
|
||||
open := ui.Local(b, "mujica.dropdown", func() bool { return false })
|
||||
if opts.Disabled {
|
||||
*open = false
|
||||
}
|
||||
if b.Clicked() || b.Shortcut(0, ui.KeyDown) {
|
||||
*open = true
|
||||
slog.Debug("mujica: dropdown_menu open")
|
||||
}
|
||||
popupMenuTrigger(c, b, label, opts.Icon, *open)
|
||||
popupMenuOpen(c, b, open, build)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type menuApp struct {
|
||||
chosen []string
|
||||
pinned bool
|
||||
locked bool
|
||||
panelBounds ui.Rect
|
||||
}
|
||||
|
||||
func (a *menuApp) build(m *PopupMenu) {
|
||||
m.Group("Letter", func() {
|
||||
if m.Item("Rename", PopupMenuItemOptions{}) {
|
||||
a.chosen = append(a.chosen, "Rename")
|
||||
}
|
||||
if m.Check("Pinned", &a.pinned, PopupMenuItemOptions{KeepOpen: true}) {
|
||||
a.chosen = append(a.chosen, "Pinned")
|
||||
}
|
||||
})
|
||||
m.Group("Move", func() {
|
||||
m.Submenu("Move to", PopupMenuItemOptions{}, func(m *PopupMenu) {
|
||||
for _, f := range []string{"Archive", "Drafts"} {
|
||||
if m.Item(f, PopupMenuItemOptions{}) {
|
||||
a.chosen = append(a.chosen, f)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
m.Separator()
|
||||
if m.Item("Burn", PopupMenuItemOptions{Disabled: !a.locked}) {
|
||||
a.chosen = append(a.chosen, "Burn")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *menuApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(20).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build)
|
||||
Button(c, "After", ButtonOptions{})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDropdownMenuChooseClosesAndRestores(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
if err := tt.Click("Actions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Letter") || !tt.Focused("Rename") {
|
||||
t.Fatalf("menu not open with focus on the first item: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Rename")
|
||||
if len(a.chosen) != 1 || a.chosen[0] != "Rename" || tt.HasText("Letter") {
|
||||
t.Fatalf("chosen %q, still open %v", a.chosen, tt.HasText("Letter"))
|
||||
}
|
||||
if !tt.Focused("Actions") {
|
||||
t.Fatal("focus not back on the trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuCheckKeepsOpenAndDisabledBlocks(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
_ = tt.Click("Actions")
|
||||
_ = tt.Click("Pinned")
|
||||
if !a.pinned || !tt.HasText("Letter") {
|
||||
t.Fatalf("pinned %v, open %v", a.pinned, tt.HasText("Letter"))
|
||||
}
|
||||
_ = tt.Click("Burn")
|
||||
if len(a.chosen) != 1 || !tt.HasText("Letter") {
|
||||
t.Fatalf("disabled item acted: %q", a.chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuKeyboard(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatal("Down did not open the menu on the first item")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Move to") {
|
||||
t.Fatal("arrows did not reach Move to")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown) // Burn is disabled: wraps to Rename
|
||||
if !tt.Focused("Rename") {
|
||||
t.Fatal("arrow did not skip the disabled item")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Archive") {
|
||||
t.Fatalf("Right did not enter the submenu: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if !tt.Focused("Move to") || tt.HasText("Archive") {
|
||||
t.Fatal("Left did not close the submenu")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(a.chosen) != 1 || a.chosen[0] != "Drafts" || tt.HasText("Letter") {
|
||||
t.Fatalf("submenu choice %q, open %v", a.chosen, tt.HasText("Letter"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Letter") || !tt.Focused("Actions") {
|
||||
t.Fatal("Escape did not close and restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuSubmenuFlips(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Fill().Justify(ui.End).Padding(20).Children(func() {
|
||||
DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build)
|
||||
})
|
||||
}, 420, 400)
|
||||
_ = tt.Click("Actions")
|
||||
r, _ := tt.Find("Move to")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
tt.Frame()
|
||||
s, ok := tt.Find("Archive")
|
||||
if !ok || s.X+s.W > 420 || s.X > r.X {
|
||||
t.Fatalf("submenu %+v not flipped left of %+v", s, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuDisabled(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
DropdownMenu(c, "Actions", DropdownMenuOptions{Disabled: true}, a.build)
|
||||
}, 300, 300)
|
||||
_ = tt.Click("Actions")
|
||||
if tt.HasText("Letter") {
|
||||
t.Fatal("disabled trigger opened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "dropdown_menu", 460, 320, func(c *ui.Context) {
|
||||
a := &menuApp{pinned: true}
|
||||
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
|
||||
b := ui.ButtonBase(c)
|
||||
popupMenuTrigger(c, b, "Actions", nil, true)
|
||||
open := true
|
||||
popupMenuOpen(c, b, &open, a.build)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleDialog() {
|
||||
open := true
|
||||
name := "Ophelia"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Dialog(c, &open, mujica.DialogOptions{
|
||||
Title: "Rename the chamber",
|
||||
Description: "The new name shows in every hall.",
|
||||
Decorated: true,
|
||||
Actions: func() {
|
||||
if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
},
|
||||
}, func() {
|
||||
ui.TextInput(c, &name)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAlertDialog() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{
|
||||
Title: "Delete the archive?",
|
||||
Description: "Twelve letters will be destroyed.",
|
||||
ConfirmText: "archive",
|
||||
})
|
||||
if r.Confirmed {
|
||||
fmt.Println("deleted")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDrawer() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() {
|
||||
ui.Text(c, "Guests are placed by rank.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePopover() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = !open
|
||||
}
|
||||
mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() {
|
||||
ui.Text(c, "Twelve guests confirmed.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTooltip() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Seal", mujica.ButtonOptions{})
|
||||
mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHoverCard() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() {
|
||||
ui.Text(c, "Keyboardist of the court.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDropdownMenu() {
|
||||
pinned := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Group("Letter", func() {
|
||||
if m.Item("Rename", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("rename")
|
||||
}
|
||||
m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true})
|
||||
})
|
||||
m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Item("Archive", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
m.Separator()
|
||||
m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleContextMenu() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
row := ui.Row(c).Padding(12).Children(func() {
|
||||
ui.Text(c, "Letter to the Countess")
|
||||
})
|
||||
mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
if m.Item("Reply", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("reply")
|
||||
}
|
||||
m.Item("Delete", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePopconfirm() {
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = true
|
||||
}
|
||||
if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed {
|
||||
fmt.Println("archived")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleOverlay() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() {
|
||||
ui.Text(c, "Symbol I: △")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,465 @@
|
||||
package main
|
||||
|
||||
var overlayPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const overlayCategory = "Overlays"
|
||||
|
||||
var overlayPages = []page{
|
||||
{ID: "081", Name: "Dialog", ZhName: "对话框", Category: overlayCategory, Demo: dialogDemo, Code: dialogCode,
|
||||
Keys: "Tab / Shift+Tab 在对话框内循环;Escape 关闭(可关闭此规则);关闭后焦点回到触发按钮。"},
|
||||
{ID: "082", Name: "AlertDialog", ZhName: "警示对话框", Category: overlayCategory, Demo: alertDialogDemo, Code: alertDialogCode,
|
||||
Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮;Escape 等同取消;点击遮罩无效。"},
|
||||
{ID: "083", Name: "Drawer", ZhName: "抽屉", Category: overlayCategory, Demo: drawerDemo, Code: drawerCode,
|
||||
Keys: "模态:焦点限制在抽屉内并在关闭后恢复;Escape 关闭。非模态:窗口仍可操作,Escape 关闭。"},
|
||||
{ID: "084", Name: "Popover", ZhName: "弹出框", Category: overlayCategory, Demo: popoverDemo, Code: popoverCode,
|
||||
Keys: "Tab 从锚点进入弹层;Escape 或外部点击关闭并把焦点还给锚点。"},
|
||||
{ID: "085", Name: "Tooltip", ZhName: "文字提示", Category: overlayCategory, Demo: tooltipDemo, Code: tooltipCode,
|
||||
Keys: "键盘聚焦或悬停超过延迟后显示;Escape 隐藏;移开或失焦隐藏。"},
|
||||
{ID: "086", Name: "HoverCard", ZhName: "悬停卡片", Category: overlayCategory, Demo: hoverCardDemo, Code: hoverCardCode,
|
||||
Keys: "键盘聚焦锚点后显示,Tab 进入卡片;Escape 关闭。"},
|
||||
{ID: "087", Name: "DropdownMenu", ZhName: "下拉菜单", Category: overlayCategory, Demo: dropdownMenuDemo, Code: dropdownMenuCode,
|
||||
Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 或 Tab 关闭。"},
|
||||
{ID: "088", Name: "ContextMenu", ZhName: "右键菜单", Category: overlayCategory, Demo: contextMenuDemo, Code: contextMenuCode,
|
||||
Keys: "右键在指针处打开;焦点在区域内时按 Menu 键或 Shift+F10 在区域下方打开;其余按键同下拉菜单。"},
|
||||
{ID: "089", Name: "Popconfirm", ZhName: "气泡确认", Category: overlayCategory, Demo: popconfirmDemo, Code: popconfirmCode,
|
||||
Keys: "打开后焦点在确认;Enter 确认;Escape 或外部点击取消。"},
|
||||
{ID: "090", Name: "Overlay", ZhName: "覆盖层", Category: overlayCategory, Demo: overlayDemo, Code: overlayCode,
|
||||
Keys: "Escape 关闭;模态时焦点限制在覆盖层内,点击遮罩关闭。"},
|
||||
}
|
||||
|
||||
// overlayControls lays out a demo's parameter row.
|
||||
func overlayControls(c *ui.Context, fn func()) {
|
||||
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Padding(0, 0, 12, 0).Children(fn)
|
||||
}
|
||||
|
||||
func overlayCheck(c *ui.Context, log func(string), v *bool, label string) {
|
||||
if ui.Checkbox(c, v, label).Changed() {
|
||||
log(fmt.Sprintf("%s = %v", label, *v))
|
||||
}
|
||||
}
|
||||
|
||||
const dialogCode = `open := true
|
||||
name := "Ophelia"
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Dialog(c, &open, mujica.DialogOptions{
|
||||
Title: "Rename the chamber",
|
||||
Description: "The new name shows in every hall.",
|
||||
Decorated: true,
|
||||
Actions: func() {
|
||||
if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
},
|
||||
}, func() {
|
||||
ui.TextInput(c, &name)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type dialogDemoState struct {
|
||||
open, decorated, keepBackdrop, keepEscape, hideClose bool
|
||||
name string
|
||||
}
|
||||
|
||||
func dialogDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "081", func() dialogDemoState { return dialogDemoState{decorated: true, name: "Ophelia"} })
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.decorated, "Decorated")
|
||||
overlayCheck(c, log, &s.keepBackdrop, "KeepOnBackdrop")
|
||||
overlayCheck(c, log, &s.keepEscape, "KeepOnEscape")
|
||||
overlayCheck(c, log, &s.hideClose, "HideClose")
|
||||
})
|
||||
if mujica.Button(c, "Rename…", mujica.ButtonOptions{}).Clicked() {
|
||||
s.open = true
|
||||
log("open")
|
||||
}
|
||||
was := s.open
|
||||
mujica.Dialog(c, &s.open, mujica.DialogOptions{
|
||||
Title: "Rename the chamber", Description: "The new name shows in every hall.", Decorated: s.decorated,
|
||||
KeepOnBackdrop: s.keepBackdrop, KeepOnEscape: s.keepEscape, HideClose: s.hideClose,
|
||||
Actions: func() {
|
||||
if mujica.Button(c, "Cancel", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.open = false
|
||||
}
|
||||
if mujica.Button(c, "Rename", mujica.ButtonOptions{}).Clicked() {
|
||||
log("renamed to " + s.name)
|
||||
s.open = false
|
||||
}
|
||||
},
|
||||
}, func() { ui.TextInput(c, &s.name) })
|
||||
if was && !s.open {
|
||||
log("closed")
|
||||
}
|
||||
}
|
||||
|
||||
const alertDialogCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
r := mujica.AlertDialog(c, &open, mujica.AlertDialogOptions{
|
||||
Title: "Delete the archive?",
|
||||
Description: "Twelve letters will be destroyed.",
|
||||
ConfirmText: "archive",
|
||||
})
|
||||
if r.Confirmed {
|
||||
fmt.Println("deleted")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type alertDialogDemoState struct{ open, typed bool }
|
||||
|
||||
func alertDialogDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "082", func() alertDialogDemoState { return alertDialogDemoState{} })
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.typed, "ConfirmText") })
|
||||
if mujica.Button(c, "Delete archive", mujica.ButtonOptions{Variant: mujica.Danger}).Clicked() {
|
||||
s.open = true
|
||||
log("open")
|
||||
}
|
||||
opts := mujica.AlertDialogOptions{Title: "Delete the archive?", Description: "Twelve letters will be destroyed. This cannot be undone."}
|
||||
if s.typed {
|
||||
opts.ConfirmText = "archive"
|
||||
}
|
||||
r := mujica.AlertDialog(c, &s.open, opts)
|
||||
if r.Confirmed {
|
||||
log("confirmed")
|
||||
}
|
||||
if r.Cancelled {
|
||||
log("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
const drawerCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.Button(c, "Filters", mujica.ButtonOptions{}).Clicked() {
|
||||
open = true
|
||||
}
|
||||
mujica.Drawer(c, &open, mujica.DrawerOptions{Title: "Filters", Side: mujica.DrawerRight, Size: 320}, func() {
|
||||
ui.Text(c, "Guests are placed by rank.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type drawerDemoState struct {
|
||||
open, nonModal bool
|
||||
side string
|
||||
size float64
|
||||
}
|
||||
|
||||
func drawerDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "083", func() drawerDemoState { return drawerDemoState{side: "Right", size: 320} })
|
||||
sides := map[string]mujica.DrawerSide{"Right": mujica.DrawerRight, "Left": mujica.DrawerLeft, "Top": mujica.DrawerTop, "Bottom": mujica.DrawerBottom}
|
||||
overlayControls(c, func() {
|
||||
if ui.Select(c, &s.side, []string{"Right", "Left", "Top", "Bottom"}).Changed() {
|
||||
log("side = " + s.side)
|
||||
}
|
||||
overlayCheck(c, log, &s.nonModal, "NonModal")
|
||||
ui.Text(c, fmt.Sprintf("Size %.0f", s.size))
|
||||
if ui.StepSlider(c, &s.size, 120, 900, 20).Width(160).Changed() {
|
||||
log(fmt.Sprintf("size = %.0f", s.size))
|
||||
}
|
||||
})
|
||||
if mujica.Button(c, "Open drawer", mujica.ButtonOptions{}).Clicked() {
|
||||
s.open = true
|
||||
log("open")
|
||||
}
|
||||
was := s.open
|
||||
mujica.Drawer(c, &s.open, mujica.DrawerOptions{Title: "Seating chart", Side: sides[s.side], Size: float32(s.size), NonModal: s.nonModal,
|
||||
Actions: func() {
|
||||
if mujica.Button(c, "Apply", mujica.ButtonOptions{}).Clicked() {
|
||||
log("applied")
|
||||
s.open = false
|
||||
}
|
||||
}}, func() {
|
||||
ui.Text(c, "Guests are placed by rank, then by arrival.").TextColor(mujica.Tokens(c).TextMuted)
|
||||
})
|
||||
if was && !s.open {
|
||||
log("closed")
|
||||
}
|
||||
}
|
||||
|
||||
const popoverCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = !open
|
||||
}
|
||||
mujica.Popover(c, b, &open, mujica.PopoverOptions{Title: "Guest list"}, func() {
|
||||
ui.Text(c, "Twelve guests confirmed.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type popoverDemoState struct {
|
||||
open, titled bool
|
||||
rsvp bool
|
||||
}
|
||||
|
||||
func popoverDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "084", func() popoverDemoState { return popoverDemoState{titled: true} })
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.titled, "Title") })
|
||||
b := mujica.Button(c, "Guests", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
s.open = !s.open
|
||||
log(fmt.Sprintf("open = %v", s.open))
|
||||
}
|
||||
opts := mujica.PopoverOptions{Label: "Guest list"}
|
||||
if s.titled {
|
||||
opts.Title = "Guest list"
|
||||
}
|
||||
was := s.open
|
||||
mujica.Popover(c, b, &s.open, opts, func() {
|
||||
ui.Text(c, "Twelve guests confirmed; three await a reply.")
|
||||
if ui.Checkbox(c, &s.rsvp, "Remind the others").Changed() {
|
||||
log(fmt.Sprintf("remind = %v", s.rsvp))
|
||||
}
|
||||
})
|
||||
if was && !s.open {
|
||||
log("closed")
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Seal", mujica.ButtonOptions{})
|
||||
mujica.Tooltip(c, b, "Seal the letter with wax", mujica.TooltipOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
func tooltipDemo(c *ui.Context, log func(string)) {
|
||||
delay := ui.Local(c.Root(), "085", func() float64 { return 500 })
|
||||
overlayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Delay %.0f ms", *delay))
|
||||
if ui.StepSlider(c, delay, 100, 1500, 100).Width(160).Changed() {
|
||||
log(fmt.Sprintf("delay = %.0f ms", *delay))
|
||||
}
|
||||
})
|
||||
opts := mujica.TooltipOptions{Delay: time.Duration(*delay) * time.Millisecond}
|
||||
ui.Row(c).Gap(12).Children(func() {
|
||||
b := mujica.Button(c, "Seal", mujica.ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
log("seal clicked")
|
||||
}
|
||||
mujica.Tooltip(c, b, "Seal the letter with wax", opts)
|
||||
d := mujica.Button(c, "Burn", mujica.ButtonOptions{Variant: mujica.Danger})
|
||||
if d.Clicked() {
|
||||
log("burn clicked")
|
||||
}
|
||||
mujica.Tooltip(c, d, "Burn every copy of the letter; nothing is kept", opts)
|
||||
})
|
||||
}
|
||||
|
||||
const hoverCardCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() {
|
||||
ui.Text(c, "Keyboardist of the court.")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
func hoverCardDemo(c *ui.Context, log func(string)) {
|
||||
shown := ui.Local(c.Root(), "086", func() bool { return false })
|
||||
b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
card := mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() {
|
||||
ui.Text(c, "Lady Sakiko").FontWeight(600)
|
||||
ui.Text(c, "Keyboardist of the court; writes the masquerade's score.").TextColor(mujica.Tokens(c).TextMuted)
|
||||
if mujica.Button(c, "Follow", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() {
|
||||
log("follow")
|
||||
}
|
||||
})
|
||||
if (card != nil) != *shown {
|
||||
*shown = card != nil
|
||||
log(fmt.Sprintf("card shown = %v", *shown))
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownMenuCode = `pinned := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Group("Letter", func() {
|
||||
if m.Item("Rename", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("rename")
|
||||
}
|
||||
m.Check("Pinned", &pinned, mujica.PopupMenuItemOptions{KeepOpen: true})
|
||||
})
|
||||
m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) {
|
||||
m.Item("Archive", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
m.Separator()
|
||||
m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: true})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type menuDemoState struct{ pinned, sealed, locked, disabled bool }
|
||||
|
||||
// overlayMenu fills the shared demo menu, logging every choice.
|
||||
func overlayMenu(s *menuDemoState, log func(string)) func(m *mujica.PopupMenu) {
|
||||
return func(m *mujica.PopupMenu) {
|
||||
m.Group("Letter", func() {
|
||||
if m.Item("Rename", mujica.PopupMenuItemOptions{}) {
|
||||
log("rename")
|
||||
}
|
||||
if m.Check("Pinned", &s.pinned, mujica.PopupMenuItemOptions{KeepOpen: true}) {
|
||||
log(fmt.Sprintf("pinned = %v", s.pinned))
|
||||
}
|
||||
if m.Check("Sealed", &s.sealed, mujica.PopupMenuItemOptions{}) {
|
||||
log(fmt.Sprintf("sealed = %v", s.sealed))
|
||||
}
|
||||
})
|
||||
m.Group("Move", func() {
|
||||
m.Submenu("Move to", mujica.PopupMenuItemOptions{}, func(m *mujica.PopupMenu) {
|
||||
for _, f := range []string{"Archive", "Drafts", "Vault"} {
|
||||
if m.Item(f, mujica.PopupMenuItemOptions{}) {
|
||||
log("move to " + f)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
m.Separator()
|
||||
if m.Item("Burn", mujica.PopupMenuItemOptions{Disabled: s.locked}) {
|
||||
log("burn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropdownMenuDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "087", func() menuDemoState { return menuDemoState{locked: true} })
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.locked, "Burn disabled")
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
})
|
||||
mujica.DropdownMenu(c, "Actions", mujica.DropdownMenuOptions{Disabled: s.disabled}, overlayMenu(s, log))
|
||||
}
|
||||
|
||||
const contextMenuCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
row := ui.Row(c).Padding(12).Children(func() {
|
||||
ui.Text(c, "Letter to the Countess")
|
||||
})
|
||||
mujica.ContextMenu(c, row, mujica.ContextMenuOptions{}, func(m *mujica.PopupMenu) {
|
||||
if m.Item("Reply", mujica.PopupMenuItemOptions{}) {
|
||||
fmt.Println("reply")
|
||||
}
|
||||
m.Item("Delete", mujica.PopupMenuItemOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
func contextMenuDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "088", func() menuDemoState { return menuDemoState{} })
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.locked, "Burn disabled")
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
})
|
||||
k := mujica.Tokens(c)
|
||||
row := ui.Column(c).Height(160).FillWidth().Padding(16).Gap(8).Background(k.Surface).Border(1, k.Border).Radius(4)
|
||||
row.Children(func() {
|
||||
ui.Text(c, "Right-click anywhere here, or focus the button and press Shift+F10.").TextColor(k.TextMuted)
|
||||
if mujica.Button(c, "Letter to the Countess", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
log("letter clicked")
|
||||
}
|
||||
})
|
||||
mujica.ContextMenu(c, row, mujica.ContextMenuOptions{Disabled: s.disabled}, overlayMenu(s, log))
|
||||
}
|
||||
|
||||
const popconfirmCode = `open := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
open = true
|
||||
}
|
||||
if mujica.Popconfirm(c, b, &open, mujica.PopconfirmOptions{Title: "Archive this letter?"}).Confirmed {
|
||||
fmt.Println("archived")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type popconfirmDemoState struct{ open, danger bool }
|
||||
|
||||
func popconfirmDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "089", func() popconfirmDemoState { return popconfirmDemoState{} })
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.danger, "Danger") })
|
||||
b := mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
|
||||
if b.Clicked() {
|
||||
s.open = true
|
||||
log("open")
|
||||
}
|
||||
r := mujica.Popconfirm(c, b, &s.open, mujica.PopconfirmOptions{Title: "Archive this letter?", Description: "It leaves the inbox.", Danger: s.danger})
|
||||
if r.Confirmed {
|
||||
log("confirmed")
|
||||
}
|
||||
if r.Cancelled {
|
||||
log("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
const overlayCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Overlay(c, &open, mujica.OverlayOptions{Place: mujica.OverlayBottomRight, Label: "Now playing"}, func() {
|
||||
ui.Text(c, "Symbol I: △")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type overlayDemoState struct {
|
||||
open, modal, dim bool
|
||||
place string
|
||||
}
|
||||
|
||||
func overlayDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "090", func() overlayDemoState { return overlayDemoState{place: "BottomRight"} })
|
||||
names := []string{"Center", "Top", "Bottom", "Left", "Right", "TopLeft", "TopRight", "BottomLeft", "BottomRight"}
|
||||
place := mujica.OverlayCenter
|
||||
for i, n := range names {
|
||||
if n == s.place {
|
||||
place = mujica.OverlayPlace(i)
|
||||
}
|
||||
}
|
||||
overlayControls(c, func() {
|
||||
if ui.Select(c, &s.place, names).Changed() {
|
||||
log("place = " + s.place)
|
||||
}
|
||||
overlayCheck(c, log, &s.modal, "Modal")
|
||||
if s.modal {
|
||||
overlayCheck(c, log, &s.dim, "Dim")
|
||||
}
|
||||
})
|
||||
if mujica.Button(c, "Show overlay", mujica.ButtonOptions{}).Clicked() {
|
||||
s.open = true
|
||||
log("open")
|
||||
}
|
||||
was := s.open
|
||||
mujica.Overlay(c, &s.open, mujica.OverlayOptions{Place: place, Modal: s.modal, Dim: s.modal && s.dim, Label: "Now playing"}, func() {
|
||||
ui.Text(c, "Now playing").FontWeight(600)
|
||||
ui.Text(c, "Symbol I: △").TextColor(mujica.Tokens(c).TextMuted)
|
||||
if mujica.Button(c, "Close", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.open = false
|
||||
}
|
||||
})
|
||||
if was && !s.open {
|
||||
log("closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// HoverCardOptions configure a HoverCard.
|
||||
type HoverCardOptions struct {
|
||||
// OpenDelay is 500 ms and CloseDelay 300 ms when zero.
|
||||
OpenDelay time.Duration
|
||||
CloseDelay time.Duration
|
||||
// Width is 280 DIPs when zero.
|
||||
Width float32
|
||||
// Label names the card for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// hoverCardState is what a card keeps between frames while its anchor lives.
|
||||
type hoverCardState struct {
|
||||
open, dismissed bool
|
||||
// keyMode keeps the card in the focus order while the keyboard is on it.
|
||||
keyMode bool
|
||||
enterAt, leaveAt time.Time
|
||||
panel ui.Rect
|
||||
}
|
||||
|
||||
// HoverCard previews content beside anchor while the pointer rests on it or
|
||||
// it has the keyboard focus. The pointer may move into the card; with the
|
||||
// keyboard, Tab from the anchor enters it. It closes after CloseDelay once
|
||||
// neither holds it, or at once on Escape. It returns the card, or nil.
|
||||
func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element {
|
||||
if anchor == nil {
|
||||
panic("mujica: HoverCard needs an anchor")
|
||||
}
|
||||
if opts.OpenDelay < 0 || opts.CloseDelay < 0 {
|
||||
panic("mujica: HoverCard delays must not be negative")
|
||||
}
|
||||
openDelay, closeDelay := opts.OpenDelay, opts.CloseDelay
|
||||
if openDelay == 0 {
|
||||
openDelay = 500 * time.Millisecond
|
||||
}
|
||||
if closeDelay == 0 {
|
||||
closeDelay = 300 * time.Millisecond
|
||||
}
|
||||
st := ui.Local(anchor, "mujica.hovercard", func() hoverCardState { return hoverCardState{} })
|
||||
b := anchor.Bounds()
|
||||
keyboard := anchor.FocusVisible()
|
||||
pointer := anchor.Hovered()
|
||||
if st.open {
|
||||
// The card's layer covers the anchor, so test the pointer by geometry.
|
||||
x, y, in := c.Root().PointerPosition()
|
||||
pointer = pointer || in && (b.Contains(x, y) || st.panel.Contains(x, y))
|
||||
}
|
||||
hot := pointer || keyboard || st.keyMode
|
||||
if !hot {
|
||||
st.dismissed = false
|
||||
}
|
||||
if st.dismissed {
|
||||
return nil
|
||||
}
|
||||
now := c.Now()
|
||||
if hot {
|
||||
st.leaveAt = time.Time{}
|
||||
if !st.open {
|
||||
if st.enterAt.IsZero() {
|
||||
st.enterAt = now
|
||||
}
|
||||
if wait := openDelay - now.Sub(st.enterAt); wait > 0 {
|
||||
c.After(wait)
|
||||
return nil
|
||||
}
|
||||
slog.Debug("mujica: hover_card open", "keyboard", keyboard)
|
||||
st.open = true
|
||||
}
|
||||
} else {
|
||||
st.enterAt = time.Time{}
|
||||
if !st.open {
|
||||
return nil
|
||||
}
|
||||
if st.leaveAt.IsZero() {
|
||||
st.leaveAt = now
|
||||
}
|
||||
if wait := closeDelay - now.Sub(st.leaveAt); wait > 0 {
|
||||
c.After(wait)
|
||||
} else {
|
||||
slog.Debug("mujica: hover_card close")
|
||||
st.open, st.keyMode, st.leaveAt = false, false, time.Time{}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
width := opts.Width
|
||||
if width == 0 {
|
||||
width = 280
|
||||
}
|
||||
style := func(panel *ui.Element, p float32) {
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Width(width).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).Label(opts.Label).
|
||||
MaxHeight(popoverRoom(c, b))
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
}
|
||||
var panel *ui.Element
|
||||
dismissed, keyMode := false, keyboard || st.keyMode
|
||||
if keyMode {
|
||||
shown := true
|
||||
panel = ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) {
|
||||
style(panel.Margin(6, 0, 0, 0), overlayAnchored(c, panel, b))
|
||||
})
|
||||
dismissed = !shown
|
||||
} else {
|
||||
ui.Overlay(c, func() {
|
||||
panel = ui.Column(c).Absolute().Role(ui.RolePopup)
|
||||
prev := panel.Bounds()
|
||||
x, y := overlayPlace(c, b, width, prev.H, 6)
|
||||
panel.Left(x).Top(y)
|
||||
p := overlayEnter(c, panel)
|
||||
if prev.H == 0 {
|
||||
p = 0 // not measured yet
|
||||
c.AnimationFrame()
|
||||
}
|
||||
panel.Children(func() { style(panel, p) })
|
||||
})
|
||||
dismissed = c.Shortcut(0, ui.KeyEscape)
|
||||
}
|
||||
if dismissed {
|
||||
slog.Debug("mujica: hover_card dismiss")
|
||||
st.open, st.dismissed, st.keyMode = false, true, false
|
||||
if keyMode {
|
||||
anchor.Focus() // at once, so the anchor stays hot and the card stays dismissed
|
||||
}
|
||||
return panel
|
||||
}
|
||||
st.panel, st.keyMode = panel.Bounds(), keyboard || keyMode && panel.FocusWithin()
|
||||
return panel
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const hoverCardTestDelay = 20 * time.Millisecond
|
||||
|
||||
type hoverCardApp struct {
|
||||
follows, next int
|
||||
}
|
||||
|
||||
func (a *hoverCardApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() {
|
||||
b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost})
|
||||
HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() {
|
||||
ui.Text(c, "Keyboardist of the court")
|
||||
if Button(c, "Follow", ButtonOptions{}).Clicked() {
|
||||
a.follows++
|
||||
}
|
||||
})
|
||||
if Button(c, "Next", ButtonOptions{}).Clicked() {
|
||||
a.next++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func hoverCardWait(tt *ui.Tester) {
|
||||
time.Sleep(hoverCardTestDelay + 15*time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestHoverCardPointerCanEnterCard(t *testing.T) {
|
||||
a := &hoverCardApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
r, _ := tt.Find("Lady Sakiko")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
if tt.HasText("Keyboardist of the court") {
|
||||
t.Fatal("opened before the delay")
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
tt.Frame()
|
||||
f, ok := tt.Find("Follow")
|
||||
if !ok {
|
||||
t.Fatalf("card did not open: %q", tt.Texts())
|
||||
}
|
||||
// Moving into the card keeps it; its button works.
|
||||
tt.Move(f.X+5, f.Y+5)
|
||||
hoverCardWait(tt)
|
||||
if err := tt.Click("Follow"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.follows != 1 {
|
||||
t.Fatalf("follows %d", a.follows)
|
||||
}
|
||||
// Leaving both closes it after the delay, and the window stays usable.
|
||||
tt.Move(490, 390)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Follow") {
|
||||
t.Fatal("closed without the close delay")
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
if tt.HasText("Follow") {
|
||||
t.Fatal("card stayed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) {
|
||||
a := &hoverCardApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
hoverCardWait(tt)
|
||||
if !tt.HasText("Keyboardist of the court") {
|
||||
t.Fatal("no card on keyboard focus")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Follow") {
|
||||
t.Fatal("Tab did not enter the card")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.follows != 1 {
|
||||
t.Fatalf("follows %d", a.follows)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Follow") || !tt.Focused("Lady Sakiko") {
|
||||
t.Fatalf("Escape: card shown %v, anchor focused %v", tt.HasText("Follow"), tt.Focused("Lady Sakiko"))
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
if tt.HasText("Follow") {
|
||||
t.Fatal("dismissed card came back while focused")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Next") || a.next != 0 {
|
||||
t.Fatal("Tab after the card did not reach Next")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "hover_card", 420, 240, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() {
|
||||
Button(c, "Lady Uika", ButtonOptions{Variant: Ghost})
|
||||
})
|
||||
ui.Overlay(c, func() {
|
||||
p := ui.Column(c).Absolute().Left(24).Top(66)
|
||||
overlayStyle(c, p, 1)
|
||||
p.Width(280).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, "Lady Uika").FontWeight(600)
|
||||
ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted)
|
||||
Button(c, "Follow", ButtonOptions{Variant: Outline})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"dialog.close": {"Close", "关闭"},
|
||||
"alert_dialog.cancel": {"Cancel", "取消"},
|
||||
"alert_dialog.confirm": {"Delete", "删除"},
|
||||
"alert_dialog.type_to_unlock": {"Type “%s” to confirm", "输入“%s”以确认"},
|
||||
"drawer.close": {"Close", "关闭"},
|
||||
"popconfirm.confirm": {"Confirm", "确定"},
|
||||
"popconfirm.cancel": {"Cancel", "取消"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// OverlayPlace is where an Overlay sits in the window. The zero value is OverlayCenter.
|
||||
type OverlayPlace int
|
||||
|
||||
const (
|
||||
OverlayCenter OverlayPlace = iota
|
||||
OverlayTop
|
||||
OverlayBottom
|
||||
OverlayLeft
|
||||
OverlayRight
|
||||
OverlayTopLeft
|
||||
OverlayTopRight
|
||||
OverlayBottomLeft
|
||||
OverlayBottomRight
|
||||
)
|
||||
|
||||
// OverlayOptions configure an Overlay.
|
||||
type OverlayOptions struct {
|
||||
Place OverlayPlace
|
||||
// Inset keeps the panel from the window edges; 16 DIPs when zero.
|
||||
Inset float32
|
||||
// Modal traps the focus, makes the window inert and closes on a backdrop click.
|
||||
Modal bool
|
||||
// Dim darkens the window behind a modal overlay. It requires Modal.
|
||||
Dim bool
|
||||
// Label names the layer for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Overlay shows content in a layer above the window while *open is true.
|
||||
// Escape closes it; a modal one also closes on a backdrop click.
|
||||
func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element {
|
||||
if opts.Dim && !opts.Modal {
|
||||
panic("mujica: Overlay Dim requires Modal")
|
||||
}
|
||||
if opts.Place < OverlayCenter || opts.Place > OverlayBottomRight {
|
||||
panic("mujica: Overlay Place out of range")
|
||||
}
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
inset := opts.Inset
|
||||
if inset == 0 {
|
||||
inset = 16
|
||||
}
|
||||
fx, fy := overlayFractions(opts.Place)
|
||||
if opts.Modal {
|
||||
var backClicked bool
|
||||
shown := true
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
backClicked = back.Clicked()
|
||||
p := overlayEnter(c, panel)
|
||||
back.Padding(inset).Justify(overlayAlign(fy)).AlignItems(overlayAlign(fx))
|
||||
if opts.Dim {
|
||||
back.Background(overlayScrim(k).Alpha(p))
|
||||
}
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Label(opts.Label).Padding(Density(c).Unit() * 4)
|
||||
content()
|
||||
})
|
||||
if !shown {
|
||||
slog.Debug("mujica: overlay close", "backdrop", backClicked)
|
||||
*open = false
|
||||
}
|
||||
return panel
|
||||
}
|
||||
var panel *ui.Element
|
||||
ui.Overlay(c, func() {
|
||||
w, h := c.Size()
|
||||
panel = ui.Column(c).Absolute().Role(ui.RoleDialog).Label(opts.Label).Padding(Density(c).Unit() * 4)
|
||||
b := panel.Bounds()
|
||||
panel.Left(inset + (w-2*inset-b.W)*fx).Top(inset + (h-2*inset-b.H)*fy)
|
||||
p := overlayEnter(c, panel)
|
||||
if b.W == 0 {
|
||||
p = 0 // not measured yet
|
||||
c.AnimationFrame()
|
||||
}
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Children(content)
|
||||
})
|
||||
if c.Shortcut(0, ui.KeyEscape) {
|
||||
slog.Debug("mujica: overlay close", "escape", true)
|
||||
*open = false
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
func overlayFractions(p OverlayPlace) (fx, fy float32) {
|
||||
switch p {
|
||||
case OverlayTop:
|
||||
return 0.5, 0
|
||||
case OverlayBottom:
|
||||
return 0.5, 1
|
||||
case OverlayLeft:
|
||||
return 0, 0.5
|
||||
case OverlayRight:
|
||||
return 1, 0.5
|
||||
case OverlayTopLeft:
|
||||
return 0, 0
|
||||
case OverlayTopRight:
|
||||
return 1, 0
|
||||
case OverlayBottomLeft:
|
||||
return 0, 1
|
||||
case OverlayBottomRight:
|
||||
return 1, 1
|
||||
}
|
||||
return 0.5, 0.5
|
||||
}
|
||||
|
||||
func overlayAlign(f float32) ui.Align {
|
||||
switch f {
|
||||
case 0:
|
||||
return ui.Start
|
||||
case 1:
|
||||
return ui.End
|
||||
}
|
||||
return ui.Center
|
||||
}
|
||||
|
||||
// overlayEnter returns how far, 0 to 1, the popup born with e has entered.
|
||||
func overlayEnter(c *ui.Context, e *ui.Element) float32 {
|
||||
born := ui.Local(e, "mujica.overlay.born", func() time.Time { return c.Now() })
|
||||
d := Motion(c, theme.PopupDuration)
|
||||
if d == 0 {
|
||||
return 1
|
||||
}
|
||||
t := float32(c.Now().Sub(*born)) / float32(d)
|
||||
if t >= 1 {
|
||||
return 1
|
||||
}
|
||||
c.AnimationFrame()
|
||||
return ui.EaseOut(t)
|
||||
}
|
||||
|
||||
// overlayScrim is the color dimming the window behind a modal layer.
|
||||
func overlayScrim(k theme.Tokens) ui.Color {
|
||||
if k.Dark {
|
||||
return ui.RGBA(0, 0, 0, 0.6)
|
||||
}
|
||||
return k.Text.Alpha(0.42)
|
||||
}
|
||||
|
||||
// overlayStyle gives a popup panel the surface, edge and shadow of the theme.
|
||||
func overlayStyle(c *ui.Context, panel *ui.Element, p float32) {
|
||||
k := Tokens(c)
|
||||
panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius).
|
||||
TextColor(k.Text).Opacity(p).Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16*p))
|
||||
}
|
||||
|
||||
// overlayPlace puts a w×h box below anchor a, above when there is no room, inside the window.
|
||||
func overlayPlace(c *ui.Context, a ui.Rect, w, h, gap float32) (x, y float32) {
|
||||
ww, wh := c.Size()
|
||||
const edge = 4
|
||||
x, y = a.X, a.Y+a.H+gap
|
||||
if y+h > wh-edge && a.Y-gap-h >= edge {
|
||||
y = a.Y - gap - h
|
||||
}
|
||||
x = max(edge, min(x, ww-edge-w))
|
||||
y = max(edge, min(y, wh-edge-h))
|
||||
return x, y
|
||||
}
|
||||
|
||||
// overlayAnchored is overlayEnter, held at 0 until the anchor b has been laid out.
|
||||
func overlayAnchored(c *ui.Context, panel *ui.Element, b ui.Rect) float32 {
|
||||
if b.W == 0 && b.H == 0 {
|
||||
c.AnimationFrame()
|
||||
return 0
|
||||
}
|
||||
return overlayEnter(c, panel)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type overlayApp struct {
|
||||
open bool
|
||||
opts OverlayOptions
|
||||
behind int
|
||||
panel ui.Rect
|
||||
}
|
||||
|
||||
func (a *overlayApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Padding(20).Gap(8).Children(func() {
|
||||
if Button(c, "Show", ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
if Button(c, "Behind", ButtonOptions{}).Clicked() {
|
||||
a.behind++
|
||||
}
|
||||
})
|
||||
if p := Overlay(c, &a.open, a.opts, func() {
|
||||
ui.Text(c, "Layer")
|
||||
if Button(c, "Done", ButtonOptions{}).Clicked() {
|
||||
a.open = false
|
||||
}
|
||||
}); p != nil {
|
||||
a.panel = p.Bounds()
|
||||
}
|
||||
}
|
||||
|
||||
func newOverlayApp(opts OverlayOptions) (*overlayApp, *ui.Tester) {
|
||||
a := &overlayApp{opts: opts}
|
||||
tt := ui.NewTester(a.view, 600, 400)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
_ = tt.Click("Show")
|
||||
tt.Frame()
|
||||
return a, tt
|
||||
}
|
||||
|
||||
func TestOverlayPlaces(t *testing.T) {
|
||||
for _, modal := range []bool{false, true} {
|
||||
for _, tc := range []struct {
|
||||
place OverlayPlace
|
||||
fx, fy float32
|
||||
}{{OverlayCenter, 0.5, 0.5}, {OverlayTopLeft, 0, 0}, {OverlayBottomRight, 1, 1}, {OverlayTop, 0.5, 0}} {
|
||||
a, _ := newOverlayApp(OverlayOptions{Place: tc.place, Modal: modal})
|
||||
p := a.panel
|
||||
wantX := 16 + (600-32-p.W)*tc.fx
|
||||
wantY := 16 + (400-32-p.H)*tc.fy
|
||||
if abs(p.X-wantX) > 1 || abs(p.Y-wantY) > 1 {
|
||||
t.Errorf("modal %v place %d: panel %+v, want at (%v, %v)", modal, tc.place, p, wantX, wantY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v float32) float32 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestOverlayModal(t *testing.T) {
|
||||
a, tt := newOverlayApp(OverlayOptions{Modal: true, Dim: true})
|
||||
if !tt.Focused("Done") {
|
||||
t.Fatal("modal overlay did not take the focus")
|
||||
}
|
||||
_ = tt.Click("Behind")
|
||||
if a.behind != 0 || a.open {
|
||||
t.Fatalf("backdrop: behind %d open %v", a.behind, a.open)
|
||||
}
|
||||
if !tt.Focused("Show") {
|
||||
t.Fatal("focus not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayNonModal(t *testing.T) {
|
||||
a, tt := newOverlayApp(OverlayOptions{Place: OverlayBottomRight})
|
||||
_ = tt.Click("Behind")
|
||||
if a.behind != 1 || !a.open {
|
||||
t.Fatalf("behind %d open %v", a.behind, a.open)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayDimNeedsModal(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Overlay(c, &open, OverlayOptions{Dim: true}, func() {})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
// TestOverlayLifecycle opens and closes every overlay 100 times and checks
|
||||
// that nothing grows: goroutines, heap objects or the elements left behind.
|
||||
func TestOverlayLifecycle(t *testing.T) {
|
||||
var open bool
|
||||
views := map[string]func(c *ui.Context, b *ui.Element){
|
||||
"Dialog": func(c *ui.Context, b *ui.Element) { Dialog(c, &open, DialogOptions{Title: "T"}, func() {}) },
|
||||
"Alert": func(c *ui.Context, b *ui.Element) {
|
||||
AlertDialog(c, &open, AlertDialogOptions{Title: "T", ConfirmText: "x"})
|
||||
},
|
||||
"Drawer": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T"}, nil) },
|
||||
"Drawer2": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T", NonModal: true}, nil) },
|
||||
"Popover": func(c *ui.Context, b *ui.Element) { Popover(c, b, &open, PopoverOptions{}, func() {}) },
|
||||
"Confirm": func(c *ui.Context, b *ui.Element) { Popconfirm(c, b, &open, PopconfirmOptions{Title: "T"}) },
|
||||
"Overlay": func(c *ui.Context, b *ui.Element) { Overlay(c, &open, OverlayOptions{Modal: true}, func() {}) },
|
||||
"Menu": func(c *ui.Context, b *ui.Element) {
|
||||
popupMenuOpen(c, b, &open, (&menuApp{}).build)
|
||||
},
|
||||
"Context": nil,
|
||||
}
|
||||
for name, view := range views {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
row := ui.Row(c)
|
||||
var b *ui.Element
|
||||
row.Children(func() { b = Button(c, "Anchor", ButtonOptions{}) })
|
||||
if view == nil {
|
||||
ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build)
|
||||
} else {
|
||||
view(c, b)
|
||||
}
|
||||
}, 600, 400)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
cycle := func() {
|
||||
open = true
|
||||
if view == nil {
|
||||
_ = tt.Click("Anchor")
|
||||
tt.Key(ui.Shift, ui.KeyF10)
|
||||
if !tt.HasText("Rename") {
|
||||
t.Fatalf("%s: did not open", name)
|
||||
}
|
||||
}
|
||||
tt.Frame()
|
||||
if name != "Drawer2" {
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if view == nil {
|
||||
open = false
|
||||
}
|
||||
} else {
|
||||
open = false
|
||||
tt.Frame()
|
||||
}
|
||||
if open || tt.HasText("Rename") {
|
||||
t.Fatalf("%s: Escape did not close", name)
|
||||
}
|
||||
}
|
||||
for range 20 {
|
||||
cycle()
|
||||
}
|
||||
texts := len(tt.Texts())
|
||||
before := heapObjects()
|
||||
goroutines := runtime.NumGoroutine()
|
||||
for range 100 {
|
||||
cycle()
|
||||
}
|
||||
if got := len(tt.Texts()); got != texts {
|
||||
t.Errorf("%s: texts %d -> %d", name, texts, got)
|
||||
}
|
||||
if g := runtime.NumGoroutine(); g > goroutines {
|
||||
t.Errorf("%s: goroutines %d -> %d", name, goroutines, g)
|
||||
}
|
||||
if after := heapObjects(); after > before+2000 {
|
||||
t.Errorf("%s: heap objects %d -> %d", name, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func heapObjects() uint64 {
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return m.HeapObjects
|
||||
}
|
||||
|
||||
func TestOverlaySnapshot(t *testing.T) {
|
||||
snapshot(t, "overlay", 480, 260, func(c *ui.Context) {
|
||||
open := true
|
||||
ui.Column(c).Padding(24).Children(func() { ui.Text(c, "Score of the second act") })
|
||||
Overlay(c, &open, OverlayOptions{Place: OverlayBottomRight, Label: "Now playing"}, func() {
|
||||
ui.Text(c, "Now playing").FontWeight(600)
|
||||
ui.Text(c, "Symbol I: △").TextColor(Tokens(c).TextMuted)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// PopconfirmOptions configure a Popconfirm. Title is required.
|
||||
type PopconfirmOptions struct {
|
||||
Title string
|
||||
Description string
|
||||
// ConfirmLabel is "Confirm" and CancelLabel "Cancel" when empty.
|
||||
ConfirmLabel string
|
||||
CancelLabel string
|
||||
// Danger paints the confirm button as destructive.
|
||||
Danger bool
|
||||
}
|
||||
|
||||
// PopconfirmResult reports the choice made in this frame.
|
||||
type PopconfirmResult struct {
|
||||
Confirmed bool
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// Popconfirm asks for a light confirmation beside anchor while *open is
|
||||
// true. Confirm takes the focus; Cancel, Escape and an outside click cancel.
|
||||
// Use AlertDialog for high-risk actions.
|
||||
func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult {
|
||||
if anchor == nil {
|
||||
panic("mujica: Popconfirm needs an anchor")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Popconfirm needs a Title")
|
||||
}
|
||||
var res PopconfirmResult
|
||||
if !*open {
|
||||
return res
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
confirm, cancel := opts.ConfirmLabel, opts.CancelLabel
|
||||
if confirm == "" {
|
||||
confirm = msg(c, "popconfirm.confirm")
|
||||
}
|
||||
if cancel == "" {
|
||||
cancel = msg(c, "popconfirm.cancel")
|
||||
}
|
||||
variant := Primary
|
||||
if opts.Danger {
|
||||
variant = Danger
|
||||
}
|
||||
b := anchor.Bounds()
|
||||
shown := true
|
||||
ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(320).Role(ui.RoleAlertDialog).
|
||||
Label(opts.Title).Description(opts.Description)
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
color := k.Warning
|
||||
if opts.Danger {
|
||||
color = k.Danger
|
||||
}
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u).Shrink(1).Children(func() {
|
||||
ui.Text(c, opts.Title).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if Button(c, cancel, ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
res.Cancelled = true
|
||||
}
|
||||
if Button(c, confirm, ButtonOptions{Variant: variant}).AutoFocus().Clicked() {
|
||||
res.Confirmed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
if !shown {
|
||||
res.Cancelled = true
|
||||
}
|
||||
if res.Confirmed || res.Cancelled {
|
||||
slog.Debug("mujica: popconfirm close", "confirmed", res.Confirmed)
|
||||
*open = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type popconfirmApp struct {
|
||||
open bool
|
||||
confirmed, cancelled int
|
||||
}
|
||||
|
||||
func (a *popconfirmApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(30).AlignItems(ui.Start).Children(func() {
|
||||
b := Button(c, "Archive", ButtonOptions{Variant: Secondary})
|
||||
if b.Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
r := Popconfirm(c, b, &a.open, PopconfirmOptions{Title: "Archive this letter?", Description: "It leaves the inbox."})
|
||||
if r.Confirmed {
|
||||
a.confirmed++
|
||||
}
|
||||
if r.Cancelled {
|
||||
a.cancelled++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPopconfirmConfirmByKeyboard(t *testing.T) {
|
||||
a := &popconfirmApp{}
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !a.open || !tt.Focused("Confirm") {
|
||||
t.Fatalf("open %v, Confirm focused %v", a.open, tt.Focused("Confirm"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 1 || a.cancelled != 0 || a.open {
|
||||
t.Fatalf("confirmed %d cancelled %d open %v", a.confirmed, a.cancelled, a.open)
|
||||
}
|
||||
if !tt.Focused("Archive") {
|
||||
t.Fatal("focus not restored to the anchor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmCancelPaths(t *testing.T) {
|
||||
a := &popconfirmApp{}
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
_ = tt.Click("Archive")
|
||||
_ = tt.Click("Cancel")
|
||||
_ = tt.Click("Archive")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
_ = tt.Click("Archive")
|
||||
tt.ClickAt(390, 290)
|
||||
if a.cancelled != 3 || a.confirmed != 0 || a.open {
|
||||
t.Fatalf("cancelled %d confirmed %d open %v", a.cancelled, a.confirmed, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmSnapshot(t *testing.T) {
|
||||
snapshot(t, "popconfirm", 420, 220, func(c *ui.Context) {
|
||||
open := true
|
||||
ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() {
|
||||
b := Button(c, "Dismiss the guard", ButtonOptions{Variant: Secondary})
|
||||
Popconfirm(c, b, &open, PopconfirmOptions{Title: "Dismiss the guard?", Description: "The east gate stays unwatched tonight.", Danger: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PopoverOptions configure a Popover.
|
||||
type PopoverOptions struct {
|
||||
// Title shows in serif at the top and names the panel.
|
||||
Title string
|
||||
// Width fixes the panel width; zero fits the content, at least the anchor's width.
|
||||
Width float32
|
||||
// Label names an untitled panel for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Popover shows content in a panel below anchor while *open is true. The
|
||||
// panel flips above where there is no room, stays in the window and scrolls
|
||||
// when taller than the room; an outside click or Escape closes it.
|
||||
func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element {
|
||||
if anchor == nil {
|
||||
panic("mujica: Popover needs an anchor")
|
||||
}
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
b := anchor.Bounds()
|
||||
label := opts.Label
|
||||
if opts.Title != "" {
|
||||
label = opts.Title
|
||||
}
|
||||
panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(b.W).MaxHeight(popoverRoom(c, b)).Label(label)
|
||||
if opts.Width > 0 {
|
||||
panel.Width(opts.Width)
|
||||
} else {
|
||||
panel.MaxWidth(360)
|
||||
}
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).FontWeight(600).Shrink(0)
|
||||
}
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
})
|
||||
if !*open {
|
||||
slog.Debug("mujica: popover close")
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
// popoverRoom is the tallest a panel below or above anchor b may be.
|
||||
func popoverRoom(c *ui.Context, b ui.Rect) float32 {
|
||||
_, h := c.Size()
|
||||
return max(b.Y, h-b.Y-b.H) - 16
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type popoverApp struct {
|
||||
open bool
|
||||
top float32
|
||||
panel, anchor ui.Rect
|
||||
behind, applied int
|
||||
}
|
||||
|
||||
func (a *popoverApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(a.top, 0, 0, 40).Gap(8).Children(func() {
|
||||
b := Button(c, "Filter", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
a.open = !a.open
|
||||
}
|
||||
a.anchor = b.Bounds()
|
||||
if Button(c, "Behind", ButtonOptions{}).Clicked() {
|
||||
a.behind++
|
||||
}
|
||||
if p := Popover(c, b, &a.open, PopoverOptions{Title: "Filter rows"}, func() {
|
||||
if Button(c, "Apply", ButtonOptions{}).Clicked() {
|
||||
a.applied++
|
||||
a.open = false
|
||||
}
|
||||
}); p != nil {
|
||||
a.panel = p.Bounds()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPopoverOpensBelowAndFlips(t *testing.T) {
|
||||
a := &popoverApp{top: 20}
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
_ = tt.Click("Filter")
|
||||
tt.Frame()
|
||||
if !a.open || a.panel.Y < a.anchor.Y+a.anchor.H {
|
||||
t.Fatalf("below: open %v panel %+v anchor %+v", a.open, a.panel, a.anchor)
|
||||
}
|
||||
a = &popoverApp{top: 240}
|
||||
tt = ui.NewTester(a.view, 400, 300)
|
||||
_ = tt.Click("Filter")
|
||||
tt.Frame()
|
||||
if a.panel.Y+a.panel.H > a.anchor.Y {
|
||||
t.Fatalf("no flip: panel %+v anchor %+v", a.panel, a.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopoverOutsideClickAndEscape(t *testing.T) {
|
||||
a := &popoverApp{top: 20}
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
_ = tt.Click("Filter")
|
||||
tt.ClickAt(390, 290)
|
||||
if a.open || a.behind != 0 {
|
||||
t.Fatalf("outside click: open %v behind %d", a.open, a.behind)
|
||||
}
|
||||
_ = tt.Click("Filter")
|
||||
tt.Key(0, ui.KeyTab) // focus follows the anchor into the panel
|
||||
if !tt.Focused("Apply") {
|
||||
t.Fatal("Tab did not enter the popover")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if a.open || !tt.Focused("Filter") {
|
||||
t.Fatalf("Escape: open %v anchor focused %v", a.open, tt.Focused("Filter"))
|
||||
}
|
||||
_ = tt.Click("Filter")
|
||||
_ = tt.Click("Apply")
|
||||
if a.applied != 1 || a.open {
|
||||
t.Fatalf("applied %d open %v", a.applied, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopoverNeedsAnchor(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Popover(c, nil, &open, PopoverOptions{}, nil)
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestPopoverSnapshot(t *testing.T) {
|
||||
snapshot(t, "popover", 420, 260, func(c *ui.Context) {
|
||||
open := true
|
||||
ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() {
|
||||
b := Button(c, "Guests", ButtonOptions{Variant: Secondary})
|
||||
Popover(c, b, &open, PopoverOptions{Title: "Guest list", Width: 240}, func() {
|
||||
ui.Text(c, "Twelve guests confirmed; three await a reply.").TextColor(Tokens(c).TextMuted)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PopupMenuItemOptions configure one item of a PopupMenu.
|
||||
type PopupMenuItemOptions struct {
|
||||
Disabled bool
|
||||
// KeepOpen keeps the menu open after the item is chosen.
|
||||
KeepOpen bool
|
||||
}
|
||||
|
||||
// PopupMenu builds the items of a DropdownMenu or ContextMenu. Its methods
|
||||
// add items in order and report choices in the frame they happen.
|
||||
type PopupMenu struct {
|
||||
c *ui.Context
|
||||
root *popupMenuRoot
|
||||
level *popupMenuLevel
|
||||
subs []popupMenuSub
|
||||
seq, rows int
|
||||
autofocus bool
|
||||
}
|
||||
|
||||
// popupMenuRoot carries what every level of one open menu shares in a frame.
|
||||
type popupMenuRoot struct{ close bool }
|
||||
|
||||
// popupMenuLevel is what one menu level keeps while it shows.
|
||||
type popupMenuLevel struct {
|
||||
open int // seq of the open submenu, 0 for none
|
||||
byKey bool // the open submenu was opened from the keyboard
|
||||
}
|
||||
|
||||
type popupMenuSub struct {
|
||||
item *ui.Element
|
||||
seq int
|
||||
build func(m *PopupMenu)
|
||||
}
|
||||
|
||||
// Item adds an item showing label and reports whether it was chosen.
|
||||
// Choosing it closes the menu unless KeepOpen is set.
|
||||
func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool {
|
||||
it := m.row(ui.ButtonBase(m.c), opts.Disabled, false)
|
||||
m.content(it, label, false, false)
|
||||
if !it.Clicked() {
|
||||
return false
|
||||
}
|
||||
slog.Debug("mujica: popup_menu choose", "item", label)
|
||||
m.root.close = m.root.close || !opts.KeepOpen
|
||||
return true
|
||||
}
|
||||
|
||||
// Check adds an item toggling *checked and reports whether it changed.
|
||||
func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions) bool {
|
||||
it := m.row(ui.CheckboxBase(m.c, checked), opts.Disabled, false)
|
||||
m.content(it, label, *checked, false)
|
||||
if !it.Changed() {
|
||||
return false
|
||||
}
|
||||
slog.Debug("mujica: popup_menu check", "item", label, "checked", *checked)
|
||||
m.root.close = m.root.close || !opts.KeepOpen
|
||||
return true
|
||||
}
|
||||
|
||||
// Submenu adds an item opening a nested menu that build fills. Hover,
|
||||
// click, Enter, Space or Right opens it; Left or Escape closes it.
|
||||
func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) {
|
||||
if build == nil {
|
||||
panic("mujica: PopupMenu Submenu needs a build function")
|
||||
}
|
||||
it := m.row(ui.ButtonBase(m.c), opts.Disabled, true)
|
||||
seq := m.seq
|
||||
m.content(it, label, false, true)
|
||||
if opts.Disabled {
|
||||
return
|
||||
}
|
||||
if it.Clicked() || it.Shortcut(0, ui.KeyRight) {
|
||||
m.level.open, m.level.byKey = seq, it.FocusVisible()
|
||||
slog.Debug("mujica: popup_menu submenu", "item", label)
|
||||
}
|
||||
m.subs = append(m.subs, popupMenuSub{it, seq, build})
|
||||
}
|
||||
|
||||
// Group adds a captioned group of items, separated from what comes before.
|
||||
func (m *PopupMenu) Group(label string, fn func()) {
|
||||
k := Tokens(m.c)
|
||||
u := Density(m.c).Unit()
|
||||
if m.rows > 0 {
|
||||
m.Separator()
|
||||
}
|
||||
if label != "" {
|
||||
ui.Text(m.c, label).FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0)
|
||||
}
|
||||
fn()
|
||||
}
|
||||
|
||||
// Separator adds a line between items.
|
||||
func (m *PopupMenu) Separator() {
|
||||
u := Density(m.c).Unit()
|
||||
ui.Box(m.c).Height(theme.BorderWidth).Margin(u, u*2).Background(Tokens(m.c).Border).Shrink(0)
|
||||
}
|
||||
|
||||
// row styles an item element and runs the hover, focus and highlight logic.
|
||||
func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element {
|
||||
c := m.c
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
m.seq++
|
||||
m.rows++
|
||||
seq := m.seq
|
||||
it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).FocusRing(false).TextColor(k.Text)
|
||||
if disabled {
|
||||
return it
|
||||
}
|
||||
if m.autofocus {
|
||||
it.AutoFocus()
|
||||
m.autofocus = false
|
||||
}
|
||||
hovered := it.Hovered()
|
||||
was := ui.Local(it, "mujica.menu.hover", func() bool { return false })
|
||||
if hovered && !*was {
|
||||
it.Focus() // focus follows the pointer
|
||||
if sub {
|
||||
m.level.open, m.level.byKey = seq, false
|
||||
} else {
|
||||
m.level.open = 0
|
||||
}
|
||||
}
|
||||
*was = hovered
|
||||
if hovered || it.FocusVisible() || m.level.open == seq {
|
||||
it.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
|
||||
})
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
// content lays out the check column, label and submenu chevron of an item.
|
||||
func (m *PopupMenu) content(it *ui.Element, label string, checked, sub bool) {
|
||||
c := m.c
|
||||
k := Tokens(c)
|
||||
it.Children(func() {
|
||||
mark := ui.Box(c).Size(16, 16).Shrink(0)
|
||||
if checked {
|
||||
mark.Children(func() { ui.Icon(c, icons.Must("check")).Size(16, 16).TextColor(k.AccentText) })
|
||||
}
|
||||
ui.Text(c, label).SingleLine().Grow(1)
|
||||
if sub {
|
||||
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted).Shrink(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// popupMenuOpen shows the menu build fills below anchor while *open is true.
|
||||
func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *PopupMenu)) *ui.Element {
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
b := anchor.Bounds()
|
||||
root := &popupMenuRoot{}
|
||||
panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(4, 0, 0, 0).MinWidth(max(b.W, 200)).MaxWidth(360).MaxHeight(popoverRoom(c, b))
|
||||
items := popupMenuLevelBuild(c, panel, root, build, true)
|
||||
if items.Shortcut(0, ui.KeyTab) || items.Shortcut(ui.Shift, ui.KeyTab) {
|
||||
root.close = true
|
||||
}
|
||||
})
|
||||
if root.close {
|
||||
*open = false
|
||||
}
|
||||
if !*open {
|
||||
slog.Debug("mujica: popup_menu close")
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
// popupMenuLevelBuild builds one level's items in outer, then its open submenu.
|
||||
func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) *ui.Element {
|
||||
u := Density(c).Unit()
|
||||
items := ui.Scroll(c).Shrink(1).Padding(u).FocusGroup(ui.Vertical)
|
||||
m := &PopupMenu{c: c, root: root, autofocus: autofocus}
|
||||
m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} })
|
||||
outer.Role(ui.RolePopup)
|
||||
items.Children(func() { build(m) })
|
||||
for _, s := range m.subs {
|
||||
if m.level.open == s.seq {
|
||||
popupMenuSubmenu(c, outer, root, m.level, s)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window.
|
||||
func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) {
|
||||
u := Density(c).Unit()
|
||||
ob, ib := outer.Bounds(), s.item.Bounds()
|
||||
box := ui.Column(c).Absolute().Key(s.seq).MinWidth(180).MaxWidth(360)
|
||||
sb := box.Bounds()
|
||||
ww, wh := c.Size()
|
||||
left, top := ib.X+ib.W+u-ob.X, ib.Y-u-ob.Y
|
||||
if ob.X+left+sb.W > ww-4 {
|
||||
left = ib.X - u - sb.W - ob.X
|
||||
}
|
||||
top = min(top, wh-4-sb.H-ob.Y)
|
||||
box.Left(left).Top(top).MaxHeight(wh - 8)
|
||||
p := overlayEnter(c, box)
|
||||
if sb.W == 0 || ob.W == 0 {
|
||||
p = 0 // not measured yet
|
||||
c.AnimationFrame()
|
||||
}
|
||||
overlayStyle(c, box, p)
|
||||
box.Children(func() { popupMenuLevelBuild(c, box, root, s.build, lv.byKey) })
|
||||
if box.Shortcut(0, ui.KeyLeft) || box.Shortcut(0, ui.KeyEscape) {
|
||||
lv.open = 0
|
||||
s.item.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// popupMenuTrigger styles a menu trigger button with a trailing chevron.
|
||||
func popupMenuTrigger(c *ui.Context, b *ui.Element, label string, icon *ui.SVG, open bool) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
b.MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3, 0, d.Unit()*4).
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
bg, fg, border := buttonColors(k, Secondary, b.Hovered() && !b.IsDisabled(), b.Pressed() || open)
|
||||
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, border)
|
||||
b.Children(func() {
|
||||
if icon != nil {
|
||||
ui.Icon(c, icon).Size(16, 16)
|
||||
}
|
||||
if label != "" {
|
||||
ui.Text(c, label).SingleLine()
|
||||
}
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,86 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TooltipOptions configure a Tooltip.
|
||||
type TooltipOptions struct {
|
||||
// Delay is how long hover or focus rests before the tip shows: 500 ms when zero.
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
// tooltipState tracks when the anchor became hot and whether Escape hid the tip.
|
||||
type tooltipState struct {
|
||||
since time.Time
|
||||
dismissed bool
|
||||
}
|
||||
|
||||
const tooltipMaxWidth = 240
|
||||
|
||||
// Tooltip shows text near anchor once the pointer rests on it or it has
|
||||
// the keyboard focus. The tip holds no interactive content, passes the
|
||||
// pointer through, stays in the window and hides on leave, blur or Escape.
|
||||
// It also describes anchor to assistive technology.
|
||||
func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element {
|
||||
if anchor == nil {
|
||||
panic("mujica: Tooltip needs an anchor")
|
||||
}
|
||||
if opts.Delay < 0 {
|
||||
panic("mujica: Tooltip Delay is negative")
|
||||
}
|
||||
if text == "" {
|
||||
return anchor
|
||||
}
|
||||
anchor.Description(text)
|
||||
st := ui.Local(anchor, "mujica.tooltip", func() tooltipState { return tooltipState{} })
|
||||
hot := anchor.Hovered() || anchor.FocusVisible()
|
||||
if !hot {
|
||||
st.since, st.dismissed = time.Time{}, false
|
||||
return anchor
|
||||
}
|
||||
if st.dismissed {
|
||||
return anchor
|
||||
}
|
||||
if st.since.IsZero() {
|
||||
st.since = c.Now()
|
||||
}
|
||||
delay := opts.Delay
|
||||
if delay == 0 {
|
||||
delay = 500 * time.Millisecond
|
||||
}
|
||||
if wait := delay - c.Now().Sub(st.since); wait > 0 {
|
||||
c.After(wait)
|
||||
return anchor
|
||||
}
|
||||
if c.Shortcut(0, ui.KeyEscape) {
|
||||
slog.Debug("mujica: tooltip dismiss")
|
||||
st.dismissed = true
|
||||
return anchor
|
||||
}
|
||||
tooltipShow(c, anchor.Bounds(), text)
|
||||
return anchor
|
||||
}
|
||||
|
||||
// tooltipShow draws the tip below a, or above it when there is no room.
|
||||
func tooltipShow(c *ui.Context, a ui.Rect, text string) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
size := theme.CaptionSize + 1
|
||||
tw, th := c.MeasureText(tooltipMaxWidth, ui.Span{Text: text, Size: size, Font: c.Theme().Font})
|
||||
padX, padY := u*2.5, u*1.5
|
||||
w, h := tw+2*(padX+theme.BorderWidth)+1, th+2*(padY+theme.BorderWidth)
|
||||
x, y := overlayPlace(c, ui.Rect{X: a.X + a.W/2 - w/2, Y: a.Y, W: w, H: a.H}, w, h, 6)
|
||||
ui.Overlay(c, func() {
|
||||
ui.Box(c).Absolute().Left(x).Top(y).Width(w).Padding(padY, padX).Radius(theme.ControlRadius).
|
||||
Background(k.Text).TextColor(k.Background).Border(theme.BorderWidth, k.Text).
|
||||
PassThrough().Role(ui.RoleTooltip).Children(func() {
|
||||
ui.Text(c, text).FontSize(size)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const tooltipTestDelay = 20 * time.Millisecond
|
||||
|
||||
func tooltipTester(x, y float32) (*ui.Tester, *int) {
|
||||
clicks := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(y, 0, 0, x).Children(func() {
|
||||
b := Button(c, "Share", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
clicks++
|
||||
}
|
||||
Tooltip(c, b, "Share with the court", TooltipOptions{Delay: tooltipTestDelay})
|
||||
})
|
||||
}, 400, 300)
|
||||
return tt, &clicks
|
||||
}
|
||||
|
||||
func waitTooltip(tt *ui.Tester) {
|
||||
time.Sleep(tooltipTestDelay + 10*time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestTooltipHoverDelay(t *testing.T) {
|
||||
tt, clicks := tooltipTester(40, 40)
|
||||
r, _ := tt.Find("Share")
|
||||
tt.Move(r.X+4, r.Y+4)
|
||||
if tt.HasText("Share with the court") {
|
||||
t.Fatal("tip showed before the delay")
|
||||
}
|
||||
waitTooltip(tt)
|
||||
tip, ok := tt.Find("Share with the court")
|
||||
if !ok || tip.Y < r.Y+r.H {
|
||||
t.Fatalf("tip %+v ok %v below %+v", tip, ok, r)
|
||||
}
|
||||
// The tip passes the pointer through: clicking it clicks what is below.
|
||||
tt.Move(400, 300)
|
||||
tt.Frame()
|
||||
if tt.HasText("Share with the court") {
|
||||
t.Fatal("tip stayed after leave")
|
||||
}
|
||||
_ = tt.Click("Share")
|
||||
if *clicks != 1 {
|
||||
t.Fatalf("clicks %d", *clicks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooltipFocusAndEscape(t *testing.T) {
|
||||
tt, _ := tooltipTester(40, 40)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
waitTooltip(tt)
|
||||
if !tt.HasText("Share with the court") {
|
||||
t.Fatal("no tip on keyboard focus")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Share with the court") {
|
||||
t.Fatal("Escape did not hide the tip")
|
||||
}
|
||||
waitTooltip(tt)
|
||||
if tt.HasText("Share with the court") {
|
||||
t.Fatal("tip came back while still focused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooltipEdgeFlips(t *testing.T) {
|
||||
tt, _ := tooltipTester(330, 262)
|
||||
r, _ := tt.Find("Share")
|
||||
tt.Move(r.X+4, r.Y+4)
|
||||
waitTooltip(tt)
|
||||
tip, ok := tt.Find("Share with the court")
|
||||
if !ok || tip.Y > r.Y || tip.X+tip.W > 400 {
|
||||
t.Fatalf("tip %+v not above and inside for anchor %+v", tip, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooltipSnapshot(t *testing.T) {
|
||||
snapshot(t, "tooltip", 360, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() {
|
||||
Button(c, "Seal", ButtonOptions{Variant: Secondary})
|
||||
tooltipShow(c, ui.Rect{X: 24, Y: 24, W: 62, H: 36}, "Seal the letter with wax")
|
||||
})
|
||||
})
|
||||
}
|
||||