Files
MujicaUI/input/input_group.go
T
ZacharyZhang-NYandClaude Opus 5.5 bcce34cda1 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>
2026-10-17 01:37:54 -04:00

200 lines
5.5 KiB
Go

package input
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// InputGroupButton is a button joined to an InputGroup's border.
type InputGroupButton struct {
// Label is shown, or names an icon-only button for assistive technology.
Label string
Icon *ui.SVG
// IconOnly hides Label on screen; Icon is then required.
IconOnly bool
// Leading puts the button before the input.
Leading bool
Disabled bool
}
// InputGroupOptions configure an InputGroup.
type InputGroupOptions struct {
// Prefix and Suffix are fixed text beside the input, such as "https://".
Prefix, Suffix string
Buttons []InputGroupButton
Placeholder string
// Label names the input when it is not inside a FormField.
Label string
Disabled bool
// ReadOnly shows the value as selectable text that cannot change.
ReadOnly bool
// Invalid draws the border in the danger color.
Invalid bool
}
// InputGroupParts are the parts of an InputGroup.
type InputGroupParts struct {
Group ui.Element
// Input takes the focus; it is selectable text when ReadOnly.
Input ui.Element
// Clicked is the index in Buttons of the button clicked, or -1.
Clicked int
locked bool
}
// Changed reports whether the user edited the value this frame.
func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() }
// Submitted reports whether Enter was pressed in the input.
func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() }
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
for _, b := range opts.Buttons {
if b.IconOnly && b.Icon == nil {
panic("mujica: InputGroupButton IconOnly needs an Icon")
}
if b.Label == "" {
panic("mujica: InputGroupButton needs a Label")
}
}
k := core.Tokens(c)
d := core.Density(c)
parts := InputGroupParts{Clicked: -1}
g := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).Radius(theme.ControlRadius).
Padding(theme.BorderWidth).Background(k.Surface).Disabled(opts.Disabled)
parts.Group = g
r := theme.ControlRadius - theme.BorderWidth
// Index of each segment, for its corners and separators.
n := len(opts.Buttons) + 1
if opts.Prefix != "" {
n++
}
if opts.Suffix != "" {
n++
}
seg := 0
segment := func(e ui.Element) {
var left, right float32
if seg == 0 {
left = r
}
if seg == n-1 {
right = r
}
e.Radius(left, right, right, left)
if seg > 0 {
e.BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.ControlBorder)
}
seg++
}
button := func(i int) {
b := opts.Buttons[i]
bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
segment(bb)
internal.Ring(bb, k.Focus, theme.ControlRadius)
if bb.Pressed() {
bb.Background(k.Selection)
} else if bb.Hovered() {
bb.Background(k.SurfaceHover)
}
bb.Children(func() {
if b.Icon != nil {
ui.Icon(c, b.Icon).Size(16, 16)
}
if !b.IconOnly {
ui.Text(c, b.Label).SingleLine()
}
})
if bb.Clicked() {
parts.Clicked = i
}
}
addon := func(s string) {
a := ui.Row(c).Padding(0, d.Unit()*3).Background(k.Background).Shrink(0)
segment(a)
a.Children(func() { ui.Text(c, s).TextColor(k.TextMuted).SingleLine() })
}
g.Children(func() {
for i, b := range opts.Buttons {
if b.Leading {
button(i)
}
}
if opts.Prefix != "" {
addon(opts.Prefix)
}
box := ui.Row(c).Grow(1).MinWidth(d.Unit()*12).Padding(0, d.Unit()*3)
segment(box)
box.Children(func() {
if opts.ReadOnly {
// As wide as MyGo makes a text input.
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
} else {
parts.locked = box.IsDisabled()
parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
}
if opts.Label != "" {
parts.Input.Label(opts.Label)
}
})
if box.Clicked() {
parts.Input.Focus()
}
if opts.Suffix != "" {
addon(opts.Suffix)
}
for i, b := range opts.Buttons {
if !b.Leading {
button(i)
}
}
})
in := parts.Input
border := k.ControlBorder
switch {
case opts.Invalid:
border = k.Danger
case in.Focused():
border = k.Focus
case g.Hovered() && !opts.Disabled:
border = k.ControlBorder.Mix(k.Text, 0.25)
}
g.Border(theme.BorderWidth, border)
g.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.Focused() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
return parts
}
// inputGroupLock remembers locks so an unlocked input starts afresh.
type inputGroupLock struct {
locked bool
gen int
}
// 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 {
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() {
if locked {
in = ui.Text(c, *value).SingleLine().Selectable()
} else {
in = ui.TextInputBase(c, value)
}
})
return in
}