Merge branch 'main' into worktree-agent-ae66cd6d621c16060
This commit is contained in:
151 files changed
+2401
-670
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -43,8 +43,10 @@ func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
|
||||
return k.Warning, icons.Must("triangle-alert")
|
||||
case SeverityError:
|
||||
return k.Danger, icons.Must("circle-x")
|
||||
case SeverityInfo:
|
||||
return k.Info, icons.Must("info")
|
||||
}
|
||||
return k.Info, icons.Must("info")
|
||||
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
|
||||
}
|
||||
|
||||
// AlertOptions configure an Alert.
|
||||
@@ -66,9 +68,7 @@ type AlertResult struct {
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Alert shows an inline message marked by color, icon and accessible name.
|
||||
// A non-nil open adds a dismiss button that sets *open to false; nothing
|
||||
// shows while *open is false.
|
||||
// Alert shows an inline message; a non-nil open adds a dismiss button that clears it.
|
||||
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
|
||||
if message == "" {
|
||||
panic("mujica: Alert needs a message")
|
||||
|
||||
+10
-4
@@ -90,10 +90,12 @@ func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) B
|
||||
link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius).
|
||||
Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label)
|
||||
internal.Ring(link, k.Focus, theme.ControlRadius)
|
||||
link.TextColor(k.TextMuted)
|
||||
fg := k.TextMuted
|
||||
if link.Hovered() {
|
||||
link.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
link.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
internal.Ease(link, Motion(c, theme.StateDuration), fg)
|
||||
link.Children(func() { breadcrumbContent(c, it, width) })
|
||||
if link.Clicked() {
|
||||
choose(i)
|
||||
@@ -119,12 +121,15 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize).
|
||||
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer)
|
||||
Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).Cursor(ui.CursorPointer)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
fg := k.TextMuted
|
||||
if b.Hovered() || *open {
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text)
|
||||
b.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
internal.Ease(b, Motion(c, theme.StateDuration), fg)
|
||||
if b.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
@@ -142,6 +147,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
|
||||
if item.Hovered() {
|
||||
item.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Ease(item, Motion(c, theme.StateDuration), k.Text)
|
||||
item.Children(func() {
|
||||
if it.Icon != nil {
|
||||
ui.Icon(c, it.Icon).Size(14, 14).Shrink(0)
|
||||
|
||||
+5
-7
@@ -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)))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element {
|
||||
}
|
||||
card.Children(func() {
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).LineHeight(1.3)
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).LineHeight(1.3)
|
||||
if opts.Decorated {
|
||||
ui.Box(c).Height(7).FillWidth().Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cy := r.Y + r.H/2
|
||||
|
||||
+4
-1
@@ -58,10 +58,13 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func
|
||||
}
|
||||
wrap := opts.Loop || opts.Autoplay
|
||||
goTo := func(i int) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
if wrap {
|
||||
i = (i%n + n) % n
|
||||
}
|
||||
if n == 0 || i < 0 || i >= n || i == *index {
|
||||
if i < 0 || i >= n || i == *index {
|
||||
return
|
||||
}
|
||||
*index = i
|
||||
|
||||
+17
-1
@@ -124,9 +124,25 @@ func TestCarouselSnapshot(t *testing.T) {
|
||||
ui.Column(c).Padding(16).Size(440, 240).Children(func() {
|
||||
Carousel(c, *i, 4, CarouselOptions{Autoplay: true, Label: "Windows"}, func(n int) {
|
||||
ui.Column(c).Grow(1).Center().Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(18)
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(FontSize(c, 18))
|
||||
})
|
||||
}).Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarouselEmptyAutoplayKeys(t *testing.T) {
|
||||
index := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Carousel(c, &index, 0, CarouselOptions{Autoplay: true}, func(int) {}).Size(300, 200)
|
||||
}, 320, 240)
|
||||
for range 4 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
}
|
||||
if index != 0 {
|
||||
t.Fatalf("index %d", index)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -84,3 +84,25 @@ func TestCollapsibleSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollapsibleClosingContentLeavesTabOrder(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
Collapsible(c, &open, CollapsibleOptions{Title: "Details"}, func() {
|
||||
Button(c, "Inner", ButtonOptions{})
|
||||
})
|
||||
Button(c, "After", ButtonOptions{})
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace) // closes; the content shrinks for 120 ms
|
||||
if open || !tt.HasText("Inner") {
|
||||
t.Fatalf("expected a closing animation: open %v", open)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("After") {
|
||||
t.Fatal("Tab entered closing content")
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,3 +124,36 @@ func TestColorPickerSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Disabling an open picker closes it before keys can change the color.
|
||||
func TestColorPickerDisabledWhileOpen(t *testing.T) {
|
||||
col, n := ui.Hex("#808080"), 0
|
||||
opts := ColorPickerOptions{Label: "Shade"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ColorPicker(c, &col, opts).Changed() {
|
||||
n++
|
||||
}
|
||||
}, 400, 560)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.Focused("Saturation and brightness") {
|
||||
t.Fatal("not open")
|
||||
}
|
||||
opts.Disabled = true
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if col != ui.Hex("#808080") || n != 0 || tt.HasText("HEX") {
|
||||
t.Fatalf("disabled picker changed to %v", col)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPickerSwatchHitArea(t *testing.T) {
|
||||
col, n := ui.Hex("#7D2034"), 0
|
||||
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
|
||||
_ = tt.Click("Accent")
|
||||
r, ok := tt.Find("#355E80")
|
||||
if !ok || r.W < 28 || r.H < 28 {
|
||||
t.Fatalf("swatch %v", r)
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -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 {
|
||||
|
||||
@@ -89,6 +89,17 @@ func TestCommandPaletteShortcutSearchRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteAnnouncesHighlight(t *testing.T) {
|
||||
a := &paletteApp{open: true}
|
||||
tt := ui.NewTester(a.view, 800, 600)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
got := tt.Announcements()
|
||||
if len(got) < 2 || got[len(got)-2] != "Open recent" || got[len(got)-1] != "Save all" {
|
||||
t.Fatalf("announcements %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteEmptyClickEscape(t *testing.T) {
|
||||
a := &paletteApp{open: true}
|
||||
tt := ui.NewTester(a.view, 800, 600)
|
||||
|
||||
+42
-32
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
@@ -62,6 +61,7 @@ type DataTableState[K comparable] struct {
|
||||
started bool
|
||||
sel K
|
||||
has bool
|
||||
toggled bool
|
||||
cache dataTableCache[K]
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (v DataTableView) Changed() bool { return v.changed }
|
||||
// Submitted reports Enter or a double click on a row.
|
||||
func (v DataTableView) Submitted() bool { return v.submitted }
|
||||
|
||||
// SortChanged reports a header click that changed State.Sort.
|
||||
// SortChanged reports a header click or menu choice that changed State.Sort.
|
||||
func (v DataTableView) SortChanged() bool { return v.sorted }
|
||||
|
||||
// Retried reports a click on the retry action of a failed load.
|
||||
@@ -109,19 +109,25 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
dataTableRefresh(s, rows, opts)
|
||||
if s.has {
|
||||
if i, ok := s.cache.at[s.sel]; ok {
|
||||
s.cursor = i
|
||||
} else {
|
||||
s.cursor = -1
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Children(func() {
|
||||
if len(rows) > 0 {
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
tableColumnMenu(c, dataTableMenu(opts), &s.Sort, &s.Layout, &s.Hidden)
|
||||
})
|
||||
}
|
||||
dataTableRefresh(s, rows, opts)
|
||||
if s.has {
|
||||
if i, ok := s.cache.at[s.sel]; ok {
|
||||
s.cursor = i
|
||||
} else {
|
||||
s.cursor = -1
|
||||
}
|
||||
}
|
||||
view := s.cache.view
|
||||
if len(view) == 0 {
|
||||
empty := opts.Empty
|
||||
@@ -139,12 +145,9 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
if opts.Multiple {
|
||||
s.rows.Selection = &s.Selection
|
||||
}
|
||||
before := s.Sort
|
||||
// Rows reach the data-row height of the density.
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Spacing = d.ControlHeight() / 8
|
||||
c.SetTheme(&t)
|
||||
restore := dataSelectTheme(c, d.ControlHeight()/8)
|
||||
head := s.Sort
|
||||
e := ui.Table(c, &s.rows, cols, len(view), func(row, col int) {
|
||||
r := rows[view[row]]
|
||||
if col == 0 {
|
||||
@@ -158,18 +161,19 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
ui.Text(c, dc.Text(r)).SingleLine()
|
||||
}
|
||||
})
|
||||
c.SetTheme(saved)
|
||||
restore()
|
||||
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
s.Layout = s.rows.Columns
|
||||
v.sorted = s.Sort != before
|
||||
v.changed = e.Changed() && !v.sorted
|
||||
v.changed = e.Changed() && s.Sort == head || s.toggled
|
||||
s.toggled = false
|
||||
v.submitted = e.Submitted()
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
if s.cursor >= 0 && s.cursor < len(view) {
|
||||
s.sel, s.has = opts.Key(rows[view[s.cursor]]), true
|
||||
}
|
||||
})
|
||||
v.sorted = s.Sort != before
|
||||
if v.sorted {
|
||||
slog.Debug("mujica: data_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
|
||||
}
|
||||
@@ -252,6 +256,15 @@ func dataTablePrune[R any, K comparable](s *DataTableState[K], rows []R, opts Da
|
||||
}
|
||||
}
|
||||
|
||||
// dataTableMenu lists the columns for the column menu.
|
||||
func dataTableMenu[R any, K comparable](opts DataTableOptions[R, K]) []tableColumn {
|
||||
cols := make([]tableColumn, len(opts.Columns))
|
||||
for i, col := range opts.Columns {
|
||||
cols[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Sortable}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func dataTableColumns[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
|
||||
w := Density(c).ControlHeight()
|
||||
cols := []ui.TableColumn{{ID: dataTableMarkID, Width: w, Fixed: true, Align: ui.Center}}
|
||||
@@ -275,27 +288,24 @@ func dataTableMark[R any, K comparable](c *ui.Context, s *DataTableState[K], opt
|
||||
if !opts.Multiple {
|
||||
if row == s.cursor {
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.OnAccent)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.AccentText)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
on := s.Selection.Has(key)
|
||||
box := ui.Box(c).Size(16, 16).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleCheckBox).Label(msg(c, "data_table.selectRow"))
|
||||
if on {
|
||||
box.Border(theme.BorderWidth, k.OnAccent).TextColor(k.OnAccent).Children(func() {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14)
|
||||
}).Center()
|
||||
} else {
|
||||
box.Border(theme.BorderWidth, k.ControlBorder)
|
||||
}
|
||||
if box.Clicked() {
|
||||
cb := ui.CheckboxBase(c, &on).Label(msg(c, "data_table.selectRow")).Size(theme.MinHitSize, theme.MinHitSize).
|
||||
Justify(ui.Center).AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
cb.Children(func() { dataTableCheckBox(c, on) })
|
||||
if cb.Changed() {
|
||||
if on {
|
||||
s.Selection.Remove(key)
|
||||
} else {
|
||||
s.Selection.Add(key)
|
||||
} else {
|
||||
s.Selection.Remove(key)
|
||||
}
|
||||
slog.Debug("mujica: data_table toggle", "on", !on)
|
||||
s.toggled = true
|
||||
slog.Debug("mujica: data_table toggle", "on", on)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
-21
@@ -1,7 +1,10 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -10,58 +13,156 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DataTableColumnPicker is a Columns button whose popover shows and hides columns.
|
||||
// The last visible column cannot be hidden.
|
||||
func DataTableColumnPicker[R any, K comparable](c *ui.Context, s *DataTableState[K], cols []DataColumn[R]) *ui.Element {
|
||||
// tableColumn is what the column menu knows of a column.
|
||||
type tableColumn struct {
|
||||
ID, Title string
|
||||
Width, MinWidth float32
|
||||
Sortable bool
|
||||
}
|
||||
|
||||
// tableWidthStep is how far one Narrower or Wider press resizes a column.
|
||||
const tableWidthStep float32 = 16
|
||||
|
||||
// tableColumnMenu is the keyboard path to sort, move, resize and, with hidden, show columns.
|
||||
func tableColumnMenu(c *ui.Context, cols []tableColumn, sort *ui.SortOrder, layout *ui.TableLayout, hidden *map[string]bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("eye")})
|
||||
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("settings")})
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
if b.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
visible := 0
|
||||
for _, col := range cols {
|
||||
if !s.Hidden[col.ID] {
|
||||
visible++
|
||||
order := tableOrder(cols, *layout)
|
||||
visible := len(cols)
|
||||
if hidden != nil {
|
||||
for _, col := range cols {
|
||||
if (*hidden)[col.ID] {
|
||||
visible--
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
|
||||
panel.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.PopupRadius).
|
||||
Padding(u * 2).Gap(u).MinWidth(200).MarginY(u)
|
||||
for _, col := range cols {
|
||||
ui.Column(c).Key(col.ID).Children(func() { dataTablePickerItem(c, s, col.ID, col.Title, visible) })
|
||||
Padding(u * 2).Gap(u).MinWidth(240).MarginY(u)
|
||||
for at, id := range order {
|
||||
col := cols[slices.IndexFunc(cols, func(col tableColumn) bool { return col.ID == id })]
|
||||
ui.Row(c).Key(id).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
if hidden != nil {
|
||||
tableVisibility(c, col, hidden, visible)
|
||||
} else {
|
||||
ui.Text(c, col.Title).SingleLine().Grow(1).Padding(0, u*2)
|
||||
}
|
||||
tableColumnActions(c, col, at, order, sort, layout)
|
||||
})
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// dataTablePickerItem is one column's check box in the picker.
|
||||
func dataTablePickerItem[K comparable](c *ui.Context, s *DataTableState[K], id, title string, visible int) {
|
||||
// tableColumnActions are one column's sort, move and width buttons.
|
||||
func tableColumnActions(c *ui.Context, col tableColumn, at int, order []string, sort *ui.SortOrder, layout *ui.TableLayout) {
|
||||
act := func(icon, key string, disabled bool) bool {
|
||||
return Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must(icon), Label: fmt.Sprintf(msg(c, key), col.Title), Disabled: disabled}).Clicked()
|
||||
}
|
||||
if col.Sortable {
|
||||
for _, desc := range []bool{false, true} {
|
||||
icon, key := "arrow-up", "data_table.sortAsc"
|
||||
if desc {
|
||||
icon, key = "arrow-down", "data_table.sortDesc"
|
||||
}
|
||||
if act(icon, key, *sort == ui.SortOrder{Column: col.ID, Descending: desc}) {
|
||||
*sort = ui.SortOrder{Column: col.ID, Descending: desc}
|
||||
slog.Debug("mujica: table sort", "column", col.ID, "descending", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, step := range []int{-1, 1} {
|
||||
icon, key := "chevron-left", "data_table.moveLeft"
|
||||
if step > 0 {
|
||||
icon, key = "chevron-right", "data_table.moveRight"
|
||||
}
|
||||
to := at + step
|
||||
if act(icon, key, to < 0 || to >= len(order)) {
|
||||
ids := slices.Clone(order)
|
||||
ids[at], ids[to] = ids[to], ids[at]
|
||||
layout.Order = ids
|
||||
slog.Debug("mujica: table move", "column", col.ID, "to", to)
|
||||
}
|
||||
}
|
||||
w := tableWidth(col, *layout)
|
||||
least := max(col.MinWidth, tableWidthStep)
|
||||
if act("minus", "data_table.narrower", w <= least) {
|
||||
tableSetWidth(layout, col.ID, max(least, w-tableWidthStep))
|
||||
}
|
||||
if act("plus", "data_table.wider", false) {
|
||||
tableSetWidth(layout, col.ID, w+tableWidthStep)
|
||||
}
|
||||
}
|
||||
|
||||
// tableVisibility is a column's show/hide check box; the last visible one cannot hide.
|
||||
func tableVisibility(c *ui.Context, col tableColumn, hidden *map[string]bool, visible int) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
on := !s.Hidden[id]
|
||||
on := !(*hidden)[col.ID]
|
||||
last := on && visible == 1
|
||||
cb := ui.CheckboxBase(c, &on).Disabled(last).MinHeight(d.ControlHeight()).
|
||||
cb := ui.CheckboxBase(c, &on).Label(col.Title).Disabled(last).MinHeight(d.ControlHeight()).Grow(1).
|
||||
Padding(0, u*2).Gap(u * 2).AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
if cb.Hovered() && !last {
|
||||
cb.Background(k.SurfaceHover)
|
||||
}
|
||||
if cb.Changed() && !last {
|
||||
if s.Hidden == nil {
|
||||
s.Hidden = map[string]bool{}
|
||||
m := maps.Clone(*hidden)
|
||||
if m == nil {
|
||||
m = map[string]bool{}
|
||||
}
|
||||
s.Hidden[id] = !on
|
||||
slog.Debug("mujica: data_table column", "id", id, "visible", on)
|
||||
m[col.ID] = !on
|
||||
*hidden = m
|
||||
slog.Debug("mujica: table column", "id", col.ID, "visible", on)
|
||||
}
|
||||
cb.Children(func() {
|
||||
dataTableCheckBox(c, on)
|
||||
ui.Text(c, title).SingleLine()
|
||||
ui.Text(c, col.Title).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// tableOrder lists column IDs as the layout orders them, the rest after.
|
||||
func tableOrder(cols []tableColumn, l ui.TableLayout) []string {
|
||||
var ids []string
|
||||
for _, id := range l.Order {
|
||||
if slices.ContainsFunc(cols, func(col tableColumn) bool { return col.ID == id }) && !slices.Contains(ids, id) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
if !slices.Contains(ids, col.ID) {
|
||||
ids = append(ids, col.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// tableWidth is a column's set width; a sharing column starts from its least, at least 120.
|
||||
func tableWidth(col tableColumn, l ui.TableLayout) float32 {
|
||||
if w, ok := l.Widths[col.ID]; ok {
|
||||
return w
|
||||
}
|
||||
if col.Width > 0 {
|
||||
return col.Width
|
||||
}
|
||||
return max(col.MinWidth, 120)
|
||||
}
|
||||
|
||||
func tableSetWidth(l *ui.TableLayout, id string, w float32) {
|
||||
m := maps.Clone(l.Widths)
|
||||
if m == nil {
|
||||
m = map[string]float32{}
|
||||
}
|
||||
m[id] = w
|
||||
l.Widths = m
|
||||
slog.Debug("mujica: table width", "column", id, "width", w)
|
||||
}
|
||||
|
||||
// dataTableCheckBox draws a 16 DIP check box: OnAccent check on Accent when on.
|
||||
func dataTableCheckBox(c *ui.Context, on bool) {
|
||||
k := Tokens(c)
|
||||
|
||||
+58
-30
@@ -3,10 +3,13 @@ package mujica
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type relic struct {
|
||||
@@ -22,7 +25,6 @@ type tableHarness struct {
|
||||
compares int
|
||||
changes int
|
||||
sorts int
|
||||
picker bool
|
||||
c *ui.Context
|
||||
seen map[string]int
|
||||
}
|
||||
@@ -45,9 +47,6 @@ func (h *tableHarness) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
h.c, h.seen = c, map[string]int{}
|
||||
ui.Column(c).Padding(10).Gap(8).Children(func() {
|
||||
if h.picker {
|
||||
DataTableColumnPicker(c, &h.st, h.opts.Columns)
|
||||
}
|
||||
cols := h.opts
|
||||
for i := range cols.Columns {
|
||||
text := cols.Columns[i].Text
|
||||
@@ -154,8 +153,11 @@ func TestDataTableMultipleMarks(t *testing.T) {
|
||||
r, _ := tt.Find(name)
|
||||
tt.ClickAt(table.X-28, r.Y+r.H/2) // the mark column sits before Name
|
||||
}
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) {
|
||||
t.Fatalf("marks chose %d", h.st.Selection.Len())
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) || h.changes != 2 {
|
||||
t.Fatalf("marks chose %d, changes %d", h.st.Selection.Len(), h.changes)
|
||||
}
|
||||
if _, ok := tt.Find("Select row"); !ok {
|
||||
t.Fatal("row check box has no name")
|
||||
}
|
||||
_ = tt.Click("Age")
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(4) {
|
||||
@@ -168,39 +170,52 @@ func TestDataTableMultipleMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableColumns(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
h.picker = true
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
h.st.Layout.Order = []string{"age", "name"}
|
||||
tt.Frame()
|
||||
age, _ := tt.Find("Age")
|
||||
name, _ := tt.Find("Name")
|
||||
if age.X >= name.X {
|
||||
t.Fatalf("order not applied: age %v name %v", age.X, name.X)
|
||||
}
|
||||
if err := tt.Click("Columns"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ages := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s == "Age" {
|
||||
ages++
|
||||
// tabTo presses Tab until the element labeled label has the focus.
|
||||
func tabTo(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
for range 60 {
|
||||
if tt.Focused(label) {
|
||||
return
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if ages != 2 { // header and picker
|
||||
t.Fatalf("picker missing: %q", tt.Texts())
|
||||
t.Fatalf("Tab never reached %q", label)
|
||||
}
|
||||
|
||||
func TestDataTableColumnMenuKeyboard(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 420, 480)
|
||||
tabTo(t, tt, "Columns")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Sort Age descending")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Sort != (ui.SortOrder{Column: "age", Descending: true}) || h.sorts != 1 {
|
||||
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tabTo(t, tt, "Move Age left")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if fmt.Sprint(h.st.Layout.Order) != "[age name]" {
|
||||
t.Fatalf("order %v", h.st.Layout.Order)
|
||||
}
|
||||
tabTo(t, tt, "Make Age wider")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Layout.Widths["age"] != 96 {
|
||||
t.Fatalf("widths %v", h.st.Layout.Widths)
|
||||
}
|
||||
tabTo(t, tt, "Name")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !h.st.Hidden["name"] || tt.HasText("Censer") {
|
||||
t.Fatalf("name not hidden: %v %q", h.st.Hidden, tt.Texts())
|
||||
if !h.st.Hidden["name"] {
|
||||
t.Fatalf("name not hidden: %v", h.st.Hidden)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tabTo(t, tt, "Age")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if h.st.Hidden["age"] {
|
||||
t.Fatal("hid the last visible column")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Censer") || !tt.HasText("Age") {
|
||||
t.Fatalf("table did not follow the menu: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCustomCellAndControlled(t *testing.T) {
|
||||
@@ -335,3 +350,16 @@ func TestDataTableSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataTableSelectionColors(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
if err := tt.Click("Bell"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Bell")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -24,12 +24,13 @@ type DatePickerOptions struct {
|
||||
// Placeholder shows in the empty field; empty uses the built-in copy.
|
||||
Placeholder string
|
||||
Disabled bool
|
||||
// ReadOnly keeps the text focusable and copyable but blocks edits and the popup.
|
||||
ReadOnly bool
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// DatePicker is a typed date field with a calendar popup editing *value.
|
||||
// Typed text commits on Enter or when the field loses focus; invalid text stays and is reported.
|
||||
// DatePicker types or picks *value; invalid text is kept and reported.
|
||||
func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResult {
|
||||
datetimeCheckDate("DatePicker value", *value)
|
||||
datetimeCheckBounds("DatePicker", opts.Min, opts.Max)
|
||||
@@ -53,6 +54,13 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
|
||||
open := ui.Local(col, "date-picker-open", func() bool { return false })
|
||||
cursor := ui.Local(col, "date-picker-cursor", func() Date { return Date{} })
|
||||
wasFocused := ui.Local(col, "date-picker-focused", func() bool { return false })
|
||||
shownLayout := ui.Local(col, "date-picker-layout", func() string { return layout })
|
||||
if *shownLayout != layout {
|
||||
if *text == Date.Format(*value, *shownLayout) || value.IsZero() && *text == "" {
|
||||
*text = format(*value)
|
||||
}
|
||||
*shownLayout = layout
|
||||
}
|
||||
if *seen != *value {
|
||||
*seen, *text, *problem = *value, format(*value), ""
|
||||
}
|
||||
@@ -96,23 +104,26 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
|
||||
}
|
||||
var field *ui.Element
|
||||
col.Children(func() {
|
||||
field = datetimeField(c, ui.Row(c), *problem != "")
|
||||
field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
|
||||
field.Children(func() {
|
||||
in := ui.TextInputBase(c, text).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
|
||||
in := textInputEditor(c, field, text, opts.ReadOnly, ui.TextInputBase).Grow(1).Shrink(1).Placeholder(placeholder).FontFeatures("tnum")
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = placeholder
|
||||
}
|
||||
in.Label(label).Error(*problem)
|
||||
focused := in.Focused()
|
||||
if in.Submitted() || *wasFocused && !focused && *text != format(*value) {
|
||||
if !opts.ReadOnly && (in.Submitted() || *wasFocused && !focused && *text != format(*value)) {
|
||||
submit()
|
||||
}
|
||||
*wasFocused = focused
|
||||
if opts.ReadOnly {
|
||||
return
|
||||
}
|
||||
if opts.Clearable && !value.IsZero() && datetimeIconButton(c, "x", msg(c, "calendar.clear")).Clicked() {
|
||||
commit(Date{}, "clear")
|
||||
}
|
||||
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Clicked() {
|
||||
if datetimeIconButton(c, "calendar", msg(c, "date_picker.open")).Role(ui.RolePopUpButton).Clicked() {
|
||||
if *open {
|
||||
*open = false
|
||||
} else {
|
||||
@@ -120,10 +131,10 @@ func DatePicker(c *ui.Context, value *Date, opts DatePickerOptions) DateTimeResu
|
||||
}
|
||||
}
|
||||
})
|
||||
if !*open && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
|
||||
if !*open && !opts.ReadOnly && (field.Shortcut(0, ui.KeyDown) || field.Shortcut(ui.Alt, ui.KeyDown)) {
|
||||
openAt()
|
||||
}
|
||||
datetimePopup(c, field, open, func() {
|
||||
datetimePopup(c, field, open, opts.ReadOnly, func() {
|
||||
picked, _ := calendarMonth(c, cursor, calendarConfig{
|
||||
chosen: func(x Date) bool { return x == *value },
|
||||
enabled: enabled,
|
||||
|
||||
+11
-7
@@ -37,12 +37,13 @@ type DateRangePickerOptions struct {
|
||||
// Today is marked in the calendar; zero uses the system clock.
|
||||
Today Date
|
||||
Disabled bool
|
||||
// ReadOnly keeps the field focusable and copyable but blocks the popup.
|
||||
ReadOnly bool
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// DateRangePicker edits *value in a popup: pick a start, preview the span, pick an end, then confirm.
|
||||
// Clear empties the value; Escape, Cancel or a click outside discards the draft.
|
||||
// DateRangePicker picks start and end; Confirm writes *value.
|
||||
func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOptions) DateTimeResult {
|
||||
datetimeCheckDate("DateRangePicker Start", value.Start)
|
||||
datetimeCheckDate("DateRangePicker End", value.End)
|
||||
@@ -84,10 +85,10 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
|
||||
}
|
||||
var field *ui.Element
|
||||
col.Children(func() {
|
||||
field = datetimeField(c, ui.Row(c), false)
|
||||
field = datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
|
||||
field.Children(func() {
|
||||
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text)
|
||||
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
|
||||
t := datetimeTrigger(c, text, msg(c, "date_range_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
|
||||
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
|
||||
*open = !*open
|
||||
if *open {
|
||||
*draft, *problem, *hover = *value, "", Date{}
|
||||
@@ -99,9 +100,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
|
||||
}
|
||||
}
|
||||
})
|
||||
datetimePopup(c, field, open, func() {
|
||||
datetimePopup(c, field, open, opts.ReadOnly, func() {
|
||||
ui.Text(c, dateRangeSummary(c, *draft, layout)).TextColor(k.TextMuted).
|
||||
FontSize(theme.CaptionSize).FontFeatures("tnum").Role(ui.RoleStatus)
|
||||
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").Role(ui.RoleStatus)
|
||||
end := draft.End
|
||||
if end.IsZero() && !draft.Start.IsZero() {
|
||||
end = *hover
|
||||
@@ -144,6 +145,9 @@ func DateRangePicker(c *ui.Context, value *DateRange, opts DateRangePickerOption
|
||||
cancelled = true
|
||||
}
|
||||
*wasOpen = *open
|
||||
if !*open {
|
||||
*problem = ""
|
||||
}
|
||||
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
|
||||
}
|
||||
|
||||
|
||||
+37
-18
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
// DateTimePickerOptions configure a DateTimePicker.
|
||||
type DateTimePickerOptions struct {
|
||||
// Location is the time zone the user edits in. Required.
|
||||
// Location is the time zone the user edits in; required.
|
||||
Location *time.Location
|
||||
// Layout is the Go layout of the date part; empty uses 2006-01-02.
|
||||
Layout string
|
||||
// Min and Max bound the instant; zero leaves a side open.
|
||||
Min, Max time.Time
|
||||
Seconds bool
|
||||
@@ -23,12 +25,13 @@ type DateTimePickerOptions struct {
|
||||
// Today is marked in the calendar; zero uses the system clock.
|
||||
Today Date
|
||||
Disabled bool
|
||||
// ReadOnly keeps the field focusable and copyable but blocks the popup.
|
||||
ReadOnly bool
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// DateTimePicker edits *value as a date and a wall-clock time in opts.Location, confirmed together.
|
||||
// Times a DST change skips or repeats are reported, never shifted.
|
||||
// DateTimePicker edits *value as wall time in opts.Location.
|
||||
func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions) DateTimeResult {
|
||||
loc := opts.Location
|
||||
if loc == nil {
|
||||
@@ -56,7 +59,11 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
clock := ui.Local(col, "date-time-clock", func() TimeOfDay { return TimeOfDay{} })
|
||||
cursor := ui.Local(col, "date-time-cursor", func() Date { return Date{} })
|
||||
problem := ui.Local(col, "date-time-error", func() string { return "" })
|
||||
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), cfg) }
|
||||
typedBad := ui.Local(col, "date-time-typed-bad", func() bool { return false })
|
||||
if opts.Layout == "" {
|
||||
opts.Layout = "2006-01-02"
|
||||
}
|
||||
format := func(t time.Time) string { return dateTimeText(c, t.In(loc), opts.Layout, cfg) }
|
||||
changed, cancelled, done := false, false, false
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
@@ -67,10 +74,10 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
text = format(*value)
|
||||
}
|
||||
col.Children(func() {
|
||||
field := datetimeField(c, ui.Row(c), false)
|
||||
field := datetimeField(c, ui.Row(c), *problem != "", opts.ReadOnly)
|
||||
field.Children(func() {
|
||||
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text)
|
||||
if t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown) {
|
||||
t := datetimeTrigger(c, text, msg(c, "date_time_picker.placeholder"), "calendar").Label(label).Description(text).Error(*problem)
|
||||
if !opts.ReadOnly && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) {
|
||||
*open = !*open
|
||||
if *open {
|
||||
*day, *clock, *problem = today, TimeOfDay{}, ""
|
||||
@@ -83,7 +90,7 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
}
|
||||
}
|
||||
})
|
||||
datetimePopup(c, field, open, func() {
|
||||
datetimePopup(c, field, open, opts.ReadOnly, func() {
|
||||
picked, _ := calendarMonth(c, cursor, calendarConfig{
|
||||
chosen: func(x Date) bool { return x == *day },
|
||||
enabled: calendarEnabled(lo, hi, nil),
|
||||
@@ -94,11 +101,17 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
if !picked.IsZero() {
|
||||
*day, *problem = picked, ""
|
||||
}
|
||||
bad := ""
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
if _, edited := timeEditor(c, clock, cfg); edited {
|
||||
*problem = ""
|
||||
cfg.problem = *problem
|
||||
_, edited, b := timeEditor(c, clock, cfg)
|
||||
switch {
|
||||
case b != "":
|
||||
*problem, *typedBad, bad = b, true, b
|
||||
case edited || *typedBad:
|
||||
*problem, *typedBad = "", false
|
||||
}
|
||||
ui.Text(c, loc.String()).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, loc.String()).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
datetimeError(c, *problem)
|
||||
datetimeFooter(c, func() {
|
||||
@@ -107,6 +120,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
}
|
||||
if Button(c, msg(c, "calendar.confirm"), ButtonOptions{}).Clicked() {
|
||||
at, p := dateTimeConfirm(c, *day, *clock, loc, opts, cfg)
|
||||
if bad != "" {
|
||||
p = bad
|
||||
}
|
||||
*problem = p
|
||||
if p == "" {
|
||||
if !at.Equal(*value) {
|
||||
@@ -124,6 +140,9 @@ func DateTimePicker(c *ui.Context, value *time.Time, opts DateTimePickerOptions)
|
||||
cancelled = true
|
||||
}
|
||||
*wasOpen = *open
|
||||
if !*open {
|
||||
*problem = ""
|
||||
}
|
||||
return DateTimeResult{Element: col, changed: changed, cancelled: cancelled}
|
||||
}
|
||||
|
||||
@@ -132,7 +151,7 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt
|
||||
if p := timePickerProblem(c, t, cfg); p != "" {
|
||||
return time.Time{}, p
|
||||
}
|
||||
wall := fmt.Sprintf("%v %s", d, timeText(c, t, cfg))
|
||||
wall := d.Format(opts.Layout) + " " + timeText(c, t, cfg)
|
||||
at, n := dateTimeResolve(d, t, loc)
|
||||
switch {
|
||||
case n == 0:
|
||||
@@ -142,14 +161,14 @@ func dateTimeConfirm(c *ui.Context, d Date, t TimeOfDay, loc *time.Location, opt
|
||||
slog.Debug("mujica: date_time_picker ambiguous", "wall", wall, "zone", loc.String())
|
||||
return at, fmt.Sprintf(msg(c, "date_time_picker.ambiguous"), wall, loc.String())
|
||||
case !opts.Min.IsZero() && at.Before(opts.Min):
|
||||
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), cfg))
|
||||
return at, fmt.Sprintf(msg(c, "calendar.before_min"), dateTimeText(c, opts.Min.In(loc), opts.Layout, cfg))
|
||||
case !opts.Max.IsZero() && at.After(opts.Max):
|
||||
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), cfg))
|
||||
return at, fmt.Sprintf(msg(c, "calendar.after_max"), dateTimeText(c, opts.Max.In(loc), opts.Layout, cfg))
|
||||
}
|
||||
return at, ""
|
||||
}
|
||||
|
||||
// dateTimeResolve finds the instants showing wall time d t in loc: none in a DST gap, two in an overlap.
|
||||
// dateTimeResolve counts instants for a wall time: 0 gap, 2 overlap.
|
||||
func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
|
||||
wall := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, time.UTC)
|
||||
guess := time.Date(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, 0, loc)
|
||||
@@ -175,7 +194,7 @@ func dateTimeResolve(d Date, t TimeOfDay, loc *time.Location) (time.Time, int) {
|
||||
return hits[0], len(hits)
|
||||
}
|
||||
|
||||
// dateTimeText formats a wall time with the picker's clock style.
|
||||
func dateTimeText(c *ui.Context, w time.Time, cfg timeConfig) string {
|
||||
return DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
|
||||
// dateTimeText formats a wall time with the picker's date layout and clock style.
|
||||
func dateTimeText(c *ui.Context, w time.Time, layout string, cfg timeConfig) string {
|
||||
return w.Format(layout) + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), w.Second()}, cfg) + " " + w.Format("MST")
|
||||
}
|
||||
+19
-10
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -11,8 +12,7 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Date is a calendar day with no time of day and no location.
|
||||
// The zero value means no date.
|
||||
// Date is a calendar day without clock or location; zero means none.
|
||||
type Date struct {
|
||||
Year int
|
||||
Month time.Month
|
||||
@@ -136,28 +136,33 @@ func datetimeIconButton(c *ui.Context, icon, label string) *ui.Element {
|
||||
}
|
||||
|
||||
// datetimeField styles row as a single-line field: solid fill and fine border.
|
||||
func datetimeField(c *ui.Context, row *ui.Element, invalid bool) *ui.Element {
|
||||
func datetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
border := k.ControlBorder
|
||||
bg, border := k.Surface, k.ControlBorder
|
||||
switch {
|
||||
case invalid:
|
||||
border = k.Danger
|
||||
case readOnly:
|
||||
bg, border = k.Background, k.Border
|
||||
case row.FocusWithin():
|
||||
border = k.Focus
|
||||
case row.Hovered():
|
||||
border = k.Text
|
||||
}
|
||||
return row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()).PaddingX(d.Unit()*3).Gap(d.Unit()).
|
||||
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border)
|
||||
AlignItems(ui.Center).Radius(theme.ControlRadius).Background(bg).Border(theme.BorderWidth, border)
|
||||
}
|
||||
|
||||
// datetimeTrigger is the button inside a field that shows text and opens a popup.
|
||||
// datetimeTrigger is the popup button inside a field; Cmd+C copies its text.
|
||||
func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element {
|
||||
k := Tokens(c)
|
||||
b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2).
|
||||
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius)
|
||||
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RolePopUpButton)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if b.Shortcut(ui.Cmd, ui.KeyC) && text != "" {
|
||||
c.WriteClipboard(text)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(k.TextMuted)
|
||||
if text == "" {
|
||||
@@ -169,10 +174,14 @@ func datetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element
|
||||
return b
|
||||
}
|
||||
|
||||
// datetimePopup shows fn in a popup panel below anchor while *open is set.
|
||||
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, fn func()) *ui.Element {
|
||||
// datetimePopup shows fn below anchor while *open; a disabled or read-only anchor closes it.
|
||||
func datetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, fn func()) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
if *open && (readOnly || anchor.IsDisabled()) {
|
||||
slog.Debug("mujica: datetime popup closed by state", "readOnly", readOnly)
|
||||
*open = false
|
||||
}
|
||||
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
born := ui.Local(panel, "datetime-born", func() bool { return true })
|
||||
if *born {
|
||||
@@ -192,7 +201,7 @@ func datetimeError(c *ui.Context, text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
ui.Text(c, text).FontSize(theme.CaptionSize).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
|
||||
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
|
||||
// datetimeFooter lays out popup actions at the end of a row.
|
||||
|
||||
+22
-10
@@ -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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// datePickerLive builds a DatePicker whose options a test changes between frames.
|
||||
func datePickerLive(value *Date, opts *DatePickerOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
o := *opts
|
||||
o.Today, o.Label = calendarToday, "Due"
|
||||
if DatePicker(c, value, o).Changed() {
|
||||
*changes++
|
||||
}
|
||||
})
|
||||
}, 420, 460)
|
||||
}
|
||||
|
||||
func TestDatePickerDisablingClosesPopup(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 5}, 0
|
||||
opts := DatePickerOptions{}
|
||||
tt := datePickerLive(&v, &opts, &n)
|
||||
_ = tt.Click("Open calendar")
|
||||
day := datetimeTestLabel(oct(5))
|
||||
if !tt.Focused(day) {
|
||||
t.Fatal("setup: the popup should focus the chosen day")
|
||||
}
|
||||
opts.Disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click(datetimeTestLabel(oct(20)))
|
||||
if v != oct(5) || n != 0 {
|
||||
t.Fatalf("disabled picker committed %v (%d events)", v, n)
|
||||
}
|
||||
if tt.HasText("October 2026") || tt.Focused(datetimeTestLabel(oct(6))) || tt.Focused("Due") {
|
||||
t.Fatal("the popup should close and nothing in the disabled picker keep focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatePickerDisabledBlocksInput(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 5}, 0
|
||||
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", Disabled: true}, &n)
|
||||
_ = tt.Click("Due")
|
||||
tt.Type("2027-01-01")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
_ = tt.Click("Open calendar")
|
||||
if v != oct(5) || n != 0 || tt.Focused("Due") || tt.HasText("October 2026") {
|
||||
t.Fatalf("disabled picker took input: %v (%d)", v, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimePickerKeepsInvalidHour(t *testing.T) {
|
||||
v, n := TimeOfDay{9, 30, 0}, 0
|
||||
tt := timePickerTester(&v, TimePickerOptions{}, &n)
|
||||
_ = tt.Click("Alarm: Hours")
|
||||
tt.Key(0, ui.Key2)
|
||||
tt.Key(0, ui.Key9)
|
||||
if v != (TimeOfDay{2, 30, 0}) || n != 1 {
|
||||
t.Fatalf("first digit should apply: %v", v)
|
||||
}
|
||||
if !tt.HasText("29") || !tt.HasText("Hours must be from 0 to 23.") || !tt.Focused("Alarm: Hours") {
|
||||
t.Fatalf("29 should stay shown with an error: %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.Key1)
|
||||
tt.Key(0, ui.Key5)
|
||||
if v != (TimeOfDay{15, 30, 0}) || n != 3 || tt.HasText("Hours must be") {
|
||||
t.Fatalf("fixing the hour: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeSegmentDescription(t *testing.T) {
|
||||
var got []string
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
got = []string{timeSegmentDescription(c, timeHourPart, " 9", 1, 12), timeSegmentDescription(c, timePeriodPart, "PM", 0, 59)}
|
||||
}, 100, 100)
|
||||
if got[0] != "9, from 1 to 12" || got[1] != "PM" {
|
||||
t.Fatalf("descriptions %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimePickerSegmentFocusRing(t *testing.T) {
|
||||
v := TimeOfDay{9, 30, 0}
|
||||
tt := timePickerTester(&v, TimePickerOptions{}, new(int))
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
r, ok := tt.Find("Alarm: Hours")
|
||||
if !ok || !tt.Focused("Alarm: Hours") {
|
||||
t.Fatal("setup: hours not focused")
|
||||
}
|
||||
want := theme.Light().Focus
|
||||
img := tt.Image()
|
||||
found := false
|
||||
for dy := float32(-4); dy <= -2 && !found; dy++ {
|
||||
c := img.RGBAAt(int(r.X+r.W/2), int(r.Y+dy))
|
||||
found = datetimeNear(c, want)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no focus ring above the focused segment")
|
||||
}
|
||||
}
|
||||
|
||||
func datetimeNear(c color.RGBA, w ui.Color) bool {
|
||||
d := func(a, b uint8) int {
|
||||
if a > b {
|
||||
return int(a - b)
|
||||
}
|
||||
return int(b - a)
|
||||
}
|
||||
return d(c.R, w.R)+d(c.G, w.G)+d(c.B, w.B) < 60
|
||||
}
|
||||
|
||||
func TestDatePickerLayoutChangeReformats(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 5}, 0
|
||||
opts := DatePickerOptions{}
|
||||
tt := datePickerLive(&v, &opts, &n)
|
||||
opts.Layout = "02/01/2006"
|
||||
tt.Frame()
|
||||
_ = tt.Click("Due")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != oct(5) || n != 0 || tt.HasText("Enter a date like") {
|
||||
t.Fatalf("committed text kept the old layout: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Due")
|
||||
tt.Command("selectAll")
|
||||
tt.Type("12/10")
|
||||
opts.Layout = "2006-01-02"
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("Enter a date like 2026-10-05.") || v != oct(5) {
|
||||
t.Fatal("a draft being edited should be kept across a layout change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYearPickerHomeEndStayOnPage(t *testing.T) {
|
||||
v := 2026
|
||||
tt := yearPickerTester(&v, YearPickerOptions{}, new(int))
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if !tt.HasText("2020 – 2029") || !tt.Focused("2020") {
|
||||
t.Fatalf("Home left the page: %v", tt.Texts()[:4])
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("2020 – 2029") || !tt.Focused("2029") {
|
||||
t.Fatal("End left the page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatePickerReadOnly(t *testing.T) {
|
||||
v, n := Date{2026, time.October, 5}, 0
|
||||
tt := datePickerTester(&v, DatePickerOptions{Label: "Due", ReadOnly: true, Clearable: true}, &n)
|
||||
_ = tt.Click("Due")
|
||||
if !tt.Focused("Due") {
|
||||
t.Fatal("read-only field should take focus")
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if tt.Clipboard() != "2026-10-05" {
|
||||
t.Fatalf("copied %q", tt.Clipboard())
|
||||
}
|
||||
tt.Type("2027-01-01")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if v != oct(5) || n != 0 || tt.HasText("October 2026") || tt.HasText("Open calendar") || tt.HasText("Clear") {
|
||||
t.Fatalf("read-only picker changed: %v %v", v, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if tt.Clipboard() != "2026-10-05" {
|
||||
t.Fatalf("typed text leaked into the field: %q", tt.Clipboard())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimePickerReadOnly(t *testing.T) {
|
||||
v, n := TimeOfDay{9, 30, 0}, 0
|
||||
tt := timePickerTester(&v, TimePickerOptions{ReadOnly: true}, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.Key1)
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if !tt.Focused("Alarm: Hours") || v != (TimeOfDay{9, 30, 0}) || n != 0 || tt.HasText("Choose time") {
|
||||
t.Fatalf("read-only time changed: %v", v)
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyC)
|
||||
if tt.Clipboard() != "09:30" {
|
||||
t.Fatalf("copied %q", tt.Clipboard())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthPickerReadOnly(t *testing.T) {
|
||||
v, n := YearMonth{2026, time.May}, 0
|
||||
tt := monthPickerTester(&v, MonthPickerOptions{ReadOnly: true, Clearable: true}, &n)
|
||||
_ = tt.Click("Billing month")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if tt.HasText("January") || tt.HasText("Clear") || !tt.Focused("Billing month") {
|
||||
t.Fatalf("read-only month opened: %v", tt.Texts())
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyC)
|
||||
if tt.Clipboard() != "May 2026" || v != (YearMonth{2026, time.May}) || n != 0 {
|
||||
t.Fatalf("copied %q, value %v", tt.Clipboard(), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimePickerLayout(t *testing.T) {
|
||||
v := time.Time{}
|
||||
n, x := 0, 0
|
||||
maxT := time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)
|
||||
tt := dateTimeTester(t, &v, DateTimePickerOptions{Location: time.UTC, Max: maxT, Layout: "02/01/2006"}, &n, &x)
|
||||
_ = tt.Click("Meeting")
|
||||
_ = tt.Click(datetimeTestLabel(oct(10)))
|
||||
_ = tt.Click("Time: Hours")
|
||||
tt.Key(0, ui.Key1)
|
||||
tt.Key(0, ui.Key3)
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 0 || !tt.HasText("Choose 10/10/2026 12:00 UTC or earlier.") {
|
||||
t.Fatalf("error ignores the layout: %v", tt.Texts())
|
||||
}
|
||||
_ = tt.Click(datetimeTestLabel(oct(9)))
|
||||
_ = tt.Click("Confirm")
|
||||
if n != 1 || !tt.HasText("09/10/2026 13:00 UTC") {
|
||||
t.Fatalf("display ignores the layout: %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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`,不会显示占位。
|
||||
@@ -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 标签不支持富文本,无法单独着色。
|
||||
- 必填标记“*”只作视觉显示,辅助技术通过说明“必填”获知。
|
||||
@@ -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)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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()` 关联。
|
||||
@@ -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 统一降低不透明度,不响应指针与键盘。
|
||||
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
|
||||
|
||||
@@ -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`)。组标题本身不能被选中。展开状态按完整父路径保存,同名组互不影响。
|
||||
@@ -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`。
|
||||
@@ -63,3 +63,4 @@ if id, ok := res.Chosen(); ok {
|
||||
|
||||
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
|
||||
- 搜索是子串匹配,不做模糊匹配或排序打分。
|
||||
- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
|
||||
@@ -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` 等滚动位置读取接口。
|
||||
@@ -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)。
|
||||
@@ -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` 回到界面线程。
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -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 序列。
|
||||
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
|
||||
## 键盘操作
|
||||
|
||||
- Enter/Space:展开或关闭。
|
||||
- 关闭后内容不在 Tab 序列中。
|
||||
- 关闭(包括关闭动画期间)的内容不在 Tab 序列中。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -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”文字与阈值刻度,并把级别写入无障碍名称。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -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` | 标记颜色。 |
|
||||
|
||||
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
|
||||
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
|
||||
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
|
||||
|
||||
负值 panic。
|
||||
负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。
|
||||
|
||||
## 状态
|
||||
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,7 +39,7 @@ var dataPages = []page{
|
||||
// dataSection lays out a titled block of a demo.
|
||||
func dataSection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(12).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size))
|
||||
fn()
|
||||
})
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func dataStates(c *ui.Context, fn func(state func(name string, body func()))) {
|
||||
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
|
||||
fn(func(name string, body func()) {
|
||||
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
body()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,7 +134,6 @@ func dataTableDemo(c *ui.Context, log func(string)) {
|
||||
ui.Checkbox(c, &s.multiple, "Multiple")
|
||||
ui.Select(c, &s.status, dataStatuses)
|
||||
ui.TextInput(c, &s.table.Filter).Width(180).Placeholder("Filter")
|
||||
mujica.DataTableColumnPicker(c, &s.table, cols)
|
||||
})
|
||||
v := mujica.DataTable(c, &s.table, rows, mujica.DataTableOptions[dataRelic, int]{
|
||||
Columns: cols, Key: func(r dataRelic) int { return r.ID }, Multiple: s.multiple, Status: st,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ var datetimePages = []page{
|
||||
func dtSection(c *ui.Context, title string, fn func()) {
|
||||
k := mujica.Tokens(c)
|
||||
ui.Column(c).Gap(8).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
fn()
|
||||
})
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func dtStates(c *ui.Context, fn func(state func(name string, body func()))) {
|
||||
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
|
||||
fn(func(name string, body func()) {
|
||||
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
body()
|
||||
})
|
||||
})
|
||||
@@ -128,6 +128,9 @@ func dtDatePickerDemo(c *ui.Context, log func(string)) {
|
||||
log("DatePicker (bounded) changed: " + s.b.String())
|
||||
}
|
||||
})
|
||||
state("Read-only", func() {
|
||||
mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Signed on", Layout: layout, ReadOnly: true})
|
||||
})
|
||||
state("Disabled", func() {
|
||||
mujica.DatePicker(c, &s.d, mujica.DatePickerOptions{Label: "Locked", Layout: layout, Disabled: true})
|
||||
})
|
||||
@@ -202,6 +205,11 @@ func dtTimePickerDemo(c *ui.Context, log func(string)) {
|
||||
log("TimePicker (bounded) changed: " + s.b.String())
|
||||
}
|
||||
})
|
||||
state("Read-only", func() {
|
||||
o := base
|
||||
o.Label, o.ReadOnly = "Logged at", true
|
||||
mujica.TimePicker(c, &s.a, o)
|
||||
})
|
||||
state("Disabled", func() {
|
||||
o := base
|
||||
o.Label, o.Disabled = "Locked", true
|
||||
@@ -261,7 +269,7 @@ func dtDateTimeDemo(c *ui.Context, log func(string)) {
|
||||
}
|
||||
k := mujica.Tokens(c)
|
||||
ui.Text(c, "Try 2026-03-08 02:30 in America/New_York: the picker reports the DST gap.").
|
||||
FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -288,6 +296,9 @@ func dtMonthDemo(c *ui.Context, log func(string)) {
|
||||
log("MonthPicker changed: " + s.v.String())
|
||||
}
|
||||
})
|
||||
state("Read-only", func() {
|
||||
mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Closed month", ReadOnly: true})
|
||||
})
|
||||
state("Disabled", func() {
|
||||
mujica.MonthPicker(c, &s.v, mujica.MonthPickerOptions{Label: "Locked", Disabled: true})
|
||||
})
|
||||
@@ -311,6 +322,9 @@ func dtYearDemo(c *ui.Context, log func(string)) {
|
||||
log(fmt.Sprintf("YearPicker changed: %d", s.v))
|
||||
}
|
||||
})
|
||||
state("Read-only", func() {
|
||||
mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Founded", ReadOnly: true})
|
||||
})
|
||||
state("Disabled", func() {
|
||||
mujica.YearPicker(c, &s.v, mujica.YearPickerOptions{Label: "Locked", Disabled: true})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -172,116 +171,6 @@ const iconCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
|
||||
})`
|
||||
|
||||
type imageState struct {
|
||||
crop, loading, fail, empty bool
|
||||
err error
|
||||
}
|
||||
|
||||
var displayPicture = func() *ui.SVG {
|
||||
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
|
||||
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
|
||||
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
|
||||
}()
|
||||
|
||||
func imageDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
|
||||
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
|
||||
displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
|
||||
displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
|
||||
if ui.Checkbox(c, &s.fail, "Error").Changed() {
|
||||
log(fmt.Sprintf("fail = %v", s.fail))
|
||||
s.err = nil
|
||||
if s.fail {
|
||||
s.err = errors.New("connection reset")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var src ui.ImageSource = displayPicture
|
||||
if s.empty {
|
||||
src = nil
|
||||
}
|
||||
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
|
||||
if v.Retry {
|
||||
s.fail, s.err = false, nil
|
||||
log("retry")
|
||||
}
|
||||
})
|
||||
displaySection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
|
||||
log("retry (static example)")
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, imageCode, log)
|
||||
}
|
||||
|
||||
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
|
||||
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
|
||||
Loading: loading, Err: loadErr,
|
||||
})
|
||||
if img.Retry {
|
||||
loading, loadErr = true, nil // start loading again here
|
||||
}`
|
||||
|
||||
type avatarState struct {
|
||||
name string
|
||||
size float64
|
||||
img bool
|
||||
}
|
||||
|
||||
var displayBitmap = func() *ui.Bitmap {
|
||||
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
|
||||
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
|
||||
}, 64, 64, 1))
|
||||
}()
|
||||
|
||||
func avatarDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
|
||||
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.name).Width(200).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
|
||||
displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
|
||||
displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var img *ui.Bitmap
|
||||
if s.img {
|
||||
img = displayBitmap
|
||||
}
|
||||
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
|
||||
})
|
||||
displaySection(c, "Fallbacks", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
|
||||
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
|
||||
})
|
||||
})
|
||||
displayCode(c, avatarCode, log)
|
||||
}
|
||||
|
||||
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})`
|
||||
|
||||
var displayPeople = []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
|
||||
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
|
||||
@@ -444,6 +333,7 @@ func linkDemo(c *ui.Context, log func(string)) {
|
||||
}
|
||||
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.external = ""
|
||||
log("open failure cleared")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
type imageState struct {
|
||||
crop, loading, fail, empty bool
|
||||
err error
|
||||
}
|
||||
|
||||
var displayPicture = func() *ui.SVG {
|
||||
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
|
||||
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
|
||||
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
|
||||
}()
|
||||
|
||||
func imageDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
|
||||
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
|
||||
displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
|
||||
displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
|
||||
if ui.Checkbox(c, &s.fail, "Error").Changed() {
|
||||
log(fmt.Sprintf("fail = %v", s.fail))
|
||||
s.err = nil
|
||||
if s.fail {
|
||||
s.err = errors.New("connection reset")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var src ui.ImageSource = displayPicture
|
||||
if s.empty {
|
||||
src = nil
|
||||
}
|
||||
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
|
||||
if v.Retry {
|
||||
s.fail, s.err = false, nil
|
||||
log("retry")
|
||||
}
|
||||
})
|
||||
displaySection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
|
||||
log("retry (static example)")
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, imageCode, log)
|
||||
}
|
||||
|
||||
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
|
||||
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
|
||||
Loading: loading, Err: loadErr,
|
||||
})
|
||||
if img.Retry {
|
||||
loading, loadErr = true, nil // start loading again here
|
||||
}`
|
||||
|
||||
type avatarState struct {
|
||||
name string
|
||||
size float64
|
||||
img bool
|
||||
}
|
||||
|
||||
var displayBitmap = func() *ui.Bitmap {
|
||||
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
|
||||
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
|
||||
}, 64, 64, 1))
|
||||
}()
|
||||
|
||||
func avatarDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
|
||||
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.name).Width(200).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
|
||||
displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
|
||||
displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var img *ui.Bitmap
|
||||
if s.img {
|
||||
img = displayBitmap
|
||||
}
|
||||
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
|
||||
})
|
||||
displaySection(c, "Fallbacks", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
|
||||
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
|
||||
})
|
||||
})
|
||||
displayCode(c, avatarCode, log)
|
||||
}
|
||||
|
||||
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})`
|
||||
@@ -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)`,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestFeedbackTextFollowsTextScale(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}, {ID: "u", Title: "Unread one"}}
|
||||
var empty, result float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
Progress(c, 0.5, ProgressOptions{ShowValue: true})
|
||||
empty = EmptyState(c, EmptyStateOptions{Title: "Empty title", Description: "Short", NoIcon: true}).Element.Bounds().H
|
||||
result = Result(c, ResultOptions{Title: "Result title"}).Element.Bounds().H
|
||||
StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
}, 900, 2400)
|
||||
tt.Frame()
|
||||
empty1, result1 := empty, result
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame() // Bounds report the previous layout
|
||||
for text, min := range map[string]float32{
|
||||
"50%": theme.CaptionSize * 2,
|
||||
"Healthy": theme.BodySize * 2,
|
||||
"1 unread": theme.CaptionSize * 2,
|
||||
} {
|
||||
if r, ok := tt.Find(text); !ok || r.H < min {
|
||||
t.Errorf("%q at 200%%: %v %v, want height >= %v", text, r, ok, min)
|
||||
}
|
||||
}
|
||||
// Titles are found only through their labeled groups: the groups must grow by the titles too.
|
||||
if grow := empty - empty1; grow < theme.BodySize*theme.LineHeight+theme.H3Size {
|
||||
t.Errorf("EmptyState grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
if grow := result - result1; grow < theme.H2Size {
|
||||
t.Errorf("Result grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackRejectsInvalidInput(t *testing.T) {
|
||||
nan, inf := math.NaN(), math.Inf(1)
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"progress shape": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Shape: 7}) },
|
||||
"progress NaN size": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Size: float32(nan)}) },
|
||||
"meter infinite range": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: inf}) },
|
||||
"meter NaN min": func(c *ui.Context) { Meter(c, 1, MeterOptions{Min: nan, Max: 2}) },
|
||||
"meter NaN threshold": func(c *ui.Context) {
|
||||
Meter(c, 1, MeterOptions{Max: 2, Levels: &ui.MeterLevels{Warning: nan, Critical: 2}})
|
||||
},
|
||||
"skeleton shape": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Shape: 5}) },
|
||||
"skeleton NaN width": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Width: float32(nan)}) },
|
||||
"spinner negative size": func(c *ui.Context) { Spinner(c, SpinnerOptions{Size: -4}) },
|
||||
"status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) },
|
||||
"alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) },
|
||||
"toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationTitleOnlyHitHeight(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
}, 600, 400)
|
||||
if r, ok := tt.Find("Backup complete"); !ok || r.H < theme.MinHitSize {
|
||||
t.Fatalf("title-only notification hit box %v %v", r, ok)
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -65,8 +65,7 @@ type fileDropZoneState struct {
|
||||
rejected []fileDropZoneReject
|
||||
}
|
||||
|
||||
// FileDropZone takes files dropped from the system or chosen with its Browse button,
|
||||
// checks type, count and size, and lists them in *files. It never reads or uploads them.
|
||||
// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them.
|
||||
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
|
||||
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
|
||||
panic("mujica: FileDropZone limits must not be negative")
|
||||
@@ -172,7 +171,7 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions)
|
||||
zone.Children(func() {
|
||||
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
|
||||
ui.Text(c, hint).TextAlign(ui.Center)
|
||||
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).TextAlign(ui.Center)
|
||||
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
|
||||
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
|
||||
if b.Clicked() {
|
||||
ch := make(chan filePickerResult, 1)
|
||||
@@ -221,7 +220,7 @@ func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile
|
||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
|
||||
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
||||
ui.Text(c, detail).TextColor(color).FontSize(theme.CaptionSize).SingleLine().Grow(1)
|
||||
ui.Text(c, detail).TextColor(color).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Grow(1)
|
||||
if remove != nil {
|
||||
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
|
||||
|
||||
+4
-5
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// FormState is a form's validation and submit state; keep it beside the form's values.
|
||||
// Its methods run on the UI thread: deliver async results through Window.Update.
|
||||
// FormState is a form's check and submit state; use it on the UI thread only.
|
||||
type FormState struct {
|
||||
fields map[string]*formFieldState
|
||||
order []string
|
||||
@@ -22,6 +21,7 @@ type FormState struct {
|
||||
requested bool
|
||||
focus string
|
||||
formError string
|
||||
generation int
|
||||
}
|
||||
|
||||
type formFieldState struct {
|
||||
@@ -33,6 +33,7 @@ type formFieldState struct {
|
||||
asyncFor uint64
|
||||
asyncErr string
|
||||
pending bool
|
||||
hasAsync bool
|
||||
check func() string
|
||||
startAsync func()
|
||||
}
|
||||
@@ -45,8 +46,7 @@ func (s *FormState) Version(name string) uint64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resolve delivers the async check of name's value at version; "" means valid.
|
||||
// It reports false and drops the result when the value has changed since.
|
||||
// Resolve delivers name's async result for version, "" if valid; stale versions return false.
|
||||
func (s *FormState) Resolve(name string, version uint64, err string) bool {
|
||||
f := s.fields[name]
|
||||
if f == nil || f.version != version {
|
||||
@@ -98,8 +98,9 @@ type FormOptions struct {
|
||||
|
||||
// FormScope registers a Form's fields with FormItem.
|
||||
type FormScope struct {
|
||||
c *ui.Context
|
||||
st *FormState
|
||||
c *ui.Context
|
||||
st *FormState
|
||||
horizontal bool
|
||||
}
|
||||
|
||||
// FormParts are the parts of a Form.
|
||||
@@ -127,8 +128,7 @@ type FormItemOptions[T any] struct {
|
||||
Async func(v T, version uint64)
|
||||
}
|
||||
|
||||
// Form lays out the fields that fields registers, validates them on blur and submit,
|
||||
// and shows submit and reset buttons. It never stores or sends the values itself.
|
||||
// Form lays out and checks its fields and shows submit and reset buttons.
|
||||
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
|
||||
if st.fields == nil {
|
||||
st.fields = map[string]*formFieldState{}
|
||||
@@ -139,13 +139,16 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
||||
parts := FormParts{}
|
||||
col := ui.Column(c).Gap(d.Unit() * 5)
|
||||
parts.Element = col
|
||||
scope := &FormScope{c: c, st: st}
|
||||
scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal}
|
||||
col.Children(func() {
|
||||
if opts.Horizontal {
|
||||
ui.Form(c, func() { fields(scope) })
|
||||
} else {
|
||||
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
|
||||
}
|
||||
// A new key after Reset drops the controls' drafts and errors.
|
||||
ui.Box(c).Key(st.generation).Children(func() {
|
||||
if opts.Horizontal {
|
||||
formFieldGrid(c, func() { fields(scope) })
|
||||
} else {
|
||||
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
|
||||
}
|
||||
})
|
||||
if st.formError != "" {
|
||||
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
@@ -198,7 +201,12 @@ func formSettle(c *ui.Context, st *FormState) bool {
|
||||
if st.Error(name) != "" && first == "" {
|
||||
first = name
|
||||
}
|
||||
pending = pending || st.fields[name].pending
|
||||
f := st.fields[name]
|
||||
// A value edited since its check owes a new one.
|
||||
if f.hasAsync && f.err == "" && f.asyncFor != f.version {
|
||||
f.startAsync()
|
||||
pending = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case first != "":
|
||||
@@ -221,12 +229,12 @@ func formReset(st *FormState) {
|
||||
r()
|
||||
}
|
||||
st.fields = map[string]*formFieldState{}
|
||||
st.generation++
|
||||
st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", ""
|
||||
slog.Debug("mujica: form reset")
|
||||
}
|
||||
|
||||
// FormItem registers field name, labels the control that control returns, and shows its error.
|
||||
// control must return the element to focus when the field has the first error.
|
||||
// FormItem registers field name around the focusable element control returns.
|
||||
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
||||
c, st := f.c, f.st
|
||||
if name == "" || opts.Label == "" {
|
||||
@@ -243,6 +251,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
||||
fs = &formFieldState{snapshot: snap, version: st.seq}
|
||||
st.fields[name] = fs
|
||||
}
|
||||
fs.hasAsync = opts.Async != nil
|
||||
fs.check = func() string {
|
||||
if opts.Required && formEmpty(*value) {
|
||||
return msg(c, "form.required")
|
||||
@@ -273,12 +282,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
||||
desc = msg(c, "form.checking")
|
||||
}
|
||||
var ctrl *ui.Element
|
||||
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, func() {
|
||||
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element {
|
||||
ctrl = control()
|
||||
return ctrl
|
||||
})
|
||||
if ctrl == nil {
|
||||
panic("mujica: FormItem " + name + ": control returned nil")
|
||||
}
|
||||
focused := ctrl.FocusWithin()
|
||||
if fs.focused && !focused {
|
||||
fs.touched = true
|
||||
|
||||
+63
-15
@@ -2,6 +2,8 @@ package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// FormFieldOptions configure a FormField.
|
||||
@@ -15,24 +17,70 @@ type FormFieldOptions struct {
|
||||
Horizontal bool
|
||||
}
|
||||
|
||||
// FormField labels the control that control builds and adds help and error text.
|
||||
// The label names the control and a click on it focuses the control.
|
||||
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
|
||||
// FormField labels the element control returns and shows help and error text for it.
|
||||
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element {
|
||||
if !opts.Horizontal {
|
||||
return formFieldBuild(c, label, opts, false, control)
|
||||
}
|
||||
var f *ui.Element
|
||||
formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) })
|
||||
return f
|
||||
}
|
||||
|
||||
// formFieldGrid holds fields whose labels sit in a column beside their controls.
|
||||
func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
|
||||
d := Density(c)
|
||||
return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4).
|
||||
AlignItems(ui.Start).Children(fields)
|
||||
}
|
||||
|
||||
// formFieldBuild builds the label and the control column; beside makes them two grid cells.
|
||||
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element {
|
||||
if label == "" {
|
||||
panic("mujica: FormField needs a label")
|
||||
}
|
||||
if opts.Horizontal {
|
||||
var f *ui.Element
|
||||
ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) })
|
||||
return f
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var head, content, ctrl *ui.Element
|
||||
body := func() {
|
||||
head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center)
|
||||
if beside {
|
||||
head.MinHeight(d.ControlHeight()).Justify(ui.End)
|
||||
}
|
||||
var text *ui.Element
|
||||
head.Children(func() {
|
||||
text = ui.Text(c, label).FontWeight(500).SingleLine()
|
||||
if opts.Required {
|
||||
ui.Text(c, "*").TextColor(k.Danger).Role(ui.RoleNone)
|
||||
}
|
||||
})
|
||||
clicked := head.Clicked()
|
||||
content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0)
|
||||
content.Children(func() {
|
||||
ctrl = control()
|
||||
if ctrl == nil {
|
||||
panic("mujica: FormField " + label + ": control returned nil")
|
||||
}
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||
}
|
||||
if opts.Error != "" {
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||
}
|
||||
})
|
||||
ctrl.Label(label).Description(opts.Description).Error(opts.Error)
|
||||
if opts.Required {
|
||||
ctrl.Description(msg(c, "form.required"))
|
||||
}
|
||||
if ctrl.IsDisabled() {
|
||||
text.TextColor(k.TextMuted)
|
||||
} else if clicked {
|
||||
ctrl.Focus()
|
||||
}
|
||||
}
|
||||
return formFieldBuild(c, label, opts, control)
|
||||
}
|
||||
|
||||
// formFieldBuild builds the field in the layout the caller set up.
|
||||
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
|
||||
if opts.Required {
|
||||
label += " *"
|
||||
if beside {
|
||||
body()
|
||||
return content
|
||||
}
|
||||
return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error)
|
||||
return ui.Column(c).Gap(d.Unit() * 1.5).Children(body)
|
||||
}
|
||||
+42
-11
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -10,29 +11,59 @@ func TestFormFieldLabelFocusesControl(t *testing.T) {
|
||||
name := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() {
|
||||
InputGroup(c, &name, InputGroupOptions{})
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element {
|
||||
return InputGroup(c, &name, InputGroupOptions{}).Input
|
||||
})
|
||||
}, 400, 200)
|
||||
if err := tt.Click("Name *"); err != nil {
|
||||
if err := tt.Click("Name"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("Sakiko")
|
||||
if name != "Sakiko" {
|
||||
t.Fatalf("label click did not focus the control: %q", name)
|
||||
}
|
||||
if !tt.Focused("Name *") || !tt.HasText("As on your seal.") {
|
||||
if !tt.Focused("Name") || !tt.HasText("As on your seal.") || !tt.HasText("*") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
// The label goes to the returned input, not the first focusable element before it.
|
||||
func TestFormFieldTargetsReturnedControl(t *testing.T) {
|
||||
tags, site := []string{"rose"}, ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Gap(12).Children(func() {
|
||||
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element {
|
||||
return TagsInput(c, &tags, TagsInputOptions{}).Input
|
||||
})
|
||||
FormField(c, "Site", FormFieldOptions{}, func() *ui.Element {
|
||||
return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input
|
||||
})
|
||||
})
|
||||
}, 500, 300)
|
||||
_ = tt.Click("Flowers")
|
||||
tt.Type("ivy")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !slices.Equal(tags, []string{"rose", "ivy"}) || !tt.Focused("Flowers") {
|
||||
t.Fatalf("tags %q", tags)
|
||||
}
|
||||
if _, ok := tt.Find("Remove rose"); !ok {
|
||||
t.Fatal("remove button lost its own name")
|
||||
}
|
||||
_ = tt.Click("Site")
|
||||
tt.Type("court")
|
||||
if site != "court" || tt.Focused("Back") {
|
||||
t.Fatalf("site %q", site)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormFieldErrorAndLayout(t *testing.T) {
|
||||
v := ""
|
||||
horizontal := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() {
|
||||
InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"})
|
||||
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element {
|
||||
return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input
|
||||
})
|
||||
}, 500, 200)
|
||||
if !tt.HasText("Enter an email address.") {
|
||||
@@ -60,7 +91,7 @@ func TestFormFieldNeedsLabel(t *testing.T) {
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
FormField(c, "", FormFieldOptions{}, func() {})
|
||||
FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) })
|
||||
}, 100, 100, 1)
|
||||
}
|
||||
|
||||
@@ -68,11 +99,11 @@ func TestFormFieldSnapshot(t *testing.T) {
|
||||
a, b := "Sakiko Togawa", "uika@"
|
||||
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(16).Children(func() {
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() {
|
||||
InputGroup(c, &a, InputGroupOptions{})
|
||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element {
|
||||
return InputGroup(c, &a, InputGroupOptions{}).Input
|
||||
})
|
||||
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() {
|
||||
InputGroup(c, &b, InputGroupOptions{Invalid: true})
|
||||
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element {
|
||||
return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+66
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestImageErrorTextNeedsRoom(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
w float32
|
||||
text bool
|
||||
}{{200, true}, {80, false}} {
|
||||
ready := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ready {
|
||||
Image(c, nil, ImageOptions{Width: tc.w, Height: 60, Err: errors.New("404")})
|
||||
}
|
||||
}, 300, 200)
|
||||
ready = true
|
||||
tt.Frame()
|
||||
n := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s == "Image failed to load" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// One is the box's name; a second is the visible text.
|
||||
if got := n == 2; got != tc.text {
|
||||
t.Errorf("width %v: error text shown %v, want %v (%v)", tc.w, got, tc.text, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -77,11 +77,14 @@ func TestImageRetry(t *testing.T) {
|
||||
retries++
|
||||
}
|
||||
}, 300, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Retry") {
|
||||
t.Fatal("retry not focused by Tab")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if retries != 2 {
|
||||
t.Fatalf("retries %d, want 2", retries)
|
||||
}
|
||||
@@ -102,12 +105,13 @@ func TestImageNegativeSizePanics(t *testing.T) {
|
||||
|
||||
func TestImageSnapshot(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
snapshot(t, "image", 600, 180, func(c *ui.Context) {
|
||||
snapshot(t, "image", 760, 170, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
Image(c, nil, ImageOptions{Width: 160, Height: 140, Err: errors.New("404")})
|
||||
Image(c, nil, ImageOptions{Width: 200, Height: 120, Err: errors.New("404")})
|
||||
Image(c, bmp, ImageOptions{Err: errors.New("404")})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -135,3 +139,41 @@ func TestImageKeepsSizeWhileLoading(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStatesShareBoxWhenShrunk(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
var got []ui.Rect
|
||||
for _, opts := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
|
||||
r = Image(c, bmp, opts).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
got = append(got, r)
|
||||
}
|
||||
if got[0].W != 40 || got[0].H != 20 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRetryInSmallBox(t *testing.T) {
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
|
||||
if Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")}).Retry {
|
||||
retries++
|
||||
}
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d, want 1", retries)
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -20,8 +20,7 @@ type ViewerImage struct {
|
||||
Alt string
|
||||
}
|
||||
|
||||
// ImageViewerView is a built ImageViewer and what happened to it in this frame.
|
||||
// Element is nil while the viewer is closed.
|
||||
// ImageViewerView is a built ImageViewer; Element is nil while closed.
|
||||
type ImageViewerView struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
|
||||
+1
-2
@@ -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 {
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -2,9 +2,13 @@ package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type listHarness struct {
|
||||
@@ -213,3 +217,45 @@ func TestListSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestListKeyFollowsLargeReorder(t *testing.T) {
|
||||
h := newListHarness(5000)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Reverse(h.ids)
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); !ok || k != 3 || h.st.cursor != 4996 {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGridKeyFollowsInsert(t *testing.T) {
|
||||
h := newListHarness(12)
|
||||
h.opts.Grid = true
|
||||
h.opts.ItemWidth, h.opts.ItemHeight = 80, 60
|
||||
h.listSize = [2]float32{380, 300}
|
||||
tt := ui.NewTester(h.view, 420, 340)
|
||||
if err := tt.Click("Item 2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ids = append([]int{100}, h.ids...)
|
||||
tt.Frame()
|
||||
if k, _ := h.st.Selected(); k != 2 || h.st.cursor != 3 {
|
||||
t.Fatalf("after insert: selected %d at %d", k, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSelectionColors(t *testing.T) {
|
||||
h := newListHarness(5)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Item 1")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -25,9 +25,7 @@ type LoadingOverlayResult struct {
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
// LoadingOverlay builds content and, while loading, covers it with a
|
||||
// spinner and label. Covered content takes no pointer, keys or focus;
|
||||
// the rest of the window stays usable. Focus inside moves to the overlay.
|
||||
// LoadingOverlay covers content while loading, blocking input to that region only.
|
||||
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
|
||||
if content == nil {
|
||||
panic("mujica: LoadingOverlay needs content")
|
||||
|
||||
+27
-1
@@ -68,6 +68,8 @@ type menubarState struct {
|
||||
hl []int
|
||||
focusTo int
|
||||
ptr [2]float32
|
||||
// reveal scrolls the highlighted item into view once.
|
||||
reveal bool
|
||||
}
|
||||
|
||||
// Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control.
|
||||
@@ -106,6 +108,10 @@ func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarRes
|
||||
if m.st.open >= 0 {
|
||||
m.overlay(titles)
|
||||
}
|
||||
// Focus stays on the title, so the highlighted item is read out.
|
||||
if name := m.current(); m.moved && name != "" {
|
||||
c.Announce(name)
|
||||
}
|
||||
res.chosen = m.chosen
|
||||
return res
|
||||
}
|
||||
@@ -136,6 +142,20 @@ type menubarRun struct {
|
||||
menus []MenubarMenu
|
||||
st *menubarState
|
||||
chosen string
|
||||
moved bool
|
||||
}
|
||||
|
||||
// current names the highlighted item of the open menu, "" for none.
|
||||
func (m *menubarRun) current() string {
|
||||
st := m.st
|
||||
if st.open < 0 || len(st.hl) == 0 {
|
||||
return ""
|
||||
}
|
||||
l := len(st.hl) - 1
|
||||
if h := st.hl[l]; h >= 0 {
|
||||
return menubarName(m.c, m.items(l)[h])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *menubarRun) title(i int, label string) *ui.Element {
|
||||
@@ -152,6 +172,7 @@ func (m *menubarRun) title(i int, label string) *ui.Element {
|
||||
case t.Hovered():
|
||||
t.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Ease(t, Motion(c, theme.StateDuration), k.Text)
|
||||
if st.focusTo == i {
|
||||
t.Focus()
|
||||
st.focusTo = -1
|
||||
@@ -165,8 +186,9 @@ func (m *menubarRun) title(i int, label string) *ui.Element {
|
||||
case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp):
|
||||
m.openMenu(i, true)
|
||||
}
|
||||
} else {
|
||||
} else if open {
|
||||
m.keys(t)
|
||||
t.Description(m.current())
|
||||
}
|
||||
if t.Clicked() {
|
||||
if open {
|
||||
@@ -185,6 +207,7 @@ func (m *menubarRun) keys(t *ui.Element) {
|
||||
l := len(st.hl) - 1
|
||||
items := m.items(l)
|
||||
h := st.hl[l]
|
||||
m.moved, st.reveal = true, true
|
||||
switch {
|
||||
case t.Shortcut(0, ui.KeyDown):
|
||||
st.hl[l] = menubarStep(items, h, 1)
|
||||
@@ -216,6 +239,8 @@ func (m *menubarRun) keys(t *ui.Element) {
|
||||
} else {
|
||||
m.close()
|
||||
}
|
||||
default:
|
||||
m.moved, st.reveal = false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +264,7 @@ func (m *menubarRun) openMenu(i int, highlight bool) {
|
||||
first = menubarStep(m.menus[i].Items, -1, 1)
|
||||
}
|
||||
m.st.open, m.st.hl = i, []int{first}
|
||||
m.moved = highlight
|
||||
m.focus(i)
|
||||
slog.Debug("mujica: menubar open", "menu", m.menus[i].Label)
|
||||
}
|
||||
|
||||
+19
-9
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", "警告"},
|
||||
|
||||
@@ -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", "开始时间"},
|
||||
|
||||
@@ -42,6 +42,7 @@ func init() {
|
||||
"transfer.all": {"Select all in %s", "全选%s"},
|
||||
"transfer.search": {"Search", "搜索"},
|
||||
"transfer.search_in": {"Search %s", "搜索%s"},
|
||||
"transfer.partial": {"%d of %d selected", "已选 %d / %d 项"},
|
||||
"transfer.empty": {"No items", "暂无条目"},
|
||||
"transfer.no_match": {"No matches", "无匹配结果"},
|
||||
"form.submit": {"Submit", "提交"},
|
||||
|
||||
+4
-2
@@ -44,11 +44,13 @@ type MonthPickerOptions struct {
|
||||
// Today marks the current month; zero uses the system clock.
|
||||
Today Date
|
||||
Disabled bool
|
||||
// ReadOnly keeps the field focusable and copyable but blocks the popup and clearing.
|
||||
ReadOnly bool
|
||||
// Label names the field for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// MonthPicker chooses a month into *value from a year page; PageUp and PageDown change the year.
|
||||
// MonthPicker chooses a month into *value from a year page.
|
||||
func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateTimeResult {
|
||||
for _, m := range []YearMonth{*value, opts.Min, opts.Max} {
|
||||
if !m.IsZero() && (m.Month < time.January || m.Month > time.December) {
|
||||
@@ -75,7 +77,7 @@ func MonthPicker(c *ui.Context, value *YearMonth, opts MonthPickerOptions) DateT
|
||||
}
|
||||
col, picked, ok, cleared := datetimeGridPicker(c, datetimeGridField{
|
||||
text: text, placeholder: msg(c, "month_picker.placeholder"), label: label,
|
||||
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, home: home.ordinal(),
|
||||
clearable: opts.Clearable, hasValue: !value.IsZero(), disabled: opts.Disabled, readOnly: opts.ReadOnly, home: home.ordinal(),
|
||||
}, datetimeGridConfig{
|
||||
pageOf: func(o int) (int, int) { return yearMonthOf(o).Year * 12, 12 },
|
||||
cols: 3,
|
||||
|
||||
+10
-5
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,7 @@ type NotificationCenterResult struct {
|
||||
ClearedAll bool
|
||||
}
|
||||
|
||||
// NotificationCenter lists *items and edits them in place: read, unread,
|
||||
// remove, clear. The list is one Tab stop; arrows move among its buttons
|
||||
// and Delete removes the focused notification.
|
||||
// NotificationCenter lists *items and edits them in place: read, unread, remove, clear.
|
||||
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
|
||||
if items == nil {
|
||||
panic("mujica: NotificationCenter needs items")
|
||||
@@ -70,9 +68,9 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC
|
||||
Role(ui.RoleGroup).Label(title).Clip()
|
||||
panel.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
|
||||
@@ -135,7 +133,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
|
||||
open := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
|
||||
Radius(theme.ControlRadius).Label(name)
|
||||
internal.Ring(open, k.Focus, theme.ControlRadius)
|
||||
open.Children(func() {
|
||||
@@ -145,7 +143,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
|
||||
t.Bold()
|
||||
}
|
||||
if n.Time != "" {
|
||||
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
|
||||
ui.Text(c, n.Time).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
})
|
||||
if n.Body != "" {
|
||||
|
||||
+10
-5
@@ -141,16 +141,19 @@ func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on,
|
||||
b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius).
|
||||
Label(name).Disabled(disabled)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
fg := k.Text
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Accent).TextColor(k.OnAccent).FontWeight(600)
|
||||
b.Background(k.Accent).Border(theme.BorderWidth, k.Accent).FontWeight(600)
|
||||
fg = k.OnAccent
|
||||
case b.Pressed():
|
||||
b.Background(k.Selection).TextColor(k.Text)
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.ControlBorder)
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover).TextColor(k.Text).Border(theme.BorderWidth, k.ControlBorder)
|
||||
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
|
||||
default:
|
||||
b.TextColor(k.Text).Border(theme.BorderWidth, k.Border)
|
||||
b.Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
internal.Ease(b, Motion(c, theme.StateDuration), fg)
|
||||
if icon != nil {
|
||||
b.Tooltip(name)
|
||||
}
|
||||
@@ -176,6 +179,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin
|
||||
if sel.Trigger.Hovered() {
|
||||
sel.Trigger.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Ease(sel.Trigger, Motion(c, theme.StateDuration), k.Text)
|
||||
sel.Trigger.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), *pageSize)).SingleLine()
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
|
||||
@@ -188,6 +192,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin
|
||||
if it.Highlighted() {
|
||||
it.Background(k.Selection)
|
||||
}
|
||||
internal.Ease(it, Motion(c, theme.StateDuration), k.Text)
|
||||
it.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), s)).SingleLine().Grow(1)
|
||||
if s == *pageSize {
|
||||
@@ -239,7 +244,7 @@ func paginationJump(c *ui.Context, pages int, set func(int)) {
|
||||
} else if in.Focused() {
|
||||
border = k.Focus
|
||||
}
|
||||
box.Border(theme.BorderWidth, border)
|
||||
internal.Ease(box.Border(theme.BorderWidth, border), Motion(c, theme.StateDuration), k.Text)
|
||||
if *bad {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine()
|
||||
}
|
||||
|
||||
+3
-4
@@ -45,8 +45,7 @@ type pinInputState struct {
|
||||
compose string
|
||||
}
|
||||
|
||||
// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros.
|
||||
// Typing fills the next cell, Backspace clears the last, and paste fills them all.
|
||||
// PinInput edits a fixed-length digit code, one cell per digit, keeping leading zeros.
|
||||
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
|
||||
n := opts.Length
|
||||
if n == 0 {
|
||||
@@ -144,9 +143,9 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
||||
cell.Children(func() {
|
||||
switch {
|
||||
case showCompose:
|
||||
ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine()
|
||||
ui.Text(c, compose).FontSize(FontSize(c, theme.H3Size)).Underline().SingleLine()
|
||||
case ch != "":
|
||||
ui.Text(c, ch).FontSize(theme.H3Size).FontFeatures("tnum").SingleLine()
|
||||
ui.Text(c, ch).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").SingleLine()
|
||||
}
|
||||
})
|
||||
caret := focused && i == active && len(*value) < n && !showCompose
|
||||
|
||||
+21
-19
@@ -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) }
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts Seg
|
||||
seg.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
seg.TextColor(fg)
|
||||
internal.Ease(seg, Motion(c, theme.StateDuration), fg)
|
||||
internal.Ring(seg, k.Focus, theme.ControlRadius)
|
||||
if s.HideLabel {
|
||||
seg.Tooltip(s.Label)
|
||||
|
||||
+57
-9
@@ -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)
|
||||
})
|
||||
|
||||
@@ -124,6 +124,44 @@ func TestSidebarInvalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarGroupKeyboard(t *testing.T) {
|
||||
sel := "inbox"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Sidebar(c, &sel, sidebarSample(), SidebarOptions{})
|
||||
}, 400, 500)
|
||||
// Tab stops: the item list, the Library title, the Projects group.
|
||||
for range 3 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if tt.HasText("Opera") {
|
||||
t.Fatal("Enter did not collapse the group")
|
||||
}
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !tt.HasText("Opera") {
|
||||
t.Fatal("Space did not expand the group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarSameNamedGroups(t *testing.T) {
|
||||
sel := "a1"
|
||||
sections := []SidebarSection{
|
||||
{Title: "Court", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "a1", Label: "Edict"}}}}},
|
||||
{Title: "Chapel", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "b1", Label: "Hymn"}}}}},
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Sidebar(c, &sel, sections, SidebarOptions{})
|
||||
}, 400, 500)
|
||||
if err := tt.Click("Drafts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.HasText("Edict") || !tt.HasText("Hymn") {
|
||||
t.Fatalf("same-named groups share state: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarSnapshot(t *testing.T) {
|
||||
snapshot(t, "sidebar", 420, 360, func(c *ui.Context) {
|
||||
a, b := "opera", "starred"
|
||||
|
||||
+10
-6
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -28,11 +29,13 @@ type SkeletonOptions struct {
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Skeleton stands in for content that is loading. It pulses while painted
|
||||
// and shows a static fill under reduced motion.
|
||||
// Skeleton stands in for loading content; it pulses while painted, static under reduced motion.
|
||||
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
|
||||
if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 {
|
||||
panic("mujica: negative Skeleton size")
|
||||
if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect {
|
||||
panic(fmt.Sprintf("mujica: unknown SkeletonShape %d", int(opts.Shape)))
|
||||
}
|
||||
if !(opts.Width >= 0) || !(opts.Height >= 0) || opts.Lines < 0 || math.IsInf(float64(opts.Width+opts.Height), 1) {
|
||||
panic(fmt.Sprintf("mujica: Skeleton size %vx%v, %d lines invalid", opts.Width, opts.Height, opts.Lines))
|
||||
}
|
||||
k := Tokens(c)
|
||||
still := c.Preferences().ReduceMotion
|
||||
@@ -70,9 +73,10 @@ func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
|
||||
e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) })
|
||||
default:
|
||||
lines := max(opts.Lines, 1)
|
||||
line := theme.BodySize * theme.LineHeight
|
||||
size := FontSize(c, theme.BodySize)
|
||||
line := size * theme.LineHeight
|
||||
e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
bar := theme.BodySize * 0.8
|
||||
bar := size * 0.8
|
||||
for i := range lines {
|
||||
w := r.W
|
||||
if lines > 1 && i == lines-1 {
|
||||
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -18,12 +19,14 @@ type SpinnerOptions struct {
|
||||
Color ui.Color
|
||||
}
|
||||
|
||||
// Spinner draws four diamonds turning around a center while it shows.
|
||||
// It repaints only while painted: hidden, it stops. Reduced motion holds it still.
|
||||
// Spinner turns four diamonds only while painted; hidden or under reduced motion it stops.
|
||||
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic(fmt.Sprintf("mujica: Spinner Size %v invalid", opts.Size))
|
||||
}
|
||||
k := Tokens(c)
|
||||
size := opts.Size
|
||||
if size <= 0 {
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
label := opts.Label
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,3 +79,18 @@ func TestStatisticSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatisticTextScale(t *testing.T) {
|
||||
var h float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() { h = Statistic(c, 7, StatisticOptions{}).Bounds().H })
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
base := h
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame()
|
||||
if h < base*1.8 {
|
||||
t.Fatalf("height %v at 200%%, %v at 100%%", h, base)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -153,6 +159,77 @@ func TestTabsSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// tabsStateSnapshot compares tabsView after act in both themes with <name>-<mode>.png.
|
||||
func tabsStateSnapshot(t *testing.T, name string, act func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
for _, m := range []Mode{Light, Dark} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: m})
|
||||
tabsView(c)
|
||||
}, 520, 300)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
act(tt)
|
||||
tt.Frame()
|
||||
got := encode(t, tt.Image())
|
||||
path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png")
|
||||
if *update {
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%v (run go test -update)", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
if err := os.WriteFile(path+".got.png", got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Errorf("%s differs; see %s.got.png", path, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabsStateSnapshots(t *testing.T) {
|
||||
center := func(tt *ui.Tester, s string) (float32, float32) {
|
||||
r, ok := tt.Find(s)
|
||||
if !ok {
|
||||
t.Fatalf("no %q", s)
|
||||
}
|
||||
return r.X + r.W/2, r.Y + r.H/2
|
||||
}
|
||||
tabsStateSnapshot(t, "tabs-hover", func(tt *ui.Tester) { tt.Move(center(tt, "Members")) })
|
||||
tabsStateSnapshot(t, "tabs-pressed", func(tt *ui.Tester) { tt.Press(center(tt, "Settings")) })
|
||||
tabsStateSnapshot(t, "tabs-focus", func(tt *ui.Tester) {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTabsStateColorsEase checks hover fades in over StateDuration, not at once.
|
||||
func TestTabsStateColorsEase(t *testing.T) {
|
||||
sel := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
Tabs(c, &sel, []Tab{{Label: "One"}, {Label: "Two"}}, TabsOptions{})
|
||||
}, 300, 120)
|
||||
r, _ := tt.Find("Two")
|
||||
x, y := int(r.X-8), int(r.Y+r.H/2)
|
||||
hover := tabsRGBA(theme.Light().SurfaceHover)
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
if got := tt.Image().RGBAAt(x, y); got == hover {
|
||||
t.Fatal("hover color applied at once")
|
||||
}
|
||||
time.Sleep(theme.StateDuration + 40*time.Millisecond)
|
||||
tt.Frame()
|
||||
if got := tt.Image().RGBAAt(x, y); got != hover {
|
||||
t.Fatalf("hover color %v after the transition, want %v", got, hover)
|
||||
}
|
||||
}
|
||||
|
||||
func tabsRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} }
|
||||
|
||||
// tabsMode recovers the mode snapshot applied, to add a density.
|
||||
func tabsMode(c *ui.Context) Mode {
|
||||
if Tokens(c).Dark {
|
||||
|
||||
+3
-5
@@ -47,7 +47,6 @@ type tagsInputState struct {
|
||||
}
|
||||
|
||||
// TagsInput edits a list of unique tags shown as chips before a text input.
|
||||
// Enter or a separator adds the text; Backspace in the empty input removes the last tag.
|
||||
func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts {
|
||||
if opts.Max < 0 {
|
||||
panic("mujica: TagsInput Max must not be negative")
|
||||
@@ -102,11 +101,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
||||
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
|
||||
box.Children(func() {
|
||||
for i, tag := range *tags {
|
||||
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Gap(d.Unit()).Padding(0, d.Unit(), 0, d.Unit()*2).
|
||||
MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
||||
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
||||
chip.Children(func() {
|
||||
ui.Text(c, tag).SingleLine()
|
||||
x := ui.ButtonBase(c).Size(20, 20).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||
x := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||
Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag))
|
||||
internal.Ring(x, k.Focus, theme.ControlRadius)
|
||||
if x.Hovered() {
|
||||
@@ -135,7 +133,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
||||
})
|
||||
})
|
||||
if st.err != "" {
|
||||
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
|
||||
ui.Text(c, st.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
in := parts.Input
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user