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)