diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md index 17e1ec3..a2f5618 100644 --- a/docs/components/026-number-input.md +++ b/docs/components/026-number-input.md @@ -39,7 +39,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab ## 键盘操作 -步进沿以 `Min`(无界时为 0)为起点、`Step` 为间隔的网格移动,结果按精度取整,并停在范围内(范围先向内取到精度上,如 `Max` 0.29999999、步长 0.1 时最大值为 0.2)。不在网格上的值先吸附到相邻网格点。 +步进沿以 `Min`(无界时为 0)为起点、`Step` 为间隔的网格移动,结果按精度取整,并停在范围内(范围先向内取到精度上,如 `Max` 0.29999999、步长 0.1 时最大值为 0.2)。不在网格上的值先走到该方向上最近的网格点。网格序号按整数计算,结果与原始 `Min`/`Max` 精确比较,越界则退回一格,不使用容差。 输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到 `Min`、End 到 `Max`(如 [0, 10] 步长 3 时 End 为 10,再 Down 为 9)。 diff --git a/input_review2_test.go b/input_review2_test.go index 4544940..77b882c 100644 --- a/input_review2_test.go +++ b/input_review2_test.go @@ -175,3 +175,29 @@ func TestRadioGroupKeysByEquality(t *testing.T) { t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One")) } } + +func TestNumberInputStepExactBounds(t *testing.T) { + for _, stepper := range []bool{false, true} { + v := 1e12 + tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 2e12, Step: 1, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 1e12+1 { + t.Errorf("stepper %v: Up from 1e12 by 1 gave %.0f", stepper, v) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + if v != 1e12-1 { + t.Errorf("stepper %v: two Downs gave %.0f", stepper, v) + } + v = 0.2 + tt = numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999995, Step: 0.1, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 0.2 { + t.Errorf("stepper %v: Up past Max 0.29999999995 gave %v", stepper, v) + } + tt.Key(0, ui.KeyEnd) + if stepper && v != 0.2 { + t.Errorf("End went to %v, past the largest value at precision 1", v) + } + } +} diff --git a/number_input.go b/number_input.go index d3d3d1d..d9d7e81 100644 --- a/number_input.go +++ b/number_input.go @@ -36,8 +36,9 @@ type NumberInputOptions struct { // numberInputSpec is NumberInputOptions resolved. type numberInputSpec struct { - lo, hi, step float64 - decimals int + // lo and hi are the bounds on the precision grid; min and max the original ones. + lo, hi, min, max, step float64 + decimals int } func (s numberInputSpec) format(v float64) string { @@ -47,13 +48,18 @@ func (s numberInputSpec) format(v float64) string { return strconv.FormatFloat(v, 'f', s.decimals, 64) } -// clamp rounds v to the precision, then bounds it; the bounds sit on that grid. -func (s numberInputSpec) clamp(v float64) float64 { +// round rounds v to the precision. +func (s numberInputSpec) round(v float64) float64 { p := math.Pow(10, float64(s.decimals)) - return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + 0 // + 0 turns -0 into 0 + return math.Round(v*p)/p + 0 // + 0 turns -0 into 0 } -// stepBy moves v n steps along the grid of Step from Min, stopping at the bounds. +// clamp rounds v to the precision, then bounds it; the bounds sit on that grid. +func (s numberInputSpec) clamp(v float64) float64 { + return math.Max(s.lo, math.Min(s.hi, s.round(v))) +} + +// stepBy moves v n grid steps from Min, exactly checked against the original bounds. func (s numberInputSpec) stepBy(v float64, n int) float64 { if math.IsNaN(v) { // From empty, the first step lands next to zero. @@ -63,21 +69,25 @@ func (s numberInputSpec) stepBy(v float64, n int) float64 { if math.IsInf(base, 0) { base = 0 } - k := (v - base) / s.step - if n > 0 { - k = numberInputFloor(k) + float64(n) - } else { - k = numberInputCeil(k) + float64(n) + at := func(k float64) float64 { return s.round(base + k*s.step) } + k := math.Round((v - base) / s.step) + // Off the grid, the nearest point in the direction is the first step. + if n > 0 && at(k) <= v || n < 0 && at(k) >= v { + k += float64(n) } - return s.clamp(base + k*s.step) + r := at(k) + switch { + case r > s.max: + r = at(k - 1) + case r < s.min: + r = at(k + 1) + } + if r > s.max || r < s.min { + return v + } + return r } -// numberInputFloor floors x, forgiving floating-point error just below an integer. -func numberInputFloor(x float64) float64 { return math.Floor(x + math.Max(1e-9, 1e-12*math.Abs(x))) } - -// numberInputCeil ceils x, forgiving floating-point error just above an integer. -func numberInputCeil(x float64) float64 { return math.Ceil(x - math.Max(1e-9, 1e-12*math.Abs(x))) } - // numberInputSame reports equal values, counting NaN (empty) as equal. func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) } @@ -103,10 +113,15 @@ func numberInputResolve(opts NumberInputOptions) numberInputSpec { s.decimals = len(t) - strings.IndexByte(t, '.') - 1 } } - // Bounds move inward onto the precision grid, so clamped values keep both. + // Bounds move inward onto the precision grid, compared exactly with the originals. + s.min, s.max = s.lo, s.hi p := math.Pow(10, float64(s.decimals)) - s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0 - s.hi = numberInputFloor(s.hi*p)/p + 0 + if s.lo = s.round(s.lo); s.lo < s.min { + s.lo = s.round(s.lo + 1/p) + } + if s.hi = s.round(s.hi); s.hi > s.max { + s.hi = s.round(s.hi - 1/p) + } if s.lo > s.hi { panic("mujica: NumberInput has no value in range at this precision") } diff --git a/password_input_test.go b/password_input_test.go index 72f82c0..8557a7e 100644 --- a/password_input_test.go +++ b/password_input_test.go @@ -23,13 +23,13 @@ func TestPasswordInputMaskPasteAndReveal(t *testing.T) { tt.Command("paste") tt.Type("et") if pw != "s3cret" || changes != 2 { - t.Fatalf("value %q changes %d", pw, changes) + t.Fatalf("value matched %v (length %d), changes %d", pw == "s3cret", len(pw), changes) } tt.SetClipboard("") tt.Command("selectAll") tt.Command("copy") if tt.Clipboard() != "" { - t.Fatalf("masked field copied %q", tt.Clipboard()) + t.Fatalf("masked field copied %d characters", len(tt.Clipboard())) } for _, s := range tt.Texts() { if s == "s3cret" { @@ -40,13 +40,13 @@ func TestPasswordInputMaskPasteAndReveal(t *testing.T) { t.Fatal(err) } if _, ok := tt.Find("Hide password"); !ok || pw != "s3cret" { - t.Fatalf("after reveal: value %q, texts %q", pw, tt.Texts()) + t.Fatalf("after reveal: value matched %v, hide toggle shown %v", pw == "s3cret", ok) } _ = tt.Click("Password") tt.Command("selectAll") tt.Command("copy") if tt.Clipboard() != "s3cret" { - t.Fatalf("revealed field copied %q", tt.Clipboard()) + t.Fatalf("revealed copy matched %v (length %d)", tt.Clipboard() == "s3cret", len(tt.Clipboard())) } tt.Key(0, ui.KeyEnter) if submits != 1 { @@ -82,7 +82,7 @@ func TestPasswordInputDisabled(t *testing.T) { _ = tt.Click("Off") tt.Type("zz") if pw != "locked" { - t.Fatalf("disabled value %q", pw) + t.Fatalf("disabled value matched %v (length %d)", pw == "locked", len(pw)) } if _, ok := tt.Find("Show password"); !ok { t.Fatal("disabled toggle revealed")