Files
MujicaUI/input/search_input.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

143 lines
4.4 KiB
Go

package input
import (
"fmt"
"log/slog"
"runtime"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SearchInputOptions configure a SearchInput.
type SearchInputOptions struct {
// Placeholder is empty for the built-in "Search" or "Find".
Placeholder string
// Label names the field; empty uses the placeholder.
Label string
Disabled bool
// Find turns on find-bar mode: match count, previous and next.
Find bool
// Matches is how many matches the caller found for the query.
Matches int
// Current is the chosen match index; Find mode requires it.
Current *int
}
// SearchInputResult adds find-bar moves to InputResult.
type SearchInputResult struct {
InputResult
moved bool
}
// Moved reports that the user went to another match this frame.
func (r SearchInputResult) Moved() bool { return r.moved }
// SearchInput is a search field with clear, or a find bar with match navigation.
func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult {
if query == nil {
panic("mujica: SearchInput needs a query")
}
if opts.Find && opts.Current == nil {
panic("mujica: SearchInput Find mode needs Current")
}
if opts.Matches < 0 {
panic("mujica: SearchInput Matches is negative")
}
k := core.Tokens(c)
place := opts.Placeholder
if place == "" {
place = core.Msg(c, "search_input.search")
if opts.Find {
place = core.Msg(c, "search_input.find")
}
}
name := opts.Label
if name == "" {
name = place
}
var r SearchInputResult
look := &textInputLook{disabled: opts.Disabled}
step := func(d int) {
if opts.Matches == 0 || look.disabled {
return
}
*opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches
r.moved = true
slog.Debug("mujica: search_input move", "current", *opts.Current)
}
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 {
if opts.Find {
// Built inside the frame, where step knows of disabled ancestors.
mac := runtime.GOOS == "darwin"
switch {
case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3):
step(1)
case mac && c.Shortcut(ui.Shift|ui.Cmd, ui.KeyG), !mac && c.Shortcut(ui.Shift, ui.KeyF3):
step(-1)
}
}
ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted)
back := ui.Local(frame, "back", func() bool { return false })
in := TextInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(core.Density(c).Unit() * 10).Placeholder(place).Label(name)
r.changed = in.Changed() && !look.disabled
if opts.Find {
// Shift+Enter would reach the editor as a plain submit.
in.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyEnter && ev.Mods == ui.Shift {
*back = true
return true
}
return false
})
switch {
case *back:
*back = false
step(-1)
case in.Submitted():
step(1)
}
} else {
r.submitted = in.Submitted() && !look.disabled
}
if *query != "" && !look.disabled && textInputClear(c, in) {
*query = ""
r.changed = true
slog.Debug("mujica: search_input clear")
}
if opts.Find {
searchInputFindControls(c, *query, opts.Matches, *opts.Current, step)
}
return in
}, nil)
return r
}
// searchInputFindControls shows the match count with previous and next buttons.
func searchInputFindControls(c *ui.Context, query string, matches, current int, step func(int)) {
k := core.Tokens(c)
status := core.Msg(c, "search_input.none")
switch {
case query == "":
status = ""
case matches > 0:
status = fmt.Sprintf(core.Msg(c, "search_input.count"), current+1, matches)
}
ui.Text(c, status).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
SingleLine().Shrink(0).Role(ui.RoleStatus)
ui.Box(c).Width(theme.BorderWidth).Height(16).Background(k.Border).Shrink(0)
opts := IconButtonOptions{Variant: Ghost, Disabled: matches == 0}
if IconButton(c, icons.Must("chevron-up"), core.Msg(c, "search_input.previous"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() {
step(-1)
}
if IconButton(c, icons.Must("chevron-down"), core.Msg(c, "search_input.next"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() {
step(1)
}
}