diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 93a881a..440a800 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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: |
diff --git a/alert.go b/alert.go
index 2b5f116..8d78108 100644
--- a/alert.go
+++ b/alert.go
@@ -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")
diff --git a/breadcrumb.go b/breadcrumb.go
index 1bfd93b..3ea31e2 100644
--- a/breadcrumb.go
+++ b/breadcrumb.go
@@ -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)
diff --git a/calendar.go b/calendar.go
index b3ebeb2..7c96a59 100644
--- a/calendar.go
+++ b/calendar.go
@@ -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)))
}
})
diff --git a/card.go b/card.go
index 6f06830..b246cd2 100644
--- a/card.go
+++ b/card.go
@@ -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
diff --git a/carousel.go b/carousel.go
index 81078e3..05f4587 100644
--- a/carousel.go
+++ b/carousel.go
@@ -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
diff --git a/carousel_test.go b/carousel_test.go
index b30e2e4..aae5119 100644
--- a/carousel_test.go
+++ b/carousel_test.go
@@ -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)
+ }
+}
diff --git a/collapsible.go b/collapsible.go
index 7e90fa4..262e7d2 100644
--- a/collapsible.go
+++ b/collapsible.go
@@ -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)
diff --git a/collapsible_test.go b/collapsible_test.go
index 90e5b28..f6ecd90 100644
--- a/collapsible_test.go
+++ b/collapsible_test.go
@@ -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")
+ }
+}
diff --git a/color_picker.go b/color_picker.go
index a4f6724..de993b6 100644
--- a/color_picker.go
+++ b/color_picker.go
@@ -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)
}
})
}
diff --git a/color_picker_test.go b/color_picker_test.go
index b5dc1a5..1b5e3d0 100644
--- a/color_picker_test.go
+++ b/color_picker_test.go
@@ -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)
+ }
+}
diff --git a/command_palette.go b/command_palette.go
index e49f66f..f440299 100644
--- a/command_palette.go
+++ b/command_palette.go
@@ -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 {
diff --git a/command_palette_test.go b/command_palette_test.go
index 724250f..5384491 100644
--- a/command_palette_test.go
+++ b/command_palette_test.go
@@ -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)
diff --git a/data_table.go b/data_table.go
index eafc719..86ca4d6 100644
--- a/data_table.go
+++ b/data_table.go
@@ -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)
}
}
diff --git a/data_table_columns.go b/data_table_columns.go
index 430b8e1..62494d8 100644
--- a/data_table_columns.go
+++ b/data_table_columns.go
@@ -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)
diff --git a/data_table_test.go b/data_table_test.go
index b327fee..dbbe47d 100644
--- a/data_table_test.go
+++ b/data_table_test.go
@@ -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)
+ }
+}
diff --git a/date_picker.go b/date_picker.go
index c17fe93..26644bf 100644
--- a/date_picker.go
+++ b/date_picker.go
@@ -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,
diff --git a/date_range_picker.go b/date_range_picker.go
index 0ce0323..096e95b 100644
--- a/date_range_picker.go
+++ b/date_range_picker.go
@@ -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}
}
diff --git a/date_time_picker.go b/date_time_picker.go
index b5c60ce..9e7c482 100644
--- a/date_time_picker.go
+++ b/date_time_picker.go
@@ -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")
}
diff --git a/datetime.go b/datetime.go
index 9da7642..e258df2 100644
--- a/datetime.go
+++ b/datetime.go
@@ -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.
diff --git a/datetime_grid.go b/datetime_grid.go
index 7cb7c48..7c35d05 100644
--- a/datetime_grid.go
+++ b/datetime_grid.go
@@ -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
diff --git a/datetime_review_test.go b/datetime_review_test.go
new file mode 100644
index 0000000..a995fbf
--- /dev/null
+++ b/datetime_review_test.go
@@ -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())
+ }
+}
diff --git a/description_list.go b/description_list.go
index 3be4d4b..9d89dca 100644
--- a/description_list.go
+++ b/description_list.go
@@ -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 {
diff --git a/docs/components/014-image.md b/docs/components/014-image.md
index b7db4fb..f730439 100644
--- a/docs/components/014-image.md
+++ b/docs/components/014-image.md
@@ -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`,不会显示占位。
diff --git a/docs/components/041-form.md b/docs/components/041-form.md
index fa36aa6..f2efe30 100644
--- a/docs/components/041-form.md
+++ b/docs/components/041-form.md
@@ -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 标签不支持富文本,无法单独着色。
+- 必填标记“*”只作视觉显示,辅助技术通过说明“必填”获知。
diff --git a/docs/components/042-form-field.md b/docs/components/042-form-field.md
index 9e3f6e0..eaca697 100644
--- a/docs/components/042-form-field.md
+++ b/docs/components/042-form-field.md
@@ -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)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
diff --git a/docs/components/051-calendar.md b/docs/components/051-calendar.md
index 72d7a72..c891a73 100644
--- a/docs/components/051-calendar.md
+++ b/docs/components/051-calendar.md
@@ -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()` 关联。
diff --git a/docs/components/052-date-picker.md b/docs/components/052-date-picker.md
index 4a7b7a4..0a3352c 100644
--- a/docs/components/052-date-picker.md
+++ b/docs/components/052-date-picker.md
@@ -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()` 关联。
diff --git a/docs/components/053-date-range-picker.md b/docs/components/053-date-range-picker.md
index f400753..9a50075 100644
--- a/docs/components/053-date-range-picker.md
+++ b/docs/components/053-date-range-picker.md
@@ -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()` 关联。
diff --git a/docs/components/054-time-picker.md b/docs/components/054-time-picker.md
index 3740297..0014447 100644
--- a/docs/components/054-time-picker.md
+++ b/docs/components/054-time-picker.md
@@ -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()` 关联。
diff --git a/docs/components/055-time-range-picker.md b/docs/components/055-time-range-picker.md
index 377fe2e..4aac9bf 100644
--- a/docs/components/055-time-range-picker.md
+++ b/docs/components/055-time-range-picker.md
@@ -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()` 关联。
diff --git a/docs/components/056-date-time-picker.md b/docs/components/056-date-time-picker.md
index b6dcb14..d2a7efa 100644
--- a/docs/components/056-date-time-picker.md
+++ b/docs/components/056-date-time-picker.md
@@ -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()` 关联。
diff --git a/docs/components/057-month-picker.md b/docs/components/057-month-picker.md
index c0eb9e5..1a701c1 100644
--- a/docs/components/057-month-picker.md
+++ b/docs/components/057-month-picker.md
@@ -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()` 关联。
diff --git a/docs/components/058-year-picker.md b/docs/components/058-year-picker.md
index 2ff6c6f..2b10e5a 100644
--- a/docs/components/058-year-picker.md
+++ b/docs/components/058-year-picker.md
@@ -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()` 关联。
diff --git a/docs/components/059-tabs.md b/docs/components/059-tabs.md
index f99cb30..436f668 100644
--- a/docs/components/059-tabs.md
+++ b/docs/components/059-tabs.md
@@ -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 统一降低不透明度,不响应指针与键盘。
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
diff --git a/docs/components/064-sidebar.md b/docs/components/064-sidebar.md
index f32e7b8..ff174ed 100644
--- a/docs/components/064-sidebar.md
+++ b/docs/components/064-sidebar.md
@@ -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`)。组标题本身不能被选中。展开状态按完整父路径保存,同名组互不影响。
diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md
index 6f45bba..985d943 100644
--- a/docs/components/066-menubar.md
+++ b/docs/components/066-menubar.md
@@ -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`。
diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md
index a249aeb..85b5911 100644
--- a/docs/components/068-command-palette.md
+++ b/docs/components/068-command-palette.md
@@ -63,3 +63,4 @@ if id, ok := res.Chosen(); ok {
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
- 搜索是子串匹配,不做模糊匹配或排序打分。
+- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
diff --git a/docs/components/070-list.md b/docs/components/070-list.md
index 48dc71e..3623837 100644
--- a/docs/components/070-list.md
+++ b/docs/components/070-list.md
@@ -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` 等滚动位置读取接口。
diff --git a/docs/components/071-data-table.md b/docs/components/071-data-table.md
index dce0755..1a3505e 100644
--- a/docs/components/071-data-table.md
+++ b/docs/components/071-data-table.md
@@ -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)。
diff --git a/docs/components/072-tree.md b/docs/components/072-tree.md
index 5e37cb8..7b6c049 100644
--- a/docs/components/072-tree.md
+++ b/docs/components/072-tree.md
@@ -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` 回到界面线程。
diff --git a/docs/components/073-tree-table.md b/docs/components/073-tree-table.md
index 60d8799..dac451b 100644
--- a/docs/components/073-tree-table.md
+++ b/docs/components/073-tree-table.md
@@ -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 关闭。
## 限制
diff --git a/docs/components/074-accordion.md b/docs/components/074-accordion.md
index 5d02cdc..fe0a242 100644
--- a/docs/components/074-accordion.md
+++ b/docs/components/074-accordion.md
@@ -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 序列。
diff --git a/docs/components/075-collapsible.md b/docs/components/075-collapsible.md
index 12b3cbe..417263d 100644
--- a/docs/components/075-collapsible.md
+++ b/docs/components/075-collapsible.md
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
## 键盘操作
- Enter/Space:展开或关闭。
-- 关闭后内容不在 Tab 序列中。
+- 关闭(包括关闭动画期间)的内容不在 Tab 序列中。
## 限制
diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md
index 8609972..36caa84 100644
--- a/docs/components/093-progress.md
+++ b/docs/components/093-progress.md
@@ -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”文字与阈值刻度,并把级别写入无障碍名称。
## 事件
diff --git a/docs/components/094-spinner.md b/docs/components/094-spinner.md
index 06d612f..f4698c3 100644
--- a/docs/components/094-spinner.md
+++ b/docs/components/094-spinner.md
@@ -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` | 标记颜色。 |
diff --git a/docs/components/095-skeleton.md b/docs/components/095-skeleton.md
index 4e7e7e1..188319f 100644
--- a/docs/components/095-skeleton.md
+++ b/docs/components/095-skeleton.md
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
-负值 panic。
+负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。
## 状态
diff --git a/empty_state.go b/empty_state.go
index 67ca961..2951801 100644
--- a/empty_state.go
+++ b/empty_state.go
@@ -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() {
diff --git a/example_form_test.go b/example_form_test.go
index 0873135..e580dcf 100644
--- a/example_form_test.go
+++ b/example_form_test.go
@@ -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)
diff --git a/examples/gallery/pages_data.go b/examples/gallery/pages_data.go
index 7e7a5df..310a332 100644
--- a/examples/gallery/pages_data.go
+++ b/examples/gallery/pages_data.go
@@ -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()
})
})
diff --git a/examples/gallery/pages_data_collections.go b/examples/gallery/pages_data_collections.go
index bfc442c..0b37378 100644
--- a/examples/gallery/pages_data_collections.go
+++ b/examples/gallery/pages_data_collections.go
@@ -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,
diff --git a/examples/gallery/pages_data_content.go b/examples/gallery/pages_data_content.go
index 57a4612..92d1b95 100644
--- a/examples/gallery/pages_data_content.go
+++ b/examples/gallery/pages_data_content.go
@@ -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))
}
diff --git a/examples/gallery/pages_data_test.go b/examples/gallery/pages_data_test.go
new file mode 100644
index 0000000..8593aeb
--- /dev/null
+++ b/examples/gallery/pages_data_test.go
@@ -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)
+ }
+}
diff --git a/examples/gallery/pages_datetime.go b/examples/gallery/pages_datetime.go
index 07f6b8c..f547335 100644
--- a/examples/gallery/pages_datetime.go
+++ b/examples/gallery/pages_datetime.go
@@ -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})
})
diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go
index 596ce4b..2f11f00 100644
--- a/examples/gallery/pages_display.go
+++ b/examples/gallery/pages_display.go
@@ -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(``))
-}()
-
-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")
}
})
})
diff --git a/examples/gallery/pages_display_media.go b/examples/gallery/pages_display_media.go
new file mode 100644
index 0000000..945b929
--- /dev/null
+++ b/examples/gallery/pages_display_media.go
@@ -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(``))
+}()
+
+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{})
+})`
diff --git a/examples/gallery/pages_form.go b/examples/gallery/pages_form.go
index fbab963..9adba88 100644
--- a/examples/gallery/pages_form.go
+++ b/examples/gallery/pages_form.go
@@ -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)`,
diff --git a/feedback_review_test.go b/feedback_review_test.go
new file mode 100644
index 0000000..d332dad
--- /dev/null
+++ b/feedback_review_test.go
@@ -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)
+ }
+}
diff --git a/file_drop_zone.go b/file_drop_zone.go
index 8b1a588..1f0a979 100644
--- a/file_drop_zone.go
+++ b/file_drop_zone.go
@@ -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)
diff --git a/file_picker.go b/file_picker.go
index 44942e8..ffd2b91 100644
--- a/file_picker.go
+++ b/file_picker.go
@@ -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)
})
}
})
diff --git a/form.go b/form.go
index 278e916..bb89323 100644
--- a/form.go
+++ b/form.go
@@ -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
diff --git a/form_field.go b/form_field.go
index 517840f..47f9e8c 100644
--- a/form_field.go
+++ b/form_field.go
@@ -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)
}
diff --git a/form_field_test.go b/form_field_test.go
index c6ca7f6..80e61d3 100644
--- a/form_field_test.go
+++ b/form_field_test.go
@@ -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
})
})
})
diff --git a/form_test.go b/form_test.go
index 2d00634..afe5ddc 100644
--- a/form_test.go
+++ b/form_test.go
@@ -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)
}
}
diff --git a/image.go b/image.go
index 50481e3..7dfc000 100644
--- a/image.go
+++ b/image.go
@@ -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
diff --git a/image_state_test.go b/image_state_test.go
new file mode 100644
index 0000000..75a800c
--- /dev/null
+++ b/image_state_test.go
@@ -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())
+ }
+ }
+}
diff --git a/image_test.go b/image_test.go
index 7316b7b..657c173 100644
--- a/image_test.go
+++ b/image_test.go
@@ -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)
+ }
+}
diff --git a/image_viewer.go b/image_viewer.go
index 30a10e2..2f2fa69 100644
--- a/image_viewer.go
+++ b/image_viewer.go
@@ -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
diff --git a/input_group.go b/input_group.go
index 2db0177..8f0b136 100644
--- a/input_group.go
+++ b/input_group.go
@@ -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 {
diff --git a/internal/navigation.go b/internal/navigation.go
new file mode 100644
index 0000000..d5f4841
--- /dev/null
+++ b/internal/navigation.go
@@ -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)})
+}
diff --git a/list.go b/list.go
index bedea8d..0275742 100644
--- a/list.go
+++ b/list.go
@@ -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))
diff --git a/list_test.go b/list_test.go
index d146136..a3eeacb 100644
--- a/list_test.go
+++ b/list_test.go
@@ -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)
+ }
+}
diff --git a/loading_overlay.go b/loading_overlay.go
index 6f8ba0d..5e826b4 100644
--- a/loading_overlay.go
+++ b/loading_overlay.go
@@ -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")
diff --git a/menubar.go b/menubar.go
index 00f63f2..d27b21e 100644
--- a/menubar.go
+++ b/menubar.go
@@ -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)
}
diff --git a/menubar_panel.go b/menubar_panel.go
index 11e7185..1ea06b1 100644
--- a/menubar_panel.go
+++ b/menubar_panel.go
@@ -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",
diff --git a/menubar_test.go b/menubar_test.go
index 1309072..2f97d12 100644
--- a/menubar_test.go
+++ b/menubar_test.go
@@ -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"
diff --git a/messages_data.go b/messages_data.go
index d5fe967..bdc75f2 100644
--- a/messages_data.go
+++ b/messages_data.go
@@ -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", "警告"},
diff --git a/messages_datetime.go b/messages_datetime.go
index 1d44b95..2b4dd2d 100644
--- a/messages_datetime.go
+++ b/messages_datetime.go
@@ -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", "开始时间"},
diff --git a/messages_form.go b/messages_form.go
index 96ca640..8366836 100644
--- a/messages_form.go
+++ b/messages_form.go
@@ -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", "提交"},
diff --git a/month_picker.go b/month_picker.go
index b88359a..766686d 100644
--- a/month_picker.go
+++ b/month_picker.go
@@ -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,
diff --git a/navigation_menu.go b/navigation_menu.go
index da0d3cc..94783ad 100644
--- a/navigation_menu.go
+++ b/navigation_menu.go
@@ -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)
}
})
})
diff --git a/navigation_review_test.go b/navigation_review_test.go
new file mode 100644
index 0000000..ff29221
--- /dev/null
+++ b/navigation_review_test.go
@@ -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)
+ }
+ }
+}
diff --git a/notification_center.go b/notification_center.go
index 19a4f93..f712d4d 100644
--- a/notification_center.go
+++ b/notification_center.go
@@ -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 != "" {
diff --git a/pagination.go b/pagination.go
index 05a6fa3..f025bd3 100644
--- a/pagination.go
+++ b/pagination.go
@@ -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()
}
diff --git a/pin_input.go b/pin_input.go
index 99f05e0..6dc4f9d 100644
--- a/pin_input.go
+++ b/pin_input.go
@@ -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
diff --git a/progress.go b/progress.go
index 85d09dc..08f60cd 100644
--- a/progress.go
+++ b/progress.go
@@ -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) }
diff --git a/progress_test.go b/progress_test.go
index 78c5bdf..4016a1e 100644
--- a/progress_test.go
+++ b/progress_test.go
@@ -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) {
diff --git a/result.go b/result.go
index 70814d0..537cc19 100644
--- a/result.go
+++ b/result.go
@@ -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)
}
diff --git a/segmented_control.go b/segmented_control.go
index f20cfe9..f3b0b50 100644
--- a/segmented_control.go
+++ b/segmented_control.go
@@ -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)
diff --git a/sidebar.go b/sidebar.go
index 339400e..fa0398d 100644
--- a/sidebar.go
+++ b/sidebar.go
@@ -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)
})
diff --git a/sidebar_test.go b/sidebar_test.go
index 9eb9f63..0ae2c17 100644
--- a/sidebar_test.go
+++ b/sidebar_test.go
@@ -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"
diff --git a/skeleton.go b/skeleton.go
index eefa1b5..3c4eb04 100644
--- a/skeleton.go
+++ b/skeleton.go
@@ -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 {
diff --git a/spinner.go b/spinner.go
index 2f1e9dd..280f968 100644
--- a/spinner.go
+++ b/spinner.go
@@ -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
diff --git a/statistic.go b/statistic.go
index 2530b5c..cee212c 100644
--- a/statistic.go
+++ b/statistic.go
@@ -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)
}
})
})
diff --git a/statistic_test.go b/statistic_test.go
index d72c92b..5bf9951 100644
--- a/statistic_test.go
+++ b/statistic_test.go
@@ -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)
+ }
+}
diff --git a/status_indicator.go b/status_indicator.go
index 045c642..1ebcf5a 100644
--- a/status_indicator.go
+++ b/status_indicator.go
@@ -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
}
diff --git a/steps.go b/steps.go
index 8971b9a..cae4dde 100644
--- a/steps.go
+++ b/steps.go
@@ -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()
}
})
}
diff --git a/tabs.go b/tabs.go
index c3b022e..36afb61 100644
--- a/tabs.go
+++ b/tabs.go
@@ -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 {
diff --git a/tabs_test.go b/tabs_test.go
index f5ba7f2..d395ae2 100644
--- a/tabs_test.go
+++ b/tabs_test.go
@@ -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 -.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 {
diff --git a/tags_input.go b/tags_input.go
index 3669bf0..424e876 100644
--- a/tags_input.go
+++ b/tags_input.go
@@ -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
diff --git a/tags_input_test.go b/tags_input_test.go
index 2d2dae2..da8aeb3 100644
--- a/tags_input_test.go
+++ b/tags_input_test.go
@@ -115,3 +115,12 @@ func TestTagsInputSnapshot(t *testing.T) {
})
})
}
+
+func TestTagsInputRemoveHitArea(t *testing.T) {
+ tags, n := []string{"rose"}, 0
+ tt := tagsInputTester(&tags, TagsInputOptions{}, &n)
+ r, ok := tt.Find("Remove rose")
+ if !ok || r.W < 28 || r.H < 28 {
+ t.Fatalf("remove button %v", r)
+ }
+}
diff --git a/testdata/snapshots/darwin/color_picker-dark.png b/testdata/snapshots/darwin/color_picker-dark.png
index 494495e..00f3d75 100644
Binary files a/testdata/snapshots/darwin/color_picker-dark.png and b/testdata/snapshots/darwin/color_picker-dark.png differ
diff --git a/testdata/snapshots/darwin/color_picker-light.png b/testdata/snapshots/darwin/color_picker-light.png
index 233bb64..804c5b8 100644
Binary files a/testdata/snapshots/darwin/color_picker-light.png and b/testdata/snapshots/darwin/color_picker-light.png differ
diff --git a/testdata/snapshots/darwin/data_table-dark.png b/testdata/snapshots/darwin/data_table-dark.png
index df9f8da..cda8438 100644
Binary files a/testdata/snapshots/darwin/data_table-dark.png and b/testdata/snapshots/darwin/data_table-dark.png differ
diff --git a/testdata/snapshots/darwin/data_table-light.png b/testdata/snapshots/darwin/data_table-light.png
index 49c7d33..ee748c6 100644
Binary files a/testdata/snapshots/darwin/data_table-light.png and b/testdata/snapshots/darwin/data_table-light.png differ
diff --git a/testdata/snapshots/darwin/form-dark.png b/testdata/snapshots/darwin/form-dark.png
index 64b96f9..5b7add7 100644
Binary files a/testdata/snapshots/darwin/form-dark.png and b/testdata/snapshots/darwin/form-dark.png differ
diff --git a/testdata/snapshots/darwin/form-light.png b/testdata/snapshots/darwin/form-light.png
index 7690b2a..f318ed7 100644
Binary files a/testdata/snapshots/darwin/form-light.png and b/testdata/snapshots/darwin/form-light.png differ
diff --git a/testdata/snapshots/darwin/form_field-dark.png b/testdata/snapshots/darwin/form_field-dark.png
index d6b2a7e..6efd9bc 100644
Binary files a/testdata/snapshots/darwin/form_field-dark.png and b/testdata/snapshots/darwin/form_field-dark.png differ
diff --git a/testdata/snapshots/darwin/form_field-light.png b/testdata/snapshots/darwin/form_field-light.png
index c330d79..ed40940 100644
Binary files a/testdata/snapshots/darwin/form_field-light.png and b/testdata/snapshots/darwin/form_field-light.png differ
diff --git a/testdata/snapshots/darwin/image-dark.png b/testdata/snapshots/darwin/image-dark.png
index 286963c..dd44a88 100644
Binary files a/testdata/snapshots/darwin/image-dark.png and b/testdata/snapshots/darwin/image-dark.png differ
diff --git a/testdata/snapshots/darwin/image-light.png b/testdata/snapshots/darwin/image-light.png
index 9b207d6..10ab9f5 100644
Binary files a/testdata/snapshots/darwin/image-light.png and b/testdata/snapshots/darwin/image-light.png differ
diff --git a/testdata/snapshots/darwin/list-dark.png b/testdata/snapshots/darwin/list-dark.png
index c55bbe6..788678d 100644
Binary files a/testdata/snapshots/darwin/list-dark.png and b/testdata/snapshots/darwin/list-dark.png differ
diff --git a/testdata/snapshots/darwin/list-light.png b/testdata/snapshots/darwin/list-light.png
index baa6c87..78950f3 100644
Binary files a/testdata/snapshots/darwin/list-light.png and b/testdata/snapshots/darwin/list-light.png differ
diff --git a/testdata/snapshots/darwin/pagination-dark.png b/testdata/snapshots/darwin/pagination-dark.png
index 25c2935..85cd150 100644
Binary files a/testdata/snapshots/darwin/pagination-dark.png and b/testdata/snapshots/darwin/pagination-dark.png differ
diff --git a/testdata/snapshots/darwin/pagination-light.png b/testdata/snapshots/darwin/pagination-light.png
index e8f1911..12bdbf2 100644
Binary files a/testdata/snapshots/darwin/pagination-light.png and b/testdata/snapshots/darwin/pagination-light.png differ
diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png
index 28c32d3..cead596 100644
Binary files a/testdata/snapshots/darwin/progress-dark.png and b/testdata/snapshots/darwin/progress-dark.png differ
diff --git a/testdata/snapshots/darwin/progress-light.png b/testdata/snapshots/darwin/progress-light.png
index ffa4b25..459659c 100644
Binary files a/testdata/snapshots/darwin/progress-light.png and b/testdata/snapshots/darwin/progress-light.png differ
diff --git a/testdata/snapshots/darwin/sidebar-dark.png b/testdata/snapshots/darwin/sidebar-dark.png
index df7abfc..378dc07 100644
Binary files a/testdata/snapshots/darwin/sidebar-dark.png and b/testdata/snapshots/darwin/sidebar-dark.png differ
diff --git a/testdata/snapshots/darwin/sidebar-light.png b/testdata/snapshots/darwin/sidebar-light.png
index 3cd875d..d7c77de 100644
Binary files a/testdata/snapshots/darwin/sidebar-light.png and b/testdata/snapshots/darwin/sidebar-light.png differ
diff --git a/testdata/snapshots/darwin/tabs-focus-dark.png b/testdata/snapshots/darwin/tabs-focus-dark.png
new file mode 100644
index 0000000..345c836
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-focus-dark.png differ
diff --git a/testdata/snapshots/darwin/tabs-focus-light.png b/testdata/snapshots/darwin/tabs-focus-light.png
new file mode 100644
index 0000000..3d4a5d9
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-focus-light.png differ
diff --git a/testdata/snapshots/darwin/tabs-hover-dark.png b/testdata/snapshots/darwin/tabs-hover-dark.png
new file mode 100644
index 0000000..7d6f8e9
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-hover-dark.png differ
diff --git a/testdata/snapshots/darwin/tabs-hover-light.png b/testdata/snapshots/darwin/tabs-hover-light.png
new file mode 100644
index 0000000..4ab1e62
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-hover-light.png differ
diff --git a/testdata/snapshots/darwin/tabs-pressed-dark.png b/testdata/snapshots/darwin/tabs-pressed-dark.png
new file mode 100644
index 0000000..90652e8
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-pressed-dark.png differ
diff --git a/testdata/snapshots/darwin/tabs-pressed-light.png b/testdata/snapshots/darwin/tabs-pressed-light.png
new file mode 100644
index 0000000..382b499
Binary files /dev/null and b/testdata/snapshots/darwin/tabs-pressed-light.png differ
diff --git a/testdata/snapshots/darwin/tags_input-dark.png b/testdata/snapshots/darwin/tags_input-dark.png
index 12ed2db..6662a49 100644
Binary files a/testdata/snapshots/darwin/tags_input-dark.png and b/testdata/snapshots/darwin/tags_input-dark.png differ
diff --git a/testdata/snapshots/darwin/tags_input-light.png b/testdata/snapshots/darwin/tags_input-light.png
index a349c5e..be8b8ae 100644
Binary files a/testdata/snapshots/darwin/tags_input-light.png and b/testdata/snapshots/darwin/tags_input-light.png differ
diff --git a/testdata/snapshots/darwin/time_picker-dark.png b/testdata/snapshots/darwin/time_picker-dark.png
index 12a4377..2486b12 100644
Binary files a/testdata/snapshots/darwin/time_picker-dark.png and b/testdata/snapshots/darwin/time_picker-dark.png differ
diff --git a/testdata/snapshots/darwin/time_picker-light.png b/testdata/snapshots/darwin/time_picker-light.png
index de6c31a..9f6887f 100644
Binary files a/testdata/snapshots/darwin/time_picker-light.png and b/testdata/snapshots/darwin/time_picker-light.png differ
diff --git a/testdata/snapshots/darwin/time_range_picker-dark.png b/testdata/snapshots/darwin/time_range_picker-dark.png
index d3f8038..ea34102 100644
Binary files a/testdata/snapshots/darwin/time_range_picker-dark.png and b/testdata/snapshots/darwin/time_range_picker-dark.png differ
diff --git a/testdata/snapshots/darwin/time_range_picker-light.png b/testdata/snapshots/darwin/time_range_picker-light.png
index e32d8f1..bb5b65b 100644
Binary files a/testdata/snapshots/darwin/time_range_picker-light.png and b/testdata/snapshots/darwin/time_range_picker-light.png differ
diff --git a/testdata/snapshots/darwin/tree-dark.png b/testdata/snapshots/darwin/tree-dark.png
index 896dd19..e779595 100644
Binary files a/testdata/snapshots/darwin/tree-dark.png and b/testdata/snapshots/darwin/tree-dark.png differ
diff --git a/testdata/snapshots/darwin/tree-light.png b/testdata/snapshots/darwin/tree-light.png
index 159f119..3709e91 100644
Binary files a/testdata/snapshots/darwin/tree-light.png and b/testdata/snapshots/darwin/tree-light.png differ
diff --git a/testdata/snapshots/darwin/tree_table-dark.png b/testdata/snapshots/darwin/tree_table-dark.png
index e2eb09e..4080e11 100644
Binary files a/testdata/snapshots/darwin/tree_table-dark.png and b/testdata/snapshots/darwin/tree_table-dark.png differ
diff --git a/testdata/snapshots/darwin/tree_table-light.png b/testdata/snapshots/darwin/tree_table-light.png
index 8d71e7b..2ee1fb7 100644
Binary files a/testdata/snapshots/darwin/tree_table-light.png and b/testdata/snapshots/darwin/tree_table-light.png differ
diff --git a/text.go b/text.go
index e275700..e2aa6ca 100644
--- a/text.go
+++ b/text.go
@@ -120,6 +120,7 @@ func editableTextField(c *ui.Context, k theme.Tokens, value *string, opts Editab
in = ui.TextInputBase(c, &st.draft).Grow(1).MinWidth(theme.MinHitSize*3).Padding(pad, pad*2).
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text).
Label(opts.Label).Error(opts.Error)
+ internal.Ring(in, k.Focus, theme.ControlRadius)
})
done := func() {
st.editing = false
diff --git a/text_test.go b/text_test.go
index 8866fb9..d6ead0c 100644
--- a/text_test.go
+++ b/text_test.go
@@ -4,6 +4,8 @@ import (
"testing"
"github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
)
func TestTextSelectAndCopy(t *testing.T) {
@@ -183,3 +185,34 @@ func TestTextSnapshot(t *testing.T) {
})
})
}
+
+func TestEditableTextInputFocusRing(t *testing.T) {
+ name := "report.pdf"
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{Mode: Light})
+ ui.Column(c).Padding(20).Gap(20).AlignItems(ui.Start).Children(func() {
+ EditableText(c, &name, EditableTextOptions{Label: "File name"})
+ Button(c, "Other", ButtonOptions{})
+ })
+ }, 300, 160)
+ tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ r, ok := tt.Find("File name")
+ if !ok || !tt.Focused("File name") {
+ t.Fatal("editing input not found or not focused")
+ }
+ // The ring's middle: focus gap plus half its width outside the box.
+ x, y := int(r.X-theme.FocusGap-theme.FocusWidth/2), int(r.Y+r.H/2)
+ focused := tt.Image().RGBAAt(x, y)
+ tt.Key(0, ui.KeyEscape)
+ tt.Key(0, ui.KeyTab)
+ if tt.Focused("File name") {
+ t.Fatal("focus did not leave")
+ }
+ plain := tt.Image().RGBAAt(x, y)
+ want := theme.Light().Focus
+ if d := int(focused.R) - int(want.R); d < -24 || d > 24 || focused == plain {
+ t.Fatalf("ring pixel %v focused, %v unfocused, want near %v", focused, plain, want)
+ }
+}
diff --git a/time_picker.go b/time_picker.go
index 12a4391..57f4eac 100644
--- a/time_picker.go
+++ b/time_picker.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"strconv"
+ "strings"
"github.com/egoist/mygo/ui"
@@ -49,12 +50,13 @@ type TimePickerOptions struct {
// Min and Max bound the time; a zero Max means 23:59:59.
Min, Max TimeOfDay
Disabled bool
+ // ReadOnly keeps the segments focusable and copyable but blocks edits and the popup.
+ ReadOnly bool
// Label names the field for assistive technology.
Label string
}
-// TimePicker edits *value by typing into hour, minute and second segments or by choosing in a popup.
-// Times outside the range or off the step are reported and not written.
+// TimePicker edits *value in segments or a popup.
func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimePicker", opts)
if !value.Valid() {
@@ -65,17 +67,23 @@ func TimePicker(c *ui.Context, value *TimeOfDay, opts TimePickerOptions) DateTim
seen := ui.Local(col, "time-picker-seen", func() TimeOfDay { return *value })
problem := ui.Local(col, "time-picker-error", func() string { return "" })
if *seen != *value {
- *draft, *seen, *problem = *value, *value, ""
+ *draft, *seen = *value, *value
}
changed := false
col.Children(func() {
- cfg.invalid = *problem != ""
- if _, edited := timeEditor(c, draft, cfg); edited {
- *problem = timePickerProblem(c, *draft, cfg)
- if *problem == "" && *draft != *value {
- slog.Debug("mujica: time_picker change", "from", *value, "to", *draft)
- *value, *seen, changed = *draft, *draft, true
- }
+ cfg.problem = *problem
+ _, edited, bad := timeEditor(c, draft, cfg)
+ p := bad
+ if p == "" {
+ p = timePickerProblem(c, *draft, cfg)
+ }
+ if p != *problem {
+ *problem = p
+ c.Invalidate()
+ }
+ if edited && p == "" && *draft != *value {
+ slog.Debug("mujica: time_picker change", "from", *value, "to", *draft)
+ *value, *seen, changed = *draft, *draft, true
}
datetimeError(c, *problem)
})
@@ -88,13 +96,15 @@ type timeConfig struct {
minuteStep, secondStep int
min, max TimeOfDay
label string
- invalid bool
+ // problem is the error shown last frame, attached to the segments.
+ problem string
+ readOnly bool
}
// timePickerConfig checks opts and fills in defaults.
func timePickerConfig(name string, opts TimePickerOptions) timeConfig {
cfg := timeConfig{seconds: opts.Seconds, hour12: opts.Hour12, minuteStep: max(opts.MinuteStep, 1),
- secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label}
+ secondStep: max(opts.SecondStep, 1), min: opts.Min, max: opts.Max, label: opts.Label, readOnly: opts.ReadOnly}
for _, s := range []int{opts.MinuteStep, opts.SecondStep} {
if s < 0 || s > 0 && 60%s != 0 {
panic(fmt.Sprintf("mujica: %s step %d does not divide 60", name, s))
@@ -174,15 +184,18 @@ func timeParts(cfg timeConfig) []timePart {
return parts
}
-// timeEditor draws a segmented time field with a popup and reports whether the user edited *draft.
-func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool) {
+// timeEditor draws segments and popup; reports edits and invalid entry.
+func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Element, edited bool, bad string) {
k := Tokens(c)
d := Density(c)
- field = datetimeField(c, ui.Row(c), cfg.invalid).Gap(d.Unit() * 2)
+ field = datetimeField(c, ui.Row(c), cfg.problem != "", cfg.readOnly).Gap(d.Unit() * 2)
open := ui.Local(field, "time-open", func() bool { return false })
fresh := ui.Local(field, "time-fresh", func() bool { return false })
field.Children(func() {
- segs := ui.Row(c).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label)
+ segs := ui.Row(c).AlignItems(ui.Center).Gap(d.Unit() * 1.5).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(cfg.label)
+ if segs.Shortcut(ui.Cmd, ui.KeyC) {
+ c.WriteClipboard(timeText(c, *draft, cfg))
+ }
segs.Children(func() {
focusNext := false
for i, part := range timeParts(cfg) {
@@ -192,84 +205,107 @@ func timeEditor(c *ui.Context, draft *TimeOfDay, cfg timeConfig) (field *ui.Elem
if part == timePeriodPart {
ui.Box(c).Width(d.Unit())
}
- seg, done := timeSegment(c, draft, part, cfg, &edited)
+ seg, done, segBad := timeSegment(c, draft, part, cfg, &edited)
if focusNext {
seg.Focus()
- focusNext = false
}
focusNext = done
+ if bad == "" {
+ bad = segBad
+ }
}
})
- b := datetimeIconButton(c, "clock", msg(c, "time_picker.open"))
+ if cfg.readOnly {
+ return
+ }
+ b := datetimeIconButton(c, "clock", msg(c, "time_picker.open")).Role(ui.RolePopUpButton)
if b.Clicked() {
*open, *fresh = !*open, true
}
})
- if !*open && field.Shortcut(ui.Alt, ui.KeyDown) {
+ if !*open && !cfg.readOnly && field.Shortcut(ui.Alt, ui.KeyDown) {
*open, *fresh = true, true
}
- datetimePopup(c, field, open, func() {
+ datetimePopup(c, field, open, cfg.readOnly, func() {
if timeColumns(c, draft, cfg, *fresh) {
edited = true
}
*fresh = false
})
- return field, edited
+ return field, edited, bad
}
-// timeSegment draws one focusable segment; done reports a fully typed value.
-func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool) {
+// timeLimits returns the lowest and highest value a segment accepts.
+func timeLimits(part timePart, cfg timeConfig) (lo, hi int) {
+ switch {
+ case part != timeHourPart:
+ return 0, 59
+ case cfg.hour12:
+ return 1, 12
+ }
+ return 0, 23
+}
+
+// timeSegment draws one segment; done reports a complete valid entry, bad an invalid one.
+func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig, edited *bool) (seg *ui.Element, done bool, bad string) {
k := Tokens(c)
d := Density(c)
name := msg(c, timePartNames[part])
if cfg.label != "" {
name = cfg.label + ": " + name
}
- seg = ui.Box(c).Focusable().FocusRing(false).Role(ui.RoleStepper).Label(name).
- Padding(d.Unit()/2, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Center()
+ seg = ui.Box(c).Focusable().Role(ui.RoleStepper).Label(name).
+ Padding(0, d.Unit()).Radius(theme.ControlRadius).MinWidth(theme.BodySize * 1.6).Shrink(0).Center()
+ internal.Ring(seg, k.Focus, theme.ControlRadius)
typed := ui.Local(seg, "time-typed", func() string { return "" })
+ badText := ui.Local(seg, "time-bad", func() string { return "" })
+ badFor := ui.Local(seg, "time-bad-for", func() TimeOfDay { return TimeOfDay{} })
if !seg.Focused() {
*typed = ""
}
+ if *badFor != *draft {
+ *badText = ""
+ }
set := func(t TimeOfDay) {
+ *badText = ""
if t != *draft {
*draft = t
*edited = true
}
}
t := *draft
- step := func(dir int) {
- switch part {
- case timeHourPart:
- t.Hour = (t.Hour + dir + 24) % 24
- case timeMinutePart:
- t.Minute = timeStep(t.Minute, cfg.minuteStep, dir)
- case timeSecondPart:
- t.Second = timeStep(t.Second, cfg.secondStep, dir)
- case timePeriodPart:
- t.Hour = (t.Hour + 12) % 24
+ lo, hi := timeLimits(part, cfg)
+ if !cfg.readOnly {
+ switch {
+ case seg.Shortcut(0, ui.KeyUp):
+ set(timeStepPart(t, part, cfg, 1))
+ case seg.Shortcut(0, ui.KeyDown):
+ set(timeStepPart(t, part, cfg, -1))
}
- set(t)
- }
- switch {
- case seg.Shortcut(0, ui.KeyUp):
- step(1)
- case seg.Shortcut(0, ui.KeyDown):
- step(-1)
- }
- if part == timePeriodPart {
- if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 {
- t.Hour = (t.Hour + 12) % 24
- set(t)
- }
- } else {
- for n := range 10 {
- if seg.Shortcut(0, ui.Key0+ui.Key(n)) {
- done = timeType(typed, n, part, cfg, &t)
+ t = *draft
+ if part == timePeriodPart {
+ if seg.Shortcut(0, ui.KeyA) && t.Hour >= 12 || seg.Shortcut(0, ui.KeyP) && t.Hour < 12 {
+ t.Hour = (t.Hour + 12) % 24
set(t)
}
+ } else {
+ for n := range 10 {
+ if !seg.Shortcut(0, ui.Key0+ui.Key(n)) {
+ continue
+ }
+ v, ok, complete := timeType(typed, n, lo, hi)
+ switch {
+ case ok:
+ set(timeWithPart(t, part, v, cfg))
+ done = complete
+ case complete:
+ *badText, *badFor = fmt.Sprintf("%02d", v), *draft
+ slog.Debug("mujica: time_picker invalid segment", "part", int(part), "typed", v)
+ }
+ }
}
}
+ t = *draft
text := ""
switch part {
case timeHourPart:
@@ -284,16 +320,64 @@ func timeSegment(c *ui.Context, draft *TimeOfDay, part timePart, cfg timeConfig,
case timePeriodPart:
text = timePeriod(c, t.Hour)
}
+ if *badText != "" {
+ text = *badText
+ bad = fmt.Sprintf(msg(c, "time_picker.part_range"), msg(c, timePartNames[part]), lo, hi)
+ }
+ seg.Description(timeSegmentDescription(c, part, text, lo, hi)).Error(cfg.problem)
fg := k.Text
switch {
- case seg.Focused():
+ case *badText != "":
+ fg = k.Danger
+ case seg.Focused() && !cfg.readOnly:
seg.Background(k.Accent)
fg = k.OnAccent
+ case seg.Focused():
+ seg.Background(k.Selection)
case seg.Hovered():
seg.Background(k.SurfaceHover)
}
seg.Children(func() { ui.Text(c, text).TextColor(fg).FontFeatures("tnum").SingleLine() })
- return seg, done
+ return seg, done, bad
+}
+
+// timeSegmentDescription gives assistive technology a segment's value and range.
+func timeSegmentDescription(c *ui.Context, part timePart, text string, lo, hi int) string {
+ if part == timePeriodPart {
+ return text
+ }
+ return fmt.Sprintf(msg(c, "time_picker.segment"), strings.TrimSpace(text), lo, hi)
+}
+
+// timeStepPart steps one segment of t by dir, wrapping round its cycle.
+func timeStepPart(t TimeOfDay, part timePart, cfg timeConfig, dir int) TimeOfDay {
+ switch part {
+ case timeHourPart:
+ t.Hour = (t.Hour + dir + 24) % 24
+ case timeMinutePart:
+ t.Minute = timeStep(t.Minute, cfg.minuteStep, dir)
+ case timeSecondPart:
+ t.Second = timeStep(t.Second, cfg.secondStep, dir)
+ case timePeriodPart:
+ t.Hour = (t.Hour + 12) % 24
+ }
+ return t
+}
+
+// timeWithPart sets one segment of t to v, keeping the period on a 12-hour clock.
+func timeWithPart(t TimeOfDay, part timePart, v int, cfg timeConfig) TimeOfDay {
+ switch part {
+ case timeHourPart:
+ if cfg.hour12 {
+ v = v%12 + t.Hour/12*12
+ }
+ t.Hour = v
+ case timeMinutePart:
+ t.Minute = v
+ case timeSecondPart:
+ t.Second = v
+ }
+ return t
}
// timeStep moves v to the next multiple of step in dir, wrapping within an hour.
@@ -307,42 +391,22 @@ func timeStep(v, step, dir int) int {
return (v - step + 60) % 60
}
-// timeType adds digit n to a segment's typed digits and reports when the segment is complete.
-func timeType(typed *string, n int, part timePart, cfg timeConfig, t *TimeOfDay) bool {
- lo, hi := 0, 59
- if part == timeHourPart {
- hi = 23
- if cfg.hour12 {
- lo, hi = 1, 12
- }
+// timeType appends digit n; reports value, validity and completion.
+func timeType(typed *string, n, lo, hi int) (v int, ok, complete bool) {
+ s := *typed + strconv.Itoa(n)
+ if len(s) > 2 {
+ s = strconv.Itoa(n)
}
- s := *typed + fmt.Sprint(n)
v, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
- if len(s) > 2 || v > hi {
- s, v = fmt.Sprint(n), n
+ complete = len(s) == 2 || v*10 > hi
+ if complete {
+ s = ""
}
*typed = s
- if v >= lo {
- switch part {
- case timeHourPart:
- if cfg.hour12 {
- v = v%12 + t.Hour/12*12
- }
- t.Hour = v
- case timeMinutePart:
- t.Minute = v
- case timeSecondPart:
- t.Second = v
- }
- }
- if len(s) == 2 || n*10 > hi && len(s) == 1 {
- *typed = ""
- return true
- }
- return false
+ return v, v >= lo && v <= hi, complete
}
// timeColumns lists choosable values per segment and reports whether a choice edited *draft.
diff --git a/time_picker_test.go b/time_picker_test.go
index a5eb6a8..c6976d7 100644
--- a/time_picker_test.go
+++ b/time_picker_test.go
@@ -143,7 +143,7 @@ func TestTimePickerRejectsBadOptions(t *testing.T) {
func TestTimePickerSnapshot(t *testing.T) {
a, b := TimeOfDay{9, 30, 0}, TimeOfDay{21, 5, 40}
- snapshot(t, "time_picker", 480, 80, func(c *ui.Context) {
+ snapshot(t, "time_picker", 520, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
TimePicker(c, &a, TimePickerOptions{})
TimePicker(c, &b, TimePickerOptions{Hour12: true, Seconds: true})
diff --git a/time_range_picker.go b/time_range_picker.go
index 4776748..3a03486 100644
--- a/time_range_picker.go
+++ b/time_range_picker.go
@@ -9,7 +9,7 @@ import (
"mujicaui/theme"
)
-// TimeRange is a start and an end time of day. End before Start means it ends the next day.
+// TimeRange is a span of day; End before Start means next day.
type TimeRange struct {
Start, End TimeOfDay
}
@@ -28,15 +28,16 @@ type TimeRangePickerOptions struct {
// AllowOvernight accepts an end before the start, meaning the next day.
AllowOvernight bool
Disabled bool
+ // ReadOnly keeps both ends focusable and copyable but blocks edits.
+ ReadOnly bool
// Label names the pair for assistive technology.
Label string
}
-// TimeRangePicker edits a start and an end time. It writes *value only when both ends are valid
-// and the end follows the start, or crosses midnight with AllowOvernight.
+// TimeRangePicker writes *value only when the range is valid.
func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOptions) DateTimeResult {
cfg := timePickerConfig("TimeRangePicker", TimePickerOptions{Seconds: opts.Seconds, Hour12: opts.Hour12,
- MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max})
+ MinuteStep: opts.MinuteStep, SecondStep: opts.SecondStep, Min: opts.Min, Max: opts.Max, ReadOnly: opts.ReadOnly})
if !value.Start.Valid() || !value.End.Valid() {
panic(fmt.Sprintf("mujica: TimeRangePicker value %v is not a pair of times", *value))
}
@@ -53,32 +54,37 @@ func TimeRangePicker(c *ui.Context, value *TimeRange, opts TimeRangePickerOption
seen := ui.Local(col, "time-range-seen", func() TimeRange { return *value })
problem := ui.Local(col, "time-range-error", func() string { return "" })
if *seen != *value {
- *draft, *seen, *problem = *value, *value, ""
+ *draft, *seen = *value, *value
}
changed := false
col.Children(func() {
- edited := false
+ edited, bad := false, ""
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
start, end := cfg, cfg
start.label, end.label = msg(c, "time_range_picker.start"), msg(c, "time_range_picker.end")
- start.invalid, end.invalid = *problem != "", *problem != ""
- if _, e := timeEditor(c, &draft.Start, start); e {
- edited = true
- }
+ start.problem, end.problem = *problem, *problem
+ _, e1, b1 := timeEditor(c, &draft.Start, start)
ui.Text(c, "–").TextColor(k.TextMuted).Role(ui.RoleNone)
- if _, e := timeEditor(c, &draft.End, end); e {
- edited = true
+ _, e2, b2 := timeEditor(c, &draft.End, end)
+ edited, bad = e1 || e2, b1
+ if bad == "" {
+ bad = b2
}
if draft.Overnight() && opts.AllowOvernight {
- ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(theme.CaptionSize).TextColor(k.AccentText)
+ ui.Text(c, msg(c, "time_range_picker.next_day")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
}
})
- if edited {
- *problem = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight)
- if *problem == "" && *draft != *value {
- slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft)
- *value, *seen, changed = *draft, *draft, true
- }
+ p := bad
+ if p == "" {
+ p = timeRangeProblem(c, *draft, cfg, opts.AllowOvernight)
+ }
+ if p != *problem {
+ *problem = p
+ c.Invalidate()
+ }
+ if edited && p == "" && *draft != *value {
+ slog.Debug("mujica: time_range_picker change", "from", *value, "to", *draft)
+ *value, *seen, changed = *draft, *draft, true
}
datetimeError(c, *problem)
})
diff --git a/timeline.go b/timeline.go
index 115f077..745e51a 100644
--- a/timeline.go
+++ b/timeline.go
@@ -44,10 +44,10 @@ func Timeline(c *ui.Context, items []TimelineItem) *ui.Element {
ui.Column(c).Grow(1).MinWidth(0).Gap(u).Padding(0, 0, u*5, 0).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
if it.Time != "" {
- ui.Text(c, it.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
+ ui.Text(c, it.Time).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
if w := timelineWord(c, it.Status); w != "" {
- ui.Text(c, w).FontSize(theme.CaptionSize).TextColor(timelineColor(k, it.Status)).FontWeight(600)
+ ui.Text(c, w).FontSize(FontSize(c, theme.CaptionSize)).TextColor(timelineColor(k, it.Status)).FontWeight(600)
}
})
if it.Title != "" {
diff --git a/toast.go b/toast.go
index e15a6d4..872997a 100644
--- a/toast.go
+++ b/toast.go
@@ -44,7 +44,10 @@ type toastItem struct {
}
// toastQueue holds a window's toasts, visible ones first in order shown.
-type toastQueue struct{ items []toastItem }
+type toastQueue struct {
+ items []toastItem
+ wake time.Time // when Toaster last asked for a frame; zero for none
+}
type toastKey struct{}
@@ -64,6 +67,7 @@ func (q *toastQueue) push(m ToastMessage, now time.Time) {
if m.Message == "" {
panic("mujica: toast needs a Message")
}
+ _ = m.Severity.String() // panics on an unknown Severity
if m.ID == "" {
m.ID = m.Message
}
@@ -106,8 +110,8 @@ func (it *toastItem) elapsed(now time.Time) time.Duration {
return it.spent + now.Sub(it.runFrom)
}
-// expire drops toasts whose time ran out and returns the wait until the next does.
-func (q *toastQueue) expire(now time.Time) (next time.Duration) {
+// expire drops toasts whose time ran out and shows queued ones.
+func (q *toastQueue) expire(now time.Time) {
live := q.items[:0]
for _, it := range q.items {
if it.visible && it.life > 0 && it.elapsed(now) >= it.life {
@@ -118,6 +122,15 @@ func (q *toastQueue) expire(now time.Time) (next time.Duration) {
}
q.items = live
q.promote(now)
+}
+
+// settle pauses the toasts held, resumes the rest, and returns the wait until one expires.
+func (q *toastQueue) settle(now time.Time, held map[string]bool) (next time.Duration) {
+ for _, it := range q.items {
+ if it.visible {
+ q.pause(it.msg.ID, held[it.msg.ID], now)
+ }
+ }
for _, it := range q.items {
if it.visible && it.life > 0 && !it.runFrom.IsZero() {
left := it.life - it.elapsed(now)
@@ -172,15 +185,12 @@ func DismissToast(c *ui.Context, id string) {
c.Invalidate()
}
-// Toaster draws the window's toasts at its bottom right. Call it once per
-// frame, last in the view. Toasts never take the focus; Tab reaches their
-// buttons. Hover or focus inside a toast pauses its time.
+// Toaster draws the window's toasts, never taking the focus; call it last in the view each frame.
func Toaster(c *ui.Context) ToasterResult {
q := toasts(c)
now := c.Now()
- if next := q.expire(now); next > 0 {
- c.After(next)
- }
+ q.expire(now)
+ held := map[string]bool{}
var res ToasterResult
if len(q.items) == 0 {
return res
@@ -201,8 +211,8 @@ func Toaster(c *ui.Context) ToasterResult {
q.items[i].announced = true
c.Announce(it.msg.Message)
}
- action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg)
- q.pause(it.msg.ID, held, now)
+ action, closed, hold := toastCard(c, k, u, min(360, w-u*8), it.msg)
+ held[it.msg.ID] = hold
switch {
case action:
res.Action = it.msg.ID
@@ -220,6 +230,12 @@ func Toaster(c *ui.Context) ToasterResult {
c.Invalidate()
}
}
+ // Scheduled after resuming, so a toast released this frame still expires.
+ q.wake = time.Time{}
+ if next := q.settle(now, held); next > 0 {
+ q.wake = now.Add(next)
+ c.After(next)
+ }
return res
}
diff --git a/toast_test.go b/toast_test.go
index d4425ba..c0da1f0 100644
--- a/toast_test.go
+++ b/toast_test.go
@@ -28,7 +28,8 @@ func TestToastQueueLimitsVisible(t *testing.T) {
t.Fatalf("visible %v waiting %v", v, w)
}
// d starts its 5 s only when it shows.
- if next := q.expire(t0.Add(ToastDuration)); next != ToastDuration {
+ q.expire(t0.Add(ToastDuration))
+ if next := q.settle(t0.Add(ToastDuration), nil); next != ToastDuration {
t.Fatalf("next wake %v", next)
}
if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) {
@@ -66,15 +67,16 @@ func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0)
q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0)
- q.pause("w", true, t0.Add(4*time.Second))
- if next := q.expire(t0.Add(time.Hour)); next != 0 {
+ q.settle(t0.Add(4*time.Second), map[string]bool{"w": true})
+ q.expire(t0.Add(time.Hour))
+ if next := q.settle(t0.Add(time.Hour), map[string]bool{"w": true}); next != 0 {
t.Fatalf("paused toast still counts down: %v", next)
}
if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) {
t.Fatalf("visible %v", toastIDs(&q, true))
}
- q.pause("w", false, t0.Add(time.Hour))
- if next := q.expire(t0.Add(time.Hour)); next != time.Second {
+ // Released: the wake-up is computed after resuming, in the same call.
+ if next := q.settle(t0.Add(time.Hour), nil); next != time.Second {
t.Fatalf("resumed toast left %v, want 1s", next)
}
q.expire(t0.Add(time.Hour + time.Second))
@@ -84,7 +86,7 @@ func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
}
func TestToastQueuePanics(t *testing.T) {
- for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}} {
+ for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}, {Message: "x", Severity: 9}} {
func() {
defer func() {
if recover() == nil {
@@ -156,27 +158,41 @@ func TestToastKeepsFocusAndEscapeCloses(t *testing.T) {
}
func TestToastExpiresAndHoverPauses(t *testing.T) {
- var res ToasterResult
- tt := toastTester(ToastMessage{Message: "Copied", Duration: 40 * time.Millisecond}, &res)
+ var q *toastQueue
+ const life = 40 * time.Millisecond
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if Button(c, "Notify", ButtonOptions{}).Clicked() {
+ ShowToast(c, ToastMessage{Message: "Copied", Duration: life})
+ }
+ q = toasts(c)
+ Toaster(c)
+ }, 600, 400)
_ = tt.Click("Notify")
- time.Sleep(60 * time.Millisecond)
- tt.Frame()
- if tt.HasText("Copied") {
- t.Fatal("toast outlived its duration")
+ if q.wake.IsZero() {
+ t.Fatal("no wake-up scheduled for a running toast")
}
- _ = tt.Click("Notify")
r, _ := tt.Find("Copied")
tt.Move(r.X+r.W/2, r.Y+r.H/2)
- time.Sleep(60 * time.Millisecond)
- tt.Frame()
+ if !q.wake.IsZero() {
+ t.Fatal("hovered toast still schedules its expiry")
+ }
+ time.Sleep(2 * life)
+ released := time.Now()
+ tt.Move(5, 395)
+ done := time.Now()
+ // The frame that ends the hover must schedule the expiry itself.
+ if q.wake.IsZero() || q.wake.Before(released) || q.wake.After(done.Add(life)) {
+ t.Fatalf("release scheduled wake %v, released %v", q.wake, released)
+ }
if !tt.HasText("Copied") {
t.Fatal("hovered toast expired")
}
- tt.Move(5, 395)
- time.Sleep(60 * time.Millisecond)
+ // Stands in for the frame MyGo's timer runs at q.wake.
+ time.Sleep(time.Until(q.wake) + 5*time.Millisecond)
tt.Frame()
if tt.HasText("Copied") {
- t.Fatal("toast stayed after the pointer left")
+ t.Fatal("toast stayed after its wake-up")
}
}
diff --git a/toolbar.go b/toolbar.go
index 712fefd..2982c3f 100644
--- a/toolbar.go
+++ b/toolbar.go
@@ -57,6 +57,7 @@ func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.E
case b.Hovered():
b.Background(k.SurfaceHover)
}
+ internal.Ease(b, Motion(c, theme.StateDuration), k.Text)
b.Children(func() {
if opts.Icon != nil {
ui.Icon(c, opts.Icon).Size(16, 16)
diff --git a/transfer.go b/transfer.go
index 47a3fc1..3b300d9 100644
--- a/transfer.go
+++ b/transfer.go
@@ -43,8 +43,7 @@ type transferState struct {
query [2]string
}
-// Transfer moves items between a source list and *target, the IDs chosen, in order.
-// Each side has search, check boxes with select-all, and move buttons between them.
+// Transfer moves items between a source list and *target, the chosen IDs in order.
func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts {
byID := make(map[string]TransferItem, len(items))
for _, it := range items {
@@ -158,6 +157,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
all := checked > 0 && checked == enabled
box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
transferMark(c, box, all, checked > 0 && !all)
+ box.Description(transferPartial(c, checked, enabled))
if box.Changed() {
for _, it := range shown {
if !it.Disabled {
@@ -166,7 +166,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
}
}
ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1)
- ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum")
+ ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
})
ui.Box(c).Padding(d.Unit() * 2).Children(func() {
InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: msg(c, "transfer.search"), Label: fmt.Sprintf(msg(c, "transfer.search_in"), title)})
@@ -202,6 +202,14 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
})
}
+// transferPartial describes a select-all box that is neither clear nor full.
+func transferPartial(c *ui.Context, checked, enabled int) string {
+ if checked == 0 || checked == enabled {
+ return ""
+ }
+ return fmt.Sprintf(msg(c, "transfer.partial"), checked, enabled)
+}
+
// transferMark draws a check box's square inside box, which takes the focus ring.
func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) {
k := Tokens(c)
diff --git a/transfer_test.go b/transfer_test.go
index fb366b0..427d4d2 100644
--- a/transfer_test.go
+++ b/transfer_test.go
@@ -104,3 +104,15 @@ func TestTransferSnapshot(t *testing.T) {
})
})
}
+
+// MyGo's tester cannot read descriptions, so this checks the text the select-all box gets.
+func TestTransferPartialDescription(t *testing.T) {
+ var got [3]string
+ ui.Render(func(c *ui.Context) {
+ Use(c, Settings{})
+ got = [3]string{transferPartial(c, 2, 3), transferPartial(c, 3, 3), transferPartial(c, 0, 3)}
+ }, 100, 100, 1)
+ if got != [3]string{"2 of 3 selected", "", ""} {
+ t.Fatalf("descriptions %q", got)
+ }
+}
diff --git a/tree.go b/tree.go
index d659ceb..fb4796f 100644
--- a/tree.go
+++ b/tree.go
@@ -76,12 +76,34 @@ func (t *treeCore[K]) begin(label func(K) string, sel *ui.Selection[K], multiple
}
}
-// end reads the chosen item after the outline was built.
-func (t *treeCore[K]) end() {
- t.has = t.cursor >= 0 && t.cursor < t.outline.Rows()
- if t.has {
- t.sel = t.outline.Item(t.cursor)
+// end reads a new choice, or finds the chosen item again after rows moved.
+func (t *treeCore[K]) end(c *ui.Context, changed bool) {
+ rows := t.outline.Rows()
+ at := t.cursor >= 0 && t.cursor < rows
+ if changed || !t.has {
+ t.has = at
+ if at {
+ t.sel = t.outline.Item(t.cursor)
+ }
+ return
}
+ if at && t.outline.Item(t.cursor) == t.sel {
+ return
+ }
+ t.cursor = -1
+ for i := range rows {
+ if t.outline.Item(i) == t.sel {
+ t.cursor = i
+ break
+ }
+ }
+ if t.cursor < 0 {
+ t.has = false
+ return
+ }
+ // The rows showed the old place: build them again at the new one.
+ c.Invalidate()
+ slog.Debug("mujica: tree selection moved", "row", t.cursor)
}
// chosen reports whether item shows as chosen.
@@ -89,7 +111,7 @@ func (t *treeCore[K]) chosen(item K, sel *ui.Selection[K], multiple bool) bool {
if multiple {
return sel.Has(item)
}
- return t.cursor >= 0 && t.cursor < t.outline.Rows() && t.outline.Item(t.cursor) == item
+ return t.has && t.sel == item
}
// itemStatus asks for unloaded children of an open item and shows loading or failure.
@@ -118,7 +140,7 @@ func (t *treeCore[K]) itemStatus(c *ui.Context, item K, status func(K) DataStatu
case DataFailed:
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(14, 14)
- ui.Text(c, msg(c, "list.failed")).FontSize(theme.CaptionSize).SingleLine()
+ ui.Text(c, msg(c, "list.failed")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
})
if Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() {
slog.Debug("mujica: tree retry", "item", item)
@@ -161,21 +183,23 @@ func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) Tre
}
// Outline rows pad by one unit above and below.
h := d.ControlHeight() - 2*c.Theme().Space(1)
+ restore := dataSelectTheme(c, 0)
e := ui.Outline(c, &t.outline, opts.Roots, opts.Children, func(item K) {
ui.Row(c).Grow(1).MinWidth(0).MinHeight(h).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, opts.Label(item)).SingleLine().Grow(1).MinWidth(0)
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
if t.chosen(item, &s.Selection, opts.Multiple) {
- 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)
}
})
})
+ restore()
e.Grow(1).MinHeight(0).Padding(u).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
v.changed, v.submitted = e.Changed(), e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
- t.end()
+ t.end(c, v.changed)
if v.changed {
slog.Debug("mujica: tree changed", "row", t.cursor, "count", s.Selection.Len())
}
diff --git a/tree_table.go b/tree_table.go
index 43313bb..6f09ab3 100644
--- a/tree_table.go
+++ b/tree_table.go
@@ -29,8 +29,7 @@ type TreeColumn[K comparable] struct {
type TreeTableOptions[K comparable] struct {
Columns []TreeColumn[K]
Roots []K
- // Children returns an item's children: nil for a leaf, empty while unloaded; required.
- // Return a new slice when they change: sorted copies are cached by slice.
+ // Children returns kids (nil leaf, empty unloaded); required; return a new slice on change.
Children func(item K) []K
// Label returns an item's text, read out and matched by typing; required.
Label func(item K) string
@@ -84,15 +83,15 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
if s.sorted == nil {
s.sorted = map[K]treeSorted[K]{}
}
- var cmp func(a, b K) int
cols := make([]ui.TableColumn, len(opts.Columns))
+ menu := make([]tableColumn, len(opts.Columns))
for i, col := range opts.Columns {
cols[i] = ui.TableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Align: col.Align, Sortable: col.Compare != nil}
- if col.ID == s.Sort.Column && col.Compare != nil {
- cmp = col.Compare
- }
+ menu[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Compare != nil}
}
- order := s.Sort
+ // The sort is read after the column menu, which may change it.
+ var cmp func(a, b K) int
+ var order ui.SortOrder
sorted := func(src []K, cache *treeSorted[K]) []K {
if cmp == nil || len(src) < 2 {
return src
@@ -120,20 +119,25 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
s.sorted[item] = cache
return out
}
+ 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(opts.Roots) == 0 {
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
return
}
+ ui.Row(c).Justify(ui.End).Children(func() { tableColumnMenu(c, menu, &s.Sort, &s.Layout, nil) })
+ order = s.Sort
+ for _, col := range opts.Columns {
+ if col.ID == order.Column {
+ cmp = col.Compare
+ }
+ }
roots := sorted(opts.Roots, &s.roots)
- before := s.Sort
+ head := s.Sort
t.outline.List.Sort, t.outline.List.Columns = &s.Sort, s.Layout
- saved := c.Theme()
- th := *saved
- th.Spacing = d.ControlHeight() / 8
- c.SetTheme(&th)
+ restore := dataSelectTheme(c, d.ControlHeight()/8)
e := ui.OutlineTable(c, &t.outline, cols, roots, children, func(item K, col int) {
if col > 0 {
opts.Columns[col].Cell(item)
@@ -143,20 +147,20 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Children(func() { opts.Columns[0].Cell(item) })
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
if t.chosen(item, &s.Selection, opts.Multiple) {
- 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)
}
})
})
- 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 = t.outline.List.Columns
- v.sorted = s.Sort != before
- v.changed = e.Changed() && !v.sorted
+ v.changed = e.Changed() && s.Sort == head
v.submitted = e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
- t.end()
+ v.sorted = s.Sort != before
+ t.end(c, v.changed)
if v.sorted {
slog.Debug("mujica: tree_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
}
diff --git a/tree_table_test.go b/tree_table_test.go
index 1f4f02a..1013187 100644
--- a/tree_table_test.go
+++ b/tree_table_test.go
@@ -182,6 +182,7 @@ func TestTreeTableSnapshot(t *testing.T) {
h.st.SetOpen("Vestry", true)
h.st.Sort = ui.SortOrder{Column: "name"}
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 2, map[string]bool{}
+ h.st.tree.sel, h.st.tree.has = "Vestry", true
return h
})
(*h).c = c
@@ -190,3 +191,56 @@ func TestTreeTableSnapshot(t *testing.T) {
})
})
}
+
+func TestTreeTableSelectionFollowsLargeSort(t *testing.T) {
+ kids := make([]int, 3000)
+ for i := range kids {
+ kids[i] = i + 1
+ }
+ var st TreeTableState[int]
+ view := func(c *ui.Context) {
+ Use(c, Settings{})
+ TreeTable(c, &st, TreeTableOptions[int]{Roots: []int{0}, Label: strconv.Itoa,
+ Children: func(n int) []int {
+ if n == 0 {
+ return kids
+ }
+ return nil
+ },
+ Columns: []TreeColumn[int]{{ID: "n", Title: "N", Cell: func(n int) { ui.Text(c, "#"+strconv.Itoa(n)) }, Compare: cmp.Compare[int]}},
+ }).Size(320, 360)
+ }
+ st.SetOpen(0, true)
+ tt := ui.NewTester(view, 400, 400)
+ if err := tt.Click("#3"); err != nil {
+ t.Fatal(err)
+ }
+ st.Sort = ui.SortOrder{Column: "n", Descending: true}
+ tt.Frame()
+ if k, ok := st.Selected(); !ok || k != 3 || st.tree.outline.Item(st.tree.cursor) != 3 {
+ t.Fatalf("after sort: selected %d %v at row %d", k, ok, st.tree.cursor)
+ }
+}
+
+func TestTreeTableColumnMenuKeyboard(t *testing.T) {
+ h := newTTHarness()
+ tt := ui.NewTester(h.view, 420, 480)
+ tabTo(t, tt, "Columns")
+ tt.Key(0, ui.KeyEnter)
+ tabTo(t, tt, "Sort Size ascending")
+ tt.Key(0, ui.KeyEnter)
+ if h.st.Sort != (ui.SortOrder{Column: "size"}) || h.sorts != 1 {
+ t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
+ }
+ tabTo(t, tt, "Move Name right")
+ tt.Key(0, ui.KeyEnter)
+ tabTo(t, tt, "Make Name narrower")
+ tt.Key(0, ui.KeyEnter)
+ if fmt.Sprint(h.st.Layout.Order) != "[size name]" || h.st.Layout.Widths["name"] != 104 {
+ t.Fatalf("layout %+v", h.st.Layout)
+ }
+ tt.Key(0, ui.KeyEscape)
+ if got := h.order(tt); got != "[Belfry Organ Vestry]" {
+ t.Fatalf("rows not sorted by size: %s", got)
+ }
+}
diff --git a/tree_test.go b/tree_test.go
index d258570..77354b2 100644
--- a/tree_test.go
+++ b/tree_test.go
@@ -2,9 +2,13 @@ package mujica
import (
"fmt"
+ "image/color"
+ "slices"
"testing"
"github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
)
type treeHarness struct {
@@ -185,8 +189,7 @@ func TestTreeLargeIsVirtualized(t *testing.T) {
}}).Size(320, 360)
}
tt := ui.NewTester(view, 400, 400)
- // Label runs twice per built row (row name and text). A jump builds a
- // view on each side of its target: 2×10 rows of 36 DIPs, plus 2×2 overscan and 2 edges.
+ // Two Label calls per row; a jump builds 2×(10 rows + 2 overscan) + 2 edges.
const bound = 2 * 26
if built == 0 || built > bound {
t.Fatalf("collapsed: label calls %d, bound %d", built, bound)
@@ -213,6 +216,7 @@ func TestTreeSnapshot(t *testing.T) {
h.st.SetOpen("Crypt", true)
h.status["Crypt"] = DataLoading
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 1, map[string]bool{}
+ h.st.tree.sel, h.st.tree.has = "Pulpit", true
return h
})
ui.Column(c).Padding(16).Children(func() {
@@ -220,3 +224,31 @@ func TestTreeSnapshot(t *testing.T) {
})
})
}
+
+func TestTreeSelectionFollowsLargeReorder(t *testing.T) {
+ roots := make([]int, 3000)
+ for i := range roots {
+ roots[i] = i
+ }
+ var st TreeState[int]
+ view := func(c *ui.Context) {
+ Use(c, Settings{Mode: Light})
+ Tree(c, &st, TreeOptions[int]{Roots: roots, Children: func(int) []int { return nil },
+ Label: func(n int) string { return fmt.Sprintf("Node %d", n) }}).Size(320, 360)
+ }
+ tt := ui.NewTester(view, 400, 400)
+ if err := tt.Click("Node 2"); err != nil {
+ t.Fatal(err)
+ }
+ r, _ := tt.Find("Node 2")
+ 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)
+ }
+ roots = slices.Clone(roots)
+ slices.Reverse(roots)
+ tt.Frame()
+ if k, ok := st.Selected(); !ok || k != 2 || st.tree.cursor != 2997 {
+ t.Fatalf("after reversing: selected %d %v at row %d", k, ok, st.tree.cursor)
+ }
+}
diff --git a/year_picker.go b/year_picker.go
index b607152..f6b990d 100644
--- a/year_picker.go
+++ b/year_picker.go
@@ -17,12 +17,13 @@ type YearPickerOptions struct {
// Today marks the current year; 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
}
-// YearPicker chooses a year into *value, zero meaning none, from a decade page.
-// Arrows move by year and row, PageUp and PageDown by decade.
+// YearPicker chooses a year into *value from a decade page; zero is none.
func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResult {
if *value < 0 || opts.Min < 0 || opts.Max < 0 || opts.Min != 0 && opts.Max != 0 && opts.Min > opts.Max {
panic(fmt.Sprintf("mujica: YearPicker value %d or range %d..%d is not valid", *value, opts.Min, opts.Max))
@@ -43,7 +44,7 @@ func YearPicker(c *ui.Context, value *int, opts YearPickerOptions) DateTimeResul
}
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
text: text, placeholder: msg(c, "year_picker.placeholder"), label: label,
- clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, home: home,
+ clearable: opts.Clearable, hasValue: *value != 0, disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home,
}, datetimeGridConfig{
pageOf: func(o int) (int, int) { return decade(o) - 1, 12 },
cols: 3,