Upgrade MyGo to v0.5.5
MyGo v0.5.5 returns ui.Element as a checked value handle instead of a
pointer. Migrated with `mygo migrate-ui`, then fixed what the codemod
leaves to review:
- Element nil checks become .Valid(); absent elements are ui.Element{}.
- Input handlers and paint callbacks run outside the build pass, where
elements expire and Context methods are inert: read IsDisabled into a
local during the build, keep a ui.Handle for Focus, use c.Services()
for clipboard access, create ui.Local state during the build.
- Add core.AutoFocus: v0.5.5 records an overlay's opener after its
content builds, so same-frame autofocus lost focus restore on close.
It focuses one frame later; cascader rows defer their focus likewise.
- core.OverlayLayer identifies build passes by the root element, not
c.Now(), which repeats across passes and under the Tester clock.
- Context menus survive the opening press counted as PressedOutside;
split button tooltip no longer reorders its open menu; disabled
menubar rows pass presses to the panel; Form defers blur-started
async checks so the growing message no longer moves Submit away
from the release; Combobox drops its disabled copy now that MyGo
blocks typing under a disabled wrapper (canary flipped to a guard).
- The Tester clock is virtual: tests use Advance/Now instead of
time.Sleep/time.Now and read element state inside the view. The web
mirror advances the clock to wall time before each frame and input.
- AGENTS.md and docs describe the value Element API and the new pin.
Snapshot goldens are darwin-only and were not re-verified here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bc716d27be
commit
bcce34cda1
603 files changed
+1366
-1277
No files matched your search
+1
-1
@@ -34,7 +34,7 @@ type ButtonOptions struct {
|
||||
const buttonSpinnerSize float32 = 16
|
||||
|
||||
// Button is a push button. Loading keeps its size and blocks clicks.
|
||||
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
||||
func Button(c *ui.Context, label string, opts ButtonOptions) ui.Element {
|
||||
if label == "" && opts.Label == "" {
|
||||
panic("mujica: Button without a label needs Label to name it")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type ButtonGroupOptions struct {
|
||||
|
||||
// ButtonGroupResult is the group element and the item clicked this frame.
|
||||
type ButtonGroupResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
clicked int
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions
|
||||
if it.Label == "" && it.Name == "" {
|
||||
panic("mujica: icon-only ButtonGroupItem needs a Name")
|
||||
}
|
||||
b := ui.ButtonBase(c).Key(i).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).
|
||||
b := ui.ButtonBase(c.Key(i)).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).
|
||||
Padding(0, d.Unit()*3).Gap(d.Unit() * 2).Disabled(it.Disabled)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if it.Name != "" {
|
||||
|
||||
+10
-5
@@ -62,6 +62,7 @@ type cascaderState struct {
|
||||
requested map[string]bool
|
||||
focus []string
|
||||
focusSet bool
|
||||
settling bool // the popup opened this frame; MyGo records its opener before rows take focus
|
||||
}
|
||||
|
||||
type cascaderKey struct{}
|
||||
@@ -84,7 +85,7 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe
|
||||
st.open = !st.open
|
||||
if st.open {
|
||||
st.active = slices.Clone(*path)
|
||||
st.focus, st.focusSet = slices.Clone(*path), true
|
||||
st.focus, st.focusSet, st.settling = slices.Clone(*path), true, true
|
||||
if len(*path) == 0 {
|
||||
st.focus = []string{""}
|
||||
}
|
||||
@@ -97,7 +98,7 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe
|
||||
}
|
||||
ui.Text(c, cascaderLabels(opts, *path)).SingleLine().Grow(1).Shrink(1)
|
||||
})
|
||||
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
|
||||
ui.PopoverBase(c, tr, &st.open, func(panel ui.Element) {
|
||||
SelectPanel(c, panel)
|
||||
panel.Padding(0)
|
||||
ui.Row(c).AlignItems(ui.Stretch).Dividers(theme.BorderWidth, core.Tokens(c).Border).Children(func() {
|
||||
@@ -108,6 +109,10 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) core.ChoiceRe
|
||||
}
|
||||
})
|
||||
})
|
||||
if st.settling {
|
||||
st.settling = false
|
||||
c.Invalidate()
|
||||
}
|
||||
changed := !slices.Equal(before, *path)
|
||||
if changed {
|
||||
slog.Debug("mujica: cascader changed", "label", opts.Label, "path", *path)
|
||||
@@ -147,7 +152,7 @@ func cascaderColumn(c *ui.Context, st *cascaderState, path *[]string, opts Casca
|
||||
delete(st.requested, id)
|
||||
}
|
||||
deeper := false
|
||||
col := ui.Column(c).Key(id).Width(180).FocusGroup(ui.Vertical)
|
||||
col := ui.Column(c.Key(id)).Width(180).FocusGroup(ui.Vertical)
|
||||
col.Children(func() {
|
||||
switch {
|
||||
case lv.Status == CascaderIdle || lv.Status == CascaderLoading:
|
||||
@@ -188,10 +193,10 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
||||
d := core.Density(c)
|
||||
full := append(slices.Clone(prefix), n.Value)
|
||||
depth := len(prefix)
|
||||
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
b := ui.ButtonBase(c.Key(n.Value)).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
|
||||
if st.focusSet && !st.settling && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
|
||||
b.Focus()
|
||||
st.focusSet = false
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,14 +21,14 @@ type CheckboxOptions struct {
|
||||
const checkboxSize float32 = 16
|
||||
|
||||
// Checkbox toggles *value; a click on the box or its label toggles, as does Space.
|
||||
func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element {
|
||||
func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) ui.Element {
|
||||
if value == nil {
|
||||
panic("mujica: Checkbox needs a value")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
// Disabled on a wrapper blocks the base's click before it toggles.
|
||||
var row *ui.Element
|
||||
var row ui.Element
|
||||
wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
|
||||
wrap.Children(func() {
|
||||
row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
||||
|
||||
@@ -54,7 +54,7 @@ func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T],
|
||||
return core.NewChoiceResult(g, changed)
|
||||
}
|
||||
|
||||
func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) {
|
||||
func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b ui.Element, on bool, it ToggleItem[T]) {
|
||||
b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 1.5).
|
||||
Radius(theme.PopupRadius)
|
||||
internal.Ring(b, k.Focus, theme.PopupRadius)
|
||||
|
||||
@@ -139,15 +139,16 @@ func TestChoiceHitAreas(t *testing.T) {
|
||||
func TestChoiceRowsFollowDensity(t *testing.T) {
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
|
||||
v, on := "apple", false
|
||||
var sw *ui.Element
|
||||
var sw ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Density: d})
|
||||
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
|
||||
sw = Switch(c, &on, "Wi-Fi", SwitchOptions{})
|
||||
sw = Switch(c, &on, "Wi-Fi", SwitchOptions{}).Bounds()
|
||||
Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"})
|
||||
})
|
||||
}, 400, 500)
|
||||
if h := sw.Bounds().H; h < d.ControlHeight() {
|
||||
tt.Frame() // Bounds reads the previous frame
|
||||
if h := sw.H; h < d.ControlHeight() {
|
||||
t.Errorf("%v: switch %v high", d, h)
|
||||
}
|
||||
_ = tt.Click("Fruit")
|
||||
|
||||
@@ -52,7 +52,7 @@ func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts C
|
||||
changed := false
|
||||
g.Children(func() {
|
||||
grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal)
|
||||
var cells []*ui.Element
|
||||
var cells []ui.Element
|
||||
grid.Children(func() {
|
||||
for i, s := range swatches {
|
||||
b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i)
|
||||
@@ -86,7 +86,7 @@ func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts C
|
||||
}
|
||||
|
||||
// colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check.
|
||||
func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element {
|
||||
func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) ui.Element {
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio).
|
||||
Label(s.Name).Tooltip(s.Name).Selected(on)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
|
||||
@@ -26,7 +26,7 @@ type ColorPickerOptions struct {
|
||||
|
||||
// ColorPickerParts are the parts of a ColorPicker.
|
||||
type ColorPickerParts struct {
|
||||
Trigger *ui.Element
|
||||
Trigger ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
|
||||
})
|
||||
opened := st.open && !st.wasOpen
|
||||
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
|
||||
ui.PopoverBase(c, trigger, &st.open, func(panel ui.Element) {
|
||||
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
|
||||
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3).Width(260)
|
||||
colorPickerSquare(c, st, set, opened)
|
||||
@@ -211,7 +211,7 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker
|
||||
}
|
||||
|
||||
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
||||
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
||||
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
const knob, look = 10, 18
|
||||
s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name)
|
||||
|
||||
+4
-10
@@ -54,16 +54,10 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
||||
// MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input.
|
||||
gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 })
|
||||
refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false })
|
||||
var p *ui.ComboboxParts
|
||||
var p ui.ComboboxParts
|
||||
f.Children(func() {
|
||||
// A focused ComboboxBase takes typing under a disabled wrapper, so it edits a copy.
|
||||
target := query
|
||||
if opts.Disabled {
|
||||
cp := *query
|
||||
target = &cp
|
||||
}
|
||||
ui.Row(c).Key(*gen).Grow(1).Children(func() {
|
||||
p = ui.ComboboxBase(c, target)
|
||||
ui.Row(c.Key(*gen)).Grow(1).Children(func() {
|
||||
p = ui.ComboboxBase(c, query)
|
||||
})
|
||||
if *refocus {
|
||||
p.Input.Focus()
|
||||
@@ -113,7 +107,7 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
||||
if !p.Open() {
|
||||
*lastHi = -1
|
||||
}
|
||||
p.Popup(func(panel *ui.Element) {
|
||||
p.Popup(func(panel ui.Element) {
|
||||
SelectPanel(c, panel)
|
||||
ui.Scroll(c).MaxHeight(240).Children(func() {
|
||||
if custom != "" {
|
||||
|
||||
@@ -26,7 +26,7 @@ func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) core.ChoiceR
|
||||
copied := core.Msg(c, "copy_button.copied")
|
||||
name := core.Msg(c, "copy_button.copy")
|
||||
label, icon := opts.Label, icons.Must("copy")
|
||||
var b *ui.Element
|
||||
var b ui.Element
|
||||
box := ui.Box(c)
|
||||
at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} })
|
||||
left := copyButtonShown - c.Now().Sub(*at)
|
||||
|
||||
@@ -54,8 +54,7 @@ func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) {
|
||||
if _, ok := tt.Find("已复制"); !ok {
|
||||
t.Fatal("icon button lacks the copied name")
|
||||
}
|
||||
time.Sleep(copyButtonShown + 50*time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(copyButtonShown + 50*time.Millisecond)
|
||||
if _, ok := tt.Find("复制"); !ok {
|
||||
t.Fatal("did not revert to Copy")
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func CurrencyInput(c *ui.Context, value *int64, opts CurrencyInputOptions) Input
|
||||
k := core.Tokens(c)
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
if opts.Symbol != "" {
|
||||
ui.Text(c, opts.Symbol).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
|
||||
+2
-2
@@ -124,7 +124,7 @@ func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout,
|
||||
if len(ids) == 0 {
|
||||
return false
|
||||
}
|
||||
box := ui.Column(c).Key(z).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup).
|
||||
box := ui.Column(c.Key(z)).MinWidth(0).MinHeight(0).Background(k.Surface).Clip().Role(ui.RoleGroup).
|
||||
Label(core.Msg(c, fmt.Sprintf("dock_panel.zone%d", z)))
|
||||
switch z {
|
||||
case DockLeft, DockRight:
|
||||
@@ -173,7 +173,7 @@ func dockPanelZone(c *ui.Context, k theme.Tokens, u float32, layout *DockLayout,
|
||||
// dockPanelEdges shows drop strips on the four edges while a tab is dragged.
|
||||
func dockPanelEdges(c *ui.Context, k theme.Tokens, u float32, move func(string, DockZone)) {
|
||||
for _, z := range []DockZone{DockLeft, DockRight, DockBottom, DockCenter} {
|
||||
e := ui.Box(c).Key(fmt.Sprint("edge", z)).Absolute().Role(ui.RoleNone)
|
||||
e := ui.Box(c.Key(fmt.Sprint("edge", z))).Absolute().Role(ui.RoleNone)
|
||||
switch z {
|
||||
case DockLeft:
|
||||
e.Left(0).Top(0).Bottom(0).Width(12 * u)
|
||||
|
||||
@@ -16,7 +16,7 @@ func ExampleForm() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res := input.Form(c, &state, input.FormOptions{}, func(f *input.FormScope) {
|
||||
input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element {
|
||||
input.FormItem(c, f, "name", &name, input.FormItemOptions[string]{Label: "Name", Required: true}, func() ui.Element {
|
||||
return input.InputGroup(c, &name, input.InputGroupOptions{Invalid: state.Error("name") != ""}).Input
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ func ExampleFormField() {
|
||||
email := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
|
||||
input.FormField(c, "Email", input.FormFieldOptions{Required: true, Description: "For receipts."}, func() ui.Element {
|
||||
return input.InputGroup(c, &email, input.InputGroupOptions{}).Input
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
row := func(key string, icon string, color ui.Color, name, detail string, remove func()) {
|
||||
ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
ui.Row(c.Key(key)).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
|
||||
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
||||
|
||||
@@ -32,7 +32,7 @@ type FilePickerOptions struct {
|
||||
|
||||
// FilePickerParts are the parts of a FilePicker.
|
||||
type FilePickerParts struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
changed bool
|
||||
canceled bool
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePick
|
||||
default:
|
||||
ui.Column(c).Gap(d.Unit()).Children(func() {
|
||||
for _, p := range *paths {
|
||||
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Row(c.Key(p)).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
|
||||
ui.Text(c, filepath.Base(p)).SingleLine()
|
||||
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
||||
|
||||
+29
-11
@@ -35,6 +35,7 @@ type FormFieldState struct {
|
||||
asyncFor uint64
|
||||
asyncErr string
|
||||
pending bool
|
||||
owed bool // blurred valid; the check starts once no form button is held
|
||||
hasAsync bool
|
||||
check func() string
|
||||
refresh func()
|
||||
@@ -107,7 +108,7 @@ type FormScope struct {
|
||||
|
||||
// FormParts are the parts of a Form.
|
||||
type FormParts struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
submitted, reset bool
|
||||
}
|
||||
|
||||
@@ -142,9 +143,10 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
||||
col := ui.Column(c).Gap(d.Unit() * 5)
|
||||
parts.Element = col
|
||||
scope := &FormScope{st: st, horizontal: opts.Horizontal}
|
||||
held := false
|
||||
col.Children(func() {
|
||||
// A new key after Reset drops the controls' drafts and errors.
|
||||
ui.Box(c).Key(st.generation).Children(func() {
|
||||
ui.Box(c.Key(st.generation)).Children(func() {
|
||||
if opts.Horizontal {
|
||||
formFieldGrid(c, func() { fields(scope) })
|
||||
} else {
|
||||
@@ -162,13 +164,17 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
||||
if reset == "" {
|
||||
reset = core.Msg(c, "form.reset")
|
||||
}
|
||||
if Button(c, submit, ButtonOptions{Loading: st.submitting || st.Awaiting}).Clicked() {
|
||||
sb := Button(c, submit, ButtonOptions{Loading: st.submitting || st.Awaiting})
|
||||
if sb.Clicked() {
|
||||
st.Requested = true
|
||||
}
|
||||
if Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting}).Clicked() {
|
||||
rb := Button(c, reset, ButtonOptions{Variant: Secondary, Disabled: st.submitting})
|
||||
if rb.Clicked() {
|
||||
formReset(st)
|
||||
parts.reset = true
|
||||
}
|
||||
// A "Checking…" line would move the buttons under the pointer before the release.
|
||||
held = sb.Pressed() || rb.Pressed()
|
||||
})
|
||||
})
|
||||
if st.Requested {
|
||||
@@ -180,15 +186,29 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
||||
if st.Awaiting {
|
||||
parts.submitted = formSettle(c, st)
|
||||
}
|
||||
if !held {
|
||||
formStartOwed(c, st)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// formStartOwed starts the async checks of fields blurred while a form button was held.
|
||||
func formStartOwed(c *ui.Context, st *FormState) {
|
||||
for _, name := range st.Order {
|
||||
if f := st.Fields[name]; f != nil && f.owed {
|
||||
f.owed = false
|
||||
f.startAsync()
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formStartSubmit validates every field and starts the async checks still owed.
|
||||
func formStartSubmit(st *FormState) {
|
||||
st.tried, st.Awaiting, st.formError = true, true, ""
|
||||
for _, name := range st.Order {
|
||||
f := st.Fields[name]
|
||||
f.err = f.check()
|
||||
f.err, f.owed = f.check(), false
|
||||
if f.err == "" {
|
||||
f.startAsync()
|
||||
}
|
||||
@@ -243,7 +263,7 @@ func formReset(st *FormState) {
|
||||
}
|
||||
|
||||
// FormItem registers field name around the focusable element control returns.
|
||||
func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
||||
func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() ui.Element) ui.Element {
|
||||
st := f.st
|
||||
if name == "" || opts.Label == "" {
|
||||
panic("mujica: FormItem needs a name and a Label")
|
||||
@@ -294,8 +314,8 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo
|
||||
if fs.pending {
|
||||
desc = core.Msg(c, "form.checking")
|
||||
}
|
||||
var ctrl *ui.Element
|
||||
field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element {
|
||||
var ctrl ui.Element
|
||||
field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() ui.Element {
|
||||
ctrl = control()
|
||||
return ctrl
|
||||
})
|
||||
@@ -304,9 +324,7 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo
|
||||
if fs.focused && !focused {
|
||||
fs.touched = true
|
||||
fs.err = fs.check()
|
||||
if fs.err == "" {
|
||||
fs.startAsync()
|
||||
}
|
||||
fs.owed = fs.err == ""
|
||||
slog.Debug("mujica: form field blurred", "field", name, "valid", fs.err == "")
|
||||
}
|
||||
fs.focused = focused
|
||||
|
||||
+7
-7
@@ -19,17 +19,17 @@ type FormFieldOptions struct {
|
||||
}
|
||||
|
||||
// FormField labels the element control returns and shows help and error text for it.
|
||||
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element {
|
||||
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() ui.Element) ui.Element {
|
||||
if !opts.Horizontal {
|
||||
return formFieldBuild(c, "", label, opts, false, control)
|
||||
}
|
||||
var f *ui.Element
|
||||
var f ui.Element
|
||||
formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) })
|
||||
return f
|
||||
}
|
||||
|
||||
// formFieldGrid holds fields whose labels sit in a column beside their controls.
|
||||
func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
|
||||
func formFieldGrid(c *ui.Context, fields func()) ui.Element {
|
||||
d := core.Density(c)
|
||||
return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4).
|
||||
AlignItems(ui.Start).Children(fields)
|
||||
@@ -37,13 +37,13 @@ func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
|
||||
|
||||
// formFieldBuild builds the label and the control column; beside makes them two grid cells.
|
||||
// formFieldBuild keys its cells by key when set, so focus follows a reordered field.
|
||||
func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element {
|
||||
func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() ui.Element) ui.Element {
|
||||
if label == "" {
|
||||
panic("mujica: FormField needs a label")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var head, content, ctrl *ui.Element
|
||||
var head, content, ctrl ui.Element
|
||||
body := func() {
|
||||
head = ui.Row(c)
|
||||
if key != "" && beside {
|
||||
@@ -53,7 +53,7 @@ func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, bes
|
||||
if beside {
|
||||
head.MinHeight(d.ControlHeight()).Justify(ui.End)
|
||||
}
|
||||
var text *ui.Element
|
||||
var text ui.Element
|
||||
head.Children(func() {
|
||||
text = ui.Text(c, label).FontWeight(500).SingleLine()
|
||||
if opts.Required {
|
||||
@@ -68,7 +68,7 @@ func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, bes
|
||||
content.Gap(d.Unit() * 1.5).MinWidth(0)
|
||||
content.Children(func() {
|
||||
ctrl = control()
|
||||
if ctrl == nil {
|
||||
if !ctrl.Valid() {
|
||||
panic("mujica: FormField " + label + ": control returned nil")
|
||||
}
|
||||
if opts.Description != "" {
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestFormFieldLabelFocusesControl(t *testing.T) {
|
||||
name := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element {
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() ui.Element {
|
||||
return InputGroup(c, &name, InputGroupOptions{}).Input
|
||||
})
|
||||
}, 400, 200)
|
||||
@@ -35,10 +35,10 @@ func TestFormFieldTargetsReturnedControl(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Gap(12).Children(func() {
|
||||
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element {
|
||||
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() ui.Element {
|
||||
return TagsInput(c, &tags, TagsInputOptions{}).Input
|
||||
})
|
||||
FormField(c, "Site", FormFieldOptions{}, func() *ui.Element {
|
||||
FormField(c, "Site", FormFieldOptions{}, func() ui.Element {
|
||||
return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input
|
||||
})
|
||||
})
|
||||
@@ -64,7 +64,7 @@ func TestFormFieldErrorAndLayout(t *testing.T) {
|
||||
horizontal := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element {
|
||||
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() ui.Element {
|
||||
return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input
|
||||
})
|
||||
}, 500, 200)
|
||||
@@ -93,7 +93,7 @@ func TestFormFieldNeedsLabel(t *testing.T) {
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) })
|
||||
FormField(c, "", FormFieldOptions{}, func() ui.Element { return ui.Box(c) })
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ func TestFormFieldSnapshot(t *testing.T) {
|
||||
a, b := "Sakiko Togawa", "uika@"
|
||||
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(16).Children(func() {
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element {
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() ui.Element {
|
||||
return InputGroup(c, &a, InputGroupOptions{}).Input
|
||||
})
|
||||
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element {
|
||||
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() ui.Element {
|
||||
return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input
|
||||
})
|
||||
})
|
||||
|
||||
+7
-7
@@ -27,7 +27,7 @@ func (p *formProbe) run(c *ui.Context) {
|
||||
|
||||
func (p *formProbe) view(c *ui.Context) {
|
||||
res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) {
|
||||
FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element {
|
||||
FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() ui.Element {
|
||||
return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input
|
||||
})
|
||||
opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string {
|
||||
@@ -42,10 +42,10 @@ func (p *formProbe) view(c *ui.Context) {
|
||||
p.asyncValues = append(p.asyncValues, v)
|
||||
}
|
||||
}
|
||||
FormItem(c, f, "email", &p.email, opts, func() *ui.Element {
|
||||
FormItem(c, f, "email", &p.email, opts, func() ui.Element {
|
||||
return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input
|
||||
})
|
||||
FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element {
|
||||
FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() ui.Element {
|
||||
return TagsInput(c, &p.tags, TagsInputOptions{}).Input
|
||||
})
|
||||
})
|
||||
@@ -224,7 +224,7 @@ func TestFormResetClearsControlState(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
|
||||
FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() ui.Element {
|
||||
return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string {
|
||||
seen = s
|
||||
return "Invalid tag"
|
||||
@@ -291,10 +291,10 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res := Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
|
||||
FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() ui.Element {
|
||||
return InputGroup(c, &user, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
|
||||
FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() ui.Element {
|
||||
return InputGroup(c, &pw, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(c, f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
|
||||
@@ -302,7 +302,7 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) {
|
||||
return "Passwords differ."
|
||||
}
|
||||
return ""
|
||||
}}, func() *ui.Element {
|
||||
}}, func() ui.Element {
|
||||
return InputGroup(c, &confirm, InputGroupOptions{}).Input
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,9 +63,10 @@ func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) core.
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} })
|
||||
s := *st
|
||||
disabled := b.IsDisabled()
|
||||
// Space holds; Enter and Space never click, so no single key press confirms.
|
||||
b.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() {
|
||||
if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || disabled {
|
||||
return false
|
||||
}
|
||||
if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) {
|
||||
@@ -74,7 +75,6 @@ func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) core.
|
||||
return true
|
||||
})
|
||||
_ = b.Clicked() // a click alone does nothing; it keeps the base pressable
|
||||
disabled := b.IsDisabled()
|
||||
held := !disabled && (s.key || b.Pressed())
|
||||
if !b.Focused() {
|
||||
s.key = false
|
||||
|
||||
@@ -57,14 +57,12 @@ func TestHoldToConfirmPointerHold(t *testing.T) {
|
||||
tt.Press(cx, cy)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
tt.Release(cx, cy)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(60 * time.Millisecond)
|
||||
if n != 0 {
|
||||
t.Fatal("an early release confirmed")
|
||||
}
|
||||
tt.Press(cx, cy)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Release(cx, cy)
|
||||
if n != 1 {
|
||||
@@ -73,8 +71,7 @@ func TestHoldToConfirmPointerHold(t *testing.T) {
|
||||
dis = true
|
||||
tt.Frame()
|
||||
tt.Press(cx, cy)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(80 * time.Millisecond)
|
||||
tt.Release(cx, cy)
|
||||
if n != 1 {
|
||||
t.Fatal("disabled hold confirmed")
|
||||
|
||||
@@ -17,7 +17,7 @@ type IconButtonOptions struct {
|
||||
}
|
||||
|
||||
// IconButton is a square button showing only icon; label names its action.
|
||||
func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element {
|
||||
func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) ui.Element {
|
||||
if icon == nil {
|
||||
panic("mujica: IconButton needs an icon")
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ type InputGroupOptions struct {
|
||||
|
||||
// InputGroupParts are the parts of an InputGroup.
|
||||
type InputGroupParts struct {
|
||||
Group *ui.Element
|
||||
Group ui.Element
|
||||
// Input takes the focus; it is selectable text when ReadOnly.
|
||||
Input *ui.Element
|
||||
Input ui.Element
|
||||
// Clicked is the index in Buttons of the button clicked, or -1.
|
||||
Clicked int
|
||||
locked bool
|
||||
@@ -77,7 +77,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
||||
n++
|
||||
}
|
||||
seg := 0
|
||||
segment := func(e *ui.Element) {
|
||||
segment := func(e ui.Element) {
|
||||
var left, right float32
|
||||
if seg == 0 {
|
||||
left = r
|
||||
@@ -180,15 +180,15 @@ type inputGroupLock struct {
|
||||
}
|
||||
|
||||
// inputGroupEditor builds the input, or plain selectable text while locked, so no edit command runs.
|
||||
func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element {
|
||||
func inputGroupEditor(c *ui.Context, box ui.Element, value *string, locked bool) ui.Element {
|
||||
lk := ui.Local(box, "lock", func() inputGroupLock { return inputGroupLock{} })
|
||||
if lk.locked && !locked {
|
||||
// A new identity drops the edits queued in the old editor while locked.
|
||||
lk.gen++
|
||||
}
|
||||
lk.locked = locked
|
||||
var in *ui.Element
|
||||
ui.Row(c).Key(lk.gen).FillWidth().MinWidth(0).Children(func() {
|
||||
var in ui.Element
|
||||
ui.Row(c.Key(lk.gen)).FillWidth().MinWidth(0).Children(func() {
|
||||
if locked {
|
||||
in = ui.Text(c, *value).SingleLine().Selectable()
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestButtonLoadingKeepsWidth(t *testing.T) {
|
||||
@@ -165,9 +166,12 @@ func inputReviewStates(t *testing.T, name string, view func(c *ui.Context)) (pla
|
||||
t.Fatalf("no %q", name)
|
||||
}
|
||||
plain = encode(t, tt.Image())
|
||||
// Colors still fade under reduced motion; let each fade finish on the Tester clock.
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Advance(theme.StateDuration)
|
||||
hover = encode(t, tt.Image())
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Advance(theme.StateDuration)
|
||||
pressed = encode(t, tt.Image())
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ func Knob(c *ui.Context, value *float64, opts KnobOptions) core.ChoiceResult {
|
||||
*value = old
|
||||
col := ui.Column(c).AlignItems(ui.Center).Gap(core.Density(c).Unit()).Disabled(opts.Disabled)
|
||||
disabled := col.IsDisabled()
|
||||
var s *ui.Element
|
||||
var s ui.Element
|
||||
col.Children(func() {
|
||||
if opts.Label != "" {
|
||||
ui.Text(c, opts.Label).TextColor(k.Text).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
|
||||
@@ -102,7 +102,7 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes
|
||||
k := core.Tokens(c)
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
in := ui.Row(c).Grow(1).Focusable().Role(ui.RoleTextField).Label(opts.Label).Description(MaskFormat(opts.Mask, *value))
|
||||
st := ui.Local(in, "masked_input", func() *maskedInputState { return &maskedInputState{caret: len(raw)} })
|
||||
s := *st
|
||||
@@ -116,8 +116,10 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes
|
||||
}
|
||||
}
|
||||
}
|
||||
disabled := in.IsDisabled()
|
||||
svc := c.Services()
|
||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if in.IsDisabled() {
|
||||
if disabled {
|
||||
return false
|
||||
}
|
||||
raw = []rune(*value)
|
||||
@@ -130,9 +132,9 @@ func MaskedInput(c *ui.Context, value *string, opts MaskedInputOptions) InputRes
|
||||
case ui.InputCommand:
|
||||
switch ev.Text {
|
||||
case "paste":
|
||||
insert(c.ReadClipboard())
|
||||
insert(svc.ReadClipboard())
|
||||
case "copy":
|
||||
c.WriteClipboard(*value)
|
||||
svc.WriteClipboard(*value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ type masonryLayout struct {
|
||||
}
|
||||
|
||||
// Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view.
|
||||
func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element {
|
||||
func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) ui.Element {
|
||||
if opts.Key == nil || item == nil {
|
||||
panic("mujica: Masonry needs Key and item")
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, b
|
||||
}
|
||||
*seen = append(*seen, i)
|
||||
key := l.keys[i]
|
||||
box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW)
|
||||
box := ui.Column(c.Key(key)).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW)
|
||||
box.Children(func() { item(i) })
|
||||
if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 {
|
||||
s.heights[key] = h
|
||||
|
||||
+27
-12
@@ -41,6 +41,14 @@ type mentionInputState struct {
|
||||
panel ui.Rect // the popup's box last frame, which keeps it through a click
|
||||
offset float32
|
||||
changed, submitted bool
|
||||
field ui.Handle
|
||||
pointer []mentionPointer // presses and drags, hit-tested at the next build
|
||||
}
|
||||
|
||||
// mentionPointer is a pointer event waiting for the build, where text can be measured.
|
||||
type mentionPointer struct {
|
||||
x float32
|
||||
extend bool
|
||||
}
|
||||
|
||||
// MentionInput edits *value with "@" mentions kept in *mentions; a mention is deleted and stepped over whole.
|
||||
@@ -57,7 +65,7 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio
|
||||
k := core.Tokens(c)
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
in := ui.Box(c).Grow(1).MinWidth(core.Density(c).Unit() * 20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText)
|
||||
s := *ui.Local(in, "mention_input", func() *mentionInputState { return &mentionInputState{at: -1} })
|
||||
if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) {
|
||||
@@ -66,16 +74,23 @@ func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts Mentio
|
||||
s.undo, s.redo = nil, nil
|
||||
s.synced, s.syncedMentions = *value, slices.Clone(*mentions)
|
||||
}
|
||||
for _, p := range s.pointer {
|
||||
s.doc.moveTo(mentionInputHit(c, s, p.x), p.extend)
|
||||
}
|
||||
s.pointer = s.pointer[:0]
|
||||
mentionInputQuery(s, opts)
|
||||
px, py, _ := frame.PointerPosition()
|
||||
fb := frame.Bounds()
|
||||
onPanel := s.panel.Contains(fb.X+px, fb.Y+py)
|
||||
popup := (in.Focused() || onPanel) && s.at >= 0 && !s.dismissed && len(s.found) > 0
|
||||
disabled := in.IsDisabled()
|
||||
in.Bind(&s.field)
|
||||
svc := c.Services()
|
||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if in.IsDisabled() {
|
||||
if disabled {
|
||||
return false
|
||||
}
|
||||
return mentionInputEvent(c, s, ev, popup, in)
|
||||
return mentionInputEvent(svc, s, ev, popup)
|
||||
})
|
||||
if s.changed && !look.disabled {
|
||||
*value, *mentions = string(s.doc.text), slices.Clone(s.doc.mentions)
|
||||
@@ -110,7 +125,7 @@ func mentionInputQuery(s *mentionInputState, opts MentionInputOptions) {
|
||||
}
|
||||
|
||||
// mentionInputEvent applies one input event to the document and reports whether it took it.
|
||||
func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, popup bool, in *ui.Element) bool {
|
||||
func mentionInputEvent(svc ui.Services, s *mentionInputState, ev ui.InputEvent, popup bool) bool {
|
||||
edit := func(f func()) {
|
||||
s.undo = append(s.undo, s.doc.clone())
|
||||
s.redo = nil
|
||||
@@ -125,23 +140,23 @@ func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, po
|
||||
case ui.InputCompose:
|
||||
s.compose = ev.Text
|
||||
case ui.InputPointerDown:
|
||||
in.Focus()
|
||||
s.doc.moveTo(mentionInputHit(c, s, ev.X), extend)
|
||||
s.field.Focus()
|
||||
s.pointer = append(s.pointer, mentionPointer{ev.X, extend})
|
||||
case ui.InputPointerMove:
|
||||
if ev.Button != 0 {
|
||||
return false
|
||||
}
|
||||
s.doc.moveTo(mentionInputHit(c, s, ev.X), true)
|
||||
s.pointer = append(s.pointer, mentionPointer{ev.X, true})
|
||||
case ui.InputCommand:
|
||||
a, b := s.doc.span()
|
||||
switch ev.Text {
|
||||
case "copy", "cut":
|
||||
c.WriteClipboard(string(s.doc.text[a:b]))
|
||||
svc.WriteClipboard(string(s.doc.text[a:b]))
|
||||
if ev.Text == "cut" && a != b {
|
||||
edit(func() { s.doc.erase(false) })
|
||||
}
|
||||
case "paste":
|
||||
edit(func() { s.doc.insert(c.ReadClipboard()) })
|
||||
edit(func() { s.doc.insert(svc.ReadClipboard()) })
|
||||
case "selectAll":
|
||||
s.doc.anchor, s.doc.caret = 0, len(s.doc.text)
|
||||
case "delete":
|
||||
@@ -230,7 +245,7 @@ func mentionInputHit(c *ui.Context, s *mentionInputState, x float32) int {
|
||||
}
|
||||
|
||||
// mentionInputView draws the text with mention chips, the selection and the caret, scrolled to keep the caret in view.
|
||||
func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in *ui.Element, placeholder string) {
|
||||
func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in ui.Element, placeholder string) {
|
||||
size := core.FontSize(c, theme.BodySize)
|
||||
measure := func(i int) float32 {
|
||||
w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: size})
|
||||
@@ -298,7 +313,7 @@ func mentionInputSpans(k theme.Tokens, s *mentionInputState, size float32, place
|
||||
}
|
||||
|
||||
// mentionInputPopup lists the candidates under the field; a click or Enter picks one.
|
||||
func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in *ui.Element) {
|
||||
func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in ui.Element) {
|
||||
b := frame.Bounds()
|
||||
u := core.Density(c).Unit()
|
||||
ui.Overlay(c, func() {
|
||||
@@ -311,7 +326,7 @@ func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, fram
|
||||
panel.Children(func() {
|
||||
ui.Scroll(c).Shrink(1).Children(func() {
|
||||
for i, m := range s.found {
|
||||
it := ui.Row(c).Key(m.ID).MinHeight(core.Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2).
|
||||
it := ui.Row(c.Key(m.ID)).MinHeight(core.Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2).
|
||||
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer)
|
||||
if i == s.pick {
|
||||
it.Selected(true).Background(k.Selection).TextColor(k.AccentText)
|
||||
|
||||
@@ -55,7 +55,7 @@ func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOptio
|
||||
SelectField(c, tr, *open, func() {
|
||||
MultiSelectTags(c, values, options, opts)
|
||||
})
|
||||
ui.PopoverBase(c, tr, open, func(panel *ui.Element) {
|
||||
ui.PopoverBase(c, tr, open, func(panel ui.Element) {
|
||||
SelectPanel(c, panel)
|
||||
panel.MinWidth(tr.Bounds().W).MaxWidth(max(tr.Bounds().W, 320))
|
||||
multiSelectPopup(c, k, values, options, query, opts)
|
||||
@@ -84,7 +84,7 @@ func MultiSelectTags[T comparable](c *ui.Context, values *[]T, options []SelectO
|
||||
break
|
||||
}
|
||||
label, _ := SelectLabel(options, v)
|
||||
ui.Row(c).Key(v).Shrink(0).Children(func() {
|
||||
ui.Row(c.Key(v)).Shrink(0).Children(func() {
|
||||
if multiSelectTag(c, label, opts.Disabled) {
|
||||
*values = slices.Delete(slices.Clone(*values), i, i+1)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
||||
if on {
|
||||
st = internal.Checked
|
||||
}
|
||||
row := ui.Row(c).Key(o.Value)
|
||||
row := ui.Row(c.Key(o.Value))
|
||||
row.Children(func() {
|
||||
cb := MultiSelectCheck(c, k, &on, st, o.Label, o.Disabled || (full && !on))
|
||||
if cb.Changed() {
|
||||
@@ -178,9 +178,9 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
||||
}
|
||||
|
||||
// MultiSelectCheck is a CheckboxBase row drawing its own box.
|
||||
func MultiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element {
|
||||
func MultiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) ui.Element {
|
||||
d := core.Density(c)
|
||||
var cb *ui.Element
|
||||
var cb ui.Element
|
||||
// A disabled wrapper blocks the base, which toggles as it is built.
|
||||
ui.Row(c).Grow(1).Disabled(disabled).Children(func() {
|
||||
cb = ui.CheckboxBase(c, on).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
@@ -227,17 +227,17 @@ func MultiSelectAll[T comparable](values []T, shown []SelectOption[T], on bool,
|
||||
}
|
||||
|
||||
// SelectSearch is the search field at the top of a popup; it takes the focus as the popup opens.
|
||||
func SelectSearch(c *ui.Context, query *string, placeholder string) *ui.Element {
|
||||
func SelectSearch(c *ui.Context, query *string, placeholder string) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
f := ui.Row(c).Gap(d.Unit()*2).Padding(0, d.Unit()*2).MinHeight(d.ControlHeight()-4).AlignItems(ui.Center).
|
||||
Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.ControlBorder)
|
||||
var in *ui.Element
|
||||
var in ui.Element
|
||||
f.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
internal.Mark(p, r, "search", k.TextMuted)
|
||||
})
|
||||
in = ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder).AutoFocus()
|
||||
in = core.AutoFocus(c, ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder))
|
||||
})
|
||||
if in.Focused() {
|
||||
f.Border(theme.BorderWidth, k.AccentText)
|
||||
|
||||
@@ -156,7 +156,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe
|
||||
}
|
||||
stepBy := func(n int) { set(s.StepBy(*value, n)) }
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
text := ui.Local(frame, "text", func() string { return s.format(*value) })
|
||||
invalid := ui.Local(frame, "invalid", func() string { return "" })
|
||||
seen := ui.Local(frame, "seen", func() float64 { return *value })
|
||||
|
||||
@@ -26,15 +26,15 @@ func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) Inpu
|
||||
}
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
shown := ui.Local(frame, "shown", func() bool { return false })
|
||||
var in *ui.Element
|
||||
var in ui.Element
|
||||
// MyGo's mask cannot be lifted, so each mode keeps its own editor.
|
||||
mode := "masked"
|
||||
if *shown {
|
||||
mode = "shown"
|
||||
}
|
||||
ui.Row(c).Key(mode).Grow(1).Children(func() {
|
||||
ui.Row(c.Key(mode)).Grow(1).Children(func() {
|
||||
in = TextInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 10).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if !*shown {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ type PinInputOptions struct {
|
||||
|
||||
// PinInputParts are the parts of a PinInput.
|
||||
type PinInputParts struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
changed, completed bool
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ type qrCodeCache struct {
|
||||
}
|
||||
|
||||
// QRCode draws text as a QR code, encoded offline; too long a text shows an error.
|
||||
func QRCode(c *ui.Context, text string, opts QRCodeOptions) *ui.Element {
|
||||
func QRCode(c *ui.Context, text string, opts QRCodeOptions) ui.Element {
|
||||
_ = opts.Level.String()
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic("mujica: QRCode Size must be finite and >= 0")
|
||||
|
||||
@@ -71,8 +71,8 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt
|
||||
// radioGroupItem builds one radio and reports whether it was chosen.
|
||||
func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool {
|
||||
// Keyed by value so focus follows the item, not its position.
|
||||
var row *ui.Element
|
||||
item := ui.Box(c).Key(key).Disabled(it.Disabled)
|
||||
var row ui.Element
|
||||
item := ui.Box(c.Key(key)).Disabled(it.Disabled)
|
||||
item.Children(func() {
|
||||
row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) cor
|
||||
px, _, _ := e.PointerPosition()
|
||||
r := e.Bounds()
|
||||
at := g.snap(g.lo + float64((px-SliderThumb/2)/max(r.W-SliderThumb, 1))*(g.hi-g.lo))
|
||||
var knobs [2]*ui.Element
|
||||
var knobs [2]ui.Element
|
||||
pressed := false
|
||||
e.Children(func() {
|
||||
for i, v := range []*float64{low, high} {
|
||||
@@ -48,11 +48,11 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) cor
|
||||
from, to = *low, g.hi
|
||||
}
|
||||
x := g.X(ui.Rect{W: r.W}, *v) - SliderThumb/2
|
||||
var kb *ui.Element
|
||||
var kb ui.Element
|
||||
var next float64
|
||||
var held bool
|
||||
// Each thumb is a SliderBase, which reports the range and value to assistive technology.
|
||||
ui.Box(c).Key(i).Absolute().Left(x).Top(g.TrackY-SliderThumb/2).Size(SliderThumb, SliderThumb).Children(func() {
|
||||
ui.Box(c.Key(i)).Absolute().Left(x).Top(g.TrackY-SliderThumb/2).Size(SliderThumb, SliderThumb).Children(func() {
|
||||
kb, next, held = SliderControl(c, *v, g, from, to)
|
||||
})
|
||||
kb.Size(SliderThumb, SliderThumb).FocusRing(false)
|
||||
|
||||
@@ -106,15 +106,15 @@ func TestRatingSnapshot(t *testing.T) {
|
||||
|
||||
func TestRatingHitAreaCompact(t *testing.T) {
|
||||
v := 2.0
|
||||
var r core.ChoiceResult
|
||||
var b ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Density: theme.Compact})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Rating(c, &v, RatingOptions{})
|
||||
b = Rating(c, &v, RatingOptions{}).Element.Bounds()
|
||||
})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 {
|
||||
if b.W < 5*28 || b.H < 28 {
|
||||
t.Fatalf("stars %v", b)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type ResizablePanelGroupOptions struct {
|
||||
|
||||
// ResizablePanelGroupResult reports a group's changes: Resized while moving, Committed when a change is done, to save.
|
||||
type ResizablePanelGroupResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
Resized bool
|
||||
Committed bool
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane
|
||||
for i, p := range panels {
|
||||
mins[i] = cmp.Or(p.Min, 10*u)
|
||||
}
|
||||
along := func(e *ui.Element, v float32) *ui.Element {
|
||||
along := func(e ui.Element, v float32) ui.Element {
|
||||
if opts.Vertical {
|
||||
return e.Height(v).FillWidth()
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane
|
||||
if (*sizes)[i] == 0 {
|
||||
continue
|
||||
}
|
||||
pane := ui.Box(c).Key(i).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label)
|
||||
pane := ui.Box(c.Key(i)).Shrink(0).Clip().Role(ui.RoleGroup).Label(p.Label)
|
||||
if avail > 0 {
|
||||
along(pane, (*sizes)[i]*avail)
|
||||
} else {
|
||||
@@ -128,7 +128,7 @@ func ResizablePanelGroup(c *ui.Context, sizes *[]float32, panels []ResizablePane
|
||||
// resizablePanelDivider builds the divider after panel i, stepped with arrows, Enter collapsing; it returns the grip's builder.
|
||||
func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizablePanelState, sizes *[]float32, panels []ResizablePanel, mins []float32, avail float32, i int, vertical, disabled bool, res *ResizablePanelGroupResult) func() {
|
||||
s := *sizes
|
||||
div := ui.Box(c).Key(fmt.Sprint("div", i)).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).
|
||||
div := ui.Box(c.Key(fmt.Sprint("div", i))).Shrink(0).Focusable().FocusRing(false).Role(ui.RoleSplitter).
|
||||
Label(fmt.Sprintf(core.Msg(c, "resizable_panel_group.divider"), panels[i].Label, panels[i+1].Label))
|
||||
if vertical {
|
||||
div.Height(theme.BorderWidth).FillWidth()
|
||||
@@ -202,7 +202,7 @@ func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizab
|
||||
div.Description(core.Msg(c, "split_pane.collapsed"))
|
||||
}
|
||||
return func() {
|
||||
grip := ui.Box(c).Key(fmt.Sprint("grip", i)).Absolute().Cursor(ui.CursorResizeEW)
|
||||
grip := ui.Box(c.Key(fmt.Sprint("grip", i))).Absolute().Cursor(ui.CursorResizeEW)
|
||||
if vertical {
|
||||
grip.Cursor(ui.CursorResizeNS)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func resizablePanelDivider(c *ui.Context, k theme.Tokens, u float32, st *resizab
|
||||
}
|
||||
|
||||
// resizablePanelGrip places the grip over divider i and draws the divider.
|
||||
func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div *ui.Element, held bool) {
|
||||
func resizablePanelGrip(c *ui.Context, k theme.Tokens, u float32, s []float32, avail float32, i int, vertical bool, grip, div ui.Element, held bool) {
|
||||
// The grip is wider than the 1 DIP line, centered on the boundary.
|
||||
var at float32
|
||||
for _, f := range s[:i+1] {
|
||||
|
||||
@@ -72,7 +72,7 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
||||
if opts.Find {
|
||||
*opts.Current = max(0, min(*opts.Current, opts.Matches-1))
|
||||
}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
if opts.Find {
|
||||
// Built inside the frame, where step knows of disabled ancestors.
|
||||
mac := runtime.GOOS == "darwin"
|
||||
|
||||
+10
-9
@@ -53,7 +53,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
||||
old := *value
|
||||
// SelectBase chooses into a copy as it is built; a choice counts once the option is known enabled.
|
||||
pick := old
|
||||
var s *ui.SelectParts[T]
|
||||
var s ui.SelectParts[T]
|
||||
site := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
|
||||
life := ui.Local(site, selectKey{"lifetime"}, func() selectLifetime { return selectLifetime{} })
|
||||
if disabled := site.IsDisabled(); disabled != life.disabled {
|
||||
@@ -61,7 +61,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
||||
life.generation++
|
||||
}
|
||||
site.Children(func() {
|
||||
ui.Row(c).Key(life.generation).Children(func() { s = ui.SelectBase(c, &pick) })
|
||||
ui.Row(c.Key(life.generation)).Children(func() { s = ui.SelectBase(c, &pick) })
|
||||
})
|
||||
tr := s.Trigger
|
||||
commit := func() {
|
||||
@@ -75,18 +75,19 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
||||
tr.Label(opts.Label)
|
||||
}
|
||||
ta := ui.Local(tr, selectKey{"typeahead"}, func() selectTypeahead { return selectTypeahead{} })
|
||||
disabled := tr.IsDisabled()
|
||||
tr.TextCaret(ui.Rect{W: 1, H: core.Density(c).ControlHeight()}).HandleInput(func(ev ui.InputEvent) bool {
|
||||
if tr.IsDisabled() {
|
||||
if disabled {
|
||||
return false
|
||||
}
|
||||
if ev.Kind != ui.InputText || strings.TrimSpace(ev.Text) == "" {
|
||||
return false
|
||||
}
|
||||
if time.Since(ta.at) > time.Second {
|
||||
if ev.Time.Sub(ta.at) > time.Second {
|
||||
ta.prefix = ""
|
||||
}
|
||||
ta.prefix += strings.ToLower(ev.Text)
|
||||
ta.pending, ta.at = true, time.Now()
|
||||
ta.pending, ta.at = true, ev.Time
|
||||
return true
|
||||
})
|
||||
if ta.pending {
|
||||
@@ -117,7 +118,7 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
||||
if tr.IsDisabled() {
|
||||
return core.NewChoiceResult(tr, false)
|
||||
}
|
||||
s.Popup(func(panel *ui.Element) {
|
||||
s.Popup(func(panel ui.Element) {
|
||||
SelectPanel(c, panel)
|
||||
ui.Scroll(c).MaxHeight(280).Children(func() {
|
||||
if len(options) == 0 {
|
||||
@@ -196,7 +197,7 @@ func selectOr(s, fallback string) string {
|
||||
}
|
||||
|
||||
// SelectField styles tr as a field that opens a popup, with content and a chevron.
|
||||
func SelectField(c *ui.Context, tr *ui.Element, open bool, content func()) {
|
||||
func SelectField(c *ui.Context, tr ui.Element, open bool, content func()) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
tr.MinHeight(d.ControlHeight()).MinWidth(180).Padding(0, d.Unit()*2, 0, d.Unit()*3).Gap(d.Unit() * 2).
|
||||
@@ -254,13 +255,13 @@ func SelectCheck(c *ui.Context, on bool) {
|
||||
}
|
||||
|
||||
// SelectPanel styles a popup panel.
|
||||
func SelectPanel(c *ui.Context, panel *ui.Element) {
|
||||
func SelectPanel(c *ui.Context, panel ui.Element) {
|
||||
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
|
||||
panel.Margin(4, 0, 0, 0).Padding(4)
|
||||
}
|
||||
|
||||
// SelectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar.
|
||||
func SelectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) {
|
||||
func SelectRow(c *ui.Context, row ui.Element, highlighted, chosen bool, label string) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||
|
||||
@@ -50,6 +50,7 @@ func SignaturePath(strokes [][]SignaturePoint) string {
|
||||
// signaturePadDrawing is the stroke in progress.
|
||||
type signaturePadDrawing struct {
|
||||
active, ended bool
|
||||
pad ui.Handle
|
||||
}
|
||||
|
||||
// SignaturePad records pointer strokes into *strokes; Changed reports a finished stroke or a clear.
|
||||
@@ -73,8 +74,9 @@ func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadO
|
||||
internal.Ring(pad, k.Focus, theme.CardRadius)
|
||||
d := ui.Local(pad, "signature_pad", func() *signaturePadDrawing { return &signaturePadDrawing{} })
|
||||
s := *d
|
||||
pad.Bind(&s.pad)
|
||||
pad.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if pad.IsDisabled() {
|
||||
if disabled {
|
||||
return false
|
||||
}
|
||||
pt := SignaturePoint{ev.X, ev.Y}
|
||||
@@ -82,7 +84,7 @@ func SignaturePad(c *ui.Context, strokes *[][]SignaturePoint, opts SignaturePadO
|
||||
case ev.Kind == ui.InputPointerDown && ev.Button == 0:
|
||||
*strokes = append(*strokes, []SignaturePoint{pt})
|
||||
s.active = true
|
||||
pad.Focus()
|
||||
s.pad.Focus()
|
||||
case ev.Kind == ui.InputPointerMove && s.active && len(*strokes) > 0:
|
||||
last := &(*strokes)[len(*strokes)-1]
|
||||
if q := (*last)[len(*last)-1]; math.Hypot(float64(pt.X-q.X), float64(pt.Y-q.Y)) >= 1 {
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ type sliderKey struct {
|
||||
type sliderKeyID struct{}
|
||||
|
||||
// SliderControl builds a SliderBase on a copy of v and returns where the pointer or a key moves v.
|
||||
func SliderControl(c *ui.Context, v float64, g SliderGeom, from, to float64) (s *ui.Element, next float64, dragged bool) {
|
||||
func SliderControl(c *ui.Context, v float64, g SliderGeom, from, to float64) (s ui.Element, next float64, dragged bool) {
|
||||
raw := v
|
||||
s = ui.SliderBase(c, &raw, g.lo, g.hi)
|
||||
ks := *ui.Local(s, sliderKeyID{}, func() *sliderKey { return &sliderKey{} })
|
||||
@@ -175,7 +175,7 @@ func Slider(c *ui.Context, value *float64, opts SliderOptions) core.ChoiceResult
|
||||
k := core.Tokens(c)
|
||||
old := g.snap(*value)
|
||||
*value = old
|
||||
var s *ui.Element
|
||||
var s ui.Element
|
||||
var next float64
|
||||
var held bool
|
||||
// Disabled on a wrapper blocks the base before it takes input.
|
||||
|
||||
@@ -49,15 +49,13 @@ func TestSpinnerMotion(t *testing.T) {
|
||||
}
|
||||
}, 100, 100)
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(150 * time.Millisecond)
|
||||
if bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("spinner did not turn")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
a = encode(t, tt.Image())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(150 * time.Millisecond)
|
||||
if !bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("spinner turned under reduced motion")
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ func TestFormFocusFollowsFieldAfterReorder(t *testing.T) {
|
||||
Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
fields := []func(){
|
||||
func() {
|
||||
FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element {
|
||||
FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() ui.Element {
|
||||
return TextInput(c, &first, TextInputOptions{Label: "First"}).Input
|
||||
})
|
||||
},
|
||||
func() {
|
||||
FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element {
|
||||
FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() ui.Element {
|
||||
return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input
|
||||
})
|
||||
},
|
||||
|
||||
+2
-2
@@ -26,11 +26,11 @@ const (
|
||||
)
|
||||
|
||||
// Switch turns *on over by click or Space; Changed reports it, the caller applies it.
|
||||
func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element {
|
||||
func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
blocked := opts.Disabled || opts.Loading
|
||||
var sw *ui.Element
|
||||
var sw ui.Element
|
||||
// Disabled on a wrapper blocks the base before it takes input.
|
||||
ui.Row(c).Shrink(0).Disabled(blocked).Children(func() {
|
||||
sw = ui.SwitchBase(c, on).Gap(d.Unit() * 2).AlignItems(ui.Center).
|
||||
|
||||
+7
-7
@@ -34,9 +34,9 @@ type TagsInputOptions struct {
|
||||
|
||||
// TagsInputParts are the parts of a TagsInput.
|
||||
type TagsInputParts struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Input takes the focus.
|
||||
Input *ui.Element
|
||||
Input ui.Element
|
||||
changed bool
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ type TagsInputParts struct {
|
||||
func (p TagsInputParts) Changed() bool { return p.changed }
|
||||
|
||||
// NewTagsInputParts reports a tag field built from parts in another component.
|
||||
func NewTagsInputParts(e, input *ui.Element, changed bool) TagsInputParts {
|
||||
func NewTagsInputParts(e, input ui.Element, changed bool) TagsInputParts {
|
||||
return TagsInputParts{e, input, changed}
|
||||
}
|
||||
|
||||
@@ -109,13 +109,13 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
||||
remove(len(*tags) - 1)
|
||||
}
|
||||
}
|
||||
var box *ui.Element
|
||||
var box ui.Element
|
||||
outer.Children(func() {
|
||||
box = ui.Row(c).Wrap().AlignItems(ui.Center).Gap(d.Unit()*1.5).MinHeight(d.ControlHeight()).
|
||||
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
|
||||
box.Children(func() {
|
||||
for i, tag := range *tags {
|
||||
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
||||
chip := ui.Row(c.Key(tag)).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
||||
chip.Children(func() {
|
||||
ui.Text(c, tag).SingleLine()
|
||||
x := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||
@@ -131,7 +131,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
||||
})
|
||||
}
|
||||
// The input keeps its identity whatever chips come before it.
|
||||
holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24)
|
||||
holder := ui.Box(c.Key("input")).Grow(1).MinWidth(d.Unit() * 24)
|
||||
holder.Children(func() {
|
||||
in := inputGroupEditor(c, holder, st.q, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
|
||||
parts.Input = in
|
||||
@@ -177,7 +177,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
||||
}
|
||||
|
||||
// tagsInputCommit adds the text before each separator, and all of it on Enter.
|
||||
func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(string) bool) {
|
||||
func tagsInputCommit(st *tagsInputState, in ui.Element, seps string, add func(string) bool) {
|
||||
if in.Changed() {
|
||||
st.err = ""
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult {
|
||||
count := textAreaCount(c, n, opts.MaxLength)
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
in := TextInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if opts.MinHeight > 0 {
|
||||
|
||||
+7
-7
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
// InputResult is a value input's outer element and its events.
|
||||
type InputResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Input is the text field inside, for focus and keys.
|
||||
Input *ui.Element
|
||||
Input ui.Element
|
||||
changed bool
|
||||
submitted bool
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func (r InputResult) Changed() bool { return r.changed }
|
||||
func (r InputResult) Submitted() bool { return r.submitted }
|
||||
|
||||
// NewInputResult reports a text control built from parts in another component.
|
||||
func NewInputResult(e, input *ui.Element, changed, submitted bool) InputResult {
|
||||
func NewInputResult(e, input ui.Element, changed, submitted bool) InputResult {
|
||||
return InputResult{e, input, changed, submitted}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult
|
||||
}
|
||||
var r InputResult
|
||||
look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: opts.Error}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame ui.Element) ui.Element {
|
||||
if opts.Leading != nil {
|
||||
opts.Leading()
|
||||
}
|
||||
@@ -85,7 +85,7 @@ type textInputLook struct {
|
||||
}
|
||||
|
||||
// textInputFrame builds the bordered field around what build returns as the input.
|
||||
func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Element) *ui.Element, footer func()) (outer, in *ui.Element) {
|
||||
func textInputFrame(c *ui.Context, look *textInputLook, build func(frame ui.Element) ui.Element, footer func()) (outer, in ui.Element) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled)
|
||||
@@ -147,7 +147,7 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele
|
||||
}
|
||||
|
||||
// TextInputEditor builds the MyGo editor; a locked one edits a copy, undone at once.
|
||||
func TextInputEditor(c *ui.Context, frame *ui.Element, value *string, locked bool, base func(*ui.Context, *string) *ui.Element) *ui.Element {
|
||||
func TextInputEditor(c *ui.Context, frame ui.Element, value *string, locked bool, base func(*ui.Context, *string) ui.Element) ui.Element {
|
||||
if !locked {
|
||||
return base(c, value)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TextInputEditor(c *ui.Context, frame *ui.Element, value *string, locked boo
|
||||
}
|
||||
|
||||
// textInputClear builds the clear button and reports a clear by click or Escape.
|
||||
func textInputClear(c *ui.Context, in *ui.Element) bool {
|
||||
func textInputClear(c *ui.Context, in ui.Element) bool {
|
||||
k := core.Tokens(c)
|
||||
b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius).
|
||||
Role(ui.RoleButton).Label(core.Msg(c, "text_input.clear")).Cursor(ui.CursorPointer)
|
||||
|
||||
+4
-4
@@ -20,11 +20,11 @@ type ToggleOptions struct {
|
||||
}
|
||||
|
||||
// Toggle is a button that stays pressed while *on. Click or Space flips it; Changed reports it.
|
||||
func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Element {
|
||||
func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) ui.Element {
|
||||
if label == "" && opts.Label == "" {
|
||||
panic("mujica: Toggle needs a label or Label")
|
||||
}
|
||||
var b *ui.Element
|
||||
var b ui.Element
|
||||
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
|
||||
b = ui.ToggleBase(c, on)
|
||||
})
|
||||
@@ -39,7 +39,7 @@ func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Eleme
|
||||
}
|
||||
|
||||
// toggleLook styles a pressed-or-not button and builds its content.
|
||||
func toggleLook(c *ui.Context, b *ui.Element, on bool, label string, icon *ui.SVG) {
|
||||
func toggleLook(c *ui.Context, b ui.Element, on bool, label string, icon *ui.SVG) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
b.MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).
|
||||
@@ -69,7 +69,7 @@ func toggleLook(c *ui.Context, b *ui.Element, on bool, label string, icon *ui.SV
|
||||
}
|
||||
|
||||
// toggleMark draws a bar under the content: the pressed state beside the color.
|
||||
func toggleMark(b *ui.Element, k theme.Tokens) {
|
||||
func toggleMark(b ui.Element, k theme.Tokens) {
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X + r.W*0.25, Y: r.Y + r.H - 4, W: r.W * 0.5, H: 2}, k.AccentText, 1)
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T],
|
||||
on := slices.Contains(*value, it.Value)
|
||||
pressed := on
|
||||
// Keyed by value so focus follows the item, not its position.
|
||||
ui.Box(c).Key(stableKey(known, it.Value)).Children(func() {
|
||||
ui.Box(c.Key(stableKey(known, it.Value))).Children(func() {
|
||||
b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled)
|
||||
// The base took input before Disabled applied: drop a disabled item's press.
|
||||
if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) {
|
||||
@@ -90,7 +90,7 @@ func toggleGroupApply[T comparable](value *[]T, v T, pressed bool, opts ToggleGr
|
||||
return true
|
||||
}
|
||||
|
||||
func toggleGroupLook[T comparable](c *ui.Context, k theme.Tokens, b *ui.Element, on bool, it ToggleItem[T]) {
|
||||
func toggleGroupLook[T comparable](c *ui.Context, k theme.Tokens, b ui.Element, on bool, it ToggleItem[T]) {
|
||||
d := core.Density(c)
|
||||
b.MinHeight(d.ControlHeight()-2).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
|
||||
+2
-2
@@ -175,7 +175,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
||||
}
|
||||
for _, it := range shown {
|
||||
on := st.checked[it.ID]
|
||||
ui.Box(c).Key(it.ID).Children(func() {
|
||||
ui.Box(c.Key(it.ID)).Children(func() {
|
||||
cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2).
|
||||
Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled)
|
||||
if cb.Changed() {
|
||||
@@ -203,7 +203,7 @@ func transferPartial(c *ui.Context, checked, enabled int) string {
|
||||
}
|
||||
|
||||
// transferMark draws a check box's square inside box, which takes the focus ring.
|
||||
func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) {
|
||||
func transferMark(c *ui.Context, box ui.Element, on, mixed bool) {
|
||||
k := core.Tokens(c)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
box.Children(func() {
|
||||
|
||||
@@ -94,7 +94,7 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts
|
||||
MultiSelectTags(c, selected, options, MultiSelectOptions{MaxTags: opts.MaxTags, Disabled: opts.Disabled})
|
||||
}
|
||||
})
|
||||
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
|
||||
ui.PopoverBase(c, tr, &st.open, func(panel ui.Element) {
|
||||
SelectPanel(c, panel)
|
||||
panel.MinWidth(max(tr.Bounds().W, 260))
|
||||
ui.Column(c).Gap(4).Children(func() {
|
||||
@@ -219,7 +219,7 @@ func treeSelectFirstChild(n *TreeSelectNode, shown map[string]bool) string {
|
||||
}
|
||||
|
||||
// treeSelectDescribe says what MyGo cannot set on custom rows (ui/element.go:282).
|
||||
func treeSelectDescribe(c *ui.Context, b *ui.Element, branch, open bool, state internal.CheckState) {
|
||||
func treeSelectDescribe(c *ui.Context, b ui.Element, branch, open bool, state internal.CheckState) {
|
||||
var parts []string
|
||||
if branch {
|
||||
parts = append(parts, core.Msg(c, map[bool]string{true: "tree_select.expanded", false: "tree_select.collapsed"}[open]))
|
||||
@@ -238,7 +238,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r
|
||||
n := r.node
|
||||
branch := len(n.Children) > 0
|
||||
open := branch && (st.expanded[n.ID] || strings.TrimSpace(st.query) != "")
|
||||
ui.Row(c).Key(n.ID).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() {
|
||||
ui.Row(c.Key(n.ID)).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() {
|
||||
arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius)
|
||||
if branch {
|
||||
arrow.Role(ui.RoleButton).Label(fmt.Sprintf(core.Msg(c, map[bool]string{true: "tree_select.collapse", false: "tree_select.expand"}[open]), n.Label))
|
||||
@@ -255,7 +255,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r
|
||||
})
|
||||
}
|
||||
state := treeSelectCheck(n, *selected, opts)
|
||||
var b *ui.Element
|
||||
var b ui.Element
|
||||
if opts.Multiple {
|
||||
on := state == internal.Checked
|
||||
b = MultiSelectCheck(c, k, &on, state, n.Label, n.Disabled)
|
||||
|
||||
@@ -41,7 +41,7 @@ func VerticalSlider(c *ui.Context, value *float64, opts VerticalSliderOptions) c
|
||||
h := cmp.Or(opts.Height, 160)
|
||||
old := g.snap(*value)
|
||||
*value = old
|
||||
var s *ui.Element
|
||||
var s ui.Element
|
||||
var next float64
|
||||
var held bool
|
||||
wrap := ui.Column(c).Disabled(opts.Disabled)
|
||||
|
||||
Reference in new issue
Block a user