diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 3d17175c6..ab598a450 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -47,7 +47,6 @@
ShroudedPanel,
TextWidget,
)
-from uilib import profiling
from uilib.gridpanel import GridPanel, TILE_W, CHANNEL
from uilib.pygame_init import font as _make_font
from uilib.lcd_ili9341 import LcdIli9341
@@ -303,8 +302,7 @@ def handle(self, event: ControllerEvent) -> bool:
return top.handle(event) if isinstance(top, InputSink) else False
def poll_updates(self):
- with profiling.measure("poll_updates"):
- self._poll_updates()
+ self._poll_updates()
def _poll_updates(self):
for d in self.w_parameter_dialogs.values():
diff --git a/pistomp/nam/panel.py b/pistomp/nam/panel.py
index 8a60557b3..06fa637c7 100644
--- a/pistomp/nam/panel.py
+++ b/pistomp/nam/panel.py
@@ -51,8 +51,6 @@
import common.token as Token
import pistomp.switchstate as switchstate
-from uilib import profiling
-
from uilib.panel import Panel
from pistomp.input.event import ControllerEvent, EncoderEvent, SwitchEvent, SwitchEventKind
from pistomp.nam import routing
@@ -249,32 +247,31 @@ def _color_at(t: float) -> tuple[int, int, int]:
return stops[-1][1]
def _draw(self, ctx: PaintContext) -> None:
- with profiling.measure("nam.bar._draw"):
- n = self._N_SEGS
- filled = int(self._progress * n)
- sw = self._seg_w
- bx = self._MARGIN
- by = self._BAR_Y
- ctx.fill((0, 0, 0))
-
- for i in range(n):
- t = i / (n - 1) if n > 1 else 0.0
- r, g, b = self._color_at(t)
- if i < filled:
- color: tuple[int, int, int] = (r, g, b)
- else:
- color = (int(r * _BAR_DIM), int(g * _BAR_DIM), int(b * _BAR_DIM))
- ctx.draw_rectangle(Box.xywh(bx + i * (sw + self._SEG_GAP), by, sw, self._BAR_H), fill=color)
-
- label_y = by + self._BAR_H + self._LABEL_GAP
- right_x = ctx.width - self._MARGIN
-
- elapsed_str = _fmt_time(self._elapsed)
- ctx.draw_text((bx, label_y), elapsed_str, fill=(130, 118, 80), font=self._font)
-
- remaining_str = f"−{_fmt_time(self._remaining)}"
- rw, _ = get_text_size(remaining_str, self._font)
- ctx.draw_text((right_x - rw, label_y), remaining_str, fill=(205, 180, 110), font=self._font)
+ n = self._N_SEGS
+ filled = int(self._progress * n)
+ sw = self._seg_w
+ bx = self._MARGIN
+ by = self._BAR_Y
+ ctx.fill((0, 0, 0))
+
+ for i in range(n):
+ t = i / (n - 1) if n > 1 else 0.0
+ r, g, b = self._color_at(t)
+ if i < filled:
+ color: tuple[int, int, int] = (r, g, b)
+ else:
+ color = (int(r * _BAR_DIM), int(g * _BAR_DIM), int(b * _BAR_DIM))
+ ctx.draw_rectangle(Box.xywh(bx + i * (sw + self._SEG_GAP), by, sw, self._BAR_H), fill=color)
+
+ label_y = by + self._BAR_H + self._LABEL_GAP
+ right_x = ctx.width - self._MARGIN
+
+ elapsed_str = _fmt_time(self._elapsed)
+ ctx.draw_text((bx, label_y), elapsed_str, fill=(130, 118, 80), font=self._font)
+
+ remaining_str = f"−{_fmt_time(self._remaining)}"
+ rw, _ = get_text_size(remaining_str, self._font)
+ ctx.draw_text((right_x - rw, label_y), remaining_str, fill=(205, 180, 110), font=self._font)
class LevelMeter(Widget):
@@ -597,7 +594,6 @@ def __init__(
# Read initial values from hardware to seed the internal trackers
self._init_knob_values()
- profiling.maybe_start()
# Connect In2 → Out1 so the user can hear the amp while the panel is open.
routing.connect_monitor()
@@ -675,10 +671,8 @@ def tick(self) -> None:
self._last_tick = now
state = self._engine.state
- profiling.set_context_tag(state.name.lower())
- with profiling.measure("nam.tick"):
- self._tick_body(now, dt, state)
+ self._tick_body(now, dt, state)
def _tick_body(self, now: float, dt: float, state: CaptureState) -> None:
if state != self._last_state:
diff --git a/pistomp/tuner/engine.py b/pistomp/tuner/engine.py
index 8e6b8fb29..5441901b6 100644
--- a/pistomp/tuner/engine.py
+++ b/pistomp/tuner/engine.py
@@ -9,7 +9,6 @@
import numpy as np
import numpy.typing as npt
-from uilib import profiling
from pistomp.tuner.ringbuffer import RingBuffer
from pistomp.tuner.source import AudioSource
from pistomp.tuner.yin import YinDetector
@@ -136,8 +135,7 @@ def _process(self) -> None:
if not self._ring.read_latest(self.FRAME_SIZE, self._frame):
return
- with profiling.measure("rms", bin_override="dsp"):
- rms = float(np.sqrt(np.mean(self._frame**2)))
+ rms = float(np.sqrt(np.mean(self._frame**2)))
if rms < self.SILENCE_RMS:
self._freq_history.clear()
@@ -160,9 +158,8 @@ def _process(self) -> None:
self._onset_holdoff -= 1
return
- with profiling.measure("detect_pitch(YIN)", bin_override="dsp"):
- assert self._detector is not None
- estimate = self._detector.detect(self._frame)
+ assert self._detector is not None
+ estimate = self._detector.detect(self._frame)
if estimate is None:
return
diff --git a/pistomp/tuner/panel.py b/pistomp/tuner/panel.py
index 704357458..71aca7ce9 100644
--- a/pistomp/tuner/panel.py
+++ b/pistomp/tuner/panel.py
@@ -18,7 +18,6 @@
from collections import deque
from typing import Callable, Literal
-from uilib import profiling
from common.fonts import font_path
from uilib.box import Box
from uilib.config import Config
@@ -409,7 +408,6 @@ def __init__(
self.add_sel_widget(self._btn_input)
self._apply_mute_style(muted)
self._cents_history: deque[float] = deque(maxlen=3)
- profiling.maybe_start()
def _create_engine(self, port: int) -> TunerBackend:
return self._backend_factory(port)
@@ -450,8 +448,6 @@ def tick(self) -> None:
self._cents_history.clear()
cents = None
- profiling.set_cents_bin(profiling.bin_for_cents(cents))
- with profiling.measure("TunerPanel.tick"):
- self._header.tick(reading)
- self._bar.tick(cents)
- self._strobe.tick(cents)
+ self._header.tick(reading)
+ self._bar.tick(cents)
+ self._strobe.tick(cents)
diff --git a/plugins/eq/parametric.py b/plugins/eq/parametric.py
index ee04243e3..2ce773585 100644
--- a/plugins/eq/parametric.py
+++ b/plugins/eq/parametric.py
@@ -25,7 +25,7 @@
)
from uilib.box import Box
from uilib.config import Config
-from uilib.footswitch import tint_mask
+from uilib.glyphs.tint import tint_mask
from uilib.glyphs.circle import CircleGlyph, RingGlyph
from uilib.misc import INACTIVE_SHADE, InputEvent, get_text_size
from uilib.widget import Widget
diff --git a/plugins/layouts/reticule_graph.py b/plugins/layouts/reticule_graph.py
index acc694a25..32ba32504 100644
--- a/plugins/layouts/reticule_graph.py
+++ b/plugins/layouts/reticule_graph.py
@@ -5,6 +5,7 @@
from uilib.box import Box
from uilib.glyphs.circle import RingGlyph
+from uilib.glyphs.tint import tint_mask
from uilib.misc import INACTIVE_SHADE, get_text_size, shade_color
from uilib.widget import Widget
@@ -188,8 +189,4 @@ def _draw_crosshair(self, ctx, shade: float) -> None:
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
ctx.draw_line([(x + dx * 3, y + dy * 3), (x + dx * 8, y + dy * 8)], fill=col, width=1)
ring = RingGlyph(6, ring_half=0.9).render()
- tinted = ring.copy()
- cs = pygame.Surface(ring.get_size(), pygame.SRCALPHA)
- cs.fill(col)
- tinted.blit(cs, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
- ctx.paste(tinted, (x - 7, y - 7))
+ ctx.paste(tint_mask(ring, col), (x - 7, y - 7))
diff --git a/tests/snapshots/v3/test_footswitch_widget/test_v3_footswitch_widget_truncates_long_labels/0.png b/tests/snapshots/v3/test_footswitch_widget/test_v3_footswitch_widget_truncates_long_labels/0.png
index d18c3f5bd..1f33fac17 100644
Binary files a/tests/snapshots/v3/test_footswitch_widget/test_v3_footswitch_widget_truncates_long_labels/0.png and b/tests/snapshots/v3/test_footswitch_widget/test_v3_footswitch_widget_truncates_long_labels/0.png differ
diff --git a/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/opened.png b/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/opened.png
index 310c6801a..77f57929f 100644
Binary files a/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/opened.png and b/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/opened.png differ
diff --git a/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/scrolled_mid.png b/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/scrolled_mid.png
index 604291fec..64e52c717 100644
Binary files a/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/scrolled_mid.png and b/tests/snapshots/v3/test_notes_panel/test_notes_panel_saga/scrolled_mid.png differ
diff --git a/uilib/__init__.py b/uilib/__init__.py
index b1c700bbb..64a88f74b 100644
--- a/uilib/__init__.py
+++ b/uilib/__init__.py
@@ -58,7 +58,8 @@
from uilib.config import Config
from uilib.container import ContainerWidget
from uilib.dialog import ConfirmDialog, Dialog, DialogDecorator, MessageDialog
-from uilib.footswitch import FootswitchWidget, TapTempoProtocol, tint_mask
+from uilib.footswitch import FootswitchWidget, TapTempoProtocol
+from uilib.glyphs.tint import tint_mask
from uilib.icon import Icon
from uilib.image import ImageWidget, load_surface
from uilib.menu import Menu
diff --git a/uilib/dialog.py b/uilib/dialog.py
index 93505aafc..b1d6eadb7 100644
--- a/uilib/dialog.py
+++ b/uilib/dialog.py
@@ -54,7 +54,7 @@ def _adjust_box(self):
# Extra margin around text
tmargin = 2
- self.tw, self.th = get_text_size(self.title.text, self.title.font, self.title.font_metrics)
+ self.tw, self.th = get_text_size(self.title.text, self.title.font)
self.box = Box(pb.x0 - m, pb.y0 - self.th - m - tmargin, pb.x1 + m, pb.y1 + m)
trace(self, "new box=", self.box)
tbox = Box(o, o, self.box.width - o, self.th + o)
diff --git a/uilib/footswitch.py b/uilib/footswitch.py
index dc7f93cf3..8d8f0633b 100644
--- a/uilib/footswitch.py
+++ b/uilib/footswitch.py
@@ -23,6 +23,7 @@
from uilib.box import Box
from uilib.config import Color, Config
from uilib.glyphs import CircleGlyph, RingGlyph
+from uilib.glyphs.tint import tint_mask
from uilib.misc import get_text_size
from uilib.paint import PaintContext
from uilib.widget import Widget
@@ -57,15 +58,6 @@ def get_bpm(self) -> float: ...
TITLE_WHITE: Color = (255, 255, 255)
-def tint_mask(mask: pygame.Surface, color: Color) -> pygame.Surface:
- """Tint a white alpha-mask glyph into `color` (BLEND_RGBA_MULT on a copy)."""
- tinted = mask.copy()
- color_surf = pygame.Surface(mask.get_size(), pygame.SRCALPHA)
- color_surf.fill(color)
- tinted.blit(color_surf, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
- return tinted
-
-
class FootswitchWidget(Widget):
"""Footswitch indicator: a colored dot (the "LED") with a label below,
or a letter badge when unassigned. When the underlying footswitch is the
diff --git a/uilib/glyphs/arc_ring.py b/uilib/glyphs/arc_ring.py
index ecfee8ded..8d1d14a08 100644
--- a/uilib/glyphs/arc_ring.py
+++ b/uilib/glyphs/arc_ring.py
@@ -25,22 +25,25 @@
free for a label.
The glyph renders to an SRCALPHA surface. Blit at (cx - half_size, cy -
-half_size) to centre on (cx, cy). The radial/angular grids are built once
-in __init__; only the per-t compositing runs in render().
+half_size) to centre on (cx, cy). Geometry is precomputed in __init__ over the
+ink band only; render() is cached.
"""
from __future__ import annotations
import math
+from functools import lru_cache
import numpy as np
import pygame
+from common.color import ColorRGB
+
_START_DEG = 210.0 # arc start — 7-o'clock
_SWEEP_DEG = 300.0 # total arc travel
_AA_DEG = 1.5 # angular AA falloff (≈1 px at r=35)
-ColorRGB = tuple[int, int, int]
+__all__ = ["ArcRingGlyph", "ColorRGB"]
class ArcRingGlyph:
@@ -61,20 +64,34 @@ def __init__(
self._size = size
xs = np.arange(size, dtype=float)
- ys = np.arange(size, dtype=float)
- X, Y = np.meshgrid(xs, ys)
- self._X = X
- self._Y = Y
+ X, Y = np.meshgrid(xs, xs)
dx = X - self._half
dy = Y - self._half
d = np.sqrt(dx ** 2 + dy ** 2)
- self._ring_cov: np.ndarray = np.clip(
- ring_half + 0.5 - np.abs(d - self._r), 0.0, 1.0
- )
+ ring_cov = np.clip(ring_half + 0.5 - np.abs(d - self._r), 0.0, 1.0)
# Clockwise-from-top angle [0, 360), shifted so _START_DEG maps to 0
angle = np.degrees(np.arctan2(dx, -dy)) % 360.0
- self._shifted: np.ndarray = (angle - _START_DEG) % 360.0
+ shifted = (angle - _START_DEG) % 360.0
+
+ # Keep only the annulus the ring stroke or tip dot can reach — a third of
+ # the grid — so render() works on a ~2k vector, not size². Pixels outside
+ # it are transparent under the full-grid formula anyway.
+ reach = max(ring_half, tip_radius) + 1.0
+ rows, cols = np.nonzero(np.abs(d - self._r) <= reach)
+ self._ring_cov: np.ndarray = ring_cov[rows, cols]
+ self._shifted: np.ndarray = shifted[rows, cols]
+ # Tip distance is derived in unflipped space, so keep unflipped coords.
+ self._px: np.ndarray = cols.astype(float)
+ self._py: np.ndarray = rows.astype(float)
+
+ # Row-major destination index, with flip_v folded in so the reflection is
+ # free at render time. Packing bytes for image.frombuffer beats scattering
+ # into a locked surfarray view by ~3x.
+ iy = (size - 1 - rows) if self._flip_v else rows
+ self._lin: np.ndarray = iy.astype(np.intp) * size + cols.astype(np.intp)
+ # Never cleared: the index set is fixed and every render rewrites all of it.
+ self._rgba: np.ndarray = np.zeros((size * size, 4), dtype=np.uint8)
@property
def half_size(self) -> int:
@@ -101,6 +118,10 @@ def tip_center(self, t: float) -> tuple[float, float]:
y = self._half + cos if self._flip_v else self._half - cos
return (x, y)
+ # Keyed on the glyph too, so it pins self — fine, glyphs outlive their panel.
+ # A dial repaints far more often than its value changes (any sibling cell going
+ # dirty repaints the whole column), so most calls re-ask for the same t.
+ @lru_cache(maxsize=64)
def render(
self,
t: float,
@@ -108,7 +129,10 @@ def render(
empty_color: ColorRGB,
tip_color: ColorRGB,
) -> pygame.Surface:
- """Return an SRCALPHA surface with the arc drawn in two colours + tip dot."""
+ """Arc in two colours + tip dot, on an SRCALPHA surface.
+
+ The surface is shared — blit it, never mutate it.
+ """
t = max(0.0, min(1.0, t))
sweep = t * _SWEEP_DEG
aa = _AA_DEG
@@ -128,20 +152,13 @@ def render(
* np.clip((_SWEEP_DEG - shifted) / aa + 0.5, 0.0, 1.0)
)
- # Tip dot at the current value position on the ring
- tip_deg = _START_DEG + sweep
- tip_rad = math.radians(tip_deg)
+ # Tip dot at the value position; unflipped, per _lin.
+ tip_rad = math.radians(_START_DEG + sweep)
tip_cx = self._half + self._r * math.sin(tip_rad)
tip_cy = self._half - self._r * math.cos(tip_rad)
- tip_d = np.sqrt((self._X - tip_cx) ** 2 + (self._Y - tip_cy) ** 2)
+ tip_d = np.sqrt((self._px - tip_cx) ** 2 + (self._py - tip_cy) ** 2)
tip_cov = np.clip(self._tip_radius + 0.5 - tip_d, 0.0, 1.0)
- if self._flip_v:
- # True vertical reflection of the final image: gap moves to the top.
- filled_cov = np.flipud(filled_cov)
- empty_cov = np.flipud(empty_cov)
- tip_cov = np.flipud(tip_cov)
-
fr, fg_c, fb = filled_color
er, eg, eb = empty_color
tr, tg, tb = tip_color
@@ -151,13 +168,10 @@ def render(
B = np.clip(filled_cov * fb + empty_cov * eb + tip_cov * tb, 0, 255).astype(np.uint8)
A = np.clip((filled_cov + empty_cov + tip_cov) * 255, 0, 255).astype(np.uint8)
- surf = pygame.Surface((self._size, self._size), pygame.SRCALPHA)
- pix = pygame.surfarray.pixels3d(surf)
- pix[:, :, 0] = R.T
- pix[:, :, 1] = G.T
- pix[:, :, 2] = B.T
- del pix
- pa = pygame.surfarray.pixels_alpha(surf)
- pa[:] = A.T
- del pa
- return surf
+ lin = self._lin
+ rgba = self._rgba
+ rgba[lin, 0] = R
+ rgba[lin, 1] = G
+ rgba[lin, 2] = B
+ rgba[lin, 3] = A
+ return pygame.image.frombuffer(rgba.tobytes(), (self._size, self._size), "RGBA")
diff --git a/uilib/glyphs/rounded_rect.py b/uilib/glyphs/rounded_rect.py
index c330b728e..bd37dd348 100644
--- a/uilib/glyphs/rounded_rect.py
+++ b/uilib/glyphs/rounded_rect.py
@@ -125,6 +125,7 @@ def _iq(qx, qy, rc):
return np.minimum(np.minimum(tl_s, tr_s), np.minimum(bl_s, br_s))
+@lru_cache(maxsize=256)
def _render_filled_rounded_rect(
width: int,
height: int,
@@ -132,15 +133,20 @@ def _render_filled_rounded_rect(
r_tr: int,
r_br: int,
r_bl: int,
- fill: ColorLike | None,
+ fill: ColorRGB | None,
border_width: int,
- border_top: ColorLike | None,
- border_right: ColorLike | None,
- border_bottom: ColorLike | None,
- border_left: ColorLike | None,
+ border_top: ColorRGB | None,
+ border_right: ColorRGB | None,
+ border_bottom: ColorRGB | None,
+ border_left: ColorRGB | None,
) -> pygame.Surface:
"""Render fill + border as a single SRCALPHA surface with analytic AA.
+ Results are cached by the full parameter set (``@lru_cache(256)``).
+ All parameters are hashable — ``RectBorder`` and ``Radius`` are frozen
+ dataclasses, and colors are normalized to ``ColorRGB`` tuples by
+ ``RoundedRectGlyph.render()`` before entering the cache.
+
SDF conventions:
- ``sdf > 0`` : outside the rounded rect
- ``sdf < 0`` : inside the rounded rect
@@ -320,9 +326,10 @@ def _render_filled_rounded_rect(
class RoundedRectGlyph:
"""Filled rounded rectangle with optional per-side border.
- Results are cached by the full parameter set so multiple glyph
- instances with the same shape share the same underlying surface.
- Fill and border are rendered into a single surface, not composited.
+ Results are cached by the full parameter set via ``@lru_cache`` on
+ ``_render_filled_rounded_rect`` so multiple glyph instances with the
+ same shape and colors share the same underlying surface. Fill and
+ border are rendered into a single surface, not composited.
"""
def __init__(
@@ -350,7 +357,12 @@ def height(self) -> int:
return self._h
def render(self) -> pygame.Surface:
- """Render fill + border as a single opaque SRCALPHA surface."""
+ """Render fill + border as a single opaque SRCALPHA surface.
+
+ Colors are normalized to ``ColorRGB`` tuples before entering the
+ cached ``_render_filled_rounded_rect`` so the cache key is stable
+ across equivalent color specifications (e.g. 'white' vs (255,255,255)).
+ """
return _render_filled_rounded_rect(
self._w,
self._h,
@@ -358,12 +370,12 @@ def render(self) -> pygame.Surface:
self._r.top_right,
self._r.bottom_right,
self._r.bottom_left,
- self._fill,
+ _to_rgb(self._fill),
self._border_width,
- self._border.top,
- self._border.right,
- self._border.bottom,
- self._border.left,
+ _to_rgb(self._border.top),
+ _to_rgb(self._border.right),
+ _to_rgb(self._border.bottom),
+ _to_rgb(self._border.left),
)
diff --git a/uilib/glyphs/tint.py b/uilib/glyphs/tint.py
new file mode 100644
index 000000000..e0e6988d4
--- /dev/null
+++ b/uilib/glyphs/tint.py
@@ -0,0 +1,46 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Colorizing white alpha-mask glyphs.
+
+Glyph caches are keyed on geometry only, so masks come back white and the caller
+tints at blit time. The tint is cached too: mask surfaces are `lru_cache`d (so
+their identity is stable) and the colors come from small constant sets, so a
+handful of entries covers every footswitch dot, icon, EQ node and reticule.
+
+The returned surface is shared — blit it, never mutate it.
+"""
+
+from functools import lru_cache
+
+import pygame
+
+from common.color import ColorRGB
+from uilib.paint import ColorLike, as_color
+
+
+@lru_cache(maxsize=128)
+def _tinted(mask: pygame.Surface, color: ColorRGB) -> pygame.Surface:
+ tinted = mask.copy()
+ color_surf = pygame.Surface(mask.get_size(), pygame.SRCALPHA)
+ color_surf.fill(color)
+ tinted.blit(color_surf, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
+ return tinted
+
+
+def tint_mask(mask: pygame.Surface, color: ColorLike) -> pygame.Surface:
+ """Tint a white alpha-mask glyph into `color` (BLEND_RGBA_MULT on a copy)."""
+ c = as_color(color)
+ return _tinted(mask, (c.r, c.g, c.b))
diff --git a/uilib/icon.py b/uilib/icon.py
index 63c1b6630..8f60fd0eb 100644
--- a/uilib/icon.py
+++ b/uilib/icon.py
@@ -17,6 +17,7 @@
from uilib.box import Box
from uilib.glyphs import ExpressionPedalGlyph, KnobGlyph
+from uilib.glyphs.tint import tint_mask
from uilib.text import TextWidget
@@ -74,15 +75,7 @@ def _draw(self, ctx):
ox, oy = ctx._f().topleft
# Vertically center the square glyph in the widget height.
gy = loc[1] + (ctx.height - mask.get_height()) // 2
- # Tint the white mask into the fgnd colour: blit a solid colour
- # fill onto a copy of the mask using BLEND_RGBA_MULT, then blit
- # the tinted copy onto the target. A plain MULT against the mask
- # in-place would corrupt the cached surface.
- tinted = mask.copy()
- color_surf = pygame.Surface(mask.get_size(), pygame.SRCALPHA)
- color_surf.fill(self.fgnd_color)
- tinted.blit(color_surf, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)
- ctx.surface.blit(tinted, (loc[0] + ox, gy + oy))
+ ctx.surface.blit(tint_mask(mask, self.fgnd_color), (loc[0] + ox, gy + oy))
text_x = loc[0] + self.height + h_margin
text_y = loc[1]
diff --git a/uilib/lcd_ili9341.py b/uilib/lcd_ili9341.py
index ef61446d0..6c25d01b6 100644
--- a/uilib/lcd_ili9341.py
+++ b/uilib/lcd_ili9341.py
@@ -18,7 +18,6 @@
from uilib.panel import LcdBase, Box
from uilib.spi_timing import actual_spi_hz
from uilib.spi_timing import transfer_ms as spi_transfer_ms
-from uilib import profiling
import logging
import threading
import os
@@ -53,9 +52,7 @@ def __init__(self, spi, cs_pin, dc_pin, reset_pin, baudrate, flip=True):
self.requested_baudrate = baudrate
self.baudrate = actual_spi_hz(baudrate)
if self.baudrate != baudrate:
- logging.info(
- "SPI %.2f MHz requested, %.2f MHz actual", baudrate / 1e6, self.baudrate / 1e6
- )
+ logging.info("SPI %.2f MHz requested, %.2f MHz actual", baudrate / 1e6, self.baudrate / 1e6)
self.lock = threading.Lock()
@@ -208,7 +205,6 @@ def update(self, image: pygame.Surface, box=None):
pixels_bytes = staged[:sh, :sw].byteswap().tobytes()
del staged # unlock the surface
- with profiling.measure("lcd.update:_block(SPI)"):
- self.disp._block(x1, y1, x1 + sw - 1, y1 + sh - 1, pixels_bytes)
+ self.disp._block(x1, y1, x1 + sw - 1, y1 + sh - 1, pixels_bytes)
finally:
self.lock.release()
diff --git a/uilib/misc.py b/uilib/misc.py
index 9c882c6c9..818da89fd 100644
--- a/uilib/misc.py
+++ b/uilib/misc.py
@@ -16,6 +16,7 @@
from __future__ import annotations
from enum import Enum, Flag
+from functools import lru_cache
from common.parameter import Parameter, Type
@@ -66,7 +67,10 @@ def trace(obj, *args):
# Utility function (from stack overflow). TODO: Move to a TextUtils
-def get_text_size(text_string, font, metrics=None):
+# Cached: walking font.get_metrics() glyph-by-glyph costs ~20us, and callers hit
+# this several times per widget draw (per *character* when truncating a label).
+@lru_cache(maxsize=1024)
+def get_text_size(text_string, font):
"""Return (width, height) of `text_string` rendered with `font`.
Width matches PIL's `font.getbbox(text)[2] - getbbox(text)[0]` exactly:
@@ -153,6 +157,7 @@ def fmt_db(value: float) -> str:
return f"{value:+.0f}dB"
+@lru_cache(maxsize=1024)
def get_text_bbox(text_string, font):
"""Return (x0, y0, x1, y1) of `text_string`'s ink, matching PIL's
`ImageFont.getbbox(text)` with the default 'la' anchor.
diff --git a/uilib/paint.py b/uilib/paint.py
index 1c6bf6fcf..bdc1cf333 100644
--- a/uilib/paint.py
+++ b/uilib/paint.py
@@ -14,7 +14,8 @@
# along with pi-stomp. If not, see .
from dataclasses import dataclass, replace
-from typing import Generator, Optional, Sequence, Tuple, Union
+from functools import lru_cache
+from typing import TYPE_CHECKING, Generator, Optional, Sequence, Tuple, Union
from contextlib import contextmanager
from uilib.pygame_init import freetype as _get_freetype
@@ -29,6 +30,9 @@
from uilib.box import Box
from uilib.radius import Radius
+if TYPE_CHECKING:
+ import pygame._freetype
+
# Hashable color forms only — Sequence[int] is excluded because it makes the
# ColorLike unhashable, which breaks @lru_cache callers (e.g. RoundedRectGlyph).
# In practice every color is one of these: a pygame.Color, a string ("white"),
@@ -48,10 +52,38 @@ def _color(c: ColorLike) -> pygame.Color:
return pygame.Color(*c) if not isinstance(c, int) else pygame.Color(c)
+# Public alias for callers outside this module (e.g. uilib.glyphs.tint).
+as_color = _color
+
+
def _ipt(p: Sequence[int]) -> Point:
return (int(p[0]), int(p[1]))
+# Padding around the cached glyph run, in px. Covers ink that overhangs the pen
+# box (negative LSB, accents above the ascender) so nothing is clipped.
+_TEXT_PAD = 8
+
+
+@lru_cache(maxsize=512)
+def _text_surface(text: str, font: "pygame._freetype.Font", color: Tuple[int, int, int, int]) -> pygame.Surface: # pyright: ignore[reportAttributeAccessIssue]
+ """Cached RGBA surface of `text`, pen origin at (_TEXT_PAD, _TEXT_PAD + ascender)."""
+ from uilib.misc import get_text_size # local: uilib.misc imports uilib.paint
+
+ asc = int(font.get_sized_ascender())
+ desc = abs(int(font.get_sized_descender()))
+ tw, _ = get_text_size(text, font)
+ surf = pygame.Surface((tw + 2 * _TEXT_PAD, asc + desc + 2 * _TEXT_PAD), pygame.SRCALPHA)
+ surf.fill((0, 0, 0, 0))
+ prev_origin = font.origin
+ font.origin = True
+ try:
+ font.render_to(surf, (_TEXT_PAD, _TEXT_PAD + asc), text, fgcolor=pygame.Color(*color))
+ finally:
+ font.origin = prev_origin
+ return surf
+
+
def _pg_rect(box: Box) -> pygame.Rect:
return pygame.Rect(int(box.x0), int(box.y0), max(0, int(box.width)), max(0, int(box.height)))
@@ -223,22 +255,12 @@ def draw_text(
base_dst = (int(x - tw / 2), int(y - (asc + desc) / 2))
else:
base_dst = (int(x), int(y))
- # pygame._freetype.Font.render_to bypasses surface.set_clip (it clamps
- # only to the destination surface's bounds). To enforce the active
- # clip without a temp+blit, render into a subsurface of the current
- # clip rect — the rasterizer then clamps to that, giving us SDL-style
- # clipping for free. `painting()` guarantees a non-empty clip.
- clip = self.surface.get_clip()
- if clip.width <= 0 or clip.height <= 0:
- return
- sub = self.surface.subsurface(clip)
- pen = (base_dst[0] - clip.x, base_dst[1] + asc - clip.y)
- prev_origin = font.origin
- font.origin = True
- try:
- font.render_to(sub, pen, text, fgcolor=color)
- finally:
- font.origin = prev_origin
+ # Blit a cached glyph run rather than rasterizing per draw: text is
+ # re-drawn on every widget refresh, so freetype rasterization otherwise
+ # dominates the hot paths (meters, readouts, axis labels). The blit
+ # honors surface.set_clip, which font.render_to does not.
+ surf = _text_surface(text, font, (color.r, color.g, color.b, color.a))
+ self.surface.blit(surf, (base_dst[0] - _TEXT_PAD, base_dst[1] - _TEXT_PAD))
def draw_arc_aa(self, cx: int, cy: int, r: int, clip: Box, color: ColorLike) -> None:
"""AA circle arc clipped to a quadrant box (widget-relative).
diff --git a/uilib/panel.py b/uilib/panel.py
index fd80a11a0..64ce1b6d3 100644
--- a/uilib/panel.py
+++ b/uilib/panel.py
@@ -16,10 +16,10 @@
from typing import Optional, Tuple
from typing_extensions import override
from abc import ABC
+from contextlib import contextmanager
import pygame
-from uilib import profiling
from uilib.box import Box
from uilib.container import ContainerWidget
from uilib.glyphs import render_rounded_mask, render_rounded_outline
@@ -111,10 +111,23 @@ def add_widget(self, widget):
self.sel_list.append(widget) # TODO if a widget is not selectable, adding to sel_list seems wrong
def _select_widget_ref(self, w):
- if self.sel_ref is not None and self.sel_ref is not w:
- self.sel_ref.set_selected(False)
- self.sel_ref = w
- w.set_selected(True)
+ # Batch the deselect + select so both dirty rects coalesce into a
+ # single LCD push, avoiding a two-frame artifact where the deselected
+ # widget pops inline before the selected widget's update arrives.
+ # During construction (before the panel is pushed onto a PanelStack)
+ # there is no LCD to push to, so skip batching.
+ stack = self.parent
+ if isinstance(stack, PanelStack):
+ with stack.batch():
+ if self.sel_ref is not None and self.sel_ref is not w:
+ self.sel_ref.set_selected(False)
+ self.sel_ref = w
+ w.set_selected(True)
+ else:
+ if self.sel_ref is not None and self.sel_ref is not w:
+ self.sel_ref.set_selected(False)
+ self.sel_ref = w
+ w.set_selected(True)
def _notify_detach(self, widget):
if widget in self.sel_list:
@@ -376,6 +389,24 @@ def __init__(
self.lcd_needs_update = False
self._pending_lcd_clip: Optional[Box] = None # None = full screen or nothing pending
self.capture_callback = None
+ self._batching = False
+
+ @contextmanager
+ def batch(self):
+ """Suppress inline LCD pushes so multiple dirty rects coalesce into one flush.
+
+ During a batch, ``propagate_dirty`` always coalesces into
+ ``_pending_lcd_clip`` instead of pushing small clips inline.
+ On exit, any coalesced dirty region is flushed in a single
+ ``lcd.update()`` call.
+ """
+ self._batching = True
+ try:
+ yield
+ finally:
+ self._batching = False
+ if self.lcd_needs_update:
+ self._flush_lcd()
def poll_updates(self):
if self.lcd_needs_update:
@@ -423,40 +454,47 @@ def propagate_dirty(self, local_clip: Box):
if top is not None and top.opaque and top.box is not None and top.box.contains(clip):
# Fast path: opaque fullscreen top panel covers the dirty clip —
# skip erasing the stack surface and skip all lower panels.
- with profiling.measure("panelstack.recompose"):
- inter = clip.intersection(top.box)
- if not inter.is_empty():
- ctx = PaintContext(self.surface, inter)
- top.do_draw(ctx, top.box)
+ inter = clip.intersection(top.box)
+ if not inter.is_empty():
+ ctx = PaintContext(self.surface, inter)
+ top.do_draw(ctx, top.box)
else:
- with profiling.measure("panelstack.recompose"):
- erase_ctx = PaintContext(self.surface, clip, frame=clip)
- self._draw_erase(erase_ctx)
-
- for p in self.stack:
- if self.dimmer is not None and not p.no_dim:
- self.surface.blit(self.dimmer, clip.topleft, area=_pg_rect(clip))
- d = p.decorator
- if d is not None:
- inter = clip.intersection(d.box)
- if not inter.is_empty():
- ctx = PaintContext(self.surface, inter)
- d.do_draw(ctx, d.box)
- inter = clip.intersection(p.box)
+ erase_ctx = PaintContext(self.surface, clip, frame=clip)
+ self._draw_erase(erase_ctx)
+
+ for p in self.stack:
+ if self.dimmer is not None and not p.no_dim:
+ self.surface.blit(self.dimmer, clip.topleft, area=_pg_rect(clip))
+ d = p.decorator
+ if d is not None:
+ inter = clip.intersection(d.box)
if not inter.is_empty():
ctx = PaintContext(self.surface, inter)
- p.do_draw(ctx, p.box)
+ d.do_draw(ctx, d.box)
+ inter = clip.intersection(p.box)
+ if not inter.is_empty():
+ ctx = PaintContext(self.surface, inter)
+ p.do_draw(ctx, p.box)
if self.capture_callback:
self.capture_callback(self.surface)
- if self.lcd.transfer_ms(clip) <= self.INLINE_BUDGET_MS:
+ if not self._batching and self.lcd.transfer_ms(clip) <= self.INLINE_BUDGET_MS:
self.lcd.update(self.surface, clip)
return
- # Coalesce into the pending push; None means a full-screen redraw is
- # already pending (from push/pop).
- if self._pending_lcd_clip is not None:
+ # Coalesce into the pending push. ``_pending_lcd_clip`` is None only
+ # when a full-screen redraw is already pending (from push/pop); in
+ # that case, leave it None (full screen ⊇ any clip). Otherwise, union
+ # the clip into the pending region — if nothing was pending yet, this
+ # establishes the clip as the pending region.
+ if self._pending_lcd_clip is None:
+ # A structural change (push/pop) set None = full screen pending.
+ # If lcd_needs_update is False, nothing is actually pending and we
+ # should start a new coalesced region with this clip.
+ if not self.lcd_needs_update:
+ self._pending_lcd_clip = clip
+ else:
self._pending_lcd_clip = self._pending_lcd_clip.union(clip)
self.lcd_needs_update = True
diff --git a/uilib/text.py b/uilib/text.py
index 72107c36e..935ff956a 100644
--- a/uilib/text.py
+++ b/uilib/text.py
@@ -28,6 +28,7 @@
from uilib.config import Config
from common.color import ColorRGB, RectBorder
+from uilib.paint import ColorLike
from uilib.glyphs import RoundedRectGlyph
from uilib.radius import Radius
@@ -228,18 +229,19 @@ def __init__(
self.h_margin = h_margin
self.v_margin = v_margin
self.text_halign = text_halign
- self.font_metrics = None # legacy field, pygame.freetype encodes size in get_rect
self.text_size_valid = False
+ self._text_cache: Optional[pygame.Surface] = None
+ self._text_cache_key: tuple | None = None
super(TextWidget, self).__init__(box, **kwargs)
def _get_text_size(self):
if not self.text_size_valid:
lines = self.text.split("\n")
if len(lines) == 1:
- self.text_w, self.text_h = get_text_size(self.text, self.font, self.font_metrics)
+ self.text_w, self.text_h = get_text_size(self.text, self.font)
else:
_, line_h = get_text_size("", self.font)
- self.text_w = max(get_text_size(line, self.font, self.font_metrics)[0] for line in lines)
+ self.text_w = max(get_text_size(line, self.font)[0] for line in lines)
self.text_h = line_h * len(lines)
self.text_size_valid = True
return (self.text_w, self.text_h)
@@ -284,11 +286,21 @@ def _adjust_box(self):
trace(self, "resulting box=", self.box)
super(TextWidget, self)._adjust_box()
+ def _clear_text_cache(self) -> None:
+ self._text_cache = None
+ self._text_cache_key = None
+
+ def set_foreground(self, color) -> None:
+ self.fgnd_color = color
+ self._clear_text_cache()
+ self._invalidate_self()
+
def set_text(self, text):
if self.text == text:
return
self.text = text
self.text_size_valid = False
+ self._clear_text_cache()
self.refresh()
def set_edit_message(self, message):
@@ -296,8 +308,8 @@ def set_edit_message(self, message):
def set_font(self, font):
self.font = font
- self.font_metrics = None
self.text_size_valid = False
+ self._clear_text_cache()
self.refresh()
@override
@@ -309,19 +321,33 @@ def _draw(self, ctx):
if hroom < 0 or vroom < 0:
return
+ # Build cache key from all parameters that affect rendered output.
+ cache_key = (self.text, self.fgnd_color, id(self.font), hroom, vroom, self.text_halign)
+ if self._text_cache is not None and self._text_cache_key == cache_key:
+ assert self._text_cache is not None
+ ctx.paste(self._text_cache, (h_margin, v_margin))
+ return
+
+ # Render into a transparent surface sized to the content area.
+ surf = pygame.Surface((max(1, hroom), max(1, vroom)), pygame.SRCALPHA)
+ surf.fill((0, 0, 0, 0))
+
if self.SPLIT_SEP in self.text:
parts = self.text.split(self.SPLIT_SEP)
if len(parts) == 2:
left, right = parts
lw, _ = get_text_size(left, self.font)
rw, _ = get_text_size(right, self.font)
- ctx.draw_text((h_margin, v_margin), left, fill=self.fgnd_color, font=self.font)
- ctx.draw_text((ctx.width - h_margin - rw, v_margin), right, fill=self.fgnd_color, font=self.font)
+ self._render_line_to(surf, (0, 0), left, self.fgnd_color)
+ self._render_line_to(surf, (hroom - rw, 0), right, self.fgnd_color)
+ self._text_cache = surf
+ self._text_cache_key = cache_key
+ ctx.paste(surf, (h_margin, v_margin))
return
lines = self.text.split("\n")
_, line_h = get_text_size("", self.font)
- y = v_margin
+ y = 0
for line in lines:
tw, _ = get_text_size(line, self.font)
if tw > hroom:
@@ -332,11 +358,30 @@ def _draw(self, ctx):
hoffset = hroom - tw
else:
hoffset = int((hroom - tw) / 2)
- ctx.draw_text((h_margin + hoffset, y), line, fill=self.fgnd_color, font=self.font)
+ self._render_line_to(surf, (hoffset, y), line, self.fgnd_color)
y += line_h
- if y >= v_margin + vroom:
+ if y >= vroom:
break
+ self._text_cache = surf
+ self._text_cache_key = cache_key
+ ctx.paste(surf, (h_margin, v_margin))
+
+ def _render_line_to(self, surf: pygame.Surface, pos: tuple[int, int], text: str, color: "ColorLike") -> None:
+ """Render a single line of text onto *surf* at *pos* using the widget's font."""
+ if not text:
+ return
+ c = color
+ if isinstance(c, (list, tuple)) and len(c) == 3:
+ c = tuple(c) + (255,)
+ asc = int(self.font.get_sized_ascender())
+ prev = self.font.origin
+ self.font.origin = True
+ try:
+ self.font.render_to(surf, (pos[0], pos[1] + asc), text, fgcolor=c)
+ finally:
+ self.font.origin = prev
+
def tick(self):
"""Override in subclasses for animation."""
pass
@@ -528,6 +573,11 @@ def _clear_cache_and_restart(self) -> None:
self._anchor_time = None
self._last_tick_time = None
+ @override
+ def set_foreground(self, color) -> None:
+ self._clear_cache_and_restart()
+ super().set_foreground(color)
+
@override
def set_text(self, text: str) -> None:
if text != self.text:
diff --git a/uilib/widget.py b/uilib/widget.py
index da67f459a..2724b7bbb 100644
--- a/uilib/widget.py
+++ b/uilib/widget.py
@@ -17,7 +17,6 @@
from typing import TYPE_CHECKING, Callable, Tuple
-from uilib import profiling
from uilib.box import Box
from uilib.config import Color
from uilib.misc import InputEvent, WidgetAlign, trace
@@ -492,8 +491,7 @@ def refresh(self, box: Box | None = None):
return
assert container.surface is not None
ctx = PaintContext(container.surface, clip, frame=frame)
- with profiling.measure("widget.do_draw"):
- self.do_draw(ctx, frame)
+ self.do_draw(ctx, frame)
self._painted = True
self._dirty = False
container.propagate_dirty(clip)