diff --git a/accordion.go b/accordion.go index 5decaf9..84517f9 100644 --- a/accordion.go +++ b/accordion.go @@ -104,7 +104,7 @@ func (a *Accordion) sectionRects() (headers, bodies []Rect) { headers = make([]Rect, n) bodies = make([]Rect, n) - remaining := r.H - ExpanderHeaderH*n + remaining := r.H - ExpanderHeaderHeight()*n if remaining < 0 { remaining = 0 } @@ -123,8 +123,8 @@ func (a *Accordion) sectionRects() (headers, bodies []Rect) { y := r.Y seen := 0 for i := 0; i < n; i++ { - headers[i] = Rect{X: r.X, Y: y, W: r.W, H: ExpanderHeaderH} - y += ExpanderHeaderH + headers[i] = Rect{X: r.X, Y: y, W: r.W, H: ExpanderHeaderHeight()} + y += ExpanderHeaderHeight() if a.isExpanded(i) { h := perBody seen++ @@ -149,9 +149,9 @@ func (a *Accordion) Draw(p painter.Painter, theme *Theme) { hr := headers[i] fillRect(p, hr.X, hr.Y, hr.W, hr.H, theme.SurfaceAlt) cx := hr.X + 6 - cy := hr.Y + ExpanderHeaderH/2 + cy := hr.Y + ExpanderHeaderHeight()/2 drawDisclosureChevron(p, cx, cy, a.isExpanded(i), theme.OnSurface) - textY := hr.Y + (ExpanderHeaderH-a.glyphHeight())/2 + textY := hr.Y + (ExpanderHeaderHeight()-a.glyphHeight())/2 a.drawText(p, hr.X+16, textY, sec.Title, theme.OnSurface) if sec.Body == nil { continue diff --git a/check.go b/check.go index 67586fd..9095d77 100644 --- a/check.go +++ b/check.go @@ -20,15 +20,19 @@ type CheckButton struct { OnToggle func(checked bool) } -// checkBoxSize is the default pixel side length of the box (used when Size == 0). +// checkBoxSize is the default LOGICAL side length of the box (used when +// Size == 0); the effective size is this at the current metric scale. const checkBoxSize = 12 +// checkLabelGap is the logical gap between the box and its label. +const checkLabelGap = 4 + // boxSize returns the effective box side length. func (c *CheckButton) boxSize() int { if c.Size > 0 { - return c.Size + return c.Size // a caller-chosen size is already in the units it chose } - return checkBoxSize + return scaled(checkBoxSize) } // NewCheckButton constructs a CheckButton with the given label + @@ -55,7 +59,11 @@ func (c *CheckButton) Draw(p painter.Painter, theme *Theme) { fillRect(p, r.X, boxY, box, box, fill) strokeRect(p, r.X, boxY, box, box, border) if c.Checked { - if c.Size > 0 { + if box != checkBoxSize { + // Any box that is not the classic 12 -- a caller-chosen Size, or the + // default one at a metric scale above 1 -- gets the proportional + // tick, so the mark grows with the box instead of sitting in its + // top-left corner. drawCheckmark(p, r.X, boxY, box, tick) } else { // The classic fixed 12px checkmark (byte-identical to prior releases). @@ -69,7 +77,7 @@ func (c *CheckButton) Draw(p painter.Painter, theme *Theme) { } // Label to the right of the box, vertically centred on glyph row. textY := r.Y + (r.H-c.glyphHeight())/2 - c.drawText(p, r.X+box+4, textY, c.Label, labelInk) + c.drawText(p, r.X+box+scaled(checkLabelGap), textY, c.Label, labelInk) c.drawFocusRing(p, theme, r) } diff --git a/expander.go b/expander.go index 60dce1b..44134e2 100644 --- a/expander.go +++ b/expander.go @@ -21,9 +21,15 @@ type Expander struct { OnExpand func(expanded bool) } -// ExpanderHeaderH is the pixel height of the clickable header row. +// ExpanderHeaderH is the LOGICAL height of the clickable header row. Use +// [ExpanderHeaderHeight] for the height to lay out with: at a metric scale +// above 1 the header is taller, like every other metric. const ExpanderHeaderH = 24 +// ExpanderHeaderHeight is the header height in device pixels at the current +// [MetricScale]. +func ExpanderHeaderHeight() int { return scaled(ExpanderHeaderH) } + // NewExpander builds an Expander with a label + initial content // widget (may be nil to render header-only). func NewExpander(label string, content Widget) *Expander { @@ -35,11 +41,11 @@ func NewExpander(label string, content Widget) *Expander { func (e *Expander) Draw(p painter.Painter, theme *Theme) { r := e.Bounds() // Header background. - fillRect(p, r.X, r.Y, r.W, ExpanderHeaderH, theme.SurfaceAlt) + fillRect(p, r.X, r.Y, r.W, ExpanderHeaderHeight(), theme.SurfaceAlt) // Chevron: small triangle in Theme.OnSurface. Collapsed → right- // pointing (▶), expanded → down-pointing (▼). 5-px tall. cx := r.X + 6 - cy := r.Y + ExpanderHeaderH/2 + cy := r.Y + ExpanderHeaderHeight()/2 if e.Expanded { // ▼ : flat top (widest row), point at bottom (narrow tip). // At t=0 the 1-pixel tip lands at cy+2; at t=4 the 9-pixel @@ -55,16 +61,16 @@ func (e *Expander) Draw(p painter.Painter, theme *Theme) { fillRect(p, cx+2-t, cy-t, 1, 1+2*t, theme.OnSurface) } } - textY := r.Y + (ExpanderHeaderH-e.glyphHeight())/2 + textY := r.Y + (ExpanderHeaderHeight()-e.glyphHeight())/2 e.drawText(p, r.X+16, textY, e.Label, theme.OnSurface) if e.Expanded && e.Content != nil { - body := Rect{X: r.X, Y: r.Y + ExpanderHeaderH, W: r.W, H: r.H - ExpanderHeaderH} + body := Rect{X: r.X, Y: r.Y + ExpanderHeaderHeight(), W: r.W, H: r.H - ExpanderHeaderHeight()} e.Content.SetBounds(body) e.Content.Draw(p, theme) } // Focus ring around the clickable header row (paints nothing when unfocused, // so an unfocused render is byte-identical). - e.drawFocusRing(p, theme, Rect{X: r.X, Y: r.Y, W: r.W, H: ExpanderHeaderH}) + e.drawFocusRing(p, theme, Rect{X: r.X, Y: r.Y, W: r.W, H: ExpanderHeaderHeight()}) } // OnEvent: click on the header toggles Expanded + fires OnExpand; @@ -84,17 +90,17 @@ func (e *Expander) OnEvent(ev Event) { if ev.Kind != EventClick { return } - if ev.Y < ExpanderHeaderH { + if ev.Y < ExpanderHeaderHeight() { e.toggle() return } if e.Expanded && e.Content != nil { - // Content occupies the body below the ExpanderHeaderH-tall header. Bound + // Content occupies the body below the ExpanderHeaderHeight()-tall header. Bound // it (matching Draw) and translate the click into its local frame, so a // click on interactive content isn't shifted down by the header height // (plus the Expander's own origin) and misrouted. r := e.Bounds() - body := Rect{X: r.X, Y: r.Y + ExpanderHeaderH, W: r.W, H: r.H - ExpanderHeaderH} + body := Rect{X: r.X, Y: r.Y + ExpanderHeaderHeight(), W: r.W, H: r.H - ExpanderHeaderHeight()} e.Content.SetBounds(body) e.Content.OnEvent(translateEvent(ev, r, body)) } diff --git a/gauge.go b/gauge.go index 60b67f8..c9d3b7f 100644 --- a/gauge.go +++ b/gauge.go @@ -10,7 +10,7 @@ import ( "github.com/go-widgets/painter" ) -// GaugeThickness is the default arc stroke width in pixels, used when +// GaugeThickness is the default arc stroke width in LOGICAL pixels, used when // Gauge.Thickness is left at its zero value. It leaves room inside the // ring for the centred Caption on the toolkit's 5x7 font while keeping // the track visually prominent. @@ -114,7 +114,7 @@ func (g *Gauge) Draw(p painter.Painter, theme *Theme) { } thick := g.Thickness if thick <= 0 { - thick = GaugeThickness + thick = scaled(GaugeThickness) } innerR := outerR - thick if innerR < 0 { diff --git a/go.mod b/go.mod index 2742c3f..c50aee0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/go-widgets/toolkit go 1.26.4 -require github.com/go-widgets/painter v0.9.0 +require github.com/go-widgets/painter v0.11.0 require github.com/go-opentype/opentype v0.5.0 @@ -16,3 +16,5 @@ require github.com/go-opentype/fonts v0.6.0 require github.com/go-widgets/mvvm v0.5.0 require github.com/go-images/images v0.0.0-20260811115337-bc5d586f8e38 + +require github.com/go-gfx/gfx v0.1.0 // indirect diff --git a/go.sum b/go.sum index 8bbdece..bf41c21 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/go-gfx/gfx v0.1.0 h1:tBwT4YYnsdZlESY5MRp8aptg30iZxIH0G/8pZ/Ve3ZY= +github.com/go-gfx/gfx v0.1.0/go.mod h1:bFt/MWyYWRU3Ic9IaB8XOC9KLMMHRRmahMk4FaIGK7g= github.com/go-images/images v0.0.0-20260811115337-bc5d586f8e38 h1:p+DjIujiwUvBiyD0oS9SUatf3pMKo0GpcA4p/Kyv7d8= github.com/go-images/images v0.0.0-20260811115337-bc5d586f8e38/go.mod h1:nWwQCf77xG1GhaSD3rvlp7FNjjDhz47vrztyeBvIerk= github.com/go-opentype/bidi v0.2.1 h1:7xCGqywPYxmwSfPBgP1Ewrp2Iwg2S03NeKxGWTz2guo= @@ -10,7 +12,5 @@ github.com/go-opentype/shape v0.4.0 h1:Yz8ooo9+7HlzuzS72YWjJVM22GNGFf6jcxWfNNJOs github.com/go-opentype/shape v0.4.0/go.mod h1:7kybm69yrtRgQnjaxPyYI9nPRFFdBdzlrmjcYCBfCbg= github.com/go-widgets/mvvm v0.5.0 h1:o5hh6HAxbApONcbZxmyV9q12pCAPGRd6L24aS3gaCbA= github.com/go-widgets/mvvm v0.5.0/go.mod h1:Phdrd434RLxXW1D6dL1PPQH1tABwYLIN2X7jQVC4TbY= -github.com/go-widgets/painter v0.8.0 h1:QeBiNalv84+qvjRJ/SywMNXHaxHA0lF5uN0oJCQX01s= -github.com/go-widgets/painter v0.8.0/go.mod h1:ccmlkH2UmcXQh6rt9Fu2eDt2RI0B5V0POaGmUKeW0EQ= -github.com/go-widgets/painter v0.9.0 h1:/y0qn0+TP3bhdLMi4+/vmUUBnSp3/L2RQBBgwAmJkes= -github.com/go-widgets/painter v0.9.0/go.mod h1:ccmlkH2UmcXQh6rt9Fu2eDt2RI0B5V0POaGmUKeW0EQ= +github.com/go-widgets/painter v0.11.0 h1:xsj4zTz8B43rOZnWrx7ZsaUBMgJyvgaE/Pq2FfcG2Sw= +github.com/go-widgets/painter v0.11.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ= diff --git a/metricscale_audit_test.go b/metricscale_audit_test.go new file mode 100644 index 0000000..bb8c03f --- /dev/null +++ b/metricscale_audit_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2026 the wasmdesk/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "fmt" + "sort" + "strings" + "testing" +) + +// Does a widget actually honour [SetMetricScale]? +// +// The knob exists and is documented as the thing a HiDPI host sets once, so +// that "the whole toolkit lays out and paints crisp at that resolution". Ten +// files out of a hundred and sixty route their metrics through it. The rest +// carry raw pixel constants -- a 6-pixel radius, a 1-pixel border, an 8-pixel +// pad -- which stay 6, 1 and 8 on a screen with twice the pixels, so the chrome +// shrinks to half its intended size while the window around it is sharp. +// +// This finds them, rather than my reading a hundred files and guessing which +// numbers are pixels. +// +// The instrument is the RUN-LENGTH PROFILE of the middle row and middle column. +// Draw the widget at scale 1 in a w×h box and at scale 2 in a 2w×2h box, then +// walk the middle scanline of each and record the runs of equal colour. A widget +// whose metrics all scale gives the SAME NUMBER of runs with each one twice as +// wide; one with an unscaled constant gives a run that did not double. Neither +// the painted area nor the bounding box can tell the two apart -- both are +// driven by the bounds the caller supplies, which double either way -- and that +// is why this measures the interior and not the outline. +// +// It measures CHROME, not text. The catalogue gives widgets no labels on +// purpose: type carries its own scale (SetFont / NewBitmapFont), a centred +// baseline rounds -- (H-glyphH)/2 is 12 at one scale and 25 at twice it, not 24 +// -- and a scanline that lands one glyph row off reports a difference that is +// arithmetic rather than a defect. Whether text should follow the metric scale +// is a real question, and a separate one. + +// runLengths walks one row (or column) of an RGBA buffer and returns the length +// of each run of identical pixels. +func runLengths(buf []byte, w, h int, index int, column bool) []int { + n := w + at := func(i int) RGBA { return pixelAt(buf, w, i, index) } + if column { + n = h + at = func(i int) RGBA { return pixelAt(buf, w, index, i) } + } + var runs []int + cur := 1 + prev := at(0) + for i := 1; i < n; i++ { + c := at(i) + if c == prev { + cur++ + continue + } + runs = append(runs, cur) + cur, prev = 1, c + } + return append(runs, cur) +} + +// withoutSlivers drops runs no wider than one LOGICAL pixel at the scale they +// were measured at. +// +// Such a run is an anti-aliasing sliver, not a metric: the boundary of a filled +// round-rect is one pixel of partial coverage at any size, and at twice the +// scale it may resolve into two steps where it had one. Comparing those reports +// arithmetic as a defect. +// +// In logical units, because a border that DOES scale is one pixel at 1x and two +// at 2x -- dropping "runs of one pixel" would keep the second and not the first, +// and then every framed widget looks broken for having been fixed. +// +// It does not hide a border that failed to scale, which is the defect this whole +// audit exists for: a border that stayed one pixel leaves the interior run two +// pixels too wide, and the interior is nobody's sliver. +func withoutSlivers(runs []int, scale int) []int { + out := make([]int, 0, len(runs)) + for _, r := range runs { + if r > scale { + out = append(out, r) + } + } + return out +} + +// scaleReport is what one widget's audit produced. +type scaleReport struct { + name string + // mismatch is the first run that did not double, empty when all of them did. + mismatch string +} + +// auditScaling draws build() at scale 1 and at scale 2 and reports a run that +// did not double. +// +// WIDTHS, not positions. A boundary's position moves by a single pixel when a +// one-pixel border fails to scale, which is indistinguishable from the rounding +// of an odd metric -- I tried that instrument first and it declared the very +// defect this was written to find to be within tolerance. A run's WIDTH is not +// ambiguous: a border that stayed one pixel wide while everything around it +// doubled is a border that did not scale. +// +// Three lines per axis, and a widget is reported only when all three disagree. +// A metric that does not scale is wrong on EVERY line -- the border, the pad, +// the box are there whatever row you look along. A diagonal feature crossing an +// exact scanline is not: a checkmark's second stroke may meet row h/2 at one +// scale and row h/2+1 at twice it, purely from integer rounding, and calling +// that a defect would have me contorting a widget to satisfy a probe. +// +// Tolerance is one pixel, for the odd metric that rounds. +func auditScaling(t *testing.T, name string, w, h int, build func() Widget) scaleReport { + t.Helper() + draw := func(scale int) ([]byte, int, int) { + defer SetMetricScale(1) + defer SetFont(NewBitmapFont(1)) + SetMetricScale(float64(scale)) + SetFont(NewBitmapFont(scale)) + bw, bh := w*scale, h*scale + buf := makeSurface(bw, bh) + wd := build() + wd.SetBounds(Rect{X: 0, Y: 0, W: bw, H: bh}) + wd.Draw(newP(buf, bw), DefaultDark()) + return buf, bw, bh + } + one, w1, h1 := draw(1) + two, w2, h2 := draw(2) + + for _, axis := range []struct { + name string + column bool + }{{"row", false}, {"column", true}} { + var failures []string + for _, frac := range []float64{1.0 / 3, 1.0 / 2, 2.0 / 3} { + var r1, r2 []int + if axis.column { + r1 = runLengths(one, w1, h1, int(float64(w1)*frac), true) + r2 = runLengths(two, w2, h2, int(float64(w2)*frac), true) + } else { + r1 = runLengths(one, w1, h1, int(float64(h1)*frac), false) + r2 = runLengths(two, w2, h2, int(float64(h2)*frac), false) + } + r1, r2 = withoutSlivers(r1, 1), withoutSlivers(r2, 2) + if len(r1) != len(r2) { + failures = append(failures, fmt.Sprintf("at %.2f: %d runs at 1x and %d at 2x (%v vs %v)", + frac, len(r1), len(r2), r1, r2)) + continue + } + for i := range r1 { + want := 2 * r1[i] + if diff := r2[i] - want; diff < -1 || diff > 1 { + failures = append(failures, fmt.Sprintf("at %.2f: run %d is %d at 1x and %d at 2x, want ~%d", + frac, i, r1[i], r2[i], want)) + break + } + } + } + if len(failures) == 3 { + return scaleReport{name, axis.name + " " + failures[1]} + } + } + return scaleReport{name: name} +} + +// TestMetricScaleAudit is the inventory: it audits the widget catalogue and +// FAILS with the list of widgets whose interior does not scale. +// +// It is written to be read, not merely to be green. Every name it prints is a +// widget that will be half-size on a HiDPI screen, and the list shrinks as they +// are fixed. +func TestMetricScaleAudit(t *testing.T) { + type entry struct { + name string + w, h int + build func() Widget + } + catalogue := []entry{ + {"Button", 120, 32, func() Widget { return &Button{} }}, + {"Button/flat", 120, 32, func() Widget { return &Button{Flat: true} }}, + {"CheckButton", 120, 24, func() Widget { return &CheckButton{Checked: true} }}, + {"Switch", 60, 24, func() Widget { return &Switch{On: true} }}, + {"Card", 160, 120, func() Widget { return &Card{} }}, + {"ProgressBar", 120, 16, func() Widget { return &ProgressBar{Fraction: 0.5} }}, + {"Chip", 80, 24, func() Widget { return &Chip{} }}, + {"Kbd", 40, 20, func() Widget { return &Kbd{} }}, + {"Avatar", 40, 40, func() Widget { return &Avatar{} }}, + {"Entry", 160, 28, func() Widget { return &Entry{} }}, + {"IconButton", 32, 32, func() Widget { return &IconButton{} }}, + {"Alert", 200, 60, func() Widget { return &Alert{} }}, + {"Banner", 200, 40, func() Widget { return &Banner{} }}, + {"Frame", 160, 100, func() Widget { return &Frame{} }}, + {"Gauge", 80, 80, func() Widget { return &Gauge{} }}, + {"Switch/off", 60, 24, func() Widget { return &Switch{} }}, + {"ToggleButton", 100, 30, func() Widget { return &ToggleButton{} }}, + {"Expander", 160, 30, func() Widget { return &Expander{} }}, + } + + var broken []scaleReport + for _, e := range catalogue { + rep := auditScaling(t, e.name, e.w, e.h, e.build) + if rep.mismatch != "" { + broken = append(broken, rep) + } + } + sort.Slice(broken, func(i, j int) bool { return broken[i].name < broken[j].name }) + if len(broken) == 0 { + return + } + var b strings.Builder + fmt.Fprintf(&b, "%d of %d widgets do not honour SetMetricScale:\n", len(broken), len(catalogue)) + for _, r := range broken { + fmt.Fprintf(&b, " %-16s %s\n", r.name, r.mismatch) + } + t.Error(b.String()) +} diff --git a/raster.go b/raster.go index c298f8c..797124f 100644 --- a/raster.go +++ b/raster.go @@ -22,14 +22,28 @@ func fillRect(p painter.Painter, x, y, w, h int, c RGBA) { p.FillRect(painter.Rect{X: x, Y: y, W: w, H: h}, c) } -// strokeRect paints a 1-pixel border on the outline of (x, y, w, h) -// with c. Used by widgets that draw a frame around their body — -// Button, Frame, focus indicator, etc. +// strokeRect paints a one-LOGICAL-pixel border on the outline of (x, y, w, h) +// with c. Used by widgets that draw a frame around their body — Button, Frame, +// focus indicator, etc. +// +// One logical pixel, not one device pixel: a border is a metric like any other, +// and at twice the resolution it has to be twice as thick or it reads as a +// hairline against chrome that grew around it. This one line is why Button, +// Card, Badge, ProgressBar and every other framed widget scale at all. func strokeRect(p painter.Painter, x, y, w, h int, c RGBA) { if w <= 0 || h <= 0 { return } - p.StrokeRect(painter.Rect{X: x, Y: y, W: w, H: h}, c, 1) + p.StrokeRect(painter.Rect{X: x, Y: y, W: w, H: h}, c, strokeWidth()) +} + +// strokeWidth is one logical pixel in device pixels, never less than one: a +// border rounded to zero is a border that disappeared. +func strokeWidth() int { + if w := scaled(1); w > 1 { + return w + } + return 1 } // fillRoundRect fills (x, y, w, h) with c, corners rounded to radius, through @@ -43,12 +57,13 @@ func fillRoundRect(p painter.Painter, x, y, w, h, radius int, c RGBA) { p.FillRoundRect(painter.Rect{X: x, Y: y, W: w, H: h}, radius, c) } -// strokeRoundRect paints a 1-pixel rounded border on (x, y, w, h) with c. +// strokeRoundRect paints a one-logical-pixel rounded border on (x, y, w, h) +// with c. See strokeRect on the thickness. func strokeRoundRect(p painter.Painter, x, y, w, h, radius int, c RGBA) { if w <= 0 || h <= 0 { return } - p.StrokeRoundRect(painter.Rect{X: x, Y: y, W: w, H: h}, radius, c, 1) + p.StrokeRoundRect(painter.Rect{X: x, Y: y, W: w, H: h}, radius, c, strokeWidth()) } // drawLine paints a 1-unit-wide line from (x0, y0) to (x1, y1) with c using diff --git a/switch.go b/switch.go index d076f73..b523ca0 100644 --- a/switch.go +++ b/switch.go @@ -26,9 +26,9 @@ type Switch struct { OnToggle func(on bool) } -// switchPad is the inset from the track edge to the knob's edge, in -// pixels. Matches the visual gap most iOS-style switches use so the -// knob never touches the track border. +// switchPad is the inset from the track edge to the knob's edge, in LOGICAL +// pixels. Matches the visual gap most iOS-style switches use so the knob never +// touches the track border. const switchPad = 2 // NewSwitch constructs a Switch with the given initial state. The @@ -55,14 +55,15 @@ func (s *Switch) Draw(p painter.Painter, theme *Theme) { // Fully-rounded pill track + circular knob -- the iOS/macOS switch shape. fillRoundRect(p, r.X, r.Y, r.W, r.H, r.H/2, track) strokeRoundRect(p, r.X, r.Y, r.W, r.H, r.H/2, border) - knobH := r.H - 2*switchPad + pad := scaled(switchPad) + knobH := r.H - 2*pad knobW := knobH - knobX := r.X + switchPad + knobX := r.X + pad if s.On { - knobX = r.X + r.W - knobW - switchPad + knobX = r.X + r.W - knobW - pad } - fillRoundRect(p, knobX, r.Y+switchPad, knobW, knobH, knobH/2, knob) - strokeRoundRect(p, knobX, r.Y+switchPad, knobW, knobH, knobH/2, border) + fillRoundRect(p, knobX, r.Y+pad, knobW, knobH, knobH/2, knob) + strokeRoundRect(p, knobX, r.Y+pad, knobW, knobH, knobH/2, border) s.drawFocusRing(p, theme, r) }