Inputs: review r3 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 13:15:22 -04:00
1 parent 529aea6fa4
commit 07a90f4e80
4 files changed
+68 -27

No files matched your search

+1 -1
View File
@@ -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)。 输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到 `Min`、End 到 `Max`(如 [0, 10] 步长 3 时 End 为 10,再 Down 为 9)。
+26
View File
@@ -175,3 +175,29 @@ func TestRadioGroupKeysByEquality(t *testing.T) {
t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One")) 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)
}
}
}
+36 -21
View File
@@ -36,8 +36,9 @@ type NumberInputOptions struct {
// numberInputSpec is NumberInputOptions resolved. // numberInputSpec is NumberInputOptions resolved.
type numberInputSpec struct { type numberInputSpec struct {
lo, hi, step float64 // lo and hi are the bounds on the precision grid; min and max the original ones.
decimals int lo, hi, min, max, step float64
decimals int
} }
func (s numberInputSpec) format(v float64) string { 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) return strconv.FormatFloat(v, 'f', s.decimals, 64)
} }
// clamp rounds v to the precision, then bounds it; the bounds sit on that grid. // round rounds v to the precision.
func (s numberInputSpec) clamp(v float64) float64 { func (s numberInputSpec) round(v float64) float64 {
p := math.Pow(10, float64(s.decimals)) 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 { func (s numberInputSpec) stepBy(v float64, n int) float64 {
if math.IsNaN(v) { if math.IsNaN(v) {
// From empty, the first step lands next to zero. // 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) { if math.IsInf(base, 0) {
base = 0 base = 0
} }
k := (v - base) / s.step at := func(k float64) float64 { return s.round(base + k*s.step) }
if n > 0 { k := math.Round((v - base) / s.step)
k = numberInputFloor(k) + float64(n) // Off the grid, the nearest point in the direction is the first step.
} else { if n > 0 && at(k) <= v || n < 0 && at(k) >= v {
k = numberInputCeil(k) + float64(n) 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. // numberInputSame reports equal values, counting NaN (empty) as equal.
func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) } 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 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)) p := math.Pow(10, float64(s.decimals))
s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0 if s.lo = s.round(s.lo); s.lo < s.min {
s.hi = numberInputFloor(s.hi*p)/p + 0 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 { if s.lo > s.hi {
panic("mujica: NumberInput has no value in range at this precision") panic("mujica: NumberInput has no value in range at this precision")
} }
+5 -5
View File
@@ -23,13 +23,13 @@ func TestPasswordInputMaskPasteAndReveal(t *testing.T) {
tt.Command("paste") tt.Command("paste")
tt.Type("et") tt.Type("et")
if pw != "s3cret" || changes != 2 { 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.SetClipboard("")
tt.Command("selectAll") tt.Command("selectAll")
tt.Command("copy") tt.Command("copy")
if tt.Clipboard() != "" { 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() { for _, s := range tt.Texts() {
if s == "s3cret" { if s == "s3cret" {
@@ -40,13 +40,13 @@ func TestPasswordInputMaskPasteAndReveal(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := tt.Find("Hide password"); !ok || pw != "s3cret" { 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.Click("Password")
tt.Command("selectAll") tt.Command("selectAll")
tt.Command("copy") tt.Command("copy")
if tt.Clipboard() != "s3cret" { 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) tt.Key(0, ui.KeyEnter)
if submits != 1 { if submits != 1 {
@@ -82,7 +82,7 @@ func TestPasswordInputDisabled(t *testing.T) {
_ = tt.Click("Off") _ = tt.Click("Off")
tt.Type("zz") tt.Type("zz")
if pw != "locked" { 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 { if _, ok := tt.Find("Show password"); !ok {
t.Fatal("disabled toggle revealed") t.Fatal("disabled toggle revealed")