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