Merge branch 'main' into worktree-agent-ae66cd6d621c16060

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:59:33 -04:00
commit 7a0d304fa4
151 files changed
+2401 -670

No files matched your search

+5
View File
@@ -33,6 +33,11 @@ jobs:
- name: Format
if: runner.os == 'Linux'
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: File size limit
if: runner.os == 'Linux'
run: |
big=$(git ls-files '*.go' '*.html' | xargs wc -l | awk '$2!="total" && $1>500')
test -z "$big" || { echo "$big"; exit 1; }
- name: Build and vet every platform
if: runner.os == 'Linux'
run: |
+4 -4
View File
@@ -43,8 +43,10 @@ func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
return k.Warning, icons.Must("triangle-alert")
case SeverityError:
return k.Danger, icons.Must("circle-x")
case SeverityInfo:
return k.Info, icons.Must("info")
}
return k.Info, icons.Must("info")
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
}
// AlertOptions configure an Alert.
@@ -66,9 +68,7 @@ type AlertResult struct {
Closed bool
}
// Alert shows an inline message marked by color, icon and accessible name.
// A non-nil open adds a dismiss button that sets *open to false; nothing
// shows while *open is false.
// Alert shows an inline message; a non-nil open adds a dismiss button that clears it.
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
if message == "" {
panic("mujica: Alert needs a message")
+10 -4
View File
@@ -90,10 +90,12 @@ func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) B
link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius).
Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label)
internal.Ring(link, k.Focus, theme.ControlRadius)
link.TextColor(k.TextMuted)
fg := k.TextMuted
if link.Hovered() {
link.Background(k.SurfaceHover).TextColor(k.Text)
link.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(link, Motion(c, theme.StateDuration), fg)
link.Children(func() { breadcrumbContent(c, it, width) })
if link.Clicked() {
choose(i)
@@ -119,12 +121,15 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
k := Tokens(c)
u := Density(c).Unit()
b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize).
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer)
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).Cursor(ui.CursorPointer)
internal.Ring(b, k.Focus, theme.ControlRadius)
open := ui.Local(b, "open", func() bool { return false })
fg := k.TextMuted
if b.Hovered() || *open {
b.Background(k.SurfaceHover).TextColor(k.Text)
b.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(b, Motion(c, theme.StateDuration), fg)
if b.Clicked() {
*open = !*open
}
@@ -142,6 +147,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
if item.Hovered() {
item.Background(k.SurfaceHover)
}
internal.Ease(item, Motion(c, theme.StateDuration), k.Text)
item.Children(func() {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
+5 -7
View File
@@ -12,8 +12,7 @@ import (
"mujicaui/theme"
)
// WeekStart is the first column of a month. The zero value follows the locale:
// Monday for zh-CN, Sunday for en-US.
// WeekStart is a month's first column; zero follows the locale.
type WeekStart int
const (
@@ -25,7 +24,7 @@ const (
// CalendarOptions configure a Calendar.
type CalendarOptions struct {
// Min and Max bound the days that can be chosen; zero leaves a side open.
// Min and Max bound choosable days; zero is open.
Min, Max Date
// DisableDate marks single days that cannot be chosen.
DisableDate func(Date) bool
@@ -38,7 +37,6 @@ type CalendarOptions struct {
}
// Calendar shows one month and chooses a day into *value.
// Arrows move by day and week, PageUp/PageDown by month, Shift with them by year.
func Calendar(c *ui.Context, value *Date, opts CalendarOptions) DateTimeResult {
datetimeCheckDate("Calendar value", *value)
datetimeCheckBounds("Calendar", opts.Min, opts.Max)
@@ -115,7 +113,7 @@ func calendarSeek(d Date, dir int, enabled func(Date) bool) Date {
return d
}
// calendarMonth draws the month of *cursor and returns the day picked and the day hovered.
// calendarMonth draws *cursor's month; returns picked and hovered days.
func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hovered Date) {
k := Tokens(c)
d := Density(c)
@@ -140,7 +138,7 @@ func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hov
if datetimeIconButton(c, "chevron-left", msg(c, "calendar.prev_month")).Clicked() {
move(cur.AddMonths(-1), -1, false)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+2)).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", msg(c, "calendar.next_month")).Clicked() {
move(cur.AddMonths(1), 1, false)
@@ -154,7 +152,7 @@ func calendarMonth(c *ui.Context, cursor *Date, cfg calendarConfig) (picked, hov
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center).
FontSize(theme.CaptionSize).TextColor(k.TextMuted).Role(ui.RoleColumnHeader).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader).
Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
+1 -1
View File
@@ -41,7 +41,7 @@ func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element {
}
card.Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).LineHeight(1.3)
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).LineHeight(1.3)
if opts.Decorated {
ui.Box(c).Height(7).FillWidth().Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
cy := r.Y + r.H/2
+4 -1
View File
@@ -58,10 +58,13 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func
}
wrap := opts.Loop || opts.Autoplay
goTo := func(i int) {
if n == 0 {
return
}
if wrap {
i = (i%n + n) % n
}
if n == 0 || i < 0 || i >= n || i == *index {
if i < 0 || i >= n || i == *index {
return
}
*index = i
+17 -1
View File
@@ -124,9 +124,25 @@ func TestCarouselSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Size(440, 240).Children(func() {
Carousel(c, *i, 4, CarouselOptions{Autoplay: true, Label: "Windows"}, func(n int) {
ui.Column(c).Grow(1).Center().Children(func() {
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(18)
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(FontSize(c, 18))
})
}).Grow(1)
})
})
}
func TestCarouselEmptyAutoplayKeys(t *testing.T) {
index := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Carousel(c, &index, 0, CarouselOptions{Autoplay: true}, func(int) {}).Size(300, 200)
}, 320, 240)
for range 4 {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyLeft)
}
if index != 0 {
t.Fatalf("index %d", index)
}
}
+2 -1
View File
@@ -85,7 +85,8 @@ func collapsiblePanel(c *ui.Context, open bool, prog float32, fn func()) *ui.Ele
if !open && prog == 0 {
return nil
}
clip := ui.Column(c).Shrink(0)
// Closing content takes no input or focus while it shrinks away.
clip := ui.Column(c).Shrink(0).Disabled(!open)
var inner *ui.Element
clip.Children(func() {
inner = ui.Column(c).Shrink(0)
+22
View File
@@ -84,3 +84,25 @@ func TestCollapsibleSnapshot(t *testing.T) {
})
})
}
func TestCollapsibleClosingContentLeavesTabOrder(t *testing.T) {
open := true
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
Collapsible(c, &open, CollapsibleOptions{Title: "Details"}, func() {
Button(c, "Inner", ButtonOptions{})
})
Button(c, "After", ButtonOptions{})
})
}, 300, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace) // closes; the content shrinks for 120 ms
if open || !tt.HasText("Inner") {
t.Fatalf("expected a closing animation: open %v", open)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("After") {
t.Fatal("Tab entered closing content")
}
}
+8 -7
View File
@@ -51,8 +51,7 @@ type colorPickerState struct {
bad string
}
// ColorPicker is a swatch button opening a panel to pick *color: a saturation and
// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets.
// ColorPicker is a swatch button opening a panel that picks *color.
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
k := Tokens(c)
d := Density(c)
@@ -92,7 +91,9 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
st.sync(*color)
}
}
if trigger.Clicked() {
if opts.Disabled {
st.open = false
} else if trigger.Clicked() {
st.open = !st.open
}
border := k.ControlBorder
@@ -139,7 +140,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
for _, sw := range presets {
hex := strings.ToUpper(colorPickerHex(sw))
b := ui.ButtonBase(c).Height(d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
b := ui.ButtonBase(c).Height(theme.MinHitSize).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
internal.Ring(b, k.Focus, theme.ControlRadius)
chosen := *color == sw
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
@@ -159,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
}
})
})
if st.wasOpen && !st.open {
if st.wasOpen && !st.open && !opts.Disabled {
trigger.Focus()
}
st.wasOpen = st.open
@@ -269,7 +270,7 @@ func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
ui.Column(c).Gap(d.Unit()).Children(func() {
ui.Text(c, f.name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, f.name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
editing = editing || in.Input.Focused()
if !in.Changed() {
@@ -296,7 +297,7 @@ func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set
st.sync(*color)
}
if st.bad != "" {
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(theme.CaptionSize).TextColor(k.Danger).Role(ui.RoleStatus)
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
}
})
}
+33
View File
@@ -124,3 +124,36 @@ func TestColorPickerSnapshot(t *testing.T) {
})
})
}
// Disabling an open picker closes it before keys can change the color.
func TestColorPickerDisabledWhileOpen(t *testing.T) {
col, n := ui.Hex("#808080"), 0
opts := ColorPickerOptions{Label: "Shade"}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ColorPicker(c, &col, opts).Changed() {
n++
}
}, 400, 560)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.Focused("Saturation and brightness") {
t.Fatal("not open")
}
opts.Disabled = true
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyRight)
if col != ui.Hex("#808080") || n != 0 || tt.HasText("HEX") {
t.Fatalf("disabled picker changed to %v", col)
}
}
func TestColorPickerSwatchHitArea(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
_ = tt.Click("Accent")
r, ok := tt.Find("#355E80")
if !ok || r.W < 28 || r.H < 28 {
t.Fatalf("swatch %v", r)
}
}
+13 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
@@ -102,6 +103,15 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP
st.hl, st.reveal = (st.hl-1+n)%n, true
}
st.hl = max(0, min(st.hl, n-1))
// Focus stays in the search box, so the highlighted command is read out.
if n > 0 {
in.Description(found[st.hl].Label)
if st.reveal {
c.Announce(found[st.hl].Label)
}
} else {
in.Description(msg(c, "command_palette.empty"))
}
run := -1
if in.Submitted() && n > 0 {
run = st.hl
@@ -118,7 +128,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP
if cmd.Group != group {
group = cmd.Group
if group != "" {
ui.Text(c, group).Font(theme.SerifFont).FontSize(theme.CaptionSize).
ui.Text(c, group).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2).SingleLine()
}
}
@@ -129,7 +139,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP
})
ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() {
for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} {
ui.Text(c, msg(c, hint)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
ui.Text(c, msg(c, hint)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if run >= 0 {
@@ -162,6 +172,7 @@ func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteStat
st.reveal = false
}
}
internal.Ease(row, Motion(c, theme.StateDuration), k.Text)
px, py, over := row.PointerPosition()
b := row.Bounds()
if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr {
+11
View File
@@ -89,6 +89,17 @@ func TestCommandPaletteShortcutSearchRun(t *testing.T) {
}
}
func TestCommandPaletteAnnouncesHighlight(t *testing.T) {
a := &paletteApp{open: true}
tt := ui.NewTester(a.view, 800, 600)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
got := tt.Announcements()
if len(got) < 2 || got[len(got)-2] != "Open recent" || got[len(got)-1] != "Save all" {
t.Fatalf("announcements %q", got)
}
}
func TestCommandPaletteEmptyClickEscape(t *testing.T) {
a := &paletteApp{open: true}
tt := ui.NewTester(a.view, 800, 600)
+42 -32
View File
@@ -7,7 +7,6 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
@@ -62,6 +61,7 @@ type DataTableState[K comparable] struct {
started bool
sel K
has bool
toggled bool
cache dataTableCache[K]
}
@@ -95,7 +95,7 @@ func (v DataTableView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on a row.
func (v DataTableView) Submitted() bool { return v.submitted }
// SortChanged reports a header click that changed State.Sort.
// SortChanged reports a header click or menu choice that changed State.Sort.
func (v DataTableView) SortChanged() bool { return v.sorted }
// Retried reports a click on the retry action of a failed load.
@@ -109,19 +109,25 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
if !s.started {
s.started, s.cursor = true, -1
}
dataTableRefresh(s, rows, opts)
if s.has {
if i, ok := s.cache.at[s.sel]; ok {
s.cursor = i
} else {
s.cursor = -1
}
}
k := Tokens(c)
d := Density(c)
before := s.Sort
var v DataTableView
v.Element = ui.Column(c).MinHeight(0)
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
v.Children(func() {
if len(rows) > 0 {
ui.Row(c).Justify(ui.End).Children(func() {
tableColumnMenu(c, dataTableMenu(opts), &s.Sort, &s.Layout, &s.Hidden)
})
}
dataTableRefresh(s, rows, opts)
if s.has {
if i, ok := s.cache.at[s.sel]; ok {
s.cursor = i
} else {
s.cursor = -1
}
}
view := s.cache.view
if len(view) == 0 {
empty := opts.Empty
@@ -139,12 +145,9 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
if opts.Multiple {
s.rows.Selection = &s.Selection
}
before := s.Sort
// Rows reach the data-row height of the density.
saved := c.Theme()
t := *saved
t.Spacing = d.ControlHeight() / 8
c.SetTheme(&t)
restore := dataSelectTheme(c, d.ControlHeight()/8)
head := s.Sort
e := ui.Table(c, &s.rows, cols, len(view), func(row, col int) {
r := rows[view[row]]
if col == 0 {
@@ -158,18 +161,19 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
ui.Text(c, dc.Text(r)).SingleLine()
}
})
c.SetTheme(saved)
restore()
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
s.Layout = s.rows.Columns
v.sorted = s.Sort != before
v.changed = e.Changed() && !v.sorted
v.changed = e.Changed() && s.Sort == head || s.toggled
s.toggled = false
v.submitted = e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
if s.cursor >= 0 && s.cursor < len(view) {
s.sel, s.has = opts.Key(rows[view[s.cursor]]), true
}
})
v.sorted = s.Sort != before
if v.sorted {
slog.Debug("mujica: data_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
}
@@ -252,6 +256,15 @@ func dataTablePrune[R any, K comparable](s *DataTableState[K], rows []R, opts Da
}
}
// dataTableMenu lists the columns for the column menu.
func dataTableMenu[R any, K comparable](opts DataTableOptions[R, K]) []tableColumn {
cols := make([]tableColumn, len(opts.Columns))
for i, col := range opts.Columns {
cols[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Sortable}
}
return cols
}
func dataTableColumns[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
w := Density(c).ControlHeight()
cols := []ui.TableColumn{{ID: dataTableMarkID, Width: w, Fixed: true, Align: ui.Center}}
@@ -275,27 +288,24 @@ func dataTableMark[R any, K comparable](c *ui.Context, s *DataTableState[K], opt
if !opts.Multiple {
if row == s.cursor {
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.OnAccent)
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.AccentText)
})
}
return
}
on := s.Selection.Has(key)
box := ui.Box(c).Size(16, 16).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleCheckBox).Label(msg(c, "data_table.selectRow"))
if on {
box.Border(theme.BorderWidth, k.OnAccent).TextColor(k.OnAccent).Children(func() {
ui.Icon(c, icons.Must("check")).Size(14, 14)
}).Center()
} else {
box.Border(theme.BorderWidth, k.ControlBorder)
}
if box.Clicked() {
cb := ui.CheckboxBase(c, &on).Label(msg(c, "data_table.selectRow")).Size(theme.MinHitSize, theme.MinHitSize).
Justify(ui.Center).AlignItems(ui.Center).Radius(theme.ControlRadius)
internal.Ring(cb, k.Focus, theme.ControlRadius)
cb.Children(func() { dataTableCheckBox(c, on) })
if cb.Changed() {
if on {
s.Selection.Remove(key)
} else {
s.Selection.Add(key)
} else {
s.Selection.Remove(key)
}
slog.Debug("mujica: data_table toggle", "on", !on)
s.toggled = true
slog.Debug("mujica: data_table toggle", "on", on)
}
}
+122 -21
View File
@@ -1,7 +1,10 @@
package mujica
import (
"fmt"
"log/slog"
"maps"
"slices"
"github.com/egoist/mygo/ui"
@@ -10,58 +13,156 @@ import (
"mujicaui/theme"
)
// DataTableColumnPicker is a Columns button whose popover shows and hides columns.
// The last visible column cannot be hidden.
func DataTableColumnPicker[R any, K comparable](c *ui.Context, s *DataTableState[K], cols []DataColumn[R]) *ui.Element {
// tableColumn is what the column menu knows of a column.
type tableColumn struct {
ID, Title string
Width, MinWidth float32
Sortable bool
}
// tableWidthStep is how far one Narrower or Wider press resizes a column.
const tableWidthStep float32 = 16
// tableColumnMenu is the keyboard path to sort, move, resize and, with hidden, show columns.
func tableColumnMenu(c *ui.Context, cols []tableColumn, sort *ui.SortOrder, layout *ui.TableLayout, hidden *map[string]bool) {
k := Tokens(c)
u := Density(c).Unit()
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("eye")})
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("settings")})
open := ui.Local(b, "open", func() bool { return false })
if b.Clicked() {
*open = !*open
}
visible := 0
for _, col := range cols {
if !s.Hidden[col.ID] {
visible++
order := tableOrder(cols, *layout)
visible := len(cols)
if hidden != nil {
for _, col := range cols {
if (*hidden)[col.ID] {
visible--
}
}
}
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
panel.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.PopupRadius).
Padding(u * 2).Gap(u).MinWidth(200).MarginY(u)
for _, col := range cols {
ui.Column(c).Key(col.ID).Children(func() { dataTablePickerItem(c, s, col.ID, col.Title, visible) })
Padding(u * 2).Gap(u).MinWidth(240).MarginY(u)
for at, id := range order {
col := cols[slices.IndexFunc(cols, func(col tableColumn) bool { return col.ID == id })]
ui.Row(c).Key(id).Gap(u).AlignItems(ui.Center).Children(func() {
if hidden != nil {
tableVisibility(c, col, hidden, visible)
} else {
ui.Text(c, col.Title).SingleLine().Grow(1).Padding(0, u*2)
}
tableColumnActions(c, col, at, order, sort, layout)
})
}
})
return b
}
// dataTablePickerItem is one column's check box in the picker.
func dataTablePickerItem[K comparable](c *ui.Context, s *DataTableState[K], id, title string, visible int) {
// tableColumnActions are one column's sort, move and width buttons.
func tableColumnActions(c *ui.Context, col tableColumn, at int, order []string, sort *ui.SortOrder, layout *ui.TableLayout) {
act := func(icon, key string, disabled bool) bool {
return Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must(icon), Label: fmt.Sprintf(msg(c, key), col.Title), Disabled: disabled}).Clicked()
}
if col.Sortable {
for _, desc := range []bool{false, true} {
icon, key := "arrow-up", "data_table.sortAsc"
if desc {
icon, key = "arrow-down", "data_table.sortDesc"
}
if act(icon, key, *sort == ui.SortOrder{Column: col.ID, Descending: desc}) {
*sort = ui.SortOrder{Column: col.ID, Descending: desc}
slog.Debug("mujica: table sort", "column", col.ID, "descending", desc)
}
}
}
for _, step := range []int{-1, 1} {
icon, key := "chevron-left", "data_table.moveLeft"
if step > 0 {
icon, key = "chevron-right", "data_table.moveRight"
}
to := at + step
if act(icon, key, to < 0 || to >= len(order)) {
ids := slices.Clone(order)
ids[at], ids[to] = ids[to], ids[at]
layout.Order = ids
slog.Debug("mujica: table move", "column", col.ID, "to", to)
}
}
w := tableWidth(col, *layout)
least := max(col.MinWidth, tableWidthStep)
if act("minus", "data_table.narrower", w <= least) {
tableSetWidth(layout, col.ID, max(least, w-tableWidthStep))
}
if act("plus", "data_table.wider", false) {
tableSetWidth(layout, col.ID, w+tableWidthStep)
}
}
// tableVisibility is a column's show/hide check box; the last visible one cannot hide.
func tableVisibility(c *ui.Context, col tableColumn, hidden *map[string]bool, visible int) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
on := !s.Hidden[id]
on := !(*hidden)[col.ID]
last := on && visible == 1
cb := ui.CheckboxBase(c, &on).Disabled(last).MinHeight(d.ControlHeight()).
cb := ui.CheckboxBase(c, &on).Label(col.Title).Disabled(last).MinHeight(d.ControlHeight()).Grow(1).
Padding(0, u*2).Gap(u * 2).AlignItems(ui.Center).Radius(theme.ControlRadius)
internal.Ring(cb, k.Focus, theme.ControlRadius)
if cb.Hovered() && !last {
cb.Background(k.SurfaceHover)
}
if cb.Changed() && !last {
if s.Hidden == nil {
s.Hidden = map[string]bool{}
m := maps.Clone(*hidden)
if m == nil {
m = map[string]bool{}
}
s.Hidden[id] = !on
slog.Debug("mujica: data_table column", "id", id, "visible", on)
m[col.ID] = !on
*hidden = m
slog.Debug("mujica: table column", "id", col.ID, "visible", on)
}
cb.Children(func() {
dataTableCheckBox(c, on)
ui.Text(c, title).SingleLine()
ui.Text(c, col.Title).SingleLine()
})
}
// tableOrder lists column IDs as the layout orders them, the rest after.
func tableOrder(cols []tableColumn, l ui.TableLayout) []string {
var ids []string
for _, id := range l.Order {
if slices.ContainsFunc(cols, func(col tableColumn) bool { return col.ID == id }) && !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
for _, col := range cols {
if !slices.Contains(ids, col.ID) {
ids = append(ids, col.ID)
}
}
return ids
}
// tableWidth is a column's set width; a sharing column starts from its least, at least 120.
func tableWidth(col tableColumn, l ui.TableLayout) float32 {
if w, ok := l.Widths[col.ID]; ok {
return w
}
if col.Width > 0 {
return col.Width
}
return max(col.MinWidth, 120)
}
func tableSetWidth(l *ui.TableLayout, id string, w float32) {
m := maps.Clone(l.Widths)
if m == nil {
m = map[string]float32{}
}
m[id] = w
l.Widths = m
slog.Debug("mujica: table width", "column", id, "width", w)
}
// dataTableCheckBox draws a 16 DIP check box: OnAccent check on Accent when on.
func dataTableCheckBox(c *ui.Context, on bool) {
k := Tokens(c)
+58 -30
View File
@@ -3,10 +3,13 @@ package mujica
import (
"cmp"
"fmt"
"image/color"
"strconv"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type relic struct {
@@ -22,7 +25,6 @@ type tableHarness struct {
compares int
changes int
sorts int
picker bool
c *ui.Context
seen map[string]int
}
@@ -45,9 +47,6 @@ func (h *tableHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.c, h.seen = c, map[string]int{}
ui.Column(c).Padding(10).Gap(8).Children(func() {
if h.picker {
DataTableColumnPicker(c, &h.st, h.opts.Columns)
}
cols := h.opts
for i := range cols.Columns {
text := cols.Columns[i].Text
@@ -154,8 +153,11 @@ func TestDataTableMultipleMarks(t *testing.T) {
r, _ := tt.Find(name)
tt.ClickAt(table.X-28, r.Y+r.H/2) // the mark column sits before Name
}
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) {
t.Fatalf("marks chose %d", h.st.Selection.Len())
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) || h.changes != 2 {
t.Fatalf("marks chose %d, changes %d", h.st.Selection.Len(), h.changes)
}
if _, ok := tt.Find("Select row"); !ok {
t.Fatal("row check box has no name")
}
_ = tt.Click("Age")
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(4) {
@@ -168,39 +170,52 @@ func TestDataTableMultipleMarks(t *testing.T) {
}
}
func TestDataTableColumns(t *testing.T) {
h := newTableHarness(relics())
h.picker = true
tt := ui.NewTester(h.view, 400, 400)
h.st.Layout.Order = []string{"age", "name"}
tt.Frame()
age, _ := tt.Find("Age")
name, _ := tt.Find("Name")
if age.X >= name.X {
t.Fatalf("order not applied: age %v name %v", age.X, name.X)
}
if err := tt.Click("Columns"); err != nil {
t.Fatal(err)
}
ages := 0
for _, s := range tt.Texts() {
if s == "Age" {
ages++
// tabTo presses Tab until the element labeled label has the focus.
func tabTo(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
for range 60 {
if tt.Focused(label) {
return
}
tt.Key(0, ui.KeyTab)
}
if ages != 2 { // header and picker
t.Fatalf("picker missing: %q", tt.Texts())
t.Fatalf("Tab never reached %q", label)
}
func TestDataTableColumnMenuKeyboard(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 420, 480)
tabTo(t, tt, "Columns")
tt.Key(0, ui.KeyEnter)
tabTo(t, tt, "Sort Age descending")
tt.Key(0, ui.KeyEnter)
if h.st.Sort != (ui.SortOrder{Column: "age", Descending: true}) || h.sorts != 1 {
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
}
tt.Key(0, ui.KeyTab)
tabTo(t, tt, "Move Age left")
tt.Key(0, ui.KeyEnter)
if fmt.Sprint(h.st.Layout.Order) != "[age name]" {
t.Fatalf("order %v", h.st.Layout.Order)
}
tabTo(t, tt, "Make Age wider")
tt.Key(0, ui.KeyEnter)
if h.st.Layout.Widths["age"] != 96 {
t.Fatalf("widths %v", h.st.Layout.Widths)
}
tabTo(t, tt, "Name")
tt.Key(0, ui.KeySpace)
if !h.st.Hidden["name"] || tt.HasText("Censer") {
t.Fatalf("name not hidden: %v %q", h.st.Hidden, tt.Texts())
if !h.st.Hidden["name"] {
t.Fatalf("name not hidden: %v", h.st.Hidden)
}
tt.Key(0, ui.KeyTab)
tabTo(t, tt, "Age")
tt.Key(0, ui.KeySpace)
if h.st.Hidden["age"] {
t.Fatal("hid the last visible column")
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Censer") || !tt.HasText("Age") {
t.Fatalf("table did not follow the menu: %q", tt.Texts())
}
}
func TestDataTableCustomCellAndControlled(t *testing.T) {
@@ -335,3 +350,16 @@ func TestDataTableSnapshot(t *testing.T) {
})
})
}
func TestDataTableSelectionColors(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if err := tt.Click("Bell"); err != nil {
t.Fatal(err)
}
r, _ := tt.Find("Bell")
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
if want := toRGBA(theme.Light().Selection); got != want {
t.Fatalf("selected row background %v, want Selection %v", got, want)
}
}
+19 -8
View File
@@ -24,12 +24,13 @@ type DatePickerOptions struct {
// Placeholder shows in the empty field; empty uses the built-in copy.
Placeholder string
Disabled bool
// ReadOnly keeps the text focusable and copyable but blocks edits and the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DatePicker is a typed date field with a calendar popup editing *value.
// Typed text commits on Enter or when the field loses focus; invalid text stays and is reported.
// DatePicker types or picks *value; invalid text is kept and reported.
func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult {
datetimeCheckDate("DatePicker value", *value)
datetimeCheckBounds("DatePicker", opts.Min, opts.Max)
@@ -53,6 +54,13 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
open := ui.Local(col, "date-picker-open", func() bool { return false })
cursor := ui.Local(col, "date-picker-cursor", func() Date { return Date{} })
wasFocused := ui.Local(col, "date-picker-focused", func() bool { return false })
shownLayout := ui.Local(col, "date-picker-layout", func() string { return layout })
if *shownLayout != layout {
if *text == Date.Format(*value, *shownLayout) || value.IsZero() && *text == "" {
*text = format(*value)
}
*shownLayout = layout
}
if *seen != *value {
*seen, *text, *problem = *value, format(*value), ""
}
@@ -96,23 +104,26 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), *problem != "")
field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
in := ui.TextInputBase(c, text).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
in := textInputEditor(c, field, text, opts.ReadOnly, ui.TextInputBase).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
label := opts.Label
if label == "" {
label = placeholder
}
in.Label(label).Error(*problem)
focused := in.Focused()
if in.Submitted() || *wasFocused && !focused && *text != format(*value) {
if !opts.ReadOnly && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) {
submit()
}
*wasFocused = focused
if opts.ReadOnly {
return
}
if opts.Clearable && !value.IsZero() && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
commit(Date{}, "clear")
}
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Clicked() {
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Role(ui.RolePopUpButton).Clicked() {
if *open {
*open = false
} else {
@@ -120,10 +131,10 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
}
}
})
if !*open && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
if !*open && !opts.ReadOnly && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
openAt()
}
datetimePopup(c, field, open, func() {
datetimePopup(c, field, open, opts.ReadOnly, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *value },
enabled: enabled,
+11 -7
View File
@@ -37,12 +37,13 @@ type DateRangePickerOptions struct {
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DateRangePicker edits *value in a popup: pick a start, preview the span, pick an end, then confirm.
// Clear empties the value; Escape, Cancel or a click outside discards the draft.
// DateRangePicker picks start and end; Confirm writes *value.
func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult {
datetimeCheckDate("DateRangePicker Start", value.Start)
datetimeCheckDate("DateRangePicker End", value.End)
@@ -84,10 +85,10 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
}
var field *ui.Element
col.Children(func() {
field = datetimeField(c, ui.Row(c), false)
field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open = !*open
if *open {
*draft, *problem, *hover = *value, "", Date{}
@@ -99,9 +100,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
}
}
})
datetimePopup(c, field, open, func() {
datetimePopup(c, field, open, opts.ReadOnly, func() {
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
FontSize(theme.CaptionSize).FontFeatures("tnum").Role(ui.RoleStatus)
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").Role(ui.RoleStatus)
end := draft.End
if end.IsZero() && !draft.Start.IsZero() {
end = *hover
@@ -144,6 +145,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
cancelled = true
}
*wasOpen = *open
if !*open {
*problem = ""
}
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
+37 -18
View File
@@ -12,8 +12,10 @@ import (
// DateTimePickerOptions configure a DateTimePicker.
type DateTimePickerOptions struct {
// Location is the time zone the user edits in. Required.
// Location is the time zone the user edits in; required.
Location *time.Location
// Layout is the Go layout of the date part; empty uses 2006-01-02.
Layout string
// Min and Max bound the instant; zero leaves a side open.
Min, Max time.Time
Seconds bool
@@ -23,12 +25,13 @@ type DateTimePickerOptions struct {
// Today is marked in the calendar; zero uses the system clock.
Today Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// DateTimePicker edits *value as a date and a wall-clock time in opts.Location, confirmed together.
// Times a DST change skips or repeats are reported, never shifted.
// DateTimePicker edits *value as wall time in opts.Location.
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
loc := opts.Location
if loc == nil {
@@ -56,7 +59,11 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
clock := ui.Local(col, "date-time-clock", func() TimeOfDay { return TimeOfDay{} })
cursor := ui.Local(col, "date-time-cursor", func() Date { return Date{} })
problem := ui.Local(col, "date-time-error", func() string { return "" })
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), cfg) }
typedBad := ui.Local(col, "date-time-typed-bad", func() bool { return false })
if opts.Layout == "" {
opts.Layout = "2006-01-02"
}
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), opts.Layout, cfg) }
changed, cancelled, done := false, false, false
label := opts.Label
if label == "" {
@@ -67,10 +74,10 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
text = format(*value)
}
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field := datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
field.Children(func() {
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open = !*open
if *open {
*day, *clock, *problem = today, TimeOfDay{}, ""
@@ -83,7 +90,7 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
}
}
})
datetimePopup(c, field, open, func() {
datetimePopup(c, field, open, opts.ReadOnly, func() {
picked, _ := calendarMonth(c, cursor, calendarConfig{
chosen: func(x Date) bool { return x == *day },
enabled: calendarEnabled(lo, hi, nil),
@@ -94,11 +101,17 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
if !picked.IsZero() {
*day, *problem = picked, ""
}
bad := ""
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
if _, edited := timeEditor(c, clock, cfg); edited {
*problem = ""
cfg.problem = *problem
_, edited, b := timeEditor(c, clock, cfg)
switch {
case b != "":
*problem, *typedBad, bad = b, true, b
case edited || *typedBad:
*problem, *typedBad = "", false
}
ui.Text(c, loc.String()).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, loc.String()).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
datetimeError(c, *problem)
datetimeFooter(c, func() {
@@ -107,6 +120,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
}
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{}).Clicked() {
at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg)
if bad != "" {
p = bad
}
*problem = p
if p == "" {
if !at.Equal(*value) {
@@ -124,6 +140,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
cancelled = true
}
*wasOpen = *open
if !*open {
*problem = ""
}
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
}
@@ -132,7 +151,7 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt
if p := timePickerProblem(c, t, cfg); p != "" {
return time.Time{}, p
}
wall := fmt.Sprintf("%v %s", d, timeText(c, t, cfg))
wall := d.Format(opts.Layout) + " " + timeText(c, t, cfg)
at, n := dateTimeResolve(d, t, loc)
switch {
case n == 0:
@@ -142,14 +161,14 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt
slog.Debug("mujica: date_time_picker ambiguous", "wall", wall, "zone", loc.String())
return at, fmt.Sprintf(msg(c, "date_time_picker.ambiguous"), wall, loc.String())
case !opts.Min.IsZero() && at.Before(opts.Min):
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), cfg))
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), opts.Layout, cfg))
case !opts.Max.IsZero() && at.After(opts.Max):
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), cfg))
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), opts.Layout, cfg))
}
return at, ""
}
// dateTimeResolve finds the instants showing wall time d t in loc: none in a DST gap, two in an overlap.
// dateTimeResolve counts instants for a wall time: 0 gap, 2 overlap.
func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
wall := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, time.UTC)
guess := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, loc)
@@ -175,7 +194,7 @@ func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
return hits[0], len(hits)
}
// dateTimeText formats a wall time with the picker's clock style.
func dateTimeText(c *ui.Context, w time.Time, cfg timeConfig) string {
return DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
// dateTimeText formats a wall time with the picker's date layout and clock style.
func dateTimeText(c *ui.Context, w time.Time, layout string, cfg timeConfig) string {
return w.Format(layout) + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
}
+19 -10
View File
@@ -2,6 +2,7 @@ package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
@@ -11,8 +12,7 @@ import (
"mujicaui/theme"
)
// Date is a calendar day with no time of day and no location.
// The zero value means no date.
// Date is a calendar day without clock or location; zero means none.
type Date struct {
Year int
Month time.Month
@@ -136,28 +136,33 @@ func datetimeIconButton(c *ui.Context, icon, label string) *ui.Element {
}
// datetimeField styles row as a single-line field: solid fill and fine border.
func datetimeField(c *ui.Context, row *ui.Element, invalid bool) *ui.Element {
func datetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.Element {
k := Tokens(c)
d := Density(c)
border := k.ControlBorder
bg, border := k.Surface, k.ControlBorder
switch {
case invalid:
border = k.Danger
case readOnly:
bg, border = k.Background, k.Border
case row.FocusWithin():
border = k.Focus
case row.Hovered():
border = k.Text
}
return row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()).PaddingX(d.Unit()*3).Gap(d.Unit()).
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border)
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(bg).Border(theme.BorderWidth, border)
}
// datetimeTrigger is the button inside a field that shows text and opens a popup.
// datetimeTrigger is the popup button inside a field; Cmd+C copies its text.
func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2).
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius)
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RolePopUpButton)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Shortcut(ui.Cmd, ui.KeyC) && text != "" {
c.WriteClipboard(text)
}
b.Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(k.TextMuted)
if text == "" {
@@ -169,10 +174,14 @@ func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element
return b
}
// datetimePopup shows fn in a popup panel below anchor while *open is set.
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, fn func()) *ui.Element {
// datetimePopup shows fn below anchor while *open; a disabled or read-only anchor closes it.
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, fn func()) *ui.Element {
k := Tokens(c)
d := Density(c)
if *open && (readOnly || anchor.IsDisabled()) {
slog.Debug("mujica: datetime popup closed by state", "readOnly", readOnly)
*open = false
}
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
born := ui.Local(panel, "datetime-born", func() bool { return true })
if *born {
@@ -192,7 +201,7 @@ func datetimeError(c *ui.Context, text string) {
if text == "" {
return
}
ui.Text(c, text).FontSize(theme.CaptionSize).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
}
// datetimeFooter lays out popup actions at the end of a row.
+22 -10
View File
@@ -39,7 +39,7 @@ func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked in
if datetimeIconButton(c, "chevron-left", cfg.prev).Clicked() {
*cursor, *keyed = *cursor-cfg.page, false
}
ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(theme.BodySize + 2).Bold().
ui.Text(c, cfg.title(start)).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+2)).Bold().
TextColor(k.Text).Grow(1).TextAlign(ui.Center).SingleLine()
if datetimeIconButton(c, "chevron-right", cfg.next).Clicked() {
*cursor, *keyed = *cursor+cfg.page, false
@@ -61,10 +61,9 @@ func datetimeGrid(c *ui.Context, cursor *int, cfg datetimeGridConfig) (picked in
case cells.Shortcut(0, ui.KeyPageDown):
*cursor += cfg.page
case cells.Shortcut(0, ui.KeyHome):
*cursor, _ = cfg.pageOf(*cursor)
*cursor, _ = datetimeGridBounds(*cursor, cfg)
case cells.Shortcut(0, ui.KeyEnd):
s, n := cfg.pageOf(*cursor)
*cursor = s + n - 1
_, *cursor = datetimeGridBounds(*cursor, cfg)
default:
moved = false
}
@@ -145,33 +144,46 @@ func datetimeGridCell(c *ui.Context, o, start, cursor int, cfg datetimeGridConfi
return cell.Clicked() && enabled
}
// datetimeGridBounds returns the page's own first and last ordinals.
func datetimeGridBounds(o int, cfg datetimeGridConfig) (first, last int) {
start, n := cfg.pageOf(o)
first, last = start, start+n-1
for first < last && cfg.muted(first, start) {
first++
}
for last > first && cfg.muted(last, start) {
last--
}
return first, last
}
// datetimeGridField is the field and popup shared by MonthPicker and YearPicker.
type datetimeGridField struct {
text, placeholder, label string
clearable, hasValue bool
disabled bool
disabled, readOnly bool
// home is the ordinal the popup opens on.
home int
}
// datetimeGridPicker draws f and its popup grid; it returns the column, a picked ordinal and whether Clear was pressed.
// datetimeGridPicker draws f and its popup; reports a pick or clear.
func datetimeGridPicker(c *ui.Context, f datetimeGridField, cfg datetimeGridConfig) (col *ui.Element, picked int, ok, cleared bool) {
d := Density(c)
col = ui.Column(c).Width(d.ControlHeight() * 6).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(f.disabled)
open := ui.Local(col, "datetime-grid-open", func() bool { return false })
cursor := ui.Local(col, "datetime-grid-cursor", func() int { return 0 })
col.Children(func() {
field := datetimeField(c, ui.Row(c), false)
field := datetimeField(c, ui.Row(c), false, f.readOnly)
field.Children(func() {
t := datetimeTrigger(c, f.text, f.placeholder, "calendar").Label(f.label).Description(f.text)
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
if !f.readOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
*open, *cursor = !*open, f.home
}
if f.clearable && f.hasValue && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
if f.clearable && f.hasValue && !f.readOnly && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
cleared = true
}
})
datetimePopup(c, field, open, func() {
datetimePopup(c, field, open, f.readOnly, func() {
cfg.autoFocus = true
if picked, ok = datetimeGrid(c, cursor, cfg); ok {
*open = false
+236
View File
@@ -0,0 +1,236 @@
package mujica
import (
"image/color"
"testing"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// datePickerLive builds a DatePicker whose options a test changes between frames.
func datePickerLive(value *Date, opts *DatePickerOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Gap(8).Children(func() {
o := *opts
o.Today, o.Label = calendarToday, "Due"
if DatePicker(c, value, o).Changed() {
*changes++
}
})
}, 420, 460)
}
func TestDatePickerDisablingClosesPopup(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
_ = tt.Click("Open calendar")
day := datetimeTestLabel(oct(5))
if !tt.Focused(day) {
t.Fatal("setup: the popup should focus the chosen day")
}
opts.Disabled = true
tt.Frame()
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
_ = tt.Click(datetimeTestLabel(oct(20)))
if v != oct(5) || n != 0 {
t.Fatalf("disabled picker committed %v (%d events)", v, n)
}
if tt.HasText("October 2026") || tt.Focused(datetimeTestLabel(oct(6))) || tt.Focused("Due") {
t.Fatal("the popup should close and nothing in the disabled picker keep focus")
}
}
func TestDatePickerDisabledBlocksInput(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
_ = tt.Click("Due")
tt.Type("2027-01-01")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
_ = tt.Click("Open calendar")
if v != oct(5) || n != 0 || tt.Focused("Due") || tt.HasText("October 2026") {
t.Fatalf("disabled picker took input: %v (%d)", v, n)
}
}
func TestTimePickerKeepsInvalidHour(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{}, &n)
_ = tt.Click("Alarm: Hours")
tt.Key(0, ui.Key2)
tt.Key(0, ui.Key9)
if v != (TimeOfDay{2, 30, 0}) || n != 1 {
t.Fatalf("first digit should apply: %v", v)
}
if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") || !tt.Focused("Alarm: Hours") {
t.Fatalf("29 should stay shown with an error: %v", tt.Texts())
}
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key5)
if v != (TimeOfDay{15, 30, 0}) || n != 3 || tt.HasText("Hours must be") {
t.Fatalf("fixing the hour: %v", v)
}
}
func TestTimeSegmentDescription(t *testing.T) {
var got []string
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
got = []string{timeSegmentDescription(c, timeHourPart, " 9", 1, 12), timeSegmentDescription(c, timePeriodPart, "PM", 0, 59)}
}, 100, 100)
if got[0] != "9, from 1 to 12" || got[1] != "PM" {
t.Fatalf("descriptions %q", got)
}
}
func TestTimePickerSegmentFocusRing(t *testing.T) {
v := TimeOfDay{9, 30, 0}
tt := timePickerTester(&v, TimePickerOptions{}, new(int))
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
r, ok := tt.Find("Alarm: Hours")
if !ok || !tt.Focused("Alarm: Hours") {
t.Fatal("setup: hours not focused")
}
want := theme.Light().Focus
img := tt.Image()
found := false
for dy := float32(-4); dy <= -2 && !found; dy++ {
c := img.RGBAAt(int(r.X+r.W/2), int(r.Y+dy))
found = datetimeNear(c, want)
}
if !found {
t.Fatal("no focus ring above the focused segment")
}
}
func datetimeNear(c color.RGBA, w ui.Color) bool {
d := func(a, b uint8) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
return d(c.R, w.R)+d(c.G, w.G)+d(c.B, w.B) < 60
}
func TestDatePickerLayoutChangeReformats(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
opts := DatePickerOptions{}
tt := datePickerLive(&v, &opts, &n)
opts.Layout = "02/01/2006"
tt.Frame()
_ = tt.Click("Due")
tt.Key(0, ui.KeyEnter)
if v != oct(5) || n != 0 || tt.HasText("Enter a date like") {
t.Fatalf("committed text kept the old layout: %v", tt.Texts())
}
_ = tt.Click("Due")
tt.Command("selectAll")
tt.Type("12/10")
opts.Layout = "2006-01-02"
tt.Frame()
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Enter a date like 2026-10-05.") || v != oct(5) {
t.Fatal("a draft being edited should be kept across a layout change")
}
}
func TestYearPickerHomeEndStayOnPage(t *testing.T) {
v := 2026
tt := yearPickerTester(&v, YearPickerOptions{}, new(int))
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyHome)
if !tt.HasText("2020 – 2029") || !tt.Focused("2020") {
t.Fatalf("Home left the page: %v", tt.Texts()[:4])
}
tt.Key(0, ui.KeyEnd)
if !tt.HasText("2020 – 2029") || !tt.Focused("2029") {
t.Fatal("End left the page")
}
}
func TestDatePickerReadOnly(t *testing.T) {
v, n := Date{2026, time.October, 5}, 0
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", ReadOnly: true, Clearable: true}, &n)
_ = tt.Click("Due")
if !tt.Focused("Due") {
t.Fatal("read-only field should take focus")
}
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "2026-10-05" {
t.Fatalf("copied %q", tt.Clipboard())
}
tt.Type("2027-01-01")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if v != oct(5) || n != 0 || tt.HasText("October 2026") || tt.HasText("Open calendar") || tt.HasText("Clear") {
t.Fatalf("read-only picker changed: %v %v", v, tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyTab)
tt.Command("selectAll")
tt.Command("copy")
if tt.Clipboard() != "2026-10-05" {
t.Fatalf("typed text leaked into the field: %q", tt.Clipboard())
}
}
func TestTimePickerReadOnly(t *testing.T) {
v, n := TimeOfDay{9, 30, 0}, 0
tt := timePickerTester(&v, TimePickerOptions{ReadOnly: true}, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.Key1)
tt.Key(ui.Alt, ui.KeyDown)
if !tt.Focused("Alarm: Hours") || v != (TimeOfDay{9, 30, 0}) || n != 0 || tt.HasText("Choose time") {
t.Fatalf("read-only time changed: %v", v)
}
tt.Key(ui.Cmd, ui.KeyC)
if tt.Clipboard() != "09:30" {
t.Fatalf("copied %q", tt.Clipboard())
}
}
func TestMonthPickerReadOnly(t *testing.T) {
v, n := YearMonth{2026, time.May}, 0
tt := monthPickerTester(&v, MonthPickerOptions{ReadOnly: true, Clearable: true}, &n)
_ = tt.Click("Billing month")
tt.Key(0, ui.KeyDown)
if tt.HasText("January") || tt.HasText("Clear") || !tt.Focused("Billing month") {
t.Fatalf("read-only month opened: %v", tt.Texts())
}
tt.Key(ui.Cmd, ui.KeyC)
if tt.Clipboard() != "May 2026" || v != (YearMonth{2026, time.May}) || n != 0 {
t.Fatalf("copied %q, value %v", tt.Clipboard(), v)
}
}
func TestDateTimePickerLayout(t *testing.T) {
v := time.Time{}
n, x := 0, 0
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT, Layout: "02/01/2006"}, &n, &x)
_ = tt.Click("Meeting")
_ = tt.Click(datetimeTestLabel(oct(10)))
_ = tt.Click("Time: Hours")
tt.Key(0, ui.Key1)
tt.Key(0, ui.Key3)
_ = tt.Click("Confirm")
if n != 0 || !tt.HasText("Choose 10/10/2026 12:00 UTC or earlier.") {
t.Fatalf("error ignores the layout: %v", tt.Texts())
}
_ = tt.Click(datetimeTestLabel(oct(9)))
_ = tt.Click("Confirm")
if n != 1 || !tt.HasText("09/10/2026 13:00 UTC") {
t.Fatalf("display ignores the layout: %v", tt.Texts())
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ func DescriptionList(c *ui.Context, items []DescriptionItem, opts DescriptionLis
cell.ColumnSpan(it.Span)
}
cell.Children(func() {
ui.Text(c, it.Label).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, it.Label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
ui.Row(c).Gap(u).MinHeight(theme.MinHitSize).AlignItems(ui.Center).Children(func() {
ui.Text(c, it.Value).Shrink(1).MinWidth(0)
if !it.Copyable {
+5 -2
View File
@@ -29,7 +29,7 @@ view := func(c *ui.Context) {
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时使用 `src` 的尺寸(只给一边时按比例补齐),在占位、加载、错误状态下也保持这个尺寸;`src` 为 nil 时为 160×120。负数 panic。 |
| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时使用 `src` 的尺寸(只给一边时按比例补齐);`src` 为 nil 时为 160×120。占位、加载、错误状态与已加载图片使用相同的宽高比和伸缩规则,父容器变窄时一起按比例缩小,所以各状态的盒子完全相同。负数 panic。 |
| `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 |
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
| `Loading` | `bool` | `false` | 调用方正在加载。 |
@@ -43,7 +43,7 @@ view := func(c *ui.Context) {
- 占位:浅色框内显示图片图标,名称“暂无图片”。
- 加载中:框内显示 Spinner,名称“正在加载图片”。
- 错误:显示错误图标、“图片加载失败”和“重试”按钮,`Err.Error()` 作为无障碍错误说明。
- 错误:盒子中央是 28×28 DIP 的仅图标“重试”按钮(`refresh-cw`),`Err.Error()` 作为按钮的悬停提示和描述,也是盒子的无障碍错误说明。盒子足够宽时,按钮旁边还会显示“图片加载失败”文字。
## 事件
@@ -55,6 +55,9 @@ view := func(c *ui.Context) {
## 限制
- 盒子高度至少 28 DIP 时,“重试”按钮才能完整显示。更矮的盒子会裁掉按钮的上下边缘,但按钮中心仍然可以点击,键盘也可以操作。
- “图片加载失败”文字只在盒子宽度足够时显示。宽度按上一帧的布局判断,盒子尺寸变化时组件会多布局一帧来修正。
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
+2 -2
View File
@@ -29,7 +29,7 @@ ui.Render(view, 400, 300, 1)
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐(MyGo `ui.Form` 布局)。 |
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐。 |
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
@@ -56,4 +56,4 @@ Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。
- 必填标记“*”只作视觉显示,辅助技术通过说明“必填”获知。
+8 -8
View File
@@ -4,7 +4,7 @@
## 用途
为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。
为单个控件提供标签、必填标记、说明与错误文字。标签、说明与错误显式关联到 control 返回的元素:屏幕阅读器以标签命名该元素并读出说明、必填与错误;点击标签聚焦该元素。
## 最小示例
@@ -12,8 +12,8 @@
email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
})
}
ui.Render(view, 400, 300, 1)
@@ -24,11 +24,11 @@ ui.Render(view, 400, 300, 1)
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 |
| `FormFieldOptions.Required` | `bool` | false | 在标签后显示危险色“*”,并向辅助技术说明“必填”。 |
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
| `control` | `func()` | 必填 | 构建控件;其中第一个可获取焦点的元素即被关联的控件。 |
| `control` | `func() *ui.Element` | 必填 | 构建控件并返回要关联的可聚焦元素(如 `InputGroupParts.Input`);返回 nil 时 panic。 |
## 状态
@@ -44,6 +44,6 @@ Tab 直接到达控件;点击标签聚焦控件,或切换复选框、开关
## 限制
- 标签、说明与错误文字由 MyGo `ui.Field` 绘制,使用主题颜色(文字、TextMuted、Danger);字号与间距跟随 MyGo。
- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。
- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
- control 返回的元素的 Label 会被字段标签覆盖。
- Error 只把元素标记为无效;InputGroup 等的红色边框需同时传入 `Invalid: true`。
- 左右布局时多个字段需放在 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
+1
View File
@@ -53,3 +53,4 @@ ui.Render(view, 400, 300, 1)
- MyGo 的 `ui.Calendar` 固定周一起始、只有英文且不支持禁用日期(ui/date.go:85-160),因此本组件基于 `RadioBase` 自绘网格,不复用 `ui.Calendar`。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+4 -2
View File
@@ -33,12 +33,13 @@ ui.Render(view, 400, 300, 1)
| `Today` | `Date` | 零值(系统时钟) | 弹层日历中的今日标记;空值时弹层打开到此月份。 |
| `Clearable` | `bool` | false | 显示清空按钮;清空输入后提交表示“无日期”。 |
| `Placeholder` | `string` | 内置“选择日期” | 空输入框的占位文字。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用输入与弹层。 |
| `Label` | `string` | 占位文字 | 辅助技术名称。 |
## 状态
默认、悬停(边框加深)、聚焦(焦点色边框)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。
默认、悬停(边框加深)、聚焦(焦点色边框)、只读(背景与边框减弱,可选择复制)、错误(危险色边框 + 下方说明,输入保留)、禁用。弹层内日历状态同 Calendar。
## 事件
@@ -54,6 +55,7 @@ ui.Render(view, 400, 300, 1)
- 无效或越界的输入(如 2026-02-30)显示具体错误并保留原文,不会被修正为相邻日期。
- MyGo 的 `ui.DateInput` 固定格式且不可键入(ui/date.go:13),因此本组件组合 `TextInputBase` 与自绘日历弹层(`PopoverBase`)。
- 暂不提供只读模式:MyGo v0.2.10 未公开 TextInputBase 的只读开关。
- 只读模式沿用 TextInput 的做法:编辑副本并在每帧恢复,文本仍可选择与复制。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+2
View File
@@ -30,6 +30,7 @@ ui.Render(view, 400, 300, 1)
| `DisableDate` | `func(Date) bool` | nil | 不可选日期;范围内包含此类日期时拒绝并提示。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的起始月份。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“日期范围” | 辅助技术名称。 |
@@ -55,3 +56,4 @@ ui.Render(view, 400, 300, 1)
- `DateRange` 为闭区间,`Days()` 计入两端。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+4 -2
View File
@@ -30,12 +30,13 @@ ui.Render(view, 400, 300, 1)
| `SecondStep` | `int` | 0(即 1) | 秒步长,必须整除 60。 |
| `Min` | `TimeOfDay` | 00:00:00 | 最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 最晚时间。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 组名;各段名称为“Label: 时/分/秒”。 |
## 状态
默认、悬停、聚焦段(酒红填充)、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。
默认、悬停、聚焦段(酒红填充 + 键盘焦点环)、只读、错误(危险色边框 + 说明,草稿保留)、禁用。弹层列表中超出范围的项禁用。
## 事件
@@ -52,8 +53,9 @@ ui.Render(view, 400, 300, 1)
## 限制
- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整。
- 超出范围或不符合步长的时间会提示,不会被自动夹取或取整;键入超出段范围的数字(如小时 29)会保留显示并报错。
- 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。
- MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+2
View File
@@ -31,6 +31,7 @@ ui.Render(view, 400, 300, 1)
| `Min` | `TimeOfDay` | 00:00:00 | 两端最早时间。 |
| `Max` | `TimeOfDay` | 零值(即 23:59:59) | 两端最晚时间。 |
| `AllowOvernight` | `bool` | false | 允许结束早于开始,表示次日结束,并显示“次日”。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 空 | 整组名称。 |
@@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1)
- `TimeRange.Overnight()` 报告是否跨午夜。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+3
View File
@@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1)
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Location` | `*time.Location` | 必填 | 用户编辑所用时区;为 nil 时 panic。 |
| `Layout` | `string` | `2006-01-02` | 日期部分的 Go 时间布局,用于显示与错误说明。 |
| `Min` | `time.Time` | 零值 | 最早时刻。 |
| `Max` | `time.Time` | 零值 | 最晚时刻。 |
| `Seconds` | `bool` | false | 显示秒。 |
@@ -36,6 +37,7 @@ ui.Render(view, 400, 300, 1)
| `MinuteStep` | `int` | 0(即 1) | 分钟步长。 |
| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日。 |
| `Today` | `Date` | 零值(系统时钟) | 今日标记与空值时的默认日期。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置占位文字 | 辅助技术名称。 |
@@ -60,3 +62,4 @@ ui.Render(view, 400, 300, 1)
- 空值(零 `time.Time`)打开时默认今天 00:00。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+2
View File
@@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1)
| `DisableMonth` | `func(YearMonth) bool` | nil | 不可选月份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前月份。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择月份” | 辅助技术名称。 |
@@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1)
- 键盘移到不可选月份时焦点保持在原处,可继续移动。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+2
View File
@@ -29,6 +29,7 @@ ui.Render(view, 400, 300, 1)
| `DisableYear` | `func(int) bool` | nil | 不可选年份。 |
| `Clearable` | `bool` | false | 有值时显示清空按钮。 |
| `Today` | `Date` | 零值(系统时钟) | 标记当前年份。 |
| `ReadOnly` | `bool` | false | 只读:可聚焦、可复制(Cmd/Ctrl+C),不能修改,不打开弹层。 |
| `Disabled` | `bool` | false | 禁用。 |
| `Label` | `string` | 内置“选择年份” | 辅助技术名称。 |
@@ -53,3 +54,4 @@ ui.Render(view, 400, 300, 1)
- 只支持公元 1 年及以后;值为负数时 panic。
- 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。
- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。
- 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。
+1 -1
View File
@@ -41,7 +41,7 @@ mujica.Tabs(c, &tab, []mujica.Tab{
## 状态
- 选中:Selection 背景、2 DIP AccentText 边线(横向在底边,纵向在右边)、600 字重,三者同时标记。
- 悬停:SurfaceHover 背景;按下:Selection 背景。
- 悬停:SurfaceHover 背景;按下:Selection 背景。背景与文字颜色在 120 ms 内过渡,减少动画时直接切换。
- 焦点:2 DIP Focus 焦点环,外留 2 DIP。
- 禁用:MyGo 统一降低不透明度,不响应指针与键盘。
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
+2 -2
View File
@@ -62,10 +62,10 @@ mujica.Sidebar(c, &selected, []mujica.SidebarSection{
- Tab:进入侧边栏(一个停靠点)。
- ↑/↓:上一项/下一项;Home / End:首项/末项;输入名称开头字母跳到匹配项。
- 分组与嵌套组标题用指针点击展开折叠。
- 分组与嵌套组标题各是一个 Tab 停靠点(辅助技术看到 disclosure,含展开状态);Enter 或 Space 展开折叠,箭头在 120 ms 内转动。
## 限制
- 复用 MyGo `ui.SidebarItem`,它用主题 Accent 绘制图标(`ui/sidebar.go:182`);深色主题 Accent 对比度不足,本组件在构建侧边栏时临时把 MyGo 主题的 Accent 换成 AccentText。
- MyGo `SidebarItem` 总是追加名称文字(`ui/sidebar.go:212`);紧凑模式传入空名称,名称改由 `Label` 与提示提供,因此紧凑模式下首字母跳转不可用。
- 嵌套组用 MyGo `SidebarSection` 实现,组标题本身不能被选中,且只能用指针展开折叠(MyGo 不给标题注册按键)。
- 组标题基于 MyGo `CollapsibleBase` 的触发器,而不是 `SidebarSection`(后者只能用指针展开,箭头固定 150 ms,`ui/sidebar.go:115`)。组标题本身不能被选中。展开状态按完整父路径保存,同名组互不影响。
+2 -2
View File
@@ -52,7 +52,7 @@ if id, ok := res.Chosen(); ok && id == "wrap" {
- 打开的菜单标题:Selection 背景;高亮项:Selection 背景。
- 勾选项:AccentText 勾号;单选项:AccentText 菱形标记;名称附“已勾选”。
- 禁用项:降低不透明度,指针与键盘均跳过。
- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时上移。
- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时限高并在菜单内滚动,键盘高亮项自动滚入视野。
## 事件
@@ -71,4 +71,4 @@ if id, ok := res.Chosen(); ok && id == "wrap" {
- 快捷键只做提示,不注册;用 `c.Shortcut` 处理。
- MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。
- 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。
- 菜单打开期间,焦点保持在标题上,高亮项由组件管理(与 MyGo `SelectBase` 相同的方式)。
- 菜单打开期间,焦点保持在标题上,高亮项由组件管理。驱动菜单的按键注册在标题上,而 MyGo 的 active descendant 不公开,因此高亮变化时用 `c.Announce` 朗读当前项,并把它写入标题的 `Description`。
+1
View File
@@ -63,3 +63,4 @@ if id, ok := res.Chosen(); ok {
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
- 搜索是子串匹配,不做模糊匹配或排序打分。
- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
+2 -2
View File
@@ -41,7 +41,7 @@ ui.Render(view, 400, 300, 1)
## 状态
默认、悬停(SurfaceHover)、选中(Accent 填充 + OnAccent 文字 + 勾选标记)、聚焦(列表外 2 DIP 焦点环)、网格选中(AccentText 边框 + 勾选)、加载、追加加载、失败、空。
默认、悬停(SurfaceHover)、选中(Selection 背景 + Text 文字 + AccentText 勾选标记)、聚焦(列表外 2 DIP 焦点环)、网格选中(Selection 背景 + AccentText 边框与勾选)、加载、追加加载、失败、空。
## 事件
@@ -57,6 +57,6 @@ ui.Render(view, 400, 300, 1)
## 限制
- 虚拟化由 MyGo `ui.List`/`ui.GridView` 提供:每帧构建的元素数由可见区域和预取范围决定;程序跳转(`ScrollTo`)时目标两侧各构建一屏。测试以 10 万条记录统计“最后一帧调用条目构建函数的次数”验证上限(list_test.go)。
- MyGo 单选跟随键时只在原位置附近 1000 行内查找(ui/list.go:30 `listSearch`);插入或删除大量上方行后,单选可能被清除。
- 单选按键保存:每帧先检查原位置的键,不符时扫描全部条目重新定位(MyGo 自身只在附近 1000 行内查找,ui/list.go:30),所以反转、排序或在网格前端插入后选择仍指向同一条目;条目被删除时选择清除。
- Shift 连选的锚点由 MyGo 决定(ui/list.go `extend`),与平台习惯一致。
- 网格模式没有 `ListState.Visible` 等滚动位置读取接口。
+7 -5
View File
@@ -34,7 +34,7 @@ ui.Render(view, 400, 300, 1)
## 参数
`func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView`。另有 `DataTableColumnPicker(c, s, cols)`(“列”按钮与勾选弹层,控制显隐)和纯函数 `DataTableRows(rows, cols, sort, filter) []int`(本地排序筛选辅助,返回行下标)。
`func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView`。表格上方有“列”按钮,弹层中每列提供显隐勾选框、升序/降序排序、左移/右移与减小/增大列宽(每次 16 DIP),这是排序、列顺序与列宽的键盘入口。另有纯函数 `DataTableRows(rows, cols, sort, filter) []int`(本地排序筛选辅助,返回行下标)。
`DataTableState` 字段:`Sort ui.SortOrder`、`Filter string`(不区分大小写包含匹配全部列的 `Text`)、`Hidden map[string]bool`(按列 ID 隐藏)、`Layout ui.TableLayout`(用户拖动得到的列顺序与列宽,可保存恢复)、`Selection ui.Selection[K]`(多选)。方法:`Selected()`、`Rows()`(筛选后行数)。
@@ -51,23 +51,25 @@ ui.Render(view, 400, 300, 1)
## 状态
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Accent 填充 + OnAccent 文字;单选首列菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Selection 背景 + Text 文字;单选首列 AccentText 菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
## 事件
`Changed()`:选择变化;`Submitted()`:Enter 或双击;`SortChanged()`:点击表头改变了 `State.Sort`;`Retried()`:点击“重试”。
`Changed()`:选择变化(含勾选框);`Submitted()`:Enter 或双击;`SortChanged()`:点击表头或列菜单改变了 `State.Sort`;`Retried()`:点击“重试”。
## 键盘操作
- ↑/↓、Home/End:移动选择;Enter:提交;输入字母按首个可见列定位。
- 多选:Shift/Cmd(Ctrl) 配合点击或方向键;Cmd/Ctrl+A 全选。
- 表头:点击排序、再次点击反向;拖动表头边缘调整列宽,双击适配内容;拖动表头移动列。
- 列选择器:Tab 进入勾选项,Space 切换;Escape 关闭。
- 列菜单:Tab 到“列”按钮,Enter 打开;Tab 在各列的勾选框与排序、移动、列宽按钮间移动,Enter/Space 执行;Escape 关闭。
- 多选勾选框可用 Tab 到达,Space 切换。
## 限制
- MyGo `ui.Table` 只按调用方给出的顺序显示(docs/ui/table.md “Sorting”),因此本组件在本地完成排序,或在受控模式下交给调用方。
- 表头内容只能是 `Title` 文字(ui/table.go `table` 构建表头),无法放置全选勾选框;全选使用 Cmd/Ctrl+A。
- 表头内容只能是 `Title` 文字且只响应指针(ui/table.go `tableHeader`),因此全选使用 Cmd/Ctrl+A,键盘排序与调整列通过列菜单。
- 共享列宽的列在列菜单中从 max(MinWidth, 120) DIP 开始调整。
- 单选的跟随由组件按键重新定位,跨越任意距离都有效;多选的键盘锚点在大规模重排后由 MyGo 重置(ui/list.go:30)。
- 删除的数据行对应的选择会在数据变化时清除;被筛选隐藏的行保留选择。
- 10 万行 × 10 列:测试统计“最后一帧构建过单元格的行数”不超过 16,并断言重复帧不再调用比较函数(data_table_test.go)。
+2 -2
View File
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
## 状态
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠(箭头旋转)、子项加载中、子项加载失败、空、首次加载与失败。
默认、悬停、选中(Selection 背景 + AccentText 勾选标记)、聚焦、展开/折叠(箭头旋转)、子项加载中、子项加载失败、空、首次加载与失败。
## 事件
@@ -53,5 +53,5 @@ ui.Render(view, 400, 300, 1)
- 基于 MyGo `ui.Outline` 而不是 `ui.Tree`:`ui.Tree` 会构建全部已展开节点(docs/ui/tree.md “A tree builds every item it shows”),不满足大量节点虚拟化要求。
- `Outline` 每帧遍历已展开的节点以计算行(ui/outline.go `flatten`),元素数受可见区域限制,但遍历成本随展开节点数增长。
- 单选跟随节点的搜索范围为 1000 行(ui/list.go:30)。
- 单选按节点保存:行移动超过 MyGo 的 1000 行搜索范围(ui/list.go:30)时,组件扫描可见行重新定位并立即重建一帧;所选节点被折叠隐藏或删除时选择清除。
- 异步加载由调用方在后台完成并通过 `Window.Update` 回到界面线程。
+3 -2
View File
@@ -29,7 +29,7 @@ ui.Render(view, 400, 300, 1)
## 参数
`func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView`。`TreeTableState` 字段:`Sort`、`Layout`、`Selection`;方法:`Selected()`、`SetOpen`、`IsOpen`。
`func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView`。`TreeTableState` 字段:`Sort`、`Layout`、`Selection`;方法:`Selected()`、`SetOpen`、`IsOpen`。表格上方的“列”按钮弹层提供每列的排序、左移/右移与列宽调整,是这些操作的键盘入口。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
@@ -45,7 +45,7 @@ ui.Render(view, 400, 300, 1)
## 状态
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠、排序箭头、子项加载中/失败、空。
默认、悬停、选中(Selection 背景 + AccentText 勾选标记)、聚焦、展开/折叠、排序箭头、子项加载中/失败、空。
## 事件
@@ -54,6 +54,7 @@ ui.Render(view, 400, 300, 1)
## 键盘操作
- 与 Tree 相同的方向键与展开键;点击表头排序同级节点,再次点击反向;拖动表头边缘或表头调整列宽与顺序。
- 列菜单:Tab 到“列”按钮,Enter 打开,Tab 到排序、移动与列宽按钮,Enter 执行,Escape 关闭。
## 限制
+1 -1
View File
@@ -44,4 +44,4 @@ ui.Render(view, 400, 300, 1)
## 限制
- 基于 MyGo `ui.CollapsibleBase`;MyGo 的 `ui.Accordion` 外观固定且用内部状态移动焦点(ui/collapsible.go `accordionKeys`),因此键盘移动在组件内重新实现:向上移动时焦点在下一帧到达。
- 关闭动画进行的 120 ms 内内容仍在界面树中。
- 关闭动画进行的 120 ms 内内容仍在界面树中,但已禁用:不接收点击,也不进入 Tab 序列。
+1 -1
View File
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
## 键盘操作
- Enter/Space:展开或关闭。
- 关闭后内容不在 Tab 序列中。
- 关闭(包括关闭动画期间)的内容不在 Tab 序列中。
## 限制
+4 -4
View File
@@ -27,18 +27,18 @@ mujica.Meter(c, 93, mujica.MeterOptions{
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 |
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 |
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`;其他值 panic。 |
| `Indeterminate` | `bool` | `false` | 未知长度的进度。 |
| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 |
| `ShowValue` | `bool` | `false` | 显示百分比。 |
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 |
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数、NaN 或无穷 panic。 |
`MeterOptions`:
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `float64` | 必填 | 须在范围内,否则 panic。 |
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;`Max <= Min` panic。 |
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;非有限值或 `Max <= Min` panic。 |
| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
| `Label` | `string` | `""` | 名称。 |
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
@@ -47,7 +47,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{
## 状态
- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示满格静态填充(对比度不低于 3:1)和“In progress”文字;切换到减少动画时进行中的过渡直接跳到目标值。
- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。
## 事件
+1 -1
View File
@@ -16,7 +16,7 @@ mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: t
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Size` | `float32` | 16 | 标记边长(DIP)。 |
| `Size` | `float32` | 16 | 标记边长(DIP);负数、NaN 或无穷 panic。 |
| `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 |
| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 |
| `Color` | `ui.Color` | `AccentText` | 标记颜色。 |
+1 -1
View File
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
负值 panic。
负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。
## 状态
+1 -1
View File
@@ -76,7 +76,7 @@ func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult {
if !opts.NoIcon {
ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text).TextAlign(ui.Center)
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).TextAlign(ui.Center)
ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight)
if opts.Action != "" {
ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() {
+2 -2
View File
@@ -30,8 +30,8 @@ func ExampleFormField() {
email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
})
}
ui.Render(view, 400, 300, 1)
+2 -2
View File
@@ -39,7 +39,7 @@ var dataPages = []page{
// dataSection lays out a titled block of a demo.
func dataSection(c *ui.Context, title string, fn func()) {
ui.Column(c).Gap(12).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size)
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size))
fn()
})
}
@@ -50,7 +50,7 @@ func dataStates(c *ui.Context, fn func(state func(name string, body func()))) {
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
fn(func(name string, body func()) {
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
body()
})
})
@@ -134,7 +134,6 @@ func dataTableDemo(c *ui.Context, log func(string)) {
ui.Checkbox(c, &s.multiple, "Multiple")
ui.Select(c, &s.status, dataStatuses)
ui.TextInput(c, &s.table.Filter).Width(180).Placeholder("Filter")
mujica.DataTableColumnPicker(c, &s.table, cols)
})
v := mujica.DataTable(c, &s.table, rows, mujica.DataTableOptions[dataRelic, int]{
Columns: cols, Key: func(r dataRelic) int { return r.ID }, Multiple: s.multiple, Status: st,
+6 -2
View File
@@ -63,7 +63,9 @@ func dataAccordionDemo(c *ui.Context, log func(string)) {
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Pews of black oak, polished for the feast.") }},
{ID: "crypt", Title: "Crypt (disabled)", Disabled: true, Content: func() {}},
{ID: "bells", Title: "Bells", Content: func() {
mujica.Button(c, "Ring", mujica.ButtonOptions{})
if mujica.Button(c, "Ring", mujica.ButtonOptions{}).Clicked() {
log("ring the bells")
}
}},
}, mujica.AccordionOptions{Multiple: s.multiple, KeepOne: s.keepOne}).Width(420).Changed() {
log(fmt.Sprintf("open %v", s.open))
@@ -84,7 +86,9 @@ func dataCollapsibleDemo(c *ui.Context, log func(string)) {
})
if mujica.Collapsible(c, &s.open, mujica.CollapsibleOptions{Title: "Rites of the evening", Disabled: s.disabled}, func() {
ui.Text(c, "Candles are lit at the seventh bell; the choir enters from the north door.")
mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline})
if mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() {
log("light candles")
}
}).Changed() {
log(fmt.Sprintf("open=%v", s.open))
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// dataDemo runs the demo of page id and returns its tester and event log.
func dataDemo(t *testing.T, id string) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(dataPages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("no page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Scroll(c).Fill().Padding(24).Children(func() {
dataPages[i].Demo(c, func(s string) { *events = append(*events, s) })
})
}, 900, 700)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return tt, events
}
func TestDataDemoButtonsLog(t *testing.T) {
tt, events := dataDemo(t, "074")
for _, s := range []string{"Bells", "Ring"} {
if err := tt.Click(s); err != nil {
t.Fatal(err)
}
}
if !slices.Contains(*events, "ring the bells") {
t.Fatalf("Ring not logged: %q", *events)
}
tt, events = dataDemo(t, "075")
for _, s := range []string{"Open from outside", "Light candles"} {
if err := tt.Click(s); err != nil {
t.Fatal(err)
}
}
if !slices.Contains(*events, "light candles") {
t.Fatalf("Light candles not logged: %q", *events)
}
}
+17 -3
View File
@@ -35,7 +35,7 @@ var datetimePages = []page{
func dtSection(c *ui.Context, title string, fn func()) {
k := mujica.Tokens(c)
ui.Column(c).Gap(8).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
fn()
})
}
@@ -46,7 +46,7 @@ func dtStates(c *ui.Context, fn func(state func(name string, body func()))) {
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
fn(func(name string, body func()) {
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
body()
})
})
@@ -128,6 +128,9 @@ func dtDatePickerDemo(c *ui.Context, log func(string)) {
log("DatePicker (bounded) changed: " + s.b.String())
}
})
state("Read-only", func() {
mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Signed on", Layout: layout, ReadOnly: true})
})
state("Disabled", func() {
mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Locked", Layout: layout, Disabled: true})
})
@@ -202,6 +205,11 @@ func dtTimePickerDemo(c *ui.Context, log func(string)) {
log("TimePicker (bounded) changed: " + s.b.String())
}
})
state("Read-only", func() {
o := base
o.Label, o.ReadOnly = "Logged at", true
mujica.TimePicker(c, &s.a, o)
})
state("Disabled", func() {
o := base
o.Label, o.Disabled = "Locked", true
@@ -261,7 +269,7 @@ func dtDateTimeDemo(c *ui.Context, log func(string)) {
}
k := mujica.Tokens(c)
ui.Text(c, "Try 2026-03-08 02:30 in America/New_York: the picker reports the DST gap.").
FontSize(theme.CaptionSize).TextColor(k.TextMuted)
FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
}
@@ -288,6 +296,9 @@ func dtMonthDemo(c *ui.Context, log func(string)) {
log("MonthPicker changed: " + s.v.String())
}
})
state("Read-only", func() {
mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Closed month", ReadOnly: true})
})
state("Disabled", func() {
mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Locked", Disabled: true})
})
@@ -311,6 +322,9 @@ func dtYearDemo(c *ui.Context, log func(string)) {
log(fmt.Sprintf("YearPicker changed: %d", s.v))
}
})
state("Read-only", func() {
mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Founded", ReadOnly: true})
})
state("Disabled", func() {
mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Locked", Disabled: true})
})
+1 -111
View File
@@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"strings"
@@ -172,116 +171,6 @@ const iconCode = `ui.Row(c).Gap(8).Children(func() {
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
})`
type imageState struct {
crop, loading, fail, empty bool
err error
}
var displayPicture = func() *ui.SVG {
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
}()
func imageDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
displaySection(c, "Parameters", func() {
displayControls(c, func() {
displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
if ui.Checkbox(c, &s.fail, "Error").Changed() {
log(fmt.Sprintf("fail = %v", s.fail))
s.err = nil
if s.fail {
s.err = errors.New("connection reset")
}
}
})
})
displaySection(c, "Preview", func() {
var src ui.ImageSource = displayPicture
if s.empty {
src = nil
}
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
if v.Retry {
s.fail, s.err = false, nil
log("retry")
}
})
displaySection(c, "States", func() {
ui.Row(c).Gap(12).Wrap().Children(func() {
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
log("retry (static example)")
}
})
})
displayCode(c, imageCode, log)
}
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
Loading: loading, Err: loadErr,
})
if img.Retry {
loading, loadErr = true, nil // start loading again here
}`
type avatarState struct {
name string
size float64
img bool
}
var displayBitmap = func() *ui.Bitmap {
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
}, 64, 64, 1))
}()
func avatarDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
displaySection(c, "Parameters", func() {
displayControls(c, func() {
if ui.TextInput(c, &s.name).Width(200).Changed() {
log("name " + s.name)
}
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
})
})
displaySection(c, "Preview", func() {
var img *ui.Bitmap
if s.img {
img = displayBitmap
}
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
})
displaySection(c, "Fallbacks", func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
})
})
displayCode(c, avatarCode, log)
}
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "", mujica.AvatarOptions{})
})`
var displayPeople = []mujica.AvatarItem{
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
@@ -444,6 +333,7 @@ func linkDemo(c *ui.Context, log func(string)) {
}
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
s.external = ""
log("open failure cleared")
}
})
})
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"errors"
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
type imageState struct {
crop, loading, fail, empty bool
err error
}
var displayPicture = func() *ui.SVG {
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
}()
func imageDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
displaySection(c, "Parameters", func() {
displayControls(c, func() {
displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
if ui.Checkbox(c, &s.fail, "Error").Changed() {
log(fmt.Sprintf("fail = %v", s.fail))
s.err = nil
if s.fail {
s.err = errors.New("connection reset")
}
}
})
})
displaySection(c, "Preview", func() {
var src ui.ImageSource = displayPicture
if s.empty {
src = nil
}
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
if v.Retry {
s.fail, s.err = false, nil
log("retry")
}
})
displaySection(c, "States", func() {
ui.Row(c).Gap(12).Wrap().Children(func() {
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
log("retry (static example)")
}
})
})
displayCode(c, imageCode, log)
}
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
Loading: loading, Err: loadErr,
})
if img.Retry {
loading, loadErr = true, nil // start loading again here
}`
type avatarState struct {
name string
size float64
img bool
}
var displayBitmap = func() *ui.Bitmap {
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
}, 64, 64, 1))
}()
func avatarDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
displaySection(c, "Parameters", func() {
displayControls(c, func() {
if ui.TextInput(c, &s.name).Width(200).Changed() {
log("name " + s.name)
}
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
})
})
displaySection(c, "Preview", func() {
var img *ui.Bitmap
if s.img {
img = displayBitmap
}
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
})
displaySection(c, "Fallbacks", func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
})
})
displayCode(c, avatarCode, log)
}
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
mujica.Avatar(c, "", mujica.AvatarOptions{})
})`
+6 -4
View File
@@ -177,10 +177,12 @@ var formPages = []page{
e = "This name is already in the guest list."
}
mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal,
Description: "As written on the invitation.", Error: e}, func() {
if mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}).Changed() {
Description: "As written on the invitation.", Error: e}, func() *ui.Element {
g := mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError})
if g.Changed() {
log(fmt.Sprintf("value %q", d.value))
}
return g.Input
})
}},
{ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"],
@@ -346,8 +348,8 @@ ui.Render(view, 400, 300, 1)`,
"FormField": `email := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
})
}
ui.Render(view, 400, 300, 1)`,
+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestFeedbackTextFollowsTextScale(t *testing.T) {
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}, {ID: "u", Title: "Unread one"}}
var empty, result float32
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Children(func() {
Progress(c, 0.5, ProgressOptions{ShowValue: true})
empty = EmptyState(c, EmptyStateOptions{Title: "Empty title", Description: "Short", NoIcon: true}).Element.Bounds().H
result = Result(c, ResultOptions{Title: "Result title"}).Element.Bounds().H
StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{})
NotificationCenter(c, &items, NotificationCenterOptions{})
})
}, 900, 2400)
tt.Frame()
empty1, result1 := empty, result
tt.SetPreferences(ui.Preferences{TextScale: 2})
tt.Frame() // Bounds report the previous layout
for text, min := range map[string]float32{
"50%": theme.CaptionSize * 2,
"Healthy": theme.BodySize * 2,
"1 unread": theme.CaptionSize * 2,
} {
if r, ok := tt.Find(text); !ok || r.H < min {
t.Errorf("%q at 200%%: %v %v, want height >= %v", text, r, ok, min)
}
}
// Titles are found only through their labeled groups: the groups must grow by the titles too.
if grow := empty - empty1; grow < theme.BodySize*theme.LineHeight+theme.H3Size {
t.Errorf("EmptyState grew %v at 200%%; its title did not scale", grow)
}
if grow := result - result1; grow < theme.H2Size {
t.Errorf("Result grew %v at 200%%; its title did not scale", grow)
}
}
func TestFeedbackRejectsInvalidInput(t *testing.T) {
nan, inf := math.NaN(), math.Inf(1)
for name, f := range map[string]func(c *ui.Context){
"progress shape": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Shape: 7}) },
"progress NaN size": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Size: float32(nan)}) },
"meter infinite range": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: inf}) },
"meter NaN min": func(c *ui.Context) { Meter(c, 1, MeterOptions{Min: nan, Max: 2}) },
"meter NaN threshold": func(c *ui.Context) {
Meter(c, 1, MeterOptions{Max: 2, Levels: &ui.MeterLevels{Warning: nan, Critical: 2}})
},
"skeleton shape": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Shape: 5}) },
"skeleton NaN width": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Width: float32(nan)}) },
"spinner negative size": func(c *ui.Context) { Spinner(c, SpinnerOptions{Size: -4}) },
"status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) },
"alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) },
"toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
}()
}
}
func TestNotificationTitleOnlyHitHeight(t *testing.T) {
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
NotificationCenter(c, &items, NotificationCenterOptions{})
}, 600, 400)
if r, ok := tt.Find("Backup complete"); !ok || r.H < theme.MinHitSize {
t.Fatalf("title-only notification hit box %v %v", r, ok)
}
}
+3 -4
View File
@@ -65,8 +65,7 @@ type fileDropZoneState struct {
rejected []fileDropZoneReject
}
// FileDropZone takes files dropped from the system or chosen with its Browse button,
// checks type, count and size, and lists them in *files. It never reads or uploads them.
// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them.
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
panic("mujica: FileDropZone limits must not be negative")
@@ -172,7 +171,7 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions)
zone.Children(func() {
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
ui.Text(c, hint).TextAlign(ui.Center)
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).TextAlign(ui.Center)
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
if b.Clicked() {
ch := make(chan filePickerResult, 1)
@@ -221,7 +220,7 @@ func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
ui.Text(c, detail).TextColor(color).FontSize(theme.CaptionSize).SingleLine().Grow(1)
ui.Text(c, detail).TextColor(color).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Grow(1)
if remove != nil {
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
+4 -5
View File
@@ -55,8 +55,7 @@ type filePickerState struct {
err string
}
// FilePicker opens the system file or folder dialog and stores the chosen paths in *paths.
// The dialog runs off the UI thread; the button shows loading until it closes.
// FilePicker opens the system file dialog off the UI thread and stores the chosen paths.
func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts {
for _, f := range opts.Filters {
for _, ext := range f.Extensions {
@@ -118,16 +117,16 @@ func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePick
}
switch {
case st.err != "":
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
ui.Text(c, st.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
case len(*paths) == 0:
ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(theme.CaptionSize)
ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
default:
ui.Column(c).Gap(d.Unit()).Children(func() {
for _, p := range *paths {
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
ui.Text(c, filepath.Base(p)).SingleLine()
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
})
}
})
+28 -21
View File
@@ -9,8 +9,7 @@ import (
"github.com/egoist/mygo/ui"
)
// FormState is a form's validation and submit state; keep it beside the form's values.
// Its methods run on the UI thread: deliver async results through Window.Update.
// FormState is a form's check and submit state; use it on the UI thread only.
type FormState struct {
fields map[string]*formFieldState
order []string
@@ -22,6 +21,7 @@ type FormState struct {
requested bool
focus string
formError string
generation int
}
type formFieldState struct {
@@ -33,6 +33,7 @@ type formFieldState struct {
asyncFor uint64
asyncErr string
pending bool
hasAsync bool
check func() string
startAsync func()
}
@@ -45,8 +46,7 @@ func (s *FormState) Version(name string) uint64 {
return 0
}
// Resolve delivers the async check of name's value at version; "" means valid.
// It reports false and drops the result when the value has changed since.
// Resolve delivers name's async result for version, "" if valid; stale versions return false.
func (s *FormState) Resolve(name string, version uint64, err string) bool {
f := s.fields[name]
if f == nil || f.version != version {
@@ -98,8 +98,9 @@ type FormOptions struct {
// FormScope registers a Form's fields with FormItem.
type FormScope struct {
c *ui.Context
st *FormState
c *ui.Context
st *FormState
horizontal bool
}
// FormParts are the parts of a Form.
@@ -127,8 +128,7 @@ type FormItemOptions[T any] struct {
Async func(v T, version uint64)
}
// Form lays out the fields that fields registers, validates them on blur and submit,
// and shows submit and reset buttons. It never stores or sends the values itself.
// Form lays out and checks its fields and shows submit and reset buttons.
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
if st.fields == nil {
st.fields = map[string]*formFieldState{}
@@ -139,13 +139,16 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
parts := FormParts{}
col := ui.Column(c).Gap(d.Unit() * 5)
parts.Element = col
scope := &FormScope{c: c, st: st}
scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal}
col.Children(func() {
if opts.Horizontal {
ui.Form(c, func() { fields(scope) })
} else {
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
}
// A new key after Reset drops the controls' drafts and errors.
ui.Box(c).Key(st.generation).Children(func() {
if opts.Horizontal {
formFieldGrid(c, func() { fields(scope) })
} else {
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
}
})
if st.formError != "" {
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
}
@@ -198,7 +201,12 @@ func formSettle(c *ui.Context, st *FormState) bool {
if st.Error(name) != "" && first == "" {
first = name
}
pending = pending || st.fields[name].pending
f := st.fields[name]
// A value edited since its check owes a new one.
if f.hasAsync && f.err == "" && f.asyncFor != f.version {
f.startAsync()
pending = true
}
}
switch {
case first != "":
@@ -221,12 +229,12 @@ func formReset(st *FormState) {
r()
}
st.fields = map[string]*formFieldState{}
st.generation++
st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", ""
slog.Debug("mujica: form reset")
}
// FormItem registers field name, labels the control that control returns, and shows its error.
// control must return the element to focus when the field has the first error.
// FormItem registers field name around the focusable element control returns.
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
c, st := f.c, f.st
if name == "" || opts.Label == "" {
@@ -243,6 +251,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
fs = &formFieldState{snapshot: snap, version: st.seq}
st.fields[name] = fs
}
fs.hasAsync = opts.Async != nil
fs.check = func() string {
if opts.Required && formEmpty(*value) {
return msg(c, "form.required")
@@ -273,12 +282,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
desc = msg(c, "form.checking")
}
var ctrl *ui.Element
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, func() {
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element {
ctrl = control()
return ctrl
})
if ctrl == nil {
panic("mujica: FormItem " + name + ": control returned nil")
}
focused := ctrl.FocusWithin()
if fs.focused && !focused {
fs.touched = true
+63 -15
View File
@@ -2,6 +2,8 @@ package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// FormFieldOptions configure a FormField.
@@ -15,24 +17,70 @@ type FormFieldOptions struct {
Horizontal bool
}
// FormField labels the control that control builds and adds help and error text.
// The label names the control and a click on it focuses the control.
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
// FormField labels the element control returns and shows help and error text for it.
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element {
if !opts.Horizontal {
return formFieldBuild(c, label, opts, false, control)
}
var f *ui.Element
formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) })
return f
}
// formFieldGrid holds fields whose labels sit in a column beside their controls.
func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
d := Density(c)
return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4).
AlignItems(ui.Start).Children(fields)
}
// formFieldBuild builds the label and the control column; beside makes them two grid cells.
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element {
if label == "" {
panic("mujica: FormField needs a label")
}
if opts.Horizontal {
var f *ui.Element
ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) })
return f
k := Tokens(c)
d := Density(c)
var head, content, ctrl *ui.Element
body := func() {
head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center)
if beside {
head.MinHeight(d.ControlHeight()).Justify(ui.End)
}
var text *ui.Element
head.Children(func() {
text = ui.Text(c, label).FontWeight(500).SingleLine()
if opts.Required {
ui.Text(c, "*").TextColor(k.Danger).Role(ui.RoleNone)
}
})
clicked := head.Clicked()
content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0)
content.Children(func() {
ctrl = control()
if ctrl == nil {
panic("mujica: FormField " + label + ": control returned nil")
}
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
}
if opts.Error != "" {
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
}
})
ctrl.Label(label).Description(opts.Description).Error(opts.Error)
if opts.Required {
ctrl.Description(msg(c, "form.required"))
}
if ctrl.IsDisabled() {
text.TextColor(k.TextMuted)
} else if clicked {
ctrl.Focus()
}
}
return formFieldBuild(c, label, opts, control)
}
// formFieldBuild builds the field in the layout the caller set up.
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
if opts.Required {
label += " *"
if beside {
body()
return content
}
return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error)
return ui.Column(c).Gap(d.Unit() * 1.5).Children(body)
}
+42 -11
View File
@@ -1,6 +1,7 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
@@ -10,29 +11,59 @@ func TestFormFieldLabelFocusesControl(t *testing.T) {
name := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() {
InputGroup(c, &name, InputGroupOptions{})
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element {
return InputGroup(c, &name, InputGroupOptions{}).Input
})
}, 400, 200)
if err := tt.Click("Name *"); err != nil {
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
tt.Type("Sakiko")
if name != "Sakiko" {
t.Fatalf("label click did not focus the control: %q", name)
}
if !tt.Focused("Name *") || !tt.HasText("As on your seal.") {
if !tt.Focused("Name") || !tt.HasText("As on your seal.") || !tt.HasText("*") {
t.Fatalf("texts %q", tt.Texts())
}
}
// The label goes to the returned input, not the first focusable element before it.
func TestFormFieldTargetsReturnedControl(t *testing.T) {
tags, site := []string{"rose"}, ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Gap(12).Children(func() {
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element {
return TagsInput(c, &tags, TagsInputOptions{}).Input
})
FormField(c, "Site", FormFieldOptions{}, func() *ui.Element {
return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input
})
})
}, 500, 300)
_ = tt.Click("Flowers")
tt.Type("ivy")
tt.Key(0, ui.KeyEnter)
if !slices.Equal(tags, []string{"rose", "ivy"}) || !tt.Focused("Flowers") {
t.Fatalf("tags %q", tags)
}
if _, ok := tt.Find("Remove rose"); !ok {
t.Fatal("remove button lost its own name")
}
_ = tt.Click("Site")
tt.Type("court")
if site != "court" || tt.Focused("Back") {
t.Fatalf("site %q", site)
}
}
func TestFormFieldErrorAndLayout(t *testing.T) {
v := ""
horizontal := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() {
InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"})
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element {
return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input
})
}, 500, 200)
if !tt.HasText("Enter an email address.") {
@@ -60,7 +91,7 @@ func TestFormFieldNeedsLabel(t *testing.T) {
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
FormField(c, "", FormFieldOptions{}, func() {})
FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) })
}, 100, 100, 1)
}
@@ -68,11 +99,11 @@ func TestFormFieldSnapshot(t *testing.T) {
a, b := "Sakiko Togawa", "uika@"
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() {
InputGroup(c, &a, InputGroupOptions{})
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element {
return InputGroup(c, &a, InputGroupOptions{}).Input
})
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() {
InputGroup(c, &b, InputGroupOptions{Invalid: true})
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element {
return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input
})
})
})
+66 -4
View File
@@ -86,7 +86,7 @@ func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) {
if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") {
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
}
if !tt.Focused("Name *") || p.email != "bad" {
if !tt.Focused("Name") || p.email != "bad" {
t.Fatal("first error not focused or value lost")
}
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" {
@@ -181,10 +181,72 @@ func TestFormResetRestoresInitialValues(t *testing.T) {
func TestFormHorizontalLayout(t *testing.T) {
p := &formProbe{horizontal: true}
tt := ui.NewTester(p.run, 600, 500)
name, _ := tt.Find("Name *")
star, _ := tt.Find("*")
email, _ := tt.Find("Email")
if name.X+name.W != email.X+email.W {
t.Fatalf("labels not right-aligned: %v %v", name, email)
input, _ := tt.Find("Name")
if star.X+star.W != email.X+email.W || input.X >= email.X {
t.Fatalf("labels not right-aligned: %v %v", star, email)
}
}
// An edit during a pending check must be checked again before the submit goes out.
func TestFormEditWhilePendingRechecks(t *testing.T) {
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true}
tt := ui.NewTester(p.run, 600, 500)
_ = tt.Click("Submit")
if len(p.asyncCalls) != 1 {
t.Fatalf("calls %v", p.asyncCalls)
}
_ = tt.Click("Email")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("taken@b")
tt.Frame()
if p.submits != 0 || len(p.asyncCalls) != 2 || p.asyncValues[1] != "taken@b" {
t.Fatalf("submits %d values %q", p.submits, p.asyncValues)
}
if p.st.Resolve("email", p.asyncCalls[0], "") {
t.Fatal("stale result accepted")
}
p.st.Resolve("email", p.asyncCalls[1], "Taken")
tt.Frame()
tt.Frame()
if p.submits != 0 || !tt.HasText("Taken") {
t.Fatalf("submitted unchecked value: %d", p.submits)
}
}
// Reset also drops drafts and errors kept inside the controls.
func TestFormResetClearsControlState(t *testing.T) {
var st FormState
tags, seen := []string{}, ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Form(c, &st, FormOptions{}, func(f *FormScope) {
FormItem(f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string {
seen = s
return "Invalid tag"
}}).Input
})
})
}, 600, 400)
_ = tt.Click("Tags")
tt.Type("bad tag")
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Invalid tag") {
t.Fatal("no tag error")
}
_ = tt.Click("Reset")
tt.Frame()
if tt.HasText("Invalid tag") {
t.Fatal("tag error survived reset")
}
seen = ""
_ = tt.Click("Tags")
tt.Type("x")
tt.Key(0, ui.KeyEnter)
if seen != "x" {
t.Fatalf("draft survived reset: %q", seen)
}
}
+42 -5
View File
@@ -5,6 +5,7 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
@@ -54,22 +55,36 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
u := Density(c).Unit()
w, h := imageBox(src, opts.Width, opts.Height)
var v ImageView
frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip().
// Same size rules as the loaded image.
frame := ui.Row(c).AspectRatio(w/h).Center().Gap(u*2).Padding(0, u).Clip().
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
if opts.Width > 0 || opts.Height == 0 {
frame.Width(w)
}
if opts.Height > 0 {
frame.Height(h)
}
v.Element = frame
room := frame.Bounds()
// Build again when the box changed, settling the error text.
if last := ui.Local(frame, imageRoomKey{}, func() ui.Rect { return ui.Rect{W: -1} }); *last != room {
*last = room
c.AnimationFrame()
}
frame.Children(func() {
switch {
case opts.Err != nil:
frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger})
ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).TextAlign(ui.Center)
if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() {
if imageRetry(c, k, opts.Err.Error()).Clicked() {
v.Retry = true
slog.Debug("mujica: image retry", "alt", opts.Alt)
}
if room.W >= theme.MinHitSize+u*4+imageErrorTextWidth {
ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
}
case opts.Loading:
frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
Spinner(c, SpinnerOptions{Size: 20, Label: msg(c, "image.loading")})
Spinner(c, SpinnerOptions{Size: min(20, room.H), Label: msg(c, "image.loading")})
default:
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted})
@@ -78,6 +93,28 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
return v
}
type imageRoomKey struct{}
// imageErrorTextWidth is the room the error text needs.
const imageErrorTextWidth = 120
// imageRetry is the 28 DIP icon-only retry button.
func imageRetry(c *ui.Context, k theme.Tokens, problem string) *ui.Element {
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
Label(msg(c, "image.retry")).Description(problem).Tooltip(problem)
bg := k.Surface
if b.Pressed() {
bg = k.Selection
} else if b.Hovered() {
bg = k.SurfaceHover
}
b.Background(bg).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Danger)
internal.Ring(b, k.Focus, theme.ControlRadius)
internal.Fade(b, Motion(c, theme.StateDuration))
b.Children(func() { Icon(c, "refresh-cw", IconOptions{Size: 16}) })
return b
}
// imageBox fills unset sides from src's aspect, else 160×120.
func imageBox(src ui.ImageSource, w, h float32) (float32, float32) {
var sw, sh float32
+35
View File
@@ -0,0 +1,35 @@
package mujica
import (
"errors"
"testing"
"github.com/egoist/mygo/ui"
)
func TestImageErrorTextNeedsRoom(t *testing.T) {
for _, tc := range []struct {
w float32
text bool
}{{200, true}, {80, false}} {
ready := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ready {
Image(c, nil, ImageOptions{Width: tc.w, Height: 60, Err: errors.New("404")})
}
}, 300, 200)
ready = true
tt.Frame()
n := 0
for _, s := range tt.Texts() {
if s == "Image failed to load" {
n++
}
}
// One is the box's name; a second is the visible text.
if got := n == 2; got != tc.text {
t.Errorf("width %v: error text shown %v, want %v (%v)", tc.w, got, tc.text, tt.Texts())
}
}
}
+46 -4
View File
@@ -77,11 +77,14 @@ func TestImageRetry(t *testing.T) {
retries++
}
}, 300, 300)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Retry") {
t.Fatal("retry not focused by Tab")
}
tt.Key(0, ui.KeyEnter)
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if retries != 2 {
t.Fatalf("retries %d, want 2", retries)
}
@@ -102,12 +105,13 @@ func TestImageNegativeSizePanics(t *testing.T) {
func TestImageSnapshot(t *testing.T) {
bmp := imageTestBitmap()
snapshot(t, "image", 600, 180, func(c *ui.Context) {
snapshot(t, "image", 760, 170, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"})
Image(c, nil, ImageOptions{Width: 120, Height: 120})
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
Image(c, nil, ImageOptions{Width: 160, Height: 140, Err: errors.New("404")})
Image(c, nil, ImageOptions{Width: 200, Height: 120, Err: errors.New("404")})
Image(c, bmp, ImageOptions{Err: errors.New("404")})
})
})
}
@@ -135,3 +139,41 @@ func TestImageKeepsSizeWhileLoading(t *testing.T) {
}
}
}
func TestImageStatesShareBoxWhenShrunk(t *testing.T) {
bmp := imageTestBitmap()
var got []ui.Rect
for _, opts := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
r = Image(c, bmp, opts).Element.Bounds()
})
}, 300, 300)
tt.Frame()
got = append(got, r)
}
if got[0].W != 40 || got[0].H != 20 || got[1] != got[0] || got[2] != got[0] {
t.Fatalf("loaded, loading, error boxes %v", got)
}
}
func TestImageRetryInSmallBox(t *testing.T) {
retries := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
if Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")}).Retry {
retries++
}
})
}, 300, 300)
tt.Frame()
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
if retries != 1 {
t.Fatalf("retries %d, want 1", retries)
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ type ViewerImage struct {
Alt string
}
// ImageViewerView is a built ImageViewer and what happened to it in this frame.
// Element is nil while the viewer is closed.
// ImageViewerView is a built ImageViewer; Element is nil while closed.
type ImageViewerView struct {
*ui.Element
changed bool
+1 -2
View File
@@ -49,8 +49,7 @@ func (p InputGroupParts) Changed() bool { return p.Input.Changed() }
// Submitted reports whether Enter was pressed in the input.
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
// InputGroup joins a single-line input with text and buttons inside one border.
// Every button keeps its own place in the Tab order.
// 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 {
+19
View File
@@ -0,0 +1,19 @@
package internal
import (
"math"
"time"
"github.com/egoist/mygo/ui"
)
// Ease moves e's background, border and text color fg to their targets over d.
func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element {
if d > 0 {
e.Transition(ui.ElementTransition{Colors: true, Duration: d})
}
ch := func(key string, v uint8) uint8 {
return uint8(math.Round(float64(e.Animate(key, float32(v), d))))
}
return e.TextColor(ui.Color{R: ch("mujica.fg.r", fg.R), G: ch("mujica.fg.g", fg.G), B: ch("mujica.fg.b", fg.B), A: ch("mujica.fg.a", fg.A)})
}
+44 -6
View File
@@ -89,6 +89,7 @@ func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[
if !s.started {
s.started, s.cursor = true, -1
}
listLocate(s, n, opts.Key)
k := Tokens(c)
var v ListView
v.Element = ui.Column(c).MinHeight(0)
@@ -118,6 +119,21 @@ func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[
return v
}
// listLocate finds the chosen key again when items moved or came and went.
func listLocate[K comparable](s *ListState[K], n int, key func(int) K) {
if !s.has || s.cursor >= 0 && s.cursor < n && key(s.cursor) == s.key {
return
}
s.cursor = -1
for i := range n {
if key(i) == s.key {
s.cursor = i
break
}
}
slog.Debug("mujica: list selection moved", "row", s.cursor)
}
func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
d := Density(c)
s.rows.Key = func(i int) any { return opts.Key(i) }
@@ -128,11 +144,14 @@ func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
} else {
s.rows.Selection = nil
}
k := Tokens(c)
restore := dataSelectTheme(c, 0)
defer restore()
return ui.List(c, &s.rows, n, func(i int) {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Gap(d.Unit() * 2).Children(func() { item(i) })
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0).TextColor(k.AccentText)
}
})
}).Padding(d.Unit())
@@ -155,16 +174,22 @@ func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
} else {
s.grid.Selection = nil
}
// Chosen items take AccentText borders, legible on both surfaces.
// Chosen items take an AccentText ring over a Selection face.
k := Tokens(c)
saved := c.Theme()
t := *saved
t.Accent = Tokens(c).AccentText
t.Accent = k.AccentText
c.SetTheme(&t)
e := ui.GridView(c, &s.grid, n, w, h, func(i int) {
ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Children(func() {
chosen := listChosen(s, opts, i)
face := ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Radius(theme.ControlRadius)
if chosen {
face.Background(k.Selection)
}
face.Children(func() {
item(i)
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit())
if chosen {
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit()).TextColor(k.AccentText)
}
})
}).Padding(d.Unit() * 2)
@@ -172,6 +197,19 @@ func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
return e
}
// dataSelectTheme shows MyGo's chosen rows as Selection with Text; spacing > 0 sets the unit.
func dataSelectTheme(c *ui.Context, spacing float32) func() {
k := Tokens(c)
saved := c.Theme()
t := *saved
t.Accent, t.AccentText = k.Selection, k.Text
if spacing > 0 {
t.Spacing = spacing
}
c.SetTheme(&t)
return func() { c.SetTheme(saved) }
}
func listChosen[K comparable](s *ListState[K], opts ListOptions[K], i int) bool {
if opts.Multiple {
return s.Selection.Has(opts.Key(i))
+46
View File
@@ -2,9 +2,13 @@ package mujica
import (
"fmt"
"image/color"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type listHarness struct {
@@ -213,3 +217,45 @@ func TestListSnapshot(t *testing.T) {
})
})
}
func TestListKeyFollowsLargeReorder(t *testing.T) {
h := newListHarness(5000)
tt := ui.NewTester(h.view, 400, 400)
if err := tt.Click("Item 3"); err != nil {
t.Fatal(err)
}
slices.Reverse(h.ids)
tt.Frame()
if k, ok := h.st.Selected(); !ok || k != 3 || h.st.cursor != 4996 {
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, h.st.cursor)
}
}
func TestListGridKeyFollowsInsert(t *testing.T) {
h := newListHarness(12)
h.opts.Grid = true
h.opts.ItemWidth, h.opts.ItemHeight = 80, 60
h.listSize = [2]float32{380, 300}
tt := ui.NewTester(h.view, 420, 340)
if err := tt.Click("Item 2"); err != nil {
t.Fatal(err)
}
h.ids = append([]int{100}, h.ids...)
tt.Frame()
if k, _ := h.st.Selected(); k != 2 || h.st.cursor != 3 {
t.Fatalf("after insert: selected %d at %d", k, h.st.cursor)
}
}
func TestListSelectionColors(t *testing.T) {
h := newListHarness(5)
tt := ui.NewTester(h.view, 400, 400)
if err := tt.Click("Item 1"); err != nil {
t.Fatal(err)
}
r, _ := tt.Find("Item 1")
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
if want := toRGBA(theme.Light().Selection); got != want {
t.Fatalf("selected row background %v, want Selection %v", got, want)
}
}
+1 -3
View File
@@ -25,9 +25,7 @@ type LoadingOverlayResult struct {
Canceled bool
}
// LoadingOverlay builds content and, while loading, covers it with a
// spinner and label. Covered content takes no pointer, keys or focus;
// the rest of the window stays usable. Focus inside moves to the overlay.
// LoadingOverlay covers content while loading, blocking input to that region only.
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
if content == nil {
panic("mujica: LoadingOverlay needs content")
+27 -1
View File
@@ -68,6 +68,8 @@ type menubarState struct {
hl []int
focusTo int
ptr [2]float32
// reveal scrolls the highlighted item into view once.
reveal bool
}
// Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control.
@@ -106,6 +108,10 @@ func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarRes
if m.st.open >= 0 {
m.overlay(titles)
}
// Focus stays on the title, so the highlighted item is read out.
if name := m.current(); m.moved && name != "" {
c.Announce(name)
}
res.chosen = m.chosen
return res
}
@@ -136,6 +142,20 @@ type menubarRun struct {
menus []MenubarMenu
st *menubarState
chosen string
moved bool
}
// current names the highlighted item of the open menu, "" for none.
func (m *menubarRun) current() string {
st := m.st
if st.open < 0 || len(st.hl) == 0 {
return ""
}
l := len(st.hl) - 1
if h := st.hl[l]; h >= 0 {
return menubarName(m.c, m.items(l)[h])
}
return ""
}
func (m *menubarRun) title(i int, label string) *ui.Element {
@@ -152,6 +172,7 @@ func (m *menubarRun) title(i int, label string) *ui.Element {
case t.Hovered():
t.Background(k.SurfaceHover)
}
internal.Ease(t, Motion(c, theme.StateDuration), k.Text)
if st.focusTo == i {
t.Focus()
st.focusTo = -1
@@ -165,8 +186,9 @@ func (m *menubarRun) title(i int, label string) *ui.Element {
case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp):
m.openMenu(i, true)
}
} else {
} else if open {
m.keys(t)
t.Description(m.current())
}
if t.Clicked() {
if open {
@@ -185,6 +207,7 @@ func (m *menubarRun) keys(t *ui.Element) {
l := len(st.hl) - 1
items := m.items(l)
h := st.hl[l]
m.moved, st.reveal = true, true
switch {
case t.Shortcut(0, ui.KeyDown):
st.hl[l] = menubarStep(items, h, 1)
@@ -216,6 +239,8 @@ func (m *menubarRun) keys(t *ui.Element) {
} else {
m.close()
}
default:
m.moved, st.reveal = false, false
}
}
@@ -239,6 +264,7 @@ func (m *menubarRun) openMenu(i int, highlight bool) {
first = menubarStep(m.menus[i].Items, -1, 1)
}
m.st.open, m.st.hl = i, []int{first}
m.moved = highlight
m.focus(i)
slog.Debug("mujica: menubar open", "menu", m.menus[i].Label)
}
+19 -9
View File
@@ -53,18 +53,18 @@ func (m *menubarRun) overlay(titles []ui.Rect) {
})
}
// panel builds level l at (x, y), flipping left of parent when it does not fit.
// It returns the panel and the row of its highlighted item.
// panel builds level l at (x, y) and returns it with its highlighted row.
func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) {
c, st := m.c, m.st
k := Tokens(c)
u := Density(c).Unit()
items := m.items(l)
panel := ui.Column(c).Absolute().Padding(u).MinWidth(200).AlignItems(ui.Stretch).Role(ui.RolePopup).
ww, wh := c.Size()
// Long menus scroll within the window instead of running off it.
panel := ui.Scroll(c).Absolute().Padding(u).MinWidth(200).MaxHeight(wh-8).AlignItems(ui.Stretch).Role(ui.RolePopup).
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18))
pb := panel.Bounds()
ww, wh := c.Size()
if x+pb.W > ww-4 {
x = ww - 4 - pb.W
if l > 0 {
@@ -85,6 +85,10 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u
row := m.item(it, st.hl[l] == j)
if st.hl[l] == j {
anchor = row
if st.reveal && l == len(st.hl)-1 {
row.ScrollIntoView()
st.reveal = false
}
}
if row.Clicked() {
st.hl = append(st.hl[:l], j)
@@ -114,15 +118,13 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element {
k := Tokens(c)
d := Density(c)
u := d.Unit()
name := it.Label
if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked {
name += ", " + msg(c, "menubar.checked")
}
name := menubarName(c, it)
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2).
Radius(theme.ControlRadius).Label(name).TextColor(k.Text).Disabled(it.Disabled).Role(ui.RoleButton)
if hl {
row.Background(k.Selection)
}
internal.Ease(row, Motion(c, theme.StateDuration), k.Text)
row.Children(func() {
mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone)
if it.Checked && it.Kind == MenubarCheck {
@@ -136,7 +138,7 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element {
ui.Text(c, it.Label).SingleLine()
ui.Box(c).Grow(1).MinWidth(u * 6)
if it.Shortcut.Key != 0 {
ui.Text(c, menubarShortcutText(it.Shortcut)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
ui.Text(c, menubarShortcutText(it.Shortcut)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
if it.Items != nil {
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted)
@@ -145,6 +147,14 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element {
return row
}
// menubarName is an item's label, with its checked state for assistive technology.
func menubarName(c *ui.Context, it MenubarItem) string {
if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked {
return it.Label + ", " + msg(c, "menubar.checked")
}
return it.Label
}
var menubarKeyNames = map[ui.Key]string{
ui.KeyEnter: "Enter", ui.KeyEscape: "Esc", ui.KeyBackspace: "Backspace", ui.KeyTab: "Tab", ui.KeySpace: "Space",
ui.KeyDelete: "Del", ui.KeyHome: "Home", ui.KeyEnd: "End", ui.KeyPageUp: "PgUp", ui.KeyPageDown: "PgDn",
+33
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"runtime"
"testing"
@@ -149,6 +150,38 @@ func TestMenubarKeyboard(t *testing.T) {
}
}
func TestMenubarLongMenuScrolls(t *testing.T) {
var items []MenubarItem
for i := range 30 {
items = append(items, MenubarItem{ID: fmt.Sprint(i), Label: fmt.Sprintf("Entry %02d", i)})
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Menubar(c, []MenubarMenu{{Label: "Long", Items: items}}, MenubarOptions{})
}, 400, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnd)
tt.Frame()
r, ok := tt.Find("Entry 29")
if !ok || r.H == 0 || r.Y+r.H > 300 {
t.Fatalf("last item outside the window: %+v", r)
}
}
func TestMenubarAnnouncesHighlight(t *testing.T) {
a := &menubarApp{wrap: true}
tt := ui.NewTester(a.view, 700, 500)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown) // View opens on Word wrap
tt.Key(0, ui.KeyDown) // Fit
got := tt.Announcements()
if len(got) < 2 || got[len(got)-2] != "Word wrap, checked" || got[len(got)-1] != "Fit" {
t.Fatalf("announcements %q", got)
}
}
func TestMenubarShortcutText(t *testing.T) {
got := menubarShortcutText(MenubarShortcut{ui.Ctrl | ui.Shift, ui.KeyF5})
want := "Ctrl+Shift+F5"
+6
View File
@@ -10,6 +10,12 @@ func init() {
"data_table.noMatch": {"No matching rows", "没有匹配的行"},
"data_table.selectRow": {"Select row", "选择行"},
"data_table.columns": {"Columns", "列"},
"data_table.sortAsc": {"Sort %s ascending", "按%s升序排序"},
"data_table.sortDesc": {"Sort %s descending", "按%s降序排序"},
"data_table.moveLeft": {"Move %s left", "将%s左移"},
"data_table.moveRight": {"Move %s right", "将%s右移"},
"data_table.narrower": {"Make %s narrower", "减小%s列宽"},
"data_table.wider": {"Make %s wider", "增大%s列宽"},
"timeline.done": {"Done", "已完成"},
"timeline.active": {"Current", "进行中"},
"timeline.warning": {"Warning", "警告"},
+2
View File
@@ -63,6 +63,8 @@ func init() {
"time_picker.open": {"Choose time", "选择时间"},
"time_picker.minute_step": {"Minutes must be a multiple of %d.", "分钟须为 %d 的倍数。"},
"time_picker.second_step": {"Seconds must be a multiple of %d.", "秒须为 %d 的倍数。"},
"time_picker.segment": {"%[1]s, from %[2]d to %[3]d", "%[1]s,范围 %[2]d–%[3]d"},
"time_picker.part_range": {"%[1]s must be from %[2]d to %[3]d.", "%[1]s须在 %[2]d 至 %[3]d 之间。"},
"time_picker.out_of_range": {"Choose a time from %s to %s.", "请选择 %s 至 %s 之间的时间。"},
"time_range_picker.start": {"Start time", "开始时间"},
+1
View File
@@ -42,6 +42,7 @@ func init() {
"transfer.all": {"Select all in %s", "全选%s"},
"transfer.search": {"Search", "搜索"},
"transfer.search_in": {"Search %s", "搜索%s"},
"transfer.partial": {"%d of %d selected", "已选 %d / %d 项"},
"transfer.empty": {"No items", "暂无条目"},
"transfer.no_match": {"No matches", "无匹配结果"},
"form.submit": {"Submit", "提交"},
+4 -2
View File
@@ -44,11 +44,13 @@ type MonthPickerOptions struct {
// Today marks the current month; zero uses the system clock.
Today Date
Disabled bool
// ReadOnly keeps the field focusable and copyable but blocks the popup and clearing.
ReadOnly bool
// Label names the field for assistive technology.
Label string
}
// MonthPicker chooses a month into *value from a year page; PageUp and PageDown change the year.
// MonthPicker chooses a month into *value from a year page.
func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult {
for _, m := range []YearMonth{*value, opts.Min, opts.Max} {
if !m.IsZero() && (m.Month < time.January || m.Month > time.December) {
@@ -75,7 +77,7 @@ func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateT
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: msg(c, "month_picker.placeholder"), label: label,
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, home: home.ordinal(),
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home.ordinal(),
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 },
cols: 3,
+10 -5
View File
@@ -101,19 +101,23 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s
u := d.Unit()
on := navigationMenuActive(l, path)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).
Label(l.Label).TextColor(k.TextMuted).Cursor(ui.CursorPointer)
Label(l.Label).Cursor(ui.CursorPointer)
open := ui.Local(b, "open", func() bool { return false })
if l.Items == nil {
b.Role(ui.RoleLink)
} else {
b.Role(ui.RoleMenuButton)
}
fg := k.TextMuted
if on {
b.TextColor(k.Text).FontWeight(600)
b.FontWeight(600)
fg = k.Text
}
if b.Hovered() || *open {
b.Background(k.SurfaceHover).TextColor(k.Text)
b.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(b, Motion(c, theme.StateDuration), fg)
b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1)
@@ -154,7 +158,7 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s
panic("mujica: NavigationLink needs a Label")
}
if it.Items != nil {
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize).TextColor(k.Ornament.Mix(k.Text, 0.35)).
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).
Padding(u*2, u*2, u, u*2).SingleLine()
list(it.Items)
continue
@@ -191,6 +195,7 @@ func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Ele
case e.Hovered():
e.Background(k.SurfaceHover)
}
internal.Ease(e, Motion(c, theme.StateDuration), k.Text)
e.Children(func() {
if l.Icon != nil {
ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0)
@@ -198,7 +203,7 @@ func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Ele
ui.Column(c).Gap(2).MinWidth(0).Children(func() {
ui.Text(c, l.Label).SingleLine()
if l.Description != "" {
ui.Text(c, l.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).FontWeight(400)
ui.Text(c, l.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontWeight(400)
}
})
})
+50
View File
@@ -0,0 +1,50 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale.
func TestNavigationCaptionsFollowTextScale(t *testing.T) {
for _, tc := range []struct {
text string
view func(c *ui.Context)
}{
{"Name and seal", func(c *ui.Context) {
cur := 0
Steps(c, &cur, []Step{{Title: "Account", Description: "Name and seal"}}, StepsOptions{})
}},
{"12", func(c *ui.Context) {
sel := "a"
Sidebar(c, &sel, []SidebarSection{{Title: "Mail", Items: []SidebarItem{{ID: "a", Label: "Inbox", Icon: icons.Must("inbox"), Count: 12}}}}, SidebarOptions{})
}},
{"Verse of the court", func(c *ui.Context) {
navigationMenuItem(c, NavigationLink{Label: "Poems", Path: "/p", Description: "Verse of the court"}, false, false)
}},
{"Esc close", func(c *ui.Context) {
open := true
CommandPalette(c, &open, []Command{{ID: "a", Label: "A"}}, CommandPaletteOptions{})
}},
} {
height := func(scale float32) float32 {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Fill().AlignItems(ui.Start).Children(func() { tc.view(c) })
}, 900, 700)
tt.SetPreferences(ui.Preferences{TextScale: scale})
tt.Frame()
r, ok := tt.Find(tc.text)
if !ok {
t.Fatalf("no %q", tc.text)
}
return r.H
}
if h1, h2 := height(1), height(2); h2 < h1*1.8 {
t.Errorf("%q: height %v at 2x text, %v at 1x", tc.text, h2, h1)
}
}
}
+5 -7
View File
@@ -44,9 +44,7 @@ type NotificationCenterResult struct {
ClearedAll bool
}
// NotificationCenter lists *items and edits them in place: read, unread,
// remove, clear. The list is one Tab stop; arrows move among its buttons
// and Delete removes the focused notification.
// NotificationCenter lists *items and edits them in place: read, unread, remove, clear.
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
if items == nil {
panic("mujica: NotificationCenter needs items")
@@ -70,9 +68,9 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC
Role(ui.RoleGroup).Label(title).Clip()
panel.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
if unread > 0 {
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
}
ui.Spacer(c)
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
@@ -135,7 +133,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
}
})
open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
open := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
Radius(theme.ControlRadius).Label(name)
internal.Ring(open, k.Focus, theme.ControlRadius)
open.Children(func() {
@@ -145,7 +143,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
t.Bold()
}
if n.Time != "" {
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
ui.Text(c, n.Time).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if n.Body != "" {
+10 -5
View File
@@ -141,16 +141,19 @@ func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on,
b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius).
Label(name).Disabled(disabled)
internal.Ring(b, k.Focus, theme.ControlRadius)
fg := k.Text
switch {
case on:
b.Background(k.Accent).TextColor(k.OnAccent).FontWeight(600)
b.Background(k.Accent).Border(theme.BorderWidth, k.Accent).FontWeight(600)
fg = k.OnAccent
case b.Pressed():
b.Background(k.Selection).TextColor(k.Text)
b.Background(k.Selection).Border(theme.BorderWidth, k.ControlBorder)
case b.Hovered():
b.Background(k.SurfaceHover).TextColor(k.Text).Border(theme.BorderWidth, k.ControlBorder)
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
default:
b.TextColor(k.Text).Border(theme.BorderWidth, k.Border)
b.Border(theme.BorderWidth, k.Border)
}
internal.Ease(b, Motion(c, theme.StateDuration), fg)
if icon != nil {
b.Tooltip(name)
}
@@ -176,6 +179,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin
if sel.Trigger.Hovered() {
sel.Trigger.Background(k.SurfaceHover)
}
internal.Ease(sel.Trigger, Motion(c, theme.StateDuration), k.Text)
sel.Trigger.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), *pageSize)).SingleLine()
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
@@ -188,6 +192,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin
if it.Highlighted() {
it.Background(k.Selection)
}
internal.Ease(it, Motion(c, theme.StateDuration), k.Text)
it.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), s)).SingleLine().Grow(1)
if s == *pageSize {
@@ -239,7 +244,7 @@ func paginationJump(c *ui.Context, pages int, set func(int)) {
} else if in.Focused() {
border = k.Focus
}
box.Border(theme.BorderWidth, border)
internal.Ease(box.Border(theme.BorderWidth, border), Motion(c, theme.StateDuration), k.Text)
if *bad {
ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine()
}
+3 -4
View File
@@ -45,8 +45,7 @@ type pinInputState struct {
compose string
}
// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros.
// Typing fills the next cell, Backspace clears the last, and paste fills them all.
// PinInput edits a fixed-length digit code, one cell per digit, keeping leading zeros.
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
n := opts.Length
if n == 0 {
@@ -144,9 +143,9 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
cell.Children(func() {
switch {
case showCompose:
ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine()
ui.Text(c, compose).FontSize(FontSize(c, theme.H3Size)).Underline().SingleLine()
case ch != "":
ui.Text(c, ch).FontSize(theme.H3Size).FontFeatures("tnum").SingleLine()
ui.Text(c, ch).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").SingleLine()
}
})
caret := focused && i == active && len(*value) < n && !showCompose
+21 -19
View File
@@ -31,14 +31,16 @@ type ProgressOptions struct {
Size float32
}
// Progress shows value, from 0 to 1, of work done. It panics outside that range.
// Indeterminate indicators move only while painted and hold still under reduced motion.
// Progress shows work done, value 0 to 1; indeterminate ones hold still under reduced motion.
func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
if opts.Shape != ProgressLinear && opts.Shape != ProgressRing {
panic(fmt.Sprintf("mujica: unknown ProgressShape %d", int(opts.Shape)))
}
if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) {
panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value))
}
if opts.Size < 0 {
panic("mujica: negative Progress Size")
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic(fmt.Sprintf("mujica: Progress Size %v invalid", opts.Size))
}
k := Tokens(c)
u := Density(c).Unit()
@@ -71,12 +73,12 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
})
}
bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border)
shown := float32(value)
if !opts.Indeterminate {
if !opts.Indeterminate && !still {
shown = bar.Animate("progress.value", shown, theme.StateDuration)
}
bar.Draw(func(p *ui.Painter, r ui.Rect) {
@@ -84,7 +86,7 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
case !opts.Indeterminate:
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius)
case still:
p.Fill(r, k.AccentText.Alpha(0.4), theme.ControlRadius)
p.Fill(r, k.AccentText, theme.ControlRadius)
default:
p.AnimationFrame()
phase := float32(p.Now().UnixMilli()%1400) / 1400
@@ -108,7 +110,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
row.Children(func() {
ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent)
shown := float32(acc)
if !opts.Indeterminate {
if !opts.Indeterminate && !still {
shown = ring.Animate("progress.value", shown, theme.StateDuration)
}
stroke := max(3, d/10)
@@ -123,7 +125,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
p.StrokePath(progressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText)
}
case still:
p.StrokePath(&track, stroke, k.AccentText.Alpha(0.4))
p.StrokePath(&track, stroke, k.AccentText)
default:
p.AnimationFrame()
start := float32(p.Now().UnixMilli()%1200) / 1200
@@ -132,7 +134,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
})
if caption != "" && d >= 44 && !opts.Indeterminate {
ring.Children(func() {
ui.Text(c, caption).FontSize(theme.CaptionSize).FontFeatures("tnum").TextColor(k.Text)
ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
})
ring.Center()
}
@@ -142,7 +144,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
ui.Text(c, opts.Label).TextColor(k.Text)
}
if caption != "" && (d < 44 || opts.Indeterminate) {
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize)
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
}
})
}
@@ -171,8 +173,7 @@ func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path {
type MeterOptions struct {
// Min and Max bound the value; both zero mean 0 to 1.
Min, Max float64
// Levels mark the value High from Warning and Critical from Critical;
// Critical below Warning flags low values instead. Nil marks none.
// Levels are the High and Critical thresholds; Critical below Warning flags low values.
Levels *ui.MeterLevels
Label string
ShowValue bool
@@ -195,21 +196,20 @@ func meterLevel(v float64, l *ui.MeterLevels) int {
return 0
}
// Meter shows a level within a range, as disk use. Thresholds show as
// ticks and the level as a word as well as a color. It panics on a value outside the range.
// Meter shows a level within a range with threshold ticks and a level word, not color alone.
func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
lo, hi := opts.Min, opts.Max
if lo == 0 && hi == 0 {
hi = 1
}
if !(hi > lo) {
if !finite(lo) || !finite(hi) || !(hi > lo) {
panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi))
}
if math.IsNaN(value) || value < lo || value > hi {
panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, hi))
}
if l := opts.Levels; l != nil && (l.Warning < lo || l.Warning > hi || l.Critical < lo || l.Critical > hi) {
panic("mujica: Meter levels outside the range")
if l := opts.Levels; l != nil && !(l.Warning >= lo && l.Warning <= hi && l.Critical >= lo && l.Critical <= hi) {
panic(fmt.Sprintf("mujica: Meter levels %+v outside %v..%v", *l, lo, hi))
}
k := Tokens(c)
u := Density(c).Unit()
@@ -246,7 +246,7 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
})
}
name := opts.Label
@@ -270,3 +270,5 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
})
return col
}
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
+46
View File
@@ -5,8 +5,54 @@ import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestProgressReducedMotionJumpsMidTransition(t *testing.T) {
v := 0.0
var bar ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Width(300).Children(func() {
bar = Progress(c, v, ProgressOptions{}).Bounds()
})
}, 400, 100)
v = 1
tt.Frame()
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if got, want := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2), theme.Light().AccentText; got != want {
t.Fatalf("fill at 95%% is %v, want %v at once", got, want)
}
}
func progressPixel(tt *ui.Tester, x, y float32) ui.Color {
r, g, b, _ := tt.Image().At(int(x), int(y)).RGBA()
return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8))
}
func TestProgressStaticFillContrast(t *testing.T) {
for _, m := range []Mode{Light, Dark} {
var bar ui.Rect
var k theme.Tokens
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: m})
k = Tokens(c)
ui.Column(c).Width(300).Children(func() {
bar = Progress(c, 0, ProgressOptions{Indeterminate: true}).Bounds()
})
}, 400, 100)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame() // Bounds report the previous layout
fill := progressPixel(tt, bar.X+bar.W/2, bar.Y+bar.H-3) // the bar is the last row
for _, against := range []ui.Color{k.Border, k.Background} {
if cr := theme.Contrast(fill, against); cr < 3 {
t.Errorf("%v: static fill %v vs %v is %.2f:1", m, fill, against, cr)
}
}
}
}
func TestProgressValueText(t *testing.T) {
v := 0.424
tt := ui.NewTester(func(c *ui.Context) {
+2 -3
View File
@@ -38,8 +38,7 @@ type ResultResult struct {
Action int
}
// Result reports the outcome of a finished task and offers next steps.
// It only reports the chosen step; the caller navigates.
// Result shows a task outcome and reports the chosen next step; the caller navigates.
func Result(c *ui.Context, opts ResultOptions) ResultResult {
k := Tokens(c)
u := Density(c).Unit()
@@ -61,7 +60,7 @@ func Result(c *ui.Context, opts ResultOptions) ResultResult {
res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*3).Padding(u*10, u*6).Role(ui.RoleGroup).Label(title)
res.Element.Children(func() {
ui.Icon(c, icon).Size(48, 48).TextColor(tone)
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H2Size).TextColor(k.Text).TextAlign(ui.Center)
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text).TextAlign(ui.Center)
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(420).LineHeight(theme.LineHeight)
}
+1 -1
View File
@@ -75,7 +75,7 @@ func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts Seg
seg.Background(k.SurfaceHover)
fg = k.Text
}
seg.TextColor(fg)
internal.Ease(seg, Motion(c, theme.StateDuration), fg)
internal.Ring(seg, k.Focus, theme.ControlRadius)
if s.HideLabel {
seg.Tooltip(s.Label)
+57 -9
View File
@@ -6,6 +6,8 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
@@ -76,8 +78,9 @@ func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts Si
if s.Title == "" {
panic("mujica: SidebarSection needs a Title")
}
ui.SidebarSection(c, s.Title, sidebarOpen(*closed, "s:"+s.Title), func() {
sidebarItems(c, *selected, s.Items, *closed, 0)
path := s.Title
sidebarGroup(c, s.Title, sidebarOpen(*closed, path), true, 0, func() {
sidebarItems(c, *selected, s.Items, *closed, path, 0)
})
}
}).FillHeight().FillWidth()
@@ -104,17 +107,17 @@ func sidebarOpen(closed map[string]*bool, key string) *bool {
return o
}
func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, depth int) {
func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, parent string, depth int) {
u := Density(c).Unit()
for _, it := range items {
if it.Label == "" {
panic("mujica: SidebarItem needs a Label")
}
if it.Items != nil {
ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
ui.SidebarSection(c, it.Label, sidebarOpen(closed, fmt.Sprintf("g%d:%s", depth, it.Label)), func() {
sidebarItems(c, selected, it.Items, closed, depth+1)
})
// The key holds the whole path, so same-named groups stay apart.
path := parent + "\x00" + it.Label
sidebarGroup(c, it.Label, sidebarOpen(closed, path), false, depth, func() {
sidebarItems(c, selected, it.Items, closed, path, depth+1)
})
continue
}
@@ -124,6 +127,50 @@ func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed ma
}
}
// sidebarGroup is a section title or nested group that Enter, Space or a click opens and closes.
func sidebarGroup(c *ui.Context, title string, open *bool, section bool, depth int, fn func()) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
ui.Column(c).Gap(u*0.5).Shrink(0).Padding(0, 0, 0, float32(depth)*u*4).Children(func() {
was := *open
tr := ui.CollapsibleBase(c, open).Trigger
if *open != was {
slog.Debug("mujica: sidebar group toggle", "group", title, "open", *open)
}
tr.AlignItems(ui.Center).Gap(u*2).Padding(u, u*2).Radius(theme.ControlRadius).Label(title).Cursor(ui.CursorPointer)
if section {
tr.Margin(u*2, 0, 0, 0).FontWeight(600).FontSize(FontSize(c, theme.CaptionSize))
} else {
tr.MinHeight(d.ControlHeight())
}
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() {
tr.Background(k.SurfaceHover)
}
fg := k.Text
if section {
fg = k.TextMuted
}
internal.Ease(tr, Motion(c, theme.StateDuration), fg)
turn := tr.Animate("mujica.turn", sidebarTurn(*open), Motion(c, theme.StateDuration))
tr.Children(func() {
ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).Rotate(turn).TextColor(k.TextMuted)
ui.Text(c, title).SingleLine().Grow(1)
})
if *open {
fn()
}
})
}
func sidebarTurn(open bool) float32 {
if open {
return 90
}
return 0
}
// sidebarFlat lists every leaf for the compact rail.
func sidebarFlat(c *ui.Context, selected string, items []SidebarItem) {
for _, it := range items {
@@ -158,12 +205,13 @@ func sidebarItem(c *ui.Context, selected string, it SidebarItem, compact bool) {
e.Tooltip(name).Label(name)
} else if it.Count > 0 {
e.Label(name).Children(func() {
ui.Text(c, sidebarCount(it.Count)).FontSize(theme.CaptionSize).FontFeatures("tnum").
ui.Text(c, sidebarCount(it.Count)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
TextColor(k.TextMuted).Padding(0, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background)
})
}
internal.Ease(e, Motion(c, theme.StateDuration), k.Text)
if on {
e.Background(k.Selection).TextColor(k.Text).FontWeight(600)
e.Background(k.Selection).FontWeight(600)
e.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
})
+38
View File
@@ -124,6 +124,44 @@ func TestSidebarInvalid(t *testing.T) {
}
}
func TestSidebarGroupKeyboard(t *testing.T) {
sel := "inbox"
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Sidebar(c, &sel, sidebarSample(), SidebarOptions{})
}, 400, 500)
// Tab stops: the item list, the Library title, the Projects group.
for range 3 {
tt.Key(0, ui.KeyTab)
}
tt.Key(0, ui.KeyEnter)
if tt.HasText("Opera") {
t.Fatal("Enter did not collapse the group")
}
tt.Key(0, ui.KeySpace)
if !tt.HasText("Opera") {
t.Fatal("Space did not expand the group")
}
}
func TestSidebarSameNamedGroups(t *testing.T) {
sel := "a1"
sections := []SidebarSection{
{Title: "Court", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "a1", Label: "Edict"}}}}},
{Title: "Chapel", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "b1", Label: "Hymn"}}}}},
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Sidebar(c, &sel, sections, SidebarOptions{})
}, 400, 500)
if err := tt.Click("Drafts"); err != nil {
t.Fatal(err)
}
if tt.HasText("Edict") || !tt.HasText("Hymn") {
t.Fatalf("same-named groups share state: %q", tt.Texts())
}
}
func TestSidebarSnapshot(t *testing.T) {
snapshot(t, "sidebar", 420, 360, func(c *ui.Context) {
a, b := "opera", "starred"
+10 -6
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
@@ -28,11 +29,13 @@ type SkeletonOptions struct {
Lines int
}
// Skeleton stands in for content that is loading. It pulses while painted
// and shows a static fill under reduced motion.
// Skeleton stands in for loading content; it pulses while painted, static under reduced motion.
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 {
panic("mujica: negative Skeleton size")
if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect {
panic(fmt.Sprintf("mujica: unknown SkeletonShape %d", int(opts.Shape)))
}
if !(opts.Width >= 0) || !(opts.Height >= 0) || opts.Lines < 0 || math.IsInf(float64(opts.Width+opts.Height), 1) {
panic(fmt.Sprintf("mujica: Skeleton size %vx%v, %d lines invalid", opts.Width, opts.Height, opts.Lines))
}
k := Tokens(c)
still := c.Preferences().ReduceMotion
@@ -70,9 +73,10 @@ func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) })
default:
lines := max(opts.Lines, 1)
line := theme.BodySize * theme.LineHeight
size := FontSize(c, theme.BodySize)
line := size * theme.LineHeight
e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) {
bar := theme.BodySize * 0.8
bar := size * 0.8
for i := range lines {
w := r.W
if lines > 1 && i == lines-1 {
+6 -3
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
@@ -18,12 +19,14 @@ type SpinnerOptions struct {
Color ui.Color
}
// Spinner draws four diamonds turning around a center while it shows.
// It repaints only while painted: hidden, it stops. Reduced motion holds it still.
// Spinner turns four diamonds only while painted; hidden or under reduced motion it stops.
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic(fmt.Sprintf("mujica: Spinner Size %v invalid", opts.Size))
}
k := Tokens(c)
size := opts.Size
if size <= 0 {
if size == 0 {
size = 16
}
label := opts.Label
+3 -3
View File
@@ -43,10 +43,10 @@ func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element
e := ui.Column(c).Gap(u).Role(ui.RoleGroup)
e.Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, opts.Title).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
ui.Row(c).Gap(u).AlignItems(ui.End).Children(func() {
ui.Text(c, num).Font(theme.SerifFont).FontSize(theme.H1Size).FontWeight(600).LineHeight(1.2).SingleLine()
ui.Text(c, num).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).FontWeight(600).LineHeight(1.2).SingleLine()
if opts.Unit != "" {
ui.Text(c, opts.Unit).TextColor(k.TextMuted).Padding(0, 0, u, 0)
}
@@ -70,7 +70,7 @@ func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element
ui.Text(c, text).FontWeight(600).SingleLine()
})
if opts.ChangeNote != "" {
ui.Text(c, opts.ChangeNote).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
ui.Text(c, opts.ChangeNote).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
})
+15
View File
@@ -79,3 +79,18 @@ func TestStatisticSnapshot(t *testing.T) {
})
})
}
func TestStatisticTextScale(t *testing.T) {
var h float32
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).AlignItems(ui.Start).Children(func() { h = Statistic(c, 7, StatisticOptions{}).Bounds().H })
}, 300, 300)
tt.Frame()
base := h
tt.SetPreferences(ui.Preferences{TextScale: 2})
tt.Frame()
if h < base*1.8 {
t.Fatalf("height %v at 200%%, %v at 100%%", h, base)
}
}
+3 -2
View File
@@ -44,11 +44,12 @@ type StatusIndicatorOptions struct {
// StatusIndicator shows a status by its own shape and a word, never by color alone.
func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element {
key := "status_indicator." + status.String() // panics on an unknown Status
k := Tokens(c)
u := Density(c).Unit()
label := opts.Label
if label == "" {
label = msg(c, "status_indicator."+status.String())
label = msg(c, key)
}
var tone ui.Color
var icon *ui.SVG
@@ -71,7 +72,7 @@ func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions)
} else {
ui.Icon(c, icon).Size(16, 16).TextColor(tone)
}
ui.Text(c, label).TextColor(k.Text).FontSize(theme.BodySize).SingleLine()
ui.Text(c, label).TextColor(k.Text).FontSize(FontSize(c, theme.BodySize)).SingleLine()
})
return row
}
+3 -2
View File
@@ -101,6 +101,7 @@ func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, d
if e.Hovered() {
e.Background(k.SurfaceHover)
}
internal.Ease(e, Motion(c, theme.StateDuration), k.Text)
} else {
e = ui.Row(c).Shrink(0).Role(ui.RoleListItem)
}
@@ -130,7 +131,7 @@ func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, d
title.TextColor(k.TextMuted)
}
if s.Description != "" {
ui.Text(c, s.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).MaxWidth(240)
ui.Text(c, s.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxWidth(240)
}
})
})
@@ -161,7 +162,7 @@ func stepsMarker(c *ui.Context, i int, st stepsState) {
if icon != "" {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
} else {
ui.Text(c, strconv.Itoa(i+1)).FontSize(theme.CaptionSize).SingleLine()
ui.Text(c, strconv.Itoa(i+1)).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
}
})
}
+1 -1
View File
@@ -152,7 +152,7 @@ func tabsStyle(c *ui.Context, tab *ui.Element, t Tab, on, vertical bool) {
tab.Background(k.SurfaceHover)
fg = k.Text
}
tab.TextColor(fg)
internal.Ease(tab, Motion(c, theme.StateDuration), fg)
tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
if vertical {
+77
View File
@@ -1,8 +1,14 @@
package mujica
import (
"bytes"
"image/color"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
@@ -153,6 +159,77 @@ func TestTabsSnapshot(t *testing.T) {
}
}
// tabsStateSnapshot compares tabsView after act in both themes with <name>-<mode>.png.
func tabsStateSnapshot(t *testing.T, name string, act func(tt *ui.Tester)) {
t.Helper()
for _, m := range []Mode{Light, Dark} {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: m})
tabsView(c)
}, 520, 300)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
act(tt)
tt.Frame()
got := encode(t, tt.Image())
path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png")
if *update {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
}
continue
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%v (run go test -update)", err)
}
if !bytes.Equal(got, want) {
if err := os.WriteFile(path+".got.png", got, 0o644); err != nil {
t.Fatal(err)
}
t.Errorf("%s differs; see %s.got.png", path, path)
}
}
}
func TestTabsStateSnapshots(t *testing.T) {
center := func(tt *ui.Tester, s string) (float32, float32) {
r, ok := tt.Find(s)
if !ok {
t.Fatalf("no %q", s)
}
return r.X + r.W/2, r.Y + r.H/2
}
tabsStateSnapshot(t, "tabs-hover", func(tt *ui.Tester) { tt.Move(center(tt, "Members")) })
tabsStateSnapshot(t, "tabs-pressed", func(tt *ui.Tester) { tt.Press(center(tt, "Settings")) })
tabsStateSnapshot(t, "tabs-focus", func(tt *ui.Tester) {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
})
}
// TestTabsStateColorsEase checks hover fades in over StateDuration, not at once.
func TestTabsStateColorsEase(t *testing.T) {
sel := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
Tabs(c, &sel, []Tab{{Label: "One"}, {Label: "Two"}}, TabsOptions{})
}, 300, 120)
r, _ := tt.Find("Two")
x, y := int(r.X-8), int(r.Y+r.H/2)
hover := tabsRGBA(theme.Light().SurfaceHover)
tt.Move(r.X+r.W/2, r.Y+r.H/2)
if got := tt.Image().RGBAAt(x, y); got == hover {
t.Fatal("hover color applied at once")
}
time.Sleep(theme.StateDuration + 40*time.Millisecond)
tt.Frame()
if got := tt.Image().RGBAAt(x, y); got != hover {
t.Fatalf("hover color %v after the transition, want %v", got, hover)
}
}
func tabsRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} }
// tabsMode recovers the mode snapshot applied, to add a density.
func tabsMode(c *ui.Context) Mode {
if Tokens(c).Dark {
+3 -5
View File
@@ -47,7 +47,6 @@ type tagsInputState struct {
}
// TagsInput edits a list of unique tags shown as chips before a text input.
// Enter or a separator adds the text; Backspace in the empty input removes the last tag.
func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts {
if opts.Max < 0 {
panic("mujica: TagsInput Max must not be negative")
@@ -102,11 +101,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
box.Children(func() {
for i, tag := range *tags {
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Gap(d.Unit()).Padding(0, d.Unit(), 0, d.Unit()*2).
MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
chip.Children(func() {
ui.Text(c, tag).SingleLine()
x := ui.ButtonBase(c).Size(20, 20).Radius(theme.ControlRadius).TextColor(k.TextMuted).
x := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).TextColor(k.TextMuted).
Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag))
internal.Ring(x, k.Focus, theme.ControlRadius)
if x.Hovered() {
@@ -135,7 +133,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
})
})
if st.err != "" {
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
ui.Text(c, st.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
}
})
in := parts.Input
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more