Files
MujicaUI/text_input.go
T
ZacharyZhang-NY 43c96a7a81 Refactor: one colorFade transition replaces internal.Fade
textInputFade becomes colorFade in theme.go and serves AvatarGroup,
Image retry, Tag and EditableText, which used internal.Fade. MyGo already
skips element transitions under ReduceMotion, so dropping the Motion
check keeps behaviour.
2026-10-05 13:50:47 -04:00

172 lines
5.1 KiB
Go

package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// InputResult is a value input's outer element and its events.
type InputResult struct {
Element *ui.Element
// Input is the text field inside, for focus and keys.
Input *ui.Element
changed bool
submitted bool
}
// Changed reports that the user changed the value this frame.
func (r InputResult) Changed() bool { return r.changed }
// Submitted reports Enter, or the submit shortcut of a TextArea.
func (r InputResult) Submitted() bool { return r.submitted }
// TextInputOptions configure a TextInput.
type TextInputOptions struct {
Placeholder string
// Label names the field for assistive technology.
Label string
// Leading and Trailing build content inside the field's edges.
Leading func()
Trailing func()
// Clearable shows a clear button while there is text; Escape clears too.
Clearable bool
Disabled bool
// ReadOnly keeps the text selectable and copyable but not editable.
ReadOnly bool
// Error marks the value invalid and shows the message below.
Error string
}
// TextInput is a single-line field editing *value.
func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult {
if value == nil {
panic("mujica: TextInput needs a value")
}
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 {
if opts.Leading != nil {
opts.Leading()
}
in := textInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).
Placeholder(opts.Placeholder).Label(opts.Label)
r.changed = in.Changed() && !opts.ReadOnly && !look.disabled
r.submitted = in.Submitted() && !look.disabled
if opts.Clearable && *value != "" && !opts.ReadOnly && !look.disabled {
if textInputClear(c, in) {
*value = ""
r.changed = true
slog.Debug("mujica: text_input clear")
}
}
if opts.Trailing != nil {
opts.Trailing()
}
return in
}, nil)
return r
}
// textInputLook is the state a field frame shows; disabled gains any disabled ancestor.
type textInputLook struct {
disabled, readOnly, area bool
err string
}
// 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) {
k := Tokens(c)
d := Density(c)
outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled)
// One disabled state, the field's own or an ancestor's, for build to obey.
look.disabled = outer.IsDisabled()
outer.Children(func() {
frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText)
if look.area {
frame.AlignItems(ui.Stretch)
} else {
frame.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3)
}
frame.Children(func() { in = build(frame) })
in.FocusRing(false)
if look.disabled && outer.FocusWithin() {
// A field disabled while focused gives the focus up.
outer.FocusRing(false).Focus()
}
if look.err != "" {
in.Error(look.err)
}
if frame.Clicked() {
in.Focus()
}
bg, border := k.Surface, k.ControlBorder
switch {
case look.readOnly:
bg, border = k.Background, k.Border
case frame.Hovered():
border = k.ControlBorder.Mix(k.Text, 0.35)
}
switch {
case look.err != "":
border = k.Danger
case in.Focused() && !look.readOnly:
border = k.Focus
}
frame.Background(bg).Border(theme.BorderWidth, border).Transition(colorFade)
frame.DrawOver(func(p *ui.Painter, r ui.Rect) {
if in.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
if look.err == "" && footer == nil {
return
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() {
if look.err != "" {
ui.Text(c, look.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Grow(1)
} else {
ui.Spacer(c)
}
if footer != nil {
footer()
}
})
})
return outer, in
}
// 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 {
if !locked {
return base(c, value)
}
shown := ui.Local(frame, "locked", func() string { return *value })
*shown = *value
in := base(c, shown)
*shown = *value
return in
}
// textInputClear builds the clear button and reports a clear by click or Escape.
func textInputClear(c *ui.Context, in *ui.Element) bool {
k := Tokens(c)
b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius).
Role(ui.RoleButton).Label(msg(c, "text_input.clear")).Cursor(ui.CursorPointer)
fg := k.TextMuted
if b.Hovered() {
b.Background(k.SurfaceHover)
fg = k.Text
}
b.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14).TextColor(fg) })
if b.Clicked() || in.Shortcut(0, ui.KeyEscape) {
in.Focus()
return true
}
return false
}