diff --git a/color_picker.go b/color_picker.go index de993b6..1264351 100644 --- a/color_picker.go +++ b/color_picker.go @@ -91,13 +91,13 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP st.sync(*color) } } - if opts.Disabled { + if trigger.IsDisabled() { st.open = false } else if trigger.Clicked() { st.open = !st.open } border := k.ControlBorder - if trigger.Hovered() && !opts.Disabled { + if trigger.Hovered() { border = k.ControlBorder.Mix(k.Text, 0.25) trigger.Background(k.SurfaceHover) } @@ -160,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP } }) }) - if st.wasOpen && !st.open && !opts.Disabled { + if st.wasOpen && !st.open && !trigger.IsDisabled() { trigger.Focus() } st.wasOpen = st.open @@ -212,10 +212,12 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker // colorPickerSlider is a channel slider from 0 to hi over the track track paints. func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element { k := Tokens(c) - const knob = 10 - s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name) + const knob, look = 10, 18 + s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name) frac := float32(*v / hi) s.Draw(func(p *ui.Painter, r ui.Rect) { + // The hit area is taller than the drawn slider, which sits in its middle. + r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look} bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6} p.Clip(bar, theme.ControlRadius, func() { track(p, bar) }) p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth) diff --git a/color_picker_test.go b/color_picker_test.go index 1b5e3d0..2a85958 100644 --- a/color_picker_test.go +++ b/color_picker_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester { @@ -111,7 +113,7 @@ func TestColorPickerDisabledAndParse(t *testing.T) { func TestColorPickerSnapshot(t *testing.T) { a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099") - snapshot(t, "color_picker", 340, 520, func(c *ui.Context) { + snapshot(t, "color_picker", 340, 560, func(c *ui.Context) { ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { ColorPicker(c, &b, ColorPickerOptions{Disabled: true}) p := ColorPicker(c, &a, ColorPickerOptions{}) @@ -157,3 +159,17 @@ func TestColorPickerSwatchHitArea(t *testing.T) { t.Fatalf("swatch %v", r) } } + +func TestColorPickerSliderHitAreaCompact(t *testing.T) { + col := ui.Hex("#7D2034") + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ColorPicker(c, &col, ColorPickerOptions{Label: "Accent"}) + }, 400, 560) + _ = tt.Click("Accent") + for _, name := range []string{"Hue", "Opacity"} { + if r, ok := tt.Find(name); !ok || r.H < 28 { + t.Fatalf("%s slider %v", name, r) + } + } +} diff --git a/file_drop_zone.go b/file_drop_zone.go index 1f0a979..5420580 100644 --- a/file_drop_zone.go +++ b/file_drop_zone.go @@ -152,11 +152,12 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) col.Children(func() { zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5). Radius(theme.CardRadius).Disabled(opts.Disabled) - if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled { + disabled := zone.IsDisabled() + if dropped := zone.DroppedFiles(); dropped != nil && !disabled { slog.Debug("mujica: file drop zone dropped", "count", len(dropped)) take(dropped) } - over := zone.FileDragOver() && !opts.Disabled + over := zone.FileDragOver() && !disabled bg, border := k.Surface, k.ControlBorder if over { bg, border = k.Selection, k.AccentText diff --git a/form.go b/form.go index bb89323..acee219 100644 --- a/form.go +++ b/form.go @@ -35,6 +35,7 @@ type formFieldState struct { pending bool hasAsync bool check func() string + refresh func() startAsync func() } @@ -196,6 +197,12 @@ func formStartSubmit(st *FormState) { // formSettle finishes a waiting submit once no check is pending; it reports success. func formSettle(c *ui.Context, st *FormState) bool { + // Current values only: later edits and cross-field rules count. + for _, name := range st.order { + f := st.fields[name] + f.refresh() + f.err = f.check() + } first, pending := "", false for _, name := range st.order { if st.Error(name) != "" && first == "" { @@ -244,11 +251,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T panic("mujica: FormItem " + name + " registered twice") } st.order = append(st.order, name) - snap := fmt.Sprintf("%#v", *value) fs := st.fields[name] if fs == nil { st.seq++ - fs = &formFieldState{snapshot: snap, version: st.seq} + fs = &formFieldState{snapshot: fmt.Sprintf("%#v", *value), version: st.seq} st.fields[name] = fs } fs.hasAsync = opts.Async != nil @@ -268,12 +274,18 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T opts.Async(*value, fs.version) } } - if snap != fs.snapshot { + // refresh gives an edited value a new version; it runs after the control took its input. + fs.refresh = func() { + snap := fmt.Sprintf("%#v", *value) + if snap == fs.snapshot { + return + } st.seq++ fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, "" if fs.touched || st.tried { fs.err = fs.check() } + c.Invalidate() } initial := opts.Initial st.resets = append(st.resets, func() { *value = initial }) @@ -286,6 +298,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T ctrl = control() return ctrl }) + fs.refresh() focused := ctrl.FocusWithin() if fs.focused && !focused { fs.touched = true diff --git a/form_test.go b/form_test.go index afe5ddc..0e895ba 100644 --- a/form_test.go +++ b/form_test.go @@ -262,3 +262,59 @@ func TestFormSnapshot(t *testing.T) { }) }) } + +// Text typed in the frame the check passes gets its own check before submitting. +func TestFormInputAfterAsyncPassIsRechecked(t *testing.T) { + p := &formProbe{name: "Uika", tags: []string{"x"}, email: "good@x", useAsync: true} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Email") + p.st.Submit() + tt.Frame() + if len(p.asyncCalls) != 1 { + t.Fatalf("calls %v", p.asyncCalls) + } + p.st.Resolve("email", p.asyncCalls[0], "") + tt.Type("bad") + if p.submits != 0 || len(p.asyncValues) != 2 || p.asyncValues[1] != "good@xbad" { + t.Fatalf("submits %d checked %q", p.submits, p.asyncValues) + } +} + +// A cross-field rule sees the other field's latest value when the submit settles. +func TestFormCrossFieldRuleAtSettle(t *testing.T) { + var st FormState + user, pw, confirm := "uika", "a", "a" + var version uint64 + submits := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res := Form(c, &st, FormOptions{}, func(f *FormScope) { + FormItem(f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element { + return InputGroup(c, &user, InputGroupOptions{}).Input + }) + FormItem(f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element { + return InputGroup(c, &pw, InputGroupOptions{}).Input + }) + FormItem(f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string { + if v != pw { + return "Passwords differ." + } + return "" + }}, func() *ui.Element { + return InputGroup(c, &confirm, InputGroupOptions{}).Input + }) + }) + if res.Submitted() { + submits++ + } + }, 600, 500) + _ = tt.Click("Submit") + pw = "b" + tt.Frame() + st.Resolve("user", version, "") + tt.Frame() + tt.Frame() + if submits != 0 || !tt.HasText("Passwords differ.") { + t.Fatalf("mismatched pair submitted: %d", submits) + } +} diff --git a/input_group.go b/input_group.go index 8f0b136..4ec5f31 100644 --- a/input_group.go +++ b/input_group.go @@ -41,13 +41,14 @@ type InputGroupParts struct { Input *ui.Element // Clicked is the index in Buttons of the button clicked, or -1. Clicked int + locked bool } // Changed reports whether the user edited the value this frame. -func (p InputGroupParts) Changed() bool { return p.Input.Changed() } +func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() } // Submitted reports whether Enter was pressed in the input. -func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() } +func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() } // 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 { @@ -91,7 +92,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup } button := func(i int) { b := opts.Buttons[i] - bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()). + bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()). Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label) segment(bb) internal.Ring(bb, k.Focus, theme.ControlRadius) @@ -133,7 +134,8 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup // As wide as MyGo makes a text input. parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0) } else { - parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder) + parts.locked = box.IsDisabled() + parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder) } if opts.Label != "" { parts.Input.Label(opts.Label) @@ -169,3 +171,15 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup }) return parts } + +// inputGroupEditor builds the input; a disabled one edits a copy, so queued input cannot reach value. +func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element { + if !locked { + return ui.TextInputBase(c, value) + } + shown := ui.Local(box, "locked", func() string { return *value }) + *shown = *value + in := ui.TextInputBase(c, shown) + *shown = *value + return in +} diff --git a/input_group_test.go b/input_group_test.go index bcdd4e1..34a7e3e 100644 --- a/input_group_test.go +++ b/input_group_test.go @@ -6,6 +6,7 @@ import ( "github.com/egoist/mygo/ui" "mujicaui/icons" + "mujicaui/theme" ) func TestInputGroupTypingAndButtons(t *testing.T) { @@ -108,3 +109,35 @@ func TestInputGroupSnapshot(t *testing.T) { }) }) } + +// Input queued while focused must not edit a group disabled since. +func TestInputGroupDisabledAfterFocus(t *testing.T) { + value, disabled, changes := "abc", false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if InputGroup(c, &value, InputGroupOptions{Label: "Code"}).Changed() { + changes++ + } + }) + }, 400, 100) + _ = tt.Click("Code") + disabled = true + tt.Type("zz") + tt.Key(0, ui.KeyBackspace) + tt.Frame() + if value != "abc" || changes != 0 { + t.Fatalf("value %q changes %d", value, changes) + } +} + +func TestInputGroupHitAreasCompact(t *testing.T) { + value := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}}}) + }, 400, 100) + if r, ok := tt.Find("Clear"); !ok || r.W < 28 || r.H < 28 { + t.Fatalf("button %v", r) + } +} diff --git a/pin_input.go b/pin_input.go index 6dc4f9d..00cc6eb 100644 --- a/pin_input.go +++ b/pin_input.go @@ -86,6 +86,10 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts slog.Debug("mujica: pin input completed", "length", n) } } + disabled := e.IsDisabled() + if disabled { + st.queue, st.compose = st.queue[:0], "" + } for _, ev := range st.queue { switch { case ev.paste: @@ -97,9 +101,7 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts } } st.queue = st.queue[:0] - if opts.Disabled { - st.compose = "" - } else { + if !disabled { e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool { switch { case ev.Kind == ui.InputText: diff --git a/pin_input_test.go b/pin_input_test.go index 07bcf9f..9fc79f5 100644 --- a/pin_input_test.go +++ b/pin_input_test.go @@ -102,3 +102,23 @@ func TestPinInputSnapshot(t *testing.T) { }) }) } + +func TestPinInputDisabledAfterFocus(t *testing.T) { + p := &pinInputProbe{value: "12"} + opts := PinInputOptions{Label: "Code"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := PinInput(c, &p.value, opts) + if r.Changed() { + p.changes++ + } + }, 480, 120) + tt.Key(0, ui.KeyTab) + opts.Disabled = true + tt.Type("3") + tt.Key(0, ui.KeyBackspace) + tt.Frame() + if p.value != "12" || p.changes != 0 { + t.Fatalf("value %q changes %d", p.value, p.changes) + } +} diff --git a/rating.go b/rating.go index ccf7942..f0110f9 100644 --- a/rating.go +++ b/rating.go @@ -48,6 +48,8 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } k := Tokens(c) size := Density(c).ControlHeight() * 0.6 + // Each star sits centered in a hit area of at least the minimum size. + hit := max(size, theme.MinHitSize) label := opts.Label if label == "" { label = msg(c, "rating.label") @@ -64,10 +66,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0) parts.Element = e e.Label(label) - interactive := !opts.ReadOnly && !opts.Disabled - if opts.Disabled { - e.Disabled(true) - } else { + e.Disabled(opts.Disabled) + disabled := e.IsDisabled() + interactive := !opts.ReadOnly && !disabled + if !disabled { e.Focusable() internal.Ring(e, k.Focus, theme.ControlRadius) } @@ -85,10 +87,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } } e.Children(func() { - stars := ui.Box(c).Size(size*float32(n), size).Shrink(0) + stars := ui.Box(c).Size(hit*float32(n), hit).Shrink(0) shown, fill := *value, k.AccentText if interactive { - at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step } + at := func(x float32) float64 { return math.Ceil(float64(x/hit)/step-1e-9) * step } if _, _, ok := stars.Dragged(); ok { x, _, _ := stars.PointerPosition() set(at(x)) @@ -99,7 +101,7 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } stars.Draw(func(p *ui.Painter, r ui.Rect) { for i := range n { - cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size} + cell := ui.Rect{X: r.X + float32(i)*hit + (hit-size)/2, Y: r.Y + (hit-size)/2, W: size, H: size} path := ratingStar(cell) p.StrokePath(path, 1.25, k.ControlBorder) part := float32(math.Max(0, math.Min(1, shown-float64(i)))) diff --git a/rating_test.go b/rating_test.go index b814fc2..c48634e 100644 --- a/rating_test.go +++ b/rating_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester { @@ -48,7 +50,7 @@ func TestRatingKeyboardSteps(t *testing.T) { func TestRatingPointer(t *testing.T) { v, n := 0.0, 0 tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n) - size := float32(36) * 0.6 + size := theme.MinHitSize // The left half of the third star. tt.ClickAt(10+2*size+size*0.25, 10+size/2) if v != 2.5 || n != 1 { @@ -100,3 +102,18 @@ func TestRatingSnapshot(t *testing.T) { }) }) } + +func TestRatingHitAreaCompact(t *testing.T) { + v := 2.0 + var r RatingParts + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + r = Rating(c, &v, RatingOptions{}) + }) + }, 300, 100) + tt.Frame() + if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 { + t.Fatalf("stars %v", b) + } +} diff --git a/tags_input.go b/tags_input.go index 424e876..3f9afc6 100644 --- a/tags_input.go +++ b/tags_input.go @@ -89,9 +89,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa parts.changed, st.err = true, "" slog.Debug("mujica: tags input removed", "count", len(*tags)) } + disabled := opts.Disabled || outer.IsDisabled() if st.backspace { st.backspace = false - if len(*tags) > 0 && st.query == "" { + if !disabled && len(*tags) > 0 && st.query == "" { remove(len(*tags) - 1) } } @@ -117,14 +118,15 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) } // The input keeps its identity whatever chips come before it. - ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24).Children(func() { - in := ui.TextInputBase(c, &st.query).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) + holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24) + holder.Children(func() { + in := inputGroupEditor(c, holder, &st.query, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) parts.Input = in if opts.Label != "" { in.Label(opts.Label) } in.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" { + if !disabled && ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" { st.backspace = true return true } @@ -137,22 +139,8 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa } }) in := parts.Input - if in.Changed() { - st.err = "" - } - if strings.ContainsAny(st.query, seps) { - pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) }) - last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00") - var kept []string - for _, p := range pieces[:len(pieces)-1] { - if !add(p) { - kept = append(kept, strings.TrimSpace(p)) - } - } - st.query = strings.Join(append(kept, last), string([]rune(seps)[0])) - } - if in.Submitted() && add(st.query) { - st.query = "" + if !disabled { + tagsInputCommit(st, in, seps, add) } if box.Clicked() { in.Focus() @@ -174,3 +162,24 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) return parts } + +// tagsInputCommit adds the text before each separator, and all of it on Enter. +func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(string) bool) { + if in.Changed() { + st.err = "" + } + if strings.ContainsAny(st.query, seps) { + pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) }) + last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00") + var kept []string + for _, p := range pieces[:len(pieces)-1] { + if !add(p) { + kept = append(kept, strings.TrimSpace(p)) + } + } + st.query = strings.Join(append(kept, last), string([]rune(seps)[0])) + } + if in.Submitted() && add(st.query) { + st.query = "" + } +} diff --git a/tags_input_test.go b/tags_input_test.go index da8aeb3..fb1be8d 100644 --- a/tags_input_test.go +++ b/tags_input_test.go @@ -124,3 +124,23 @@ func TestTagsInputRemoveHitArea(t *testing.T) { t.Fatalf("remove button %v", r) } } + +func TestTagsInputDisabledAfterFocus(t *testing.T) { + tags, n := []string{"rose"}, 0 + opts := TagsInputOptions{Label: "Tags"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TagsInput(c, &tags, opts).Changed() { + n++ + } + }, 520, 200) + _ = tt.Click("Tags") + opts.Disabled = true + tt.Key(0, ui.KeyBackspace) + tt.Type("ivy,") + tt.Key(0, ui.KeyEnter) + tt.Frame() + if !slices.Equal(tags, []string{"rose"}) || n != 0 { + t.Fatalf("tags %q changes %d", tags, n) + } +} diff --git a/testdata/snapshots/darwin/color_picker-dark.png b/testdata/snapshots/darwin/color_picker-dark.png index 00f3d75..db1b531 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 804c5b8..f06cb6a 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/rating-dark.png b/testdata/snapshots/darwin/rating-dark.png index 7c28431..75d7077 100644 Binary files a/testdata/snapshots/darwin/rating-dark.png and b/testdata/snapshots/darwin/rating-dark.png differ diff --git a/testdata/snapshots/darwin/rating-light.png b/testdata/snapshots/darwin/rating-light.png index cf87bd9..45171fe 100644 Binary files a/testdata/snapshots/darwin/rating-light.png and b/testdata/snapshots/darwin/rating-light.png differ diff --git a/testdata/snapshots/darwin/transfer-dark.png b/testdata/snapshots/darwin/transfer-dark.png index c9c50e0..44efb42 100644 Binary files a/testdata/snapshots/darwin/transfer-dark.png and b/testdata/snapshots/darwin/transfer-dark.png differ diff --git a/testdata/snapshots/darwin/transfer-light.png b/testdata/snapshots/darwin/transfer-light.png index 1030c96..11a1ac3 100644 Binary files a/testdata/snapshots/darwin/transfer-light.png and b/testdata/snapshots/darwin/transfer-light.png differ diff --git a/transfer.go b/transfer.go index 3b300d9..64771b2 100644 --- a/transfer.go +++ b/transfer.go @@ -151,11 +151,11 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite } checked := transferCount(st, shown) ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() { - head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3). + head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*3-(theme.MinHitSize-16)/2). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) head.Children(func() { all := checked > 0 && checked == enabled - box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0) + box := ui.CheckboxBase(c, &all).Size(theme.MinHitSize, theme.MinHitSize).Justify(ui.Center).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() { @@ -184,7 +184,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite for _, it := range shown { on := st.checked[it.ID] ui.Box(c).Key(it.ID).Children(func() { - cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(d.ControlHeight()-d.Unit()).Padding(0, d.Unit()*2). + cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2). Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled) if cb.Changed() { st.checked[it.ID] = on diff --git a/transfer_test.go b/transfer_test.go index 427d4d2..13e1d69 100644 --- a/transfer_test.go +++ b/transfer_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) var transferCast = []TransferItem{ @@ -116,3 +118,14 @@ func TestTransferPartialDescription(t *testing.T) { t.Fatalf("descriptions %q", got) } } + +func TestTransferSelectAllHitAreaCompact(t *testing.T) { + target := []string{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + Transfer(c, &target, transferCast, TransferOptions{}) + }, 640, 420) + if r, ok := tt.Find("Select all in Available"); !ok || r.W < 28 || r.H < 28 { + t.Fatalf("select all %v", r) + } +}