Files
MujicaUI/project/workload_view.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

129 lines
4.6 KiB
Go

package project
import (
"fmt"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Workload is one member's assigned and available hours.
type Workload struct {
Member ProjectMember
Assigned float64
Capacity float64
}
// WorkloadViewOptions configure a WorkloadView.
type WorkloadViewOptions struct {
// Unit labels the amounts; empty uses hours.
Unit string
Label string
// Version changes when loads change in place.
Version int
}
// WorkloadView virtualizes member loads with capacity and overload marks.
func WorkloadView(c *ui.Context, loads []Workload, opts WorkloadViewOptions) ui.Element {
k := core.Tokens(c)
d := core.Density(c)
unit := opts.Unit
if unit == "" {
unit = core.Msg(c, "workload_view.hours")
}
label := opts.Label
if label == "" {
label = core.Msg(c, "workload_view.label")
}
root := ui.Column(c).Grow(1).MinHeight(0).Role(ui.RoleList).Label(label)
cache := ui.Local(root, "workload-summary", func() workloadCache { return workloadCache{} })
var first *Workload
if len(loads) > 0 {
first = &loads[0]
}
if !cache.ready || cache.first != first || cache.n != len(loads) || cache.version != opts.Version {
*cache = workloadSummary(loads)
cache.first, cache.n, cache.version, cache.ready = first, len(loads), opts.Version, true
}
top := cache.top
state := ui.Local(root, "workload-list", func() *ui.ListState { return &ui.ListState{} })
st := *state
st.Key = func(i int) any { return loads[i].Member.ID }
num := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
root.Children(func() {
over := cache.over
if over > 0 {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Padding(d.Unit()).Children(func() {
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
ui.Text(c, core.CalendarCount(c, "workload_view.overloaded", over)).FontSize(core.FontSize(c, theme.CaptionSize))
})
}
ui.List(c, st, len(loads), func(i int) {
l := loads[i]
hot := l.Assigned > l.Capacity
text := fmt.Sprintf("%s / %s %s", num(l.Assigned), num(l.Capacity), unit)
note := ""
if hot {
note = fmt.Sprintf(core.Msg(c, "workload_view.over"), num(l.Assigned-l.Capacity), unit)
}
ui.Row(c).MinHeight(d.ControlHeight()).Gap(d.Unit() * 3).AlignItems(ui.Center).PaddingX(d.Unit()).Role(ui.RoleListItem).
Label(l.Member.Name + ", " + text + " " + note).Children(func() {
display.Avatar(c, l.Member.Name, display.AvatarOptions{Size: 24})
ui.Text(c, l.Member.Name).Width(d.ControlHeight() * 4).Shrink(0).SingleLine().Ellipsis("…").TextColor(k.Text)
ui.Box(c).Grow(1).Height(10).Draw(func(p *ui.Painter, r ui.Rect) { workloadBar(p, r, l, top, k.Border, k.AccentText, k.Danger, k.Text) })
fg := k.TextMuted
if hot {
fg = k.Danger
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14).TextColor(fg)
}
ui.Text(c, text).Width(d.ControlHeight() * 3).Shrink(0).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(fg).FontFeatures("tnum").TextAlign(ui.End)
})
}).Grow(1).MinHeight(d.ControlHeight() * 3)
})
return root
}
// workloadBar draws assigned work, overload hatching and capacity marks.
func workloadBar(p *ui.Painter, r ui.Rect, l Workload, top float64, track, fill, hot, tick ui.Color) {
x := func(v float64) float32 { return r.X + r.W*float32(v/top) }
p.Fill(r, track, 3)
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: x(min(l.Assigned, l.Capacity)) - r.X, H: r.H}, fill, 3)
if l.Assigned > l.Capacity {
p.Fill(ui.Rect{X: x(l.Capacity), Y: r.Y, W: x(l.Assigned) - x(l.Capacity), H: r.H}, hot, 0)
for sx := x(l.Capacity) + 2; sx < x(l.Assigned); sx += 5 {
p.Line(sx, r.Y+r.H, sx+3, r.Y, 1, track)
}
}
cx := x(l.Capacity)
p.Line(cx, r.Y-3, cx, r.Y+r.H+3, 2, tick)
}
type workloadCache struct {
first *Workload
n, version, over int
top float64
ready bool
}
func workloadSummary(loads []Workload) workloadCache {
out := workloadCache{}
seen := map[string]bool{}
for _, l := range loads {
if l.Member.ID == "" || seen[l.Member.ID] || l.Assigned < 0 || l.Capacity <= 0 || math.IsNaN(l.Assigned) || math.IsNaN(l.Capacity) || math.IsInf(l.Assigned, 0) || math.IsInf(l.Capacity, 0) {
panic("mujica: WorkloadView needs unique member IDs and finite non-negative loads with positive capacity")
}
seen[l.Member.ID] = true
out.top = max(out.top, l.Assigned, l.Capacity)
if l.Assigned > l.Capacity {
out.over++
}
}
return out
}