From 01e849d3b7c94085396ecd8fbb680bd68c3a4c0f Mon Sep 17 00:00:00 2001 From: mrsqr Date: Mon, 10 Aug 2026 09:35:52 +0100 Subject: [PATCH] Keep timer digits evenly spaced --- README.md | 2 +- font_renderer.py | 63 +++++++++++++++++++++++++++++++------ lcd_1inch28.py | 11 +++++++ tests/test_font_renderer.py | 31 ++++++++++++++++++ tests/test_live_display.py | 6 +++- touch_drive.py | 4 +-- 6 files changed, 103 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b031438..f99bfdf 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The timer utilizes the Waveshare 1.28-inch round touch display, allowing for con The firmware includes a compact proportional bitmap font rendered directly at the display's native resolution. It replaces enlargement of MicroPython's 8x8 framebuffer font, so large countdown digits and labels retain smooth shapes instead of scaling into square pixels. -The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. +The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. Timer digits use equal-width cells, so changing figures do not move the centered countdown or elapsed-time positions. `font_data.py` and its flash-backed `font_data*.bin` glyph assets are generated from Montserrat SemiBold. The assets contain pre-rasterized native UI sizes, allowing the Pico to use its fast framebuffer blitter without holding the complete font in RAM. To regenerate them, install Pillow and run: diff --git a/font_renderer.py b/font_renderer.py index 07eb7cb..e271633 100644 --- a/font_renderer.py +++ b/font_renderer.py @@ -41,10 +41,29 @@ def _glyph_index(character): return code_point - FIRST_CODE_POINT -def measure_text(text, size): - """Measure proportional text width without loading glyph pixels.""" +def _digit_cell_width(widths): + return max(widths[_glyph_index(str(digit))] for digit in range(10)) + + +def _character_advance(character, widths, tabular_digits, digit_cell_width): + if tabular_digits and "0" <= character <= "9": + return digit_cell_width + return widths[_glyph_index(character)] + + +def measure_text(text, size, tabular_digits=False): + """Measure text width without loading glyph pixels.""" widths = WIDTHS[_size_index(size)] - return sum(widths[_glyph_index(character)] for character in str(text)) + digit_cell_width = _digit_cell_width(widths) if tabular_digits else None + return sum( + _character_advance( + character, + widths, + tabular_digits, + digit_cell_width, + ) + for character in str(text) + ) def _glyph_offset(offsets, index): @@ -91,13 +110,14 @@ def _blit_glyph( surface.blit(glyph, x, y, transparent_key, palette) -def draw_text(surface, text, x, y, size, color): +def draw_text(surface, text, x, y, size, color, tabular_digits=False): """Draw a pre-rasterized native-resolution font and return its width.""" size_index = _size_index(size) height = PIXEL_HEIGHTS[size_index] widths = WIDTHS[size_index] offsets = OFFSETS[size_index] cursor = int(x) + digit_cell_width = _digit_cell_width(widths) if tabular_digits else None use_blitter = framebuf is not None and hasattr(surface, "blit") palette = None @@ -117,6 +137,13 @@ def draw_text(surface, text, x, y, size, color): for character in str(text): index = _glyph_index(character) width = widths[index] + advance = _character_advance( + character, + widths, + tabular_digits, + digit_cell_width, + ) + glyph_x = cursor + ((advance - width) // 2) glyph_size = ((width + 7) // 8) * height bitmap_file.seek(_glyph_offset(offsets, index)) bitmap = bitmap_file.read(glyph_size) @@ -129,21 +156,37 @@ def draw_text(surface, text, x, y, size, color): bitmap, width, height, - cursor, + glyph_x, int(y), palette, transparent_key, ) else: - _draw_scanlines(surface, bitmap, width, height, cursor, int(y), color) - cursor += width + _draw_scanlines( + surface, + bitmap, + width, + height, + glyph_x, + int(y), + color, + ) + cursor += advance return cursor - int(x) -def draw_centered(surface, text, y, size, color): +def draw_centered(surface, text, y, size, color, tabular_digits=False): """Draw text horizontally centered within the framebuffer.""" - width = measure_text(text, size) + width = measure_text(text, size, tabular_digits=tabular_digits) x = (surface.width - width) // 2 - draw_text(surface, text, x, y, size, color) + draw_text( + surface, + text, + x, + y, + size, + color, + tabular_digits=tabular_digits, + ) return x, width diff --git a/lcd_1inch28.py b/lcd_1inch28.py index c42235a..bb75230 100644 --- a/lcd_1inch28.py +++ b/lcd_1inch28.py @@ -398,3 +398,14 @@ def write_text(self, text, x, y, size, color): def write_centered(self, text, y, size, color): """Draw smooth proportional text centered on the display.""" return self._draw_centered(self, text, y, size, color) + + def write_time_centered(self, text, y, size, color): + """Draw a centered time using fixed-width digit cells.""" + return self._draw_centered( + self, + text, + y, + size, + color, + tabular_digits=True, + ) diff --git a/tests/test_font_renderer.py b/tests/test_font_renderer.py index a5d95b5..fe0c3e4 100644 --- a/tests/test_font_renderer.py +++ b/tests/test_font_renderer.py @@ -79,6 +79,37 @@ def test_proportional_measurement_and_scaling(self): self.assertLess(measure_text("Timer", 2), measure_text("Timer", 4)) self.assertGreater(measure_text(" ", 2), 0) + def test_tabular_times_keep_the_same_width_for_every_digit(self): + widths = { + measure_text(value, 7, tabular_digits=True) + for value in ("00:00", "11:11", "24:57", "60:00", "88:88") + } + + self.assertEqual(1, len(widths)) + self.assertNotEqual(measure_text("11:11", 7), measure_text("88:88", 7)) + + def test_tabular_centering_keeps_time_origin_fixed(self): + surface = FakeSurface() + + narrow = draw_centered( + surface, + "11:11", + 20, + 7, + 1, + tabular_digits=True, + ) + wide = draw_centered( + surface, + "88:88", + 20, + 7, + 1, + tabular_digits=True, + ) + + self.assertEqual(narrow, wide) + def test_draw_uses_single_pixel_scanlines(self): surface = FakeSurface() width = draw_text(surface, "8:15", 4, 7, 5, 0xFFFF) diff --git a/tests/test_live_display.py b/tests/test_live_display.py index eeea621..08aec36 100644 --- a/tests/test_live_display.py +++ b/tests/test_live_display.py @@ -49,7 +49,11 @@ def test_maximum_countdown_width_fits_the_live_screen_position(self): display_radius = 120 y_position = 82 text_height = pixel_height(COUNTDOWN_TEXT_SIZE) - text_width = measure_text("60:00", COUNTDOWN_TEXT_SIZE) + text_width = measure_text( + "60:00", + COUNTDOWN_TEXT_SIZE, + tabular_digits=True, + ) for edge_y in (y_position, y_position + text_height - 1): distance_from_center = edge_y - display_radius diff --git a/touch_drive.py b/touch_drive.py index a2bc2f8..baa14ac 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -287,8 +287,8 @@ def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, e textsize_rem = 5 self.Set_Mode(0) LCD.fill(backColour) - LCD.write_centered(remaining,82,textsize_rem,textColour) - LCD.write_centered(elapsed,180,3,textColour) + LCD.write_time_centered(remaining,82,textsize_rem,textColour) + LCD.write_time_centered(elapsed,180,3,textColour) LCD.show()