Files
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

323 lines
9.0 KiB
Go

package code
import (
"log/slog"
"math"
"runtime"
"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"
)
// TerminalOptions configure a Terminal.
type TerminalOptions struct {
// Input receives what the user types, encoded for the PTY.
Input func(data string)
// Resized reports a new grid size for the PTY.
Resized func(cols, rows int)
// Scrollback caps lines kept above; zero keeps 1000.
Scrollback int
Label string
Disabled bool
}
// terminalPad is the space around the grid in DIPs.
const terminalPad float32 = 6
// terminalCtrl maps letter keys to their Ctrl codes.
var terminalCtrl = map[ui.Key]byte{ui.KeyA: 1, ui.KeyB: 2, ui.KeyC: 3, ui.KeyD: 4, ui.KeyE: 5, ui.KeyF: 6, ui.KeyG: 7,
ui.KeyH: 8, ui.KeyI: 9, ui.KeyJ: 10, ui.KeyK: 11, ui.KeyL: 12, ui.KeyM: 13, ui.KeyN: 14, ui.KeyO: 15, ui.KeyP: 16,
ui.KeyQ: 17, ui.KeyR: 18, ui.KeyS: 19, ui.KeyT: 20, ui.KeyU: 21, ui.KeyV: 22, ui.KeyW: 23, ui.KeyX: 24, ui.KeyY: 25, ui.KeyZ: 26}
// terminalKeys are the sequences of keys that type no text.
var terminalKeys = map[ui.Key]string{ui.KeyEnter: "\r", ui.KeyBackspace: "\x7f", ui.KeyTab: "\t", ui.KeyEscape: "\x1b",
ui.KeyUp: "\x1b[A", ui.KeyDown: "\x1b[B", ui.KeyRight: "\x1b[C", ui.KeyLeft: "\x1b[D", ui.KeyHome: "\x1b[H", ui.KeyEnd: "\x1b[F",
ui.KeyDelete: "\x1b[3~", ui.KeyPageUp: "\x1b[5~", ui.KeyPageDown: "\x1b[6~"}
// terminalCell size and baseline for the code font.
func terminalMetrics(f ui.Font) (cw, ch, base float32) {
m := f.Metrics()
g := ui.Shape("M", f)
cw = f.Size * 0.6
if len(g) > 0 {
cw = g[0].Advance
}
ch = float32(math.Ceil(float64((m.Ascent + m.Descent + m.LineGap) * 1.15)))
return cw, ch, (ch-m.Ascent-m.Descent)/2 + m.Ascent
}
// terminalPointer is the selection drag and focus target kept across frames.
type terminalPointer struct {
handle ui.Handle
down bool
}
// Terminal draws a TerminalState and sends input to the PTY.
func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) ui.Element {
if s == nil {
panic("mujica: Terminal needs a TerminalState")
}
if opts.Scrollback < 0 {
panic("mujica: Terminal Scrollback is negative")
}
s.init()
if opts.Scrollback > 0 {
s.limit = opts.Scrollback
}
k := core.Tokens(c)
font := codeFont(c)
cw, ch, base := terminalMetrics(font)
label := opts.Label
if label == "" {
label = core.Msg(c, "terminal.label")
}
e := ui.Box(c).Focusable().Cursor(ui.CursorText).Clip().Label(label).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Disabled(opts.Disabled).MinHeight(ch * 4)
b := e.Bounds()
if b.W > 0 {
cols := max(int((b.W-2*terminalPad)/cw), 1)
rows := max(int((b.H-2*terminalPad)/ch), 1)
if cols != s.cols || rows != s.rows {
s.Resize(cols, rows)
slog.Debug("mujica: terminal resize", "cols", cols, "rows", rows)
if opts.Resized != nil {
opts.Resized(cols, rows)
}
}
}
send := func(data string) {
s.offset, s.selOn, s.preedit = 0, false, ""
if opts.Input != nil {
opts.Input(data)
}
}
cell := func(x, y float32) TerminalPos {
col := max(0, min(int((x-terminalPad)/cw), s.cols-1))
row := max(0, min(int((y-terminalPad)/ch), s.rows-1))
return TerminalPos{Line: len(s.back) - s.offset + row, Col: col}
}
anchor := ui.Local(e, "terminal.anchor", func() TerminalPos { return TerminalPos{} })
ptr := ui.Local(e, "terminal.pointer", func() terminalPointer { return terminalPointer{} })
e.Bind(&ptr.handle)
disabled := e.IsDisabled()
svc := c.Services()
e.HandleInput(func(ev ui.InputEvent) bool {
if disabled {
return false
}
switch ev.Kind {
case ui.InputKeyDown:
return terminalKey(svc, s, ev, send)
case ui.InputText:
send(ev.Text)
return true
case ui.InputCompose:
s.preedit = ev.Text
return true
case ui.InputScroll:
s.scrolled -= ev.DY / ch
n := int(s.scrolled)
s.scrolled -= float32(n)
s.ScrollBack(n)
return true
case ui.InputPointerDown:
if ev.Button != 0 {
return false
}
ptr.down = true
ptr.handle.Focus()
*anchor = cell(ev.X, ev.Y)
s.selOn = false
return true
case ui.InputPointerMove:
if ptr.down {
s.Select(*anchor, cell(ev.X, ev.Y))
}
return true
case ui.InputPointerUp:
ptr.down = false
}
return false
})
cy := float32(s.cy+s.offset) * ch
e.TextCaret(ui.Rect{X: terminalPad + float32(s.cx)*cw, Y: terminalPad + cy, W: cw, H: ch})
focused := e.Focused()
internal.Ring(e, k.Focus, theme.ControlRadius)
e.Draw(func(p *ui.Painter, r ui.Rect) {
if r.W != b.W || r.H != b.H {
// Laid out at another size: rebuild to fit the grid.
c.Invalidate()
}
terminalPaint(p, r, s, k, font, cw, ch, base, focused)
})
return e
}
// terminalKey encodes a key, handling copy, paste and scrollback.
func terminalKey(svc ui.Services, s *TerminalState, ev ui.InputEvent, send func(string)) bool {
clip := ui.Super
if runtime.GOOS != "darwin" {
clip = ui.Ctrl | ui.Shift
}
switch {
case ev.Mods == clip && ev.Key == ui.KeyC:
if t := s.Selection(); t != "" {
svc.WriteClipboard(t)
slog.Debug("mujica: terminal copy", "runes", len([]rune(t)))
}
return true
case ev.Mods == clip && ev.Key == ui.KeyV:
if t := svc.ReadClipboard(); t != "" {
send(t)
}
return true
case ev.Mods == ui.Shift && (ev.Key == ui.KeyPageUp || ev.Key == ui.KeyPageDown):
n := s.rows - 1
if ev.Key == ui.KeyPageDown {
n = -n
}
s.ScrollBack(n)
return true
case ev.Mods == ui.Ctrl && terminalCtrl[ev.Key] != 0:
send(string([]byte{terminalCtrl[ev.Key]}))
return true
}
if seq, ok := terminalKeys[ev.Key]; ok && ev.Mods&^ui.Shift == 0 {
if ev.Key == ui.KeyTab && ev.Mods == ui.Shift {
seq = "\x1b[Z"
}
send(seq)
return true
}
return false
}
// terminalPaint draws the visible rows, marks and cursor.
func terminalPaint(p *ui.Painter, r ui.Rect, s *TerminalState, k theme.Tokens, font ui.Font, cw, ch, base float32, focused bool) {
ox, oy := r.X+terminalPad, r.Y+terminalPad
top := len(s.back) - s.offset
for row := range s.rows {
li := top + row
cells := s.line(li)
y := oy + float32(row)*ch
for col := 0; col < len(cells); {
st := cells[col].st
end := col + 1
for end < len(cells) && cells[end].st == st {
end++
}
fg, bg := codeAnsiColor(k, st.FG), codeAnsiColor(k, st.BG)
if fg.A == 0 {
fg = k.Text
}
if st.Inverse {
if bg.A == 0 {
bg = k.Surface
}
fg, bg = bg, fg
}
if st.Dim {
fg = fg.Alpha(0.6)
}
x0 := ox + float32(col)*cw
if bg.A > 0 {
p.Fill(ui.Rect{X: x0, Y: y, W: float32(end-col) * cw, H: ch}, bg, 0)
}
for i := col; i < end; i++ {
if s.selected(TerminalPos{li, i}) {
p.Fill(ui.Rect{X: ox + float32(i)*cw, Y: y, W: cw, H: ch}, k.Selection, 0)
}
}
terminalRun(p, s, cells[col:end], st, font, x0, y+base, cw, fg)
if st.Underline || st.Strike {
ly := y + base + 2
if st.Strike {
ly = y + ch/2
}
internal.Rule(p, x0, x0+float32(end-col)*cw, ly, fg)
}
col = end
}
for mi, m := range s.matches {
if m.pos.Line != li {
continue
}
mr := ui.Rect{X: ox + float32(m.pos.Col)*cw, Y: y, W: float32(m.n) * cw, H: ch}
internal.Rule(p, mr.X, mr.X+mr.W, y+ch-1, k.AccentText)
if mi == s.current {
p.Stroke(mr, k.AccentText, 1, 1.5)
}
}
}
if s.offset > 0 || s.cy >= s.rows {
return
}
cr := ui.Rect{X: ox + float32(s.cx)*cw, Y: oy + float32(s.cy)*ch, W: cw, H: ch}
if s.cx >= s.cols {
cr.X = ox + float32(s.cols-1)*cw
}
if s.preedit != "" {
g := terminalShape(s, s.preedit, font)
p.Glyphs(g, cr.X, cr.Y+base, k.AccentText)
internal.Rule(p, cr.X, cr.X+float32(len([]rune(s.preedit)))*cw, cr.Y+ch-1, k.AccentText)
return
}
if !focused {
p.Stroke(cr, k.AccentText, 0, 1)
return
}
p.Fill(cr, k.AccentText, 0)
if c := min(s.cx, s.cols-1); s.screen[s.cy][c].r > ' ' {
p.Glyphs(terminalShape(s, string(s.screen[s.cy][c].r), font), cr.X, cr.Y+base, k.Surface)
}
}
// terminalRun draws same-style cells, one glyph per cell.
func terminalRun(p *ui.Painter, s *TerminalState, cells []terminalCell, st internal.AnsiStyle, font ui.Font, x, y, cw float32, fg ui.Color) {
runes := make([]rune, len(cells))
blank := true
for i, cell := range cells {
runes[i] = cell.r
if cell.r == 0 {
runes[i] = ' '
} else if cell.r != ' ' {
blank = false
}
}
if blank {
return
}
if st.Bold {
font.Weight = 700
}
font.Italic = st.Italic
p.Glyphs(terminalShape(s, string(runes), font), x, y, fg)
}
// terminalShape shapes text into cells, cached across frames.
func terminalShape(s *TerminalState, text string, f ui.Font) []ui.Glyph {
key := string(rune(f.Weight)) + string(rune(int(f.Size*4))) + text
if f.Italic {
key = "i" + key
}
if g, ok := s.shaped[key]; ok {
return g
}
if s.shaped == nil || len(s.shaped) > 4096 {
s.shaped = map[string][]ui.Glyph{}
}
g := ui.Shape(text, f)
cw := f.Size * 0.6
if m := ui.Shape("M", ui.Font{Family: f.Family, Size: f.Size}); len(m) > 0 {
cw = m[0].Advance
}
for i := range g {
g[i].X = float32(g[i].Cluster) * cw
}
s.shaped[key] = g
return g
}