From e310ed6b151fd29c228bc8074904895bf70ecf7c Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 3 Jun 2026 12:25:38 -0700 Subject: [PATCH] Resolution-flexible UI: render SSD1333 (176x176) + SSD1351 (128x128) from shared geometry Adapt the PiFinder UI to render at the display's resolution instead of a hardcoded 128x128, so the 1.91" 176x176 SSD1333 and the 1.5" 128x128 SSD1351 share one codebase. Geometry (row counts, line positions, text anchors, image scaling, entry-box grids, scroll bars) derives from display_class.resX/resY + font metrics; a small set of per-display knobs (font sizes, titlebar_height, menu_visible_items) is hand-tuned per panel. - ui/layout.py (new): carousel_layout / list_layout / rows_below_titlebar / center_box_row -- the shared geometry helpers. - displays.py: Layout176 mixin + DisplayPygame_176 / DisplayHeadless176; menu_visible_items knob (must be odd). - Screens derive layout from resolution: base, text_menu, object_list, object_details, chart, preview, align, console, status, sqm, equipment, software, log, location_list, the time/date/location/radec/text entry grids, and the SQM calibration/sweep/correction screens, ui_utils. - Three 176-panel fixes from hardware validation: carousel selection box keeps clearance from the row above; help screens normalize to the device resolution (no luma size-assert crash); the radial marking-menu radius scales with resolution so labels stay inside their slices. - docs/adr/0009 + docs/ax/ui: the hybrid (derive geometry + per-display knobs) decision. Boot splash (splash.py) is intentionally deferred to new_hardware_features: its panel auto-detection needs hardware_detect (the rev-4 battery marker), which isn't on main yet, and on main the splash is always 128 (scaling would be a no-op). Validated at 128 and 176: ruff + mypy clean (pre-existing pandas/requests stub errors only); pytest -m "smoke or unit" -> 285 passed; a crash-sweep of 16 screens through the real device.display() path renders cleanly at both resolutions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr/0009-resolution-flexible-ui-hybrid.md | 85 ++++++ docs/ax/ui.md | 4 +- docs/ax/ui/CONTEXT.md | 6 +- python/PiFinder/displays.py | 64 ++++- python/PiFinder/ui/align.py | 23 +- python/PiFinder/ui/base.py | 65 ++++- python/PiFinder/ui/chart.py | 8 +- python/PiFinder/ui/console.py | 23 +- python/PiFinder/ui/dateentry.py | 51 ++-- python/PiFinder/ui/equipment.py | 27 +- python/PiFinder/ui/layout.py | 261 ++++++++++++++++++ python/PiFinder/ui/location_list.py | 9 +- python/PiFinder/ui/locationentry.py | 39 ++- python/PiFinder/ui/log.py | 26 +- python/PiFinder/ui/menu_manager.py | 8 +- python/PiFinder/ui/object_details.py | 54 ++-- python/PiFinder/ui/object_list.py | 57 ++-- python/PiFinder/ui/preview.py | 37 ++- python/PiFinder/ui/radec_entry.py | 68 +++-- python/PiFinder/ui/software.py | 30 +- python/PiFinder/ui/sqm.py | 69 +++-- python/PiFinder/ui/sqm_calibration.py | 135 ++++----- python/PiFinder/ui/sqm_correction.py | 24 +- python/PiFinder/ui/sqm_sweep.py | 82 +++--- python/PiFinder/ui/status.py | 15 +- python/PiFinder/ui/text_menu.py | 108 +++----- python/PiFinder/ui/textentry.py | 30 +- python/PiFinder/ui/timeentry.py | 36 ++- python/PiFinder/ui/ui_utils.py | 8 +- 29 files changed, 1029 insertions(+), 423 deletions(-) create mode 100644 docs/adr/0009-resolution-flexible-ui-hybrid.md create mode 100644 python/PiFinder/ui/layout.py diff --git a/docs/adr/0009-resolution-flexible-ui-hybrid.md b/docs/adr/0009-resolution-flexible-ui-hybrid.md new file mode 100644 index 000000000..99c0eb6d6 --- /dev/null +++ b/docs/adr/0009-resolution-flexible-ui-hybrid.md @@ -0,0 +1,85 @@ +# Resolution-flexible UI: derive geometry, hand-tune a few per-display knobs + +## Context + +PiFinder gained a second display panel: the NHD **SSD1333**, 176×176 px, 1.91″, +alongside the existing **SSD1351**, 128×128 px, 1.5″. The UI had been written +against 128 throughout — screens carried hardcoded pixel positions (`(0, 114)` +for the chart RA/Dec line, `[0, 13, 25, 42, 60, 76, 89]` for object-list rows, +`(64, 64)` for the align reticle centre, `image.resize((128, 128))` in the camera +preview, and so on). + +The two panels have **near-identical pixel density** (128 px / 1.5″ ≈ 121 ppi; +176 px / 1.91″ ≈ 130 ppi). A glyph drawn at the same pixel count is therefore +~the same physical size on both. The product goal for the 176 panel was *not* +"the same UI, bigger" — it was **slightly larger elements** (measured by ruler, +which costs pixels spent on bigger fonts) **and slightly more content per screen** +(e.g. more menu rows). The 176 panel has ~1.34× the linear pixels to split +between those two wants. So the mechanism had to let us choose, per display, how +to spend the extra pixels — not just scale. + +We considered three mechanisms: + +- **(A) Uniform scale factor** — multiply every 128 coordinate by `resX/128`. + Cheapest to apply, but it spends *all* the extra pixels on "bigger" and none on + "more", can't add menu rows, and scaling bitmap glyphs/markers by 1.375× gives + fractional, blurry results. It also bakes the 128 origin in permanently. +- **(B) Fully derived** — compute *everything* (including font sizes) from the + resolution with no per-display constants. Maximally flexible, but it removes the + hand control we explicitly want (how big is "slightly bigger"? how many rows is + "slightly more"?), and forces the established, much-looked-at 128 layout to be + reverse-engineered into formulas exactly or it visibly shifts. +- **(C) Hybrid** — derive *geometry* from font metrics + resolution, but keep a + **small set of hand-tuned per-display knobs**. + +## Decision + +Adopt **(C), the hybrid**. Two halves: + +1. **Per-display knobs** live as class attributes on the `DisplayBase` subclass + (the *display instance*): the five font sizes, `titlebar_height`, and + `menu_visible_items`. These are the *intent* — "fonts ~15–20 % larger, two + extra carousel rows" — and are hand-chosen per panel (`Layout176` mixin in + `displays.py`). They are a deliberate starting point for the physical + ruler/readability sign-off, expected to be nudged. + +2. **Everything else is geometry, derived** from those knobs plus `resolution` + and live font metrics (`font.height`, `font.width`): row counts and positions, + line anchors, text/marker indents, the carousel/list selection boxes, image + scaling, zoom crops, scroll-bar edges, reticle centres. The shared maths lives + in `ui/layout.py` (`carousel_layout`, `list_layout`); individual screens read + `display_class.resX/resY/centerX/centerY/fov_res/titlebar_height` and font + metrics rather than literals. + +**We accept minor (≤1–2 px) drift on the existing 128 layout.** We do **not** +special-case 128 to reproduce its old pixels exactly; the derived formulas were +tuned to land within a couple of pixels of the legacy positions (e.g. the +object-list focus row moved from y=62 to y=66), which is below the threshold of +notice on the panel. + +## Why the native-frame constants stay explicit + +Some coordinates are not display geometry at all — they live in the **camera / +solver native frame** (documented 512×512: `target_pixel`, centroids). Those are +scaled to the display with an explicit `CAMERA_NATIVE_RES = 512` constant in the +screens that need it (`preview.py`, `align.py`), *not* folded into the resolution +knobs, because they track the camera, not the panel. The pre-existing +`SharedStateObj.target_pixel(screen_space=True)` helper is hardcoded to a 128 +screen; rather than change that Positioning-context method, the UI scales the raw +native value itself. + +## Consequences + +- One profile (`Layout176`) is shared by the real OLED (`DisplaySSD1333`, + `rotate=2`), the pygame emulator (`DisplayPygame_176`) and the headless dummy + (`DisplayHeadless176`), so the dev preview faithfully matches hardware. +- Adding a future panel is a new `DisplayBase` subclass with its own knobs — no + per-screen edits, provided screens keep reading derived geometry. +- `menu_visible_items` **must be odd** (the carousel/list focus sits on the + symmetric centre line); this is an invariant of the layout helpers. +- The 128 layout is now defined by formulas, not literals, so it can shift by a + pixel or two if a font metric or knob changes. That is the accepted trade for + not maintaining two parallel layouts. Pixel-exact 128 reproduction is a + non-goal — see `docs/ax/ui/CONTEXT.md` (term: *carousel*). +- The hand-tuned 176 font sizes are provisional until the ruler/readability + sign-off on the physical prototype. diff --git a/docs/ax/ui.md b/docs/ax/ui.md index e0c6d5e0b..9cf49f5c7 100644 --- a/docs/ax/ui.md +++ b/docs/ax/ui.md @@ -49,7 +49,9 @@ For the canonical glossary of terms, see [`ui/CONTEXT.md`](./ui/CONTEXT.md). + shared_state.set_current_ui_state(serialize…) ``` -Each `UIModule` owns a 128x128 `PIL.Image` (`self.screen`) it draws into. +Each `UIModule` owns a `PIL.Image` (`self.screen`) sized to the display +instance's `resolution` (128×128 on the SSD1351, 176×176 on the SSD1333) +that it draws into. `MenuManager.update()` asks the active module to redraw, then pushes the resulting image both to the physical display and onto `shared_state` so the web/API layer can mirror it. The whole UI runs in the **main diff --git a/docs/ax/ui/CONTEXT.md b/docs/ax/ui/CONTEXT.md index f789e89e9..d36c8afe2 100644 --- a/docs/ax/ui/CONTEXT.md +++ b/docs/ax/ui/CONTEXT.md @@ -35,7 +35,7 @@ _Avoid_: setting, config key (in prose), option. ### Modules **UIModule**: -The base class for every screen (`ui/base.py`). Owns a 128x128 `self.screen` PIL image, the `key_*` handlers, the lifecycle hooks, and the display-mode cycle. The bare word "module" in this context means a `UIModule` subclass or instance. +The base class for every screen (`ui/base.py`). Owns a `self.screen` PIL image sized to the display instance's `resolution` (128×128 on the SSD1351, 176×176 on the SSD1333), the `key_*` handlers, the lifecycle hooks, and the display-mode cycle. The bare word "module" in this context means a `UIModule` subclass or instance. _Avoid_: screen class, widget, view, page. **Screen**: @@ -46,6 +46,10 @@ _Avoid_: canvas, frame (a "frame" is a camera image). The general scrolling-list module (`ui/text_menu.py`). The root menu and every submenu are `UITextMenu` instances; it handles single/multi selection and writes `config_option`s. _Avoid_: list module, text list. +**Carousel**: +The centre-magnified scrolling list a `UITextMenu` draws: the focus line (the selected item) sits at the vertical centre in the large font at full brightness, and neighbouring rows shrink and dim with distance (a "fisheye" falloff). `UIObjectList` uses a uniform-row variant (every row in the base font, the focus row in bold). Row geometry — count, positions, fonts, the focus selection box — is computed by `ui/layout.py` (`carousel_layout` / `list_layout`) from the display instance's `resolution`, `titlebar_height`, font metrics and the `menu_visible_items` knob, so the same code lays out on the 128 and 176 panels. +_Avoid_: fisheye menu (in code/glossary — describe it as the carousel), spinner, wheel. + **display_class**: The constructor argument carrying a `DisplayBase` **instance** (not a class, despite the name) — the source of `device`, `colors`, `fonts`, and `resolution`. `DisplayHeadless` is the no-hardware variant. _Avoid_: display, screen driver (in code-arg context). diff --git a/python/PiFinder/displays.py b/python/PiFinder/displays.py index a6dee9efd..288511fb0 100644 --- a/python/PiFinder/displays.py +++ b/python/PiFinder/displays.py @@ -42,6 +42,9 @@ class DisplayBase: small_font_size = 8 large_font_size = 15 huge_font_size = 35 + # Number of carousel rows a UITextMenu shows at once. Must be ODD so the + # selected item sits on the symmetric center (focus) line. + menu_visible_items = 7 device = luma.core.device.device def __init__(self): @@ -137,9 +140,29 @@ def set_brightness(self, level): self.device.contrast(level) -class DisplaySSD1333(DisplayBase): +class Layout176: + """Shared 176x176 layout profile for the 1.91" panel. + + The SSD1333 controller only addresses 176x176 (see ``ssd1333_device``), so + every 176 render target — the real OLED, the pygame emulator, and the + headless dummy — must lay out identically for the emulator to faithfully + preview the hardware. These knobs are the hand-tuned half of the + resolution-flexible UI (geometry derives from them + font metrics): + fonts run ~15-20% larger than the 128 panel for slightly bigger glyphs at + near-identical pixel density, and the carousel shows two extra rows. + """ + resolution = (176, 176) + titlebar_height = 20 + base_font_size = 12 + bold_font_size = 14 + small_font_size = 10 + large_font_size = 18 + huge_font_size = 42 + menu_visible_items = 9 + +class DisplaySSD1333(Layout176, DisplayBase): def __init__(self): # init display (SPI hardware) serial = spi(device=0, port=0, bus_speed_hz=40000000) @@ -165,6 +188,30 @@ def set_brightness(self, level): self.device.contrast(level) +class DisplayPygame_176(Layout176, DisplayBase): + """Pygame emulator at 176x176 with the SSD1333 layout profile. + + Lets the 1.91" UI be previewed on a dev machine with no Pi/panel; the + ``Layout176`` profile means it renders with the same fonts/spacing as the + real OLED. Select with ``--display pg_176``. + """ + + def __init__(self): + from luma.emulator.device import pygame + + pygame = pygame( + width=self.resolution[0], + height=self.resolution[1], + rotate=0, + mode="RGB", + transform="scale2x", + scale=2, + frame_rate=60, + ) + self.device = pygame + super().__init__() + + class DisplayST7789_128(DisplayBase): resolution = (128, 128) @@ -232,13 +279,28 @@ def __init__(self): super().__init__() +class DisplayHeadless176(Layout176, DisplayHeadless): + """Headless (luma ``dummy``) display at 176x176 with the SSD1333 layout. + + The no-hardware target for driving/screenshotting the 1.91" UI over the + HTTP API (``/api/screen`` serves whatever resolution the UI publishes). + Select with ``--display headless_176``. + """ + + def get_display(display_hardware: str) -> DisplayBase: if display_hardware == "headless": return DisplayHeadless() + if display_hardware == "headless_176": + return DisplayHeadless176() + if display_hardware == "pg_128": return DisplayPygame_128() + if display_hardware == "pg_176": + return DisplayPygame_176() + if display_hardware == "pg_320": return DisplayPygame_320() diff --git a/python/PiFinder/ui/align.py b/python/PiFinder/ui/align.py index 0f621cddb..699a3bd1f 100644 --- a/python/PiFinder/ui/align.py +++ b/python/PiFinder/ui/align.py @@ -17,6 +17,11 @@ from PiFinder.ui.base import UIModule from PiFinder.ui.chart import get_chart_rotation_angle +# Native camera/solver frame size. target_pixel is stored in this (square) +# pixel space (see SharedStateObj.target_pixel, documented as 512x512); the +# preview/align screens scale it down to the display resolution. +CAMERA_NATIVE_RES = 512 + def align_on_radec(ra, dec, command_queues, config_object, shared_state) -> bool: """ @@ -83,9 +88,13 @@ def __init__(self, *args, **kwargs): self.visible_stars = None self.star_list = np.empty((0, 2)) self.alignment_star = None + # target_pixel is (Y, X) in native camera space; scale to display space. + target_pixel = self.config_object.get_option("target_pixel", (256, 256)) + scale_x = self.display_class.resX / CAMERA_NATIVE_RES + scale_y = self.display_class.resY / CAMERA_NATIVE_RES self.marker_position = ( - self.config_object.get_option("target_pixel", (256, 256))[1] / 4, - self.config_object.get_option("target_pixel", (256, 256))[0] / 4, + target_pixel[1] * scale_x, + target_pixel[0] * scale_y, ) # Marking menu definition @@ -424,9 +433,13 @@ def key_number(self, number): if self.align_mode: if number == 1: # reset reticle to center - self.shared_state.set_target_pixel((256, 256)) - self.config_object.set_option("target_pixel", (256, 256)) - self.marker_position = (64, 64) + center_pixel = (CAMERA_NATIVE_RES // 2, CAMERA_NATIVE_RES // 2) + self.shared_state.set_target_pixel(center_pixel) + self.config_object.set_option("target_pixel", center_pixel) + self.marker_position = ( + self.display_class.centerX, + self.display_class.centerY, + ) self.update(force=True) self.align_mode = False if number == 0: diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 2b4bcfab3..9cfc93457 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -25,6 +25,13 @@ def _(a) -> Any: return a +# Rate (brightness units per second of elapsed time) for the pulsing GPS +# "searching" animation in the title bar. This is an animation speed, not a +# geometry value — it must NOT scale with display resolution (it previously +# read as a bare ``128`` which looked resolution-derived). +GPS_ANIM_RATE = 128 + + class RotatingInfoDisplay: """Alternates between constellation and SQM with cross-fade animation.""" @@ -199,7 +206,30 @@ def help(self) -> Union[None, list[Image.Image]]: # convert_image_to_mode(help_image, self.colors.mode) # ) - help_image_list.append(make_red(help_image, self.colors)) + red_help_image = make_red(help_image, self.colors) + + # Help PNGs are authored at 128x128, but luma requires every + # displayed image to match the device resolution. Normalise each + # frame onto a black resX x resY canvas (centred) so it renders on + # any panel; scale down first if a frame is larger than the display + # so it always fits. Derived from the display, not special-cased to + # 176. + res = (self.display_class.resX, self.display_class.resY) + if red_help_image.size != res: + if red_help_image.width > res[0] or red_help_image.height > res[1]: + red_help_image = red_help_image.copy() + red_help_image.thumbnail(res) + frame = Image.new("RGB", res, self.colors.get(0)) + frame.paste( + red_help_image, + ( + (res[0] - red_help_image.width) // 2, + (res[1] - red_help_image.height) // 2, + ), + ) + red_help_image = frame + + help_image_list.append(red_help_image) if help_image_list == []: return None @@ -230,11 +260,19 @@ def clear_screen(self): fill=self.colors.get(0), ) - def message(self, message, timeout: float = 2, size=(5, 44, 123, 84)): + def message(self, message, timeout: float = 2, size=None): """ Creates a box with text in the center of the screen. Waits timeout in seconds """ + if size is None: + # Centre the popup box on the screen, deriving from the display + # resolution (was hardcoded to 128: (5, 44, 123, 84)). + box_w = self.display_class.resX - 10 + box_h = round(self.display_class.resY * 40 / 128) + x0 = 5 + y0 = (self.display_class.resY - box_h) // 2 + size = (x0, y0, x0 + box_w, y0 + box_h) # shadow self.draw.rectangle( @@ -299,17 +337,22 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: if title_bar: fg = self.colors.get(0) bg = self.colors.get(64) + tb_height = self.display_class.titlebar_height self.draw.rectangle( - [0, 0, self.display_class.resX, self.display_class.titlebar_height], + [0, 0, self.display_class.resX, tb_height], fill=bg, ) + # Vertically centre the title-bar text / icons in the bar so they + # track titlebar_height across displays (was hardcoded for 128). + title_y = max(0, (tb_height - self.fonts.bold.height) // 2) + icon_y = (tb_height - self.fonts.icon_bold_large.height) // 2 if self.ui_state.show_fps(): self.draw.text( - (6, 1), str(self.fps), font=self.fonts.bold.font, fill=fg + (6, title_y), str(self.fps), font=self.fonts.bold.font, fill=fg ) else: self.draw.text( - (6, 1), _(self.title), font=self.fonts.bold.font, fill=fg + (6, title_y), _(self.title), font=self.fonts.bold.font, fill=fg ) imu = self.shared_state.imu() moving = True if imu and imu.quat and imu.moving else False @@ -318,7 +361,9 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: if self.shared_state.altaz_ready(): self._gps_brightness = 0 else: - gps_anim = int(128 * (time.time() - self.last_update_time)) + 1 + gps_anim = ( + int(GPS_ANIM_RATE * (time.time() - self.last_update_time)) + 1 + ) self._gps_brightness += gps_anim if self._gps_brightness > 64: self._gps_brightness = -128 @@ -327,7 +372,7 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: self._gps_brightness if self._gps_brightness > 0 else 0 ) self.draw.text( - (self.display_class.resX * 0.8, -2), + (self.display_class.resX * 0.8, icon_y), self._GPS_ICON, font=self.fonts.icon_bold_large.font, fill=_gps_color, @@ -351,7 +396,7 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: if self._unmoved: self.draw.text( - (self.display_class.resX * 0.91, -2), + (self.display_class.resX * 0.91, icon_y), self._CAM_ICON, font=self.fonts.icon_bold_large.font, fill=var_fg, @@ -361,13 +406,13 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: # Draw rotating constellation/SQM wheel (replaces static constellation) self._draw_titlebar_rotating_info( x=int(self.display_class.resX * 0.54), - y=1, + y=title_y, fg=fg if self._unmoved else self.colors.get(32), ) else: # no solve yet.... self.draw.text( - (self.display_class.resX * 0.91, 0), + (self.display_class.resX * 0.91, title_y), "X", font=self.fonts.bold.font, fill=fg, diff --git a/python/PiFinder/ui/chart.py b/python/PiFinder/ui/chart.py index 316b1d088..6a3c04cad 100644 --- a/python/PiFinder/ui/chart.py +++ b/python/PiFinder/ui/chart.py @@ -218,13 +218,15 @@ def update(self, force=False): if orientation is not None: self._draw_orientation_indicator(orientation) - # Display RA/DEC in selected format if enabled + # Display RA/DEC in selected format if enabled, anchored just + # above the bottom edge (derived so it tracks resolution). + radec_y = self.display_class.resY - self.fonts.base.height - 3 if self.config_object.get_option("chart_radec") == "HH:MM": ra_h, ra_m, ra_s = calc_utils.ra_to_hms(aligned.RA) dec_d, dec_m, dec_s = calc_utils.dec_to_dms(aligned.Dec) ra_dec_disp = f"{ra_h:02d}:{ra_m:02d}:{ra_s:02d} / {dec_d:02d}°{dec_m:02d}:{dec_s}" self.draw.text( - (0, 114), + (0, radec_y), ra_dec_disp, font=self.fonts.base.font, fill=self.colors.get(255), @@ -234,7 +236,7 @@ def update(self, force=False): dec_d, dec_m, dec_s = calc_utils.dec_to_dms(aligned.Dec) ra_dec_disp = f"{aligned.RA:0>6.2f} / {aligned.Dec:0>5.2f}" self.draw.text( - (0, 114), + (0, radec_y), ra_dec_disp, font=self.fonts.base.font, fill=self.colors.get(255), diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index c4b38f97f..fa25f0dad 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -11,7 +11,8 @@ import time from PIL import Image -from PiFinder.ui.base import UIModule +from PiFinder.ui.base import GPS_ANIM_RATE, UIModule +from PiFinder.ui.layout import rows_below_titlebar from PiFinder.image_util import convert_image_to_mode @@ -42,6 +43,12 @@ def __init__(self, *args, **kwargs): welcome_image_path = os.path.join(root_dir, "images", "welcome.png") welcome_image = Image.open(welcome_image_path) welcome_image = convert_image_to_mode(welcome_image, self.colors.mode) + # The asset is authored at 128x128; scale it to fill this panel so it + # stays full-bleed behind the boot console (a no-op on the 128 panel). + if welcome_image.size != (self.display_class.resX, self.display_class.resY): + welcome_image = welcome_image.resize( + (self.display_class.resX, self.display_class.resY) + ) self.screen.paste(welcome_image) self.lines = ["---- TOP ---", "Sess UUID:" + self.__uuid__] @@ -109,9 +116,15 @@ def update(self, force=False): return self.screen_update(title_bar=False) else: self.clear_screen() - for i, line in enumerate(self.lines[-10 - self.scroll_offset :][:10]): + # Dense scrollback: as many base-font rows as fit below the + # title bar (9 on the 128 panel, more on taller displays). + layout = rows_below_titlebar(self.display_class, gap=1) + window = layout.max_visible + for i, line in enumerate( + self.lines[-window - self.scroll_offset :][:window] + ): self.draw.text( - (0, i * 10 + 20), + (0, layout.rows[i]), line, font=self.fonts.base.font, fill=self.colors.get(255), @@ -141,7 +154,9 @@ def screen_update(self, title_bar=True, button_hints=True): if self.shared_state.altaz_ready(): self._gps_brightness = 0 else: - gps_anim = int(128 * (time.time() - self.last_update_time)) + 1 + gps_anim = ( + int(GPS_ANIM_RATE * (time.time() - self.last_update_time)) + 1 + ) self._gps_brightness += gps_anim if self._gps_brightness > 64: self._gps_brightness = -128 diff --git a/python/PiFinder/ui/dateentry.py b/python/PiFinder/ui/dateentry.py index e3d66a5fb..39f7f325c 100644 --- a/python/PiFinder/ui/dateentry.py +++ b/python/PiFinder/ui/dateentry.py @@ -3,6 +3,7 @@ from PIL import Image, ImageDraw from PiFinder.ui.base import UIModule +from PiFinder.ui.layout import center_box_row if TYPE_CHECKING: @@ -36,8 +37,8 @@ def __init__(self, *args, **kwargs): self.boxes[2] = f"{local_dt.day:02d}" # Screen setup - self.width = 128 - self.height = 128 + self.width = self.display_class.resX + self.height = self.display_class.resY self.red = self.colors.get(255) self.black = self.colors.get(0) self.half_red = self.colors.get(128) @@ -45,30 +46,28 @@ def __init__(self, *args, **kwargs): self.draw = ImageDraw.Draw(self.screen) self.bold = self.fonts.bold - # Layout constants - self.text_y = 25 - self.year_box_width = 38 - self.md_box_width = 25 - self.box_height = 20 - self.box_spacing = 10 - - # Calculate start_x to center the boxes - total_width = self.year_box_width + 2 * self.md_box_width + 2 * self.box_spacing - self.start_x = (self.width - total_width) // 2 + # Layout constants - box widths derive from the bold font so the + # YYYY-MM-DD boxes scale with the display (38 / 25 / 20 on the 128 panel). + self.text_y = self.display_class.titlebar_height + 8 + self.year_box_width = self.bold.width * 4 + 10 + self.md_box_width = self.bold.width * 2 + 11 + self.box_height = self.bold.height + 7 + self.box_spacing = round(self.display_class.resX * 10 / 128) + + # Center year + month + day boxes on the actual screen width + box_row = center_box_row( + self.display_class, + [self.year_box_width, self.md_box_width, self.md_box_width], + self.box_spacing, + self.text_y, + self.box_height, + ) + self.box_xs = box_row.xs + self.start_x = box_row.xs[0] def _box_x(self, i): """Get the x position for box i.""" - if i == 0: - return self.start_x - elif i == 1: - return self.start_x + self.year_box_width + self.box_spacing - else: - return ( - self.start_x - + self.year_box_width - + self.md_box_width - + 2 * self.box_spacing - ) + return self.box_xs[i] def _box_width(self, i): """Get the width for box i.""" @@ -150,14 +149,14 @@ def draw_legend(self, start_y): font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 self.draw.text( (10, legend_y), _("\uf053 Cancel"), font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 self.draw.text( (10, legend_y), _("\U000f0374 Delete/Previous"), @@ -227,7 +226,7 @@ def inactive(self): self.custom_callback(self, date_str) def update(self, force=False): - self.draw.rectangle((0, 0, 128, 128), fill=self.black) + self.draw.rectangle((0, 0, self.width, self.height), fill=self.black) self.draw_date_boxes() diff --git a/python/PiFinder/ui/equipment.py b/python/PiFinder/ui/equipment.py index d8aeda283..7418a6916 100644 --- a/python/PiFinder/ui/equipment.py +++ b/python/PiFinder/ui/equipment.py @@ -1,6 +1,7 @@ from PiFinder.ui.base import UIModule from PiFinder import utils from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu +from PiFinder.ui.layout import rows_below_titlebar sys_utils = utils.get_sys_utils() @@ -27,16 +28,21 @@ def __init__(self, *args, **kwargs): def update(self, force=False): self.clear_screen() + # Top info rows stack below the title bar; the Telescope.../Eyepiece... + # option block is anchored up from the bottom so it stays put as the + # info section grows. + info = rows_below_titlebar(self.display_class, gap=4) + if self.config_object.equipment.active_telescope is None: self.draw.text( - (10, 20), + (10, info.rows[0]), _("No telescope selected"), font=self.fonts.small.font, fill=self.colors.get(128), ) else: self.draw.text( - (10, 20), + (10, info.rows[0]), self.config_object.equipment.active_telescope.name.strip(), font=self.fonts.base.font, fill=self.colors.get(128), @@ -44,14 +50,14 @@ def update(self, force=False): if self.config_object.equipment.active_eyepiece is None: self.draw.text( - (10, 35), + (10, info.rows[1]), _("No eyepiece selected"), font=self.fonts.small.font, fill=self.colors.get(128), ) else: self.draw.text( - (10, 35), + (10, info.rows[1]), f"{self.config_object.equipment.active_eyepiece.focal_length_mm}mm {self.config_object.equipment.active_eyepiece.name}", font=self.fonts.base.font, fill=self.colors.get(128), @@ -64,7 +70,7 @@ def update(self, force=False): mag = self.config_object.equipment.calc_magnification() if mag > 0: self.draw.text( - (10, 50), + (10, info.rows[2]), _("Mag: {mag:.0f}x").format( mag=mag ), # TRANSLATORS: Magnification e.g. 200x @@ -76,7 +82,7 @@ def update(self, force=False): tfov_degrees = int(tfov) tfov_minutes = int((tfov - tfov_degrees) * 60) self.draw.text( - (10, 70), + (10, info.rows[3]), _( "TFOV: {tfov_degrees:.0f}°{tfov_minutes:02.0f}'" ).format( # TRANSLATORS: True field of View in degree and minutes @@ -86,7 +92,12 @@ def update(self, force=False): fill=self.colors.get(128), ) - horiz_pos = self.display_class.titlebar_height + 70 + opt_pitch = self.fonts.large.height + 2 + horiz_pos = ( + self.display_class.resY + - 2 * opt_pitch + - max(2, self.fonts.base.height // 4) + ) self.draw.text( (10, horiz_pos), @@ -97,7 +108,7 @@ def update(self, force=False): if self.menu_index == 0: self.draw_menu_pointer(horiz_pos) - horiz_pos += 18 + horiz_pos += opt_pitch self.draw.text( (10, horiz_pos), diff --git a/python/PiFinder/ui/layout.py b/python/PiFinder/ui/layout.py new file mode 100644 index 000000000..8df9cb3d5 --- /dev/null +++ b/python/PiFinder/ui/layout.py @@ -0,0 +1,261 @@ +""" +Resolution-flexible layout helpers for UI modules. + +Geometry derives from the display instance's resolution, title-bar height and +font metrics, so the same screen code lays out on the 128x128 SSD1351 and the +176x176 SSD1333 (and any future panel). The hand-tuned per-display knobs these +read live on ``DisplayBase`` subclasses: font sizes, ``titlebar_height`` and +``menu_visible_items``. +""" + +from dataclasses import dataclass + +from PiFinder.ui.fonts import Font + + +@dataclass +class CarouselRow: + """One visible row of a UITextMenu carousel.""" + + y: int + font: Font + brightness: int + distance: int # rows away from the focus (selected) line + + +@dataclass +class CarouselLayout: + rows: list # list[CarouselRow], ordered top -> bottom + center_index: int # index into rows of the focus / selected line + selection_box: tuple # (x0, y0, x1, y1) outline bracketing the focus row + text_x: int # left x for item text + check_x: int # left x for the multi-select checkmark + + +@dataclass +class StackedRows: + """Uniform text rows stacked below the title bar.""" + + rows: list # list[int] y positions, top -> bottom (length == max_visible) + max_visible: int # number of rows that fit in the area below the title bar + pitch: int # vertical step between successive rows (font.height + gap) + top: int # y of the first row + font: Font + + +@dataclass +class BoxRow: + """A horizontally centred row of fixed-width boxes.""" + + xs: list # list[int] left x of each box, left -> right + y: int # top y shared by every box + widths: list # the box widths, left -> right (echoed back for convenience) + height: int # box height + spacing: int # horizontal gap between adjacent boxes + + +@dataclass +class ListRow: + """One visible row of a uniform-height object list.""" + + y: int + is_focus: bool + distance: int # rows away from the focus (selected) line + + +@dataclass +class ListLayout: + rows: list # list[ListRow], ordered top -> bottom + center_index: int # index into rows of the focus / selected line + selection_box: tuple # (x0, y0, x1, y1) outline bracketing the focus row + text_x: int # left x for item text (after the type marker) + marker_x: int # left x for the object-type marker + marker_dy: int # vertical offset to paste the marker relative to a row's y + row_font: Font # font for the non-focus rows (base) + focus_font: Font # font for the focus / selected row (bold) + + +def _tier(distance: int, fonts) -> tuple: + """Font + brightness for a row ``distance`` rows from the focus line. + + Reproduces the legacy 128 carousel for distances 0-3 (large/bold/base/base + at 256/192/128/96) and extends it for the taller 176 carousel (distance >=4 + -> small font at 64), keeping the symmetric fisheye falloff. + """ + if distance == 0: + return fonts.large, 256 + if distance == 1: + return fonts.bold, 192 + if distance == 2: + return fonts.base, 128 + if distance == 3: + return fonts.base, 96 + return fonts.small, 64 + + +def carousel_layout(display_class) -> CarouselLayout: + """Compute the carousel row layout for the given display instance. + + Rows are stacked top-to-bottom by their tier font height plus a small gap + and the whole block is centred in the area below the title bar, so the + focus (selected) line lands near the vertical centre of the screen. + """ + fonts = display_class.fonts + n = display_class.menu_visible_items + half = n // 2 + tb = display_class.titlebar_height + resX = display_class.resX + resY = display_class.resY + + # font + brightness per row, top -> bottom (symmetric around the focus line) + rows_meta = [_tier(abs(i - half), fonts) for i in range(n)] + heights = [font.height for font, _ in rows_meta] + + gap = max(2, fonts.base.height // 4) + pad = max(2, gap) # padding between the focus text and its selection box + + # The focus row reserves extra vertical space -- its glyph plus the box + # padding on each side -- so the selection box keeps a full ``gap`` of + # clearance from the rows above and below instead of touching them (the + # box top used to land exactly on the previous row's baseline). Mirrors + # the focus-slot reservation in ``list_layout``. + slot_heights = [ + height + 2 * pad if i == half else height for i, height in enumerate(heights) + ] + block = sum(slot_heights) + gap * (n - 1) + area = resY - tb + top = tb + max(0, (area - block) // 2) + + rows = [] + y = top + for i, (font, brightness) in enumerate(rows_meta): + row_y = y + pad if i == half else y + rows.append( + CarouselRow( + y=row_y, font=font, brightness=brightness, distance=abs(i - half) + ) + ) + y += slot_heights[i] + gap + + focus = rows[half] + box = (-1, focus.y - pad, resX, focus.y + focus.font.height + pad) + + # x indents scale with width (13 / 3 px on the 128 panel). + text_x = round(resX * 13 / 128) + check_x = round(resX * 3 / 128) + return CarouselLayout( + rows=rows, + center_index=half, + selection_box=box, + text_x=text_x, + check_x=check_x, + ) + + +def rows_below_titlebar( + display_class, font=None, gap=None, top_pad=None +) -> StackedRows: + """Stacked text-row y-positions for the area below the title bar. + + Rows start a small ``top_pad`` below the title bar and step by + ``font.height + gap``; ``max_visible`` is how many such rows fit before the + bottom edge. Used by the secondary screens (console, status, equipment, + software, log, location-list action menu, ...) that draw uniform stacked + text rows instead of the fisheye carousel. ``font`` defaults to the base + font and ``gap`` to the same ``max(2, base.height // 4)`` convention as the + carousel; callers pass a tighter ``gap`` for dense logs. + """ + fonts = display_class.fonts + if font is None: + font = fonts.base + if gap is None: + gap = max(2, fonts.base.height // 4) + if top_pad is None: + top_pad = gap + tb = display_class.titlebar_height + resY = display_class.resY + + pitch = font.height + gap + top = tb + top_pad + max_visible = max(1, (resY - top) // pitch) + rows = [top + i * pitch for i in range(max_visible)] + return StackedRows( + rows=rows, max_visible=max_visible, pitch=pitch, top=top, font=font + ) + + +def center_box_row(display_class, box_widths, spacing, y, height) -> BoxRow: + """Lay out a row of fixed-width boxes centred horizontally on the screen. + + Centres ``sum(box_widths) + spacing * (n - 1)`` on ``resX`` and returns the + left x of each box (left -> right). The basis for the entry-grid screens + (timeentry / dateentry / locationentry HH:MM:SS / YYYY-MM-DD / coord boxes) + and the centred legend / value rows on the SQM screens, replacing the + per-screen ``(128 - total_width) // 2`` math. + """ + resX = display_class.resX + box_widths = list(box_widths) + total = sum(box_widths) + spacing * (len(box_widths) - 1) + start_x = (resX - total) // 2 + + xs = [] + x = start_x + for w in box_widths: + xs.append(x) + x += w + spacing + return BoxRow(xs=xs, y=y, widths=box_widths, height=height, spacing=spacing) + + +def list_layout(display_class) -> ListLayout: + """Compute the uniform-row layout for a UIObjectList. + + Unlike the carousel, the object list draws every row in the base font (the + focus / selected row in bold), so rows are near-uniform height. The focus + row reserves extra vertical space for its selection box; rows stack by font + height plus a small gap and the block is centred below the title bar so the + focus line lands near the screen centre. Reproduces the legacy 128 layout + (7 rows) within a couple of pixels and extends to the taller 176 panel + (``menu_visible_items`` rows). + """ + fonts = display_class.fonts + n = display_class.menu_visible_items + center = n // 2 + tb = display_class.titlebar_height + resX = display_class.resX + resY = display_class.resY + base = fonts.base + bold = fonts.bold + + gap = max(2, base.height // 4) + pad = gap # padding between the focus text and its selection box + + # The focus row reserves room for the bold text plus the box padding so the + # outline never collides with its neighbours. + focus_slot = bold.height + 2 * pad + heights = [focus_slot if i == center else base.height for i in range(n)] + block = sum(heights) + gap * (n - 1) + area = resY - tb + top = tb + max(0, (area - block) // 2) + + rows = [] + y = top + for i in range(n): + if i == center: + rows.append(ListRow(y=y + pad, is_focus=True, distance=0)) + else: + rows.append(ListRow(y=y, is_focus=False, distance=abs(i - center))) + y += heights[i] + gap + + focus = rows[center] + box = (-1, focus.y - pad, resX, focus.y + bold.height + pad) + + return ListLayout( + rows=rows, + center_index=center, + selection_box=box, + text_x=round(resX * 12 / 128), + marker_x=0, + marker_dy=2, + row_font=base, + focus_font=bold, + ) diff --git a/python/PiFinder/ui/location_list.py b/python/PiFinder/ui/location_list.py index cb28f5f75..f1192287f 100644 --- a/python/PiFinder/ui/location_list.py +++ b/python/PiFinder/ui/location_list.py @@ -3,6 +3,7 @@ from PiFinder.state import Location from PiFinder.ui.textentry import UITextEntry from PiFinder.ui.text_menu import UITextMenu +from PiFinder.ui.layout import rows_below_titlebar if TYPE_CHECKING: @@ -51,7 +52,7 @@ def draw_action_menu(self): font=self.fonts.bold.font, fill=self.colors.get(255), ) - draw_pos += 12 + draw_pos += self.fonts.bold.height - 1 # Draw coordinates in base font self.draw.text( @@ -60,7 +61,7 @@ def draw_action_menu(self): font=self.fonts.base.font, fill=self.colors.get(128), ) - draw_pos += 16 + draw_pos += self.fonts.base.height + 5 # Draw actions for i, action in enumerate(self.actions): @@ -71,7 +72,7 @@ def draw_action_menu(self): font=self.fonts.base.font, fill=self.colors.get(color), ) - draw_pos += 10 + draw_pos += self.fonts.base.height def perform_action(self): """Execute the selected action on the current location""" @@ -174,7 +175,7 @@ def key_left(self): def update(self, force=False): if not self.locations: self.clear_screen() - draw_pos = self.display_class.titlebar_height + 20 + draw_pos = rows_below_titlebar(self.display_class, gap=4).rows[1] self.draw.text( (10, draw_pos), _("No locations"), diff --git a/python/PiFinder/ui/locationentry.py b/python/PiFinder/ui/locationentry.py index 0b8b03a57..435c6a4b4 100644 --- a/python/PiFinder/ui/locationentry.py +++ b/python/PiFinder/ui/locationentry.py @@ -4,6 +4,7 @@ import PiFinder.ui.callbacks as callbacks from PiFinder.ui.base import UIModule +from PiFinder.ui.layout import center_box_row if TYPE_CHECKING: @@ -73,8 +74,8 @@ def __init__(self, *args, **kwargs): self.boxes[0] = str(int(location.altitude)) # Screen setup - self.width = 128 - self.height = 128 + self.width = self.display_class.resX + self.height = self.display_class.resY self.red = self.colors.get(255) self.black = self.colors.get(0) self.half_red = self.colors.get(128) @@ -82,16 +83,18 @@ def __init__(self, *args, **kwargs): self.draw = ImageDraw.Draw(self.screen) self.bold = self.fonts.bold - # Layout - self.text_y = 25 - self.box_height = 20 - self.box_spacing = 12 + # Layout - box widths derive from the bold glyph width so the coordinate + # boxes scale with the display (50 / 32 / 28 on the 128 panel). + bw = self.bold.width + self.text_y = self.display_class.titlebar_height + 8 + self.box_height = self.bold.height + 7 + self.box_spacing = round(self.display_class.resX * 12 / 128) if self.coordinate == "alt": - self.box_widths = [50] + self.box_widths = [bw * 5 + 15] elif self.coordinate == "lon": - self.box_widths = [32, 28] + self.box_widths = [bw * 3 + 11, bw * 2 + 14] else: - self.box_widths = [28, 28] + self.box_widths = [bw * 2 + 14, bw * 2 + 14] def _sign_label(self): if self.coordinate == "lat": @@ -99,12 +102,18 @@ def _sign_label(self): return "E" if self.sign == "+" else "W" def draw_boxes(self): - total_width = sum(self.box_widths) + (self.num_boxes - 1) * self.box_spacing - start_x = (self.width - total_width) // 2 + box_row = center_box_row( + self.display_class, + self.box_widths, + self.box_spacing, + self.text_y, + self.box_height, + ) + start_x = box_row.xs[0] # Draw sign indicator for lat/lon if self.has_sign: - sign_x = start_x - 14 + sign_x = start_x - (self.bold.width + 7) self.draw.text( (sign_x, self.text_y + 2), self._sign_label(), @@ -207,14 +216,14 @@ def draw_legend(self, start_y): font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 self.draw.text( (10, legend_y), _("\uf053 Cancel"), font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 if self.coordinate == "lat": hint = _("\U000f0374 Delete \U000f0415 N/S") elif self.coordinate == "lon": @@ -344,7 +353,7 @@ def inactive(self): self.custom_callback(self) def update(self, force=False): - self.draw.rectangle((0, 0, 128, 128), fill=self.black) + self.draw.rectangle((0, 0, self.width, self.height), fill=self.black) self.draw_boxes() note_y = self.draw_label() separator_y = self.draw_separator(note_y + 15) diff --git a/python/PiFinder/ui/log.py b/python/PiFinder/ui/log.py index ad3382e6c..7c2359d9e 100644 --- a/python/PiFinder/ui/log.py +++ b/python/PiFinder/ui/log.py @@ -122,12 +122,16 @@ def __init__(self, *args, **kwargs): self.reset_config() def draw_stars(self, horiz_pos, star_count): + # Star pitch / left margin derive from the star glyph width so the five + # stars spread with the display instead of bunching on the left. + star_pitch = self.fonts.large.width + 6 + star_x0 = round(self.display_class.resX * 20 / 128) for i in range(5): star_color = 64 if star_count > i: star_color = 255 self.draw.text( - (i * 15 + 20, horiz_pos), + (star_x0 + i * star_pitch, horiz_pos), self._STAR, font=self.fonts.large.font, fill=self.colors.get(star_color), @@ -158,7 +162,7 @@ def update(self, force=True): if not self.shared_state.solve_state(): self.draw.text( - (0, 20), + (0, self.display_class.titlebar_height + 3), _("No Solve Yet"), font=self.fonts.large.font, fill=self.colors.get(255), @@ -166,6 +170,12 @@ def update(self, force=True): return self.screen_update() horiz_pos = self.display_class.titlebar_height + # Row advances derived from the large-font height: item->item is a touch + # over the glyph height; label->its-stars and stars->next-label are a + # touch under (reproduces the 128 panel's 18 / 14 / 11-15 cadence). + label_gap = self.fonts.large.height + 2 + to_stars = self.fonts.large.height - 2 + from_stars = self.fonts.large.height - 2 # Target Name self.draw.text( @@ -176,7 +186,7 @@ def update(self, force=True): ) if self.menu_index == 0: self.draw_menu_pointer(horiz_pos) - horiz_pos += 18 + horiz_pos += label_gap # Observability self.draw.text( @@ -187,9 +197,9 @@ def update(self, force=True): ) if self.menu_index == 1: self.draw_menu_pointer(horiz_pos) - horiz_pos += 14 + horiz_pos += to_stars self.draw_stars(horiz_pos, self.log_observability) - horiz_pos += 11 + horiz_pos += from_stars # Appeal self.draw.text( @@ -200,9 +210,9 @@ def update(self, force=True): ) if self.menu_index == 2: self.draw_menu_pointer(horiz_pos) - horiz_pos += 14 + horiz_pos += to_stars self.draw_stars(horiz_pos, self.log_appeal) - horiz_pos += 15 + horiz_pos += from_stars self.draw.text( (10, horiz_pos), @@ -212,7 +222,7 @@ def update(self, force=True): ) if self.menu_index == 3: self.draw_menu_pointer(horiz_pos) - horiz_pos += 17 + horiz_pos += label_gap self.draw.text( (10, horiz_pos), diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 6f93877bd..12f155ce3 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -131,6 +131,10 @@ def __init__( self.marking_menu_stack: list[MarkingMenu] = [] self.marking_menu_bg: Union[Image.Image, None] = None + # Radial marking-menu radius, scaled from the 128-panel value (39) so + # the curved option labels (laid out in fonts.large, which is wider on + # the 176 panel) stay inside their pie slices on any resolution. + self.marking_menu_radius = round(self.display_class.resX * 39 / 128) # This will be populated if we are in 'help' mode self.help_images: Union[None, list[Image.Image]] = None @@ -260,7 +264,7 @@ def display_marking_menu(self): self.marking_menu_bg.copy(), self.marking_menu_stack[-1], self.display_class, - 39, + self.marking_menu_radius, ) self.update_screen(marking_menu_image) @@ -270,7 +274,7 @@ def flash_marking_menu_option(self, option: MarkingMenuOption) -> None: self.marking_menu_bg.copy(), self.marking_menu_stack[-1], self.display_class, - 39, + self.marking_menu_radius, option, ) self.update_screen(marking_menu_image) diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index b01e56a59..6430407a7 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -104,9 +104,17 @@ def __init__(self, *args, **kwargs): ), } - # cache some display stuff for locate + # cache some display stuff for locate. az/alt readouts stack two huge + # lines up from the bottom; the multipliers are relative to resY and the + # huge-font height, so they already track resolution. self.az_anchor = (0, self.display_class.resY - (self.fonts.huge.height * 2.2)) self.alt_anchor = (0, self.display_class.resY - (self.fonts.huge.height * 1.2)) + # Two-line status messages ("No solve", "Searching for GPS"...) shown in + # place of the az/alt readout; positions derive from resolution + font. + msg_x = round(self.display_class.resX * 10 / 128) + msg_y1 = round(self.display_class.resY * 70 / 128) + self._pointing_msg_anchor_1 = (msg_x, msg_y1) + self._pointing_msg_anchor_2 = (msg_x, msg_y1 + self.fonts.large.height + 2) self._elipsis_count = 0 self.active() # fill in activation time @@ -344,13 +352,13 @@ def _render_pointing_instructions(self): # Pointing Instructions if not self.shared_state.solution().has_pointing(): self.draw.text( - (10, 70), + self._pointing_msg_anchor_1, _("No solve"), # TRANSLATORS: No solve yet... (Part 1/2) font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 90), + self._pointing_msg_anchor_2, _("yet{elipsis}").format( elipsis="." * int(self._elipsis_count / 10) ), # TRANSLATORS: No solve yet... (Part 2/2) @@ -364,13 +372,13 @@ def _render_pointing_instructions(self): if not self.shared_state.altaz_ready(): self.draw.text( - (10, 70), + self._pointing_msg_anchor_1, _("Searching"), # TRANSLATORS: Searching for GPS (Part 1/2) font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 90), + self._pointing_msg_anchor_2, _("for GPS{elipsis}").format( elipsis="." * int(self._elipsis_count / 10) ), # TRANSLATORS: Searching for GPS (Part 2/2) @@ -384,13 +392,13 @@ def _render_pointing_instructions(self): if not self._check_catalog_initialized(): self.draw.text( - (10, 70), + self._pointing_msg_anchor_1, _("Calculating"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 90), + self._pointing_msg_anchor_2, _(f"positions{'.' * int(self._elipsis_count / 10)}"), font=self.fonts.large.font, fill=self.colors.get(255), @@ -412,13 +420,13 @@ def _render_pointing_instructions(self): if point_az is None or point_alt is None: # No valid pointing data available self.draw.text( - (10, 70), + self._pointing_msg_anchor_1, _("Calculating"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 90), + self._pointing_msg_anchor_2, _(f"position{'.' * int(self._elipsis_count / 10)}"), font=self.fonts.large.font, fill=self.colors.get(255), @@ -507,13 +515,17 @@ def update(self, force=True): # catalog and entry field i.e. NGC-311 self.refresh_designator() desc_available_lines = 4 + # Header lines sit just below the title bar; type/const stacks one + # large-font line below the designator (derived so they track res). + desig_y = self.display_class.titlebar_height + 3 + typeconst_y = desig_y + self.fonts.large.height desig = self.texts["designator"] - desig.draw((0, 20)) + desig.draw((0, desig_y)) # Object TYPE and Constellation i.e. 'Galaxy PER' typeconst = self.texts.get("type-const") if typeconst: - typeconst.draw((0, 36)) + typeconst.draw((0, typeconst_y)) if self.object_display_mode == DM_LOCATE: self._render_pointing_instructions() @@ -521,7 +533,9 @@ def update(self, force=True): elif self.object_display_mode == DM_DESC: # Object Magnitude and size i.e. 'Mag:4.0 Sz:7"' magsize = self.texts.get("magsize") - posy = 52 + # Start just below the type/const header (derived; 52 on the 128 + # panel, lower on taller panels so it doesn't crowd the header). + posy = typeconst_y + self.fonts.bold.height + 3 if magsize and magsize.text.strip(): if self.object: # check for visibility and adjust mag/size text color @@ -594,10 +608,18 @@ def update(self, force=True): # Add explanation about what CR means explanation_lines = [ - _("CR measures object"), # TRANSLATORS: Contrast reserve explanation line 1 - _("visibility based on"), # TRANSLATORS: Contrast reserve explanation line 2 - _("sky brightness,"), # TRANSLATORS: Contrast reserve explanation line 3 - _("telescope, and EP."), # TRANSLATORS: Contrast reserve explanation (EP = entrance pupil) line 4 + _( + "CR measures object" + ), # TRANSLATORS: Contrast reserve explanation line 1 + _( + "visibility based on" + ), # TRANSLATORS: Contrast reserve explanation line 2 + _( + "sky brightness," + ), # TRANSLATORS: Contrast reserve explanation line 3 + _( + "telescope, and EP." + ), # TRANSLATORS: Contrast reserve explanation (EP = entrance pupil) line 4 ] for line in explanation_lines: diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index ccd9440fd..0f1bdfd3d 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -20,6 +20,7 @@ from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPE_MARKERS from PiFinder.ui.text_menu import UITextMenu +from PiFinder.ui.layout import list_layout from PiFinder.ui.object_details import UIObjectDetails from PiFinder.calc_utils import aim_degrees @@ -129,7 +130,8 @@ def __init__(self, *args, **kwargs) -> None: and self.item_definition.get("value") == "CM" ): marking_menu_down = MarkingMenuOption( - label=_("Refresh"), callback=self.mm_refresh_comets # TRANSLATORS: Marking menu option to refresh comet catalog + label=_("Refresh"), + callback=self.mm_refresh_comets, # TRANSLATORS: Marking menu option to refresh comet catalog ) self.marking_menu = MarkingMenu( @@ -468,16 +470,25 @@ def get_line_font_color_pos( @cache def color_modifier(self, line_number: int, sort_order: SortOrder): + # Brightness falloff per row, generated for however many rows the + # display shows (menu_visible_items). Reproduces the legacy 7-row + # curves exactly and extends them for the taller 176 panel. + n = self.display_class.menu_visible_items + center = n // 2 if sort_order == SortOrder.NEAREST: - line_number_modifiers = [0.38, 0.5, 0.75, 0.8, 0.75, 0.5, 0.38] - else: - line_number_modifiers = [1, 0.75, 0.75, 0.5, 0.5, 0.38, 0.38] - return line_number_modifiers[line_number] + # symmetric fisheye falloff, brightest at the focus (centre) line + by_distance = [0.8, 0.75, 0.5, 0.38, 0.3] + return by_distance[min(abs(line_number - center), len(by_distance) - 1)] + # CATALOG_SEQUENCE / RA: brightest at the top, descending + descending = [1, 0.75, 0.75, 0.5, 0.5, 0.38, 0.38, 0.3, 0.3] + return descending[min(line_number, len(descending) - 1)] @cache def line_position(self, line_number, title_offset=20): - line_number_positions = [0, 13, 25, 42, 60, 76, 89] - return line_number_positions[line_number] + title_offset + # Row y-positions derive from the display resolution, title-bar height + # and font metrics (see ui.layout.list_layout). title_offset is kept for + # call-site compatibility but the anchor now comes from the layout. + return list_layout(self.display_class).rows[line_number].y def active(self): # trigger refilter @@ -493,7 +504,13 @@ def active(self): def update(self, force: bool = False) -> None: self.clear_screen() - begin_x = 12 + + # Resolution-flexible row layout: y-positions, the focus selection box + # and text indent all derive from the display resolution, title-bar + # height and font metrics. + layout = list_layout(self.display_class) + half = layout.center_index + begin_x = layout.text_x # Check if loading just completed and refresh if so is_loading = self.catalogs.is_loading() @@ -553,9 +570,9 @@ def update(self, force: bool = False) -> None: if self.current_sort == SortOrder.NEAREST and self.nearby.should_refresh(): self.nearby_refresh() - # Draw sorting mode in empty space - if self._current_item_index < 3: - intensity: int = int(64 + ((2.0 - self._current_item_index) * 32.0)) + # Draw sorting mode in the empty rows above the focus line + if self._current_item_index < half: + intensity: int = int(64 + (((half - 1) - self._current_item_index) * 32.0)) self.draw.text( (begin_x, self.line_position(0)), _("{catalog_info_1} obj").format( @@ -580,13 +597,15 @@ def update(self, force: bool = False) -> None: fill=self.colors.get(intensity), ) # Draw current selection hint - self.draw.rectangle((-1, 60, 129, 80), outline=self.colors.get(128), width=1) + self.draw.rectangle(layout.selection_box, outline=self.colors.get(128), width=1) line_number, line_pos = 0, 0 line_color = None - for i in range(self._current_item_index - 3, self._current_item_index + 4): + for i in range( + self._current_item_index - half, self._current_item_index + half + 1 + ): if i >= 0 and i < len(self._menu_items_sorted): _menu_item = self._menu_items_sorted[i] - is_focus = line_number == 3 + is_focus = line_number == half item_name = self.create_shortname_text(_menu_item) item_text = "" @@ -606,10 +625,12 @@ def update(self, force: bool = False) -> None: line_bg = 32 if is_focus else 0 marker = self.get_marker(_menu_item.obj_type, line_color, line_bg) if marker is not None: - self.screen.paste(marker, (0, line_pos + 2)) + self.screen.paste( + marker, (layout.marker_x, line_pos + layout.marker_dy) + ) # calculate start of both pieces of text - begin_x = 12 + begin_x = layout.text_x begin_x2 = begin_x + (len(item_name) + 1) * line_font.width # draw first text @@ -846,7 +867,9 @@ def mm_refresh_comets(self, marking_menu, menu_item): """Force refresh of comet data from the internet""" catalog = self.catalogs.get_catalog_by_code("CM") if catalog and hasattr(catalog, "refresh"): - self.message(_("Refreshing..."), 1) # TRANSLATORS: Status message when refreshing comet catalog + self.message( + _("Refreshing..."), 1 + ) # TRANSLATORS: Status message when refreshing comet catalog catalog.refresh() # Clear the UI object list and refresh to show status self.refresh_object_list(force_update=True) diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index 1ff026713..69ba84778 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -37,6 +37,11 @@ STRETCH_MIN_SPAN = 50.0 # min ADU span so a faint frame isn't stretched hard STRETCH_DITHER_FRAC = 0.5 # uniform dither amplitude as a fraction of one step +# Native camera frame size. target_pixel and centroid coordinates live in this +# (square) pixel space (see SharedStateObj.target_pixel, documented 512x512); +# the preview scales them down to the display resolution. +CAMERA_NATIVE_RES = 512 + class UIPreview(UIModule): from PiFinder import tetra3 @@ -160,18 +165,20 @@ def draw_star_selectors(self): for _i in range(self.highlight_count): raw_y, raw_x = self.star_list[_i] - star_x = int(raw_x / 4) - star_y = int(raw_y / 4) + # centroids are in native camera space; scale to the display + star_x = int(raw_x * self.display_class.resX / CAMERA_NATIVE_RES) + star_y = int(raw_y * self.display_class.resY / CAMERA_NATIVE_RES) x_direction = 1 x_text_offset = 6 y_direction = 1 y_text_offset = -12 - if star_x > 108: + # flip the marker/label when too close to the right edge or top + if star_x > self.display_class.resX - 20: x_direction = -1 x_text_offset = -10 - if star_y < 38: + if star_y < self.display_class.titlebar_height + 21: y_direction = -1 y_text_offset = 1 @@ -388,16 +395,26 @@ def update(self, force=False): self._last_focus_frame_time = last_image_time image_obj = raw_image + native_w, native_h = image_obj.size + resX, resY = self.display_class.resX, self.display_class.resY - # Resize + # Resize / zoom. Zoom crops a centred region of the native camera + # frame (half of it for 2x, a quarter for 4x) then scales to the + # display, so the zoom factor stays 2x / 4x at any resolution. if self.zoom_level == 0: - image_obj = image_obj.resize((128, 128)) + image_obj = image_obj.resize((resX, resY)) elif self.zoom_level == 1: - image_obj = image_obj.resize((256, 256)) - image_obj = image_obj.crop((64, 64, 192, 192)) + crop_w, crop_h = native_w // 2, native_h // 2 + ox, oy = (native_w - crop_w) // 2, (native_h - crop_h) // 2 + image_obj = image_obj.crop((ox, oy, ox + crop_w, oy + crop_h)).resize( + (resX, resY) + ) elif self.zoom_level == 2: - # no resize, just crop - image_obj = image_obj.crop((192, 192, 320, 320)) + crop_w, crop_h = native_w // 4, native_h // 4 + ox, oy = (native_w - crop_w) // 2, (native_h - crop_h) // 2 + image_obj = image_obj.crop((ox, oy, ox + crop_w, oy + crop_h)).resize( + (resX, resY) + ) # Background-anchored linear stretch (replaces autocontrast), then RED. # Stretch on a single luminance band (debug frames are RGB; hardware diff --git a/python/PiFinder/ui/radec_entry.py b/python/PiFinder/ui/radec_entry.py index 3b8e2e5f0..d32d89e33 100644 --- a/python/PiFinder/ui/radec_entry.py +++ b/python/PiFinder/ui/radec_entry.py @@ -558,30 +558,44 @@ def get_default_fields(coord_format): class LayoutConfig: - """Layout configuration constants for the coordinate entry UI""" - - # Field dimensions - FIELD_HEIGHT = 16 - FIELD_WIDTH = 24 - FIELD_GAP = 30 - - # Positioning - LABEL_X = 5 - FIELD_START_X = 32 - RA_LABEL_Y = 18 - RA_Y = 28 - DEC_LABEL_Y = 46 - DEC_Y = 56 - EPOCH_LABEL_Y = 74 - EPOCH_Y = 84 - - # UI elements - BOTTOM_BAR_HEIGHT = 24 - CURSOR_WIDTH = 2 - - # Mixed/Decimal field width - MIXED_DECIMAL_FIELD_WIDTH = 50 - FORMAT_INDICATOR_OFFSET = 52 + """Layout configuration for the coordinate entry UI. + + Geometry derives from the display resolution + font metrics so the RA / DEC + / Epoch form scales across panels. All values reproduce the 128 panel + exactly (the labels sit just below the title bar, the three coordinate rows + are evenly pitched, and the help bar is anchored to the bottom). + """ + + def __init__(self, display_class): + fonts = display_class.fonts + base_h = fonts.base.height + tb = display_class.titlebar_height + resX = display_class.resX + + # Field dimensions + self.FIELD_HEIGHT = base_h + 5 # 16 on the 128 panel + self.FIELD_WIDTH = round(resX * 24 / 128) + self.FIELD_GAP = round(resX * 30 / 128) + + # Positioning: label rows + field rows, evenly pitched below the bar + self.LABEL_X = round(resX * 5 / 128) + self.FIELD_START_X = round(resX * 32 / 128) + label_to_field = base_h - 1 # 10 on the 128 panel + block_pitch = label_to_field + self.FIELD_HEIGHT + 2 # 28 on the 128 panel + self.RA_LABEL_Y = tb + 1 + self.RA_Y = self.RA_LABEL_Y + label_to_field + self.DEC_LABEL_Y = self.RA_LABEL_Y + block_pitch + self.DEC_Y = self.DEC_LABEL_Y + label_to_field + self.EPOCH_LABEL_Y = self.DEC_LABEL_Y + block_pitch + self.EPOCH_Y = self.EPOCH_LABEL_Y + label_to_field + + # UI elements + self.BOTTOM_BAR_HEIGHT = 2 * base_h + 2 # two text rows + self.CURSOR_WIDTH = 2 + + # Mixed/Decimal field width + self.MIXED_DECIMAL_FIELD_WIDTH = round(resX * 50 / 128) + self.FORMAT_INDICATOR_OFFSET = round(resX * 52 / 128) class UIRADecEntry(UIModule): @@ -617,7 +631,7 @@ def __init__(self, *args, **kwargs) -> None: self.base = self.fonts.base # Layout configuration - self.layout = LayoutConfig() + self.layout = LayoutConfig(self.display_class) self.field_height = self.layout.FIELD_HEIGHT self.label_x = self.layout.LABEL_X self.field_start_x = self.layout.FIELD_START_X @@ -904,7 +918,9 @@ def draw_bottom_bar(self): line1 = f"{square_icon}{_('Format')} {arrow_icons}{_('Nav')}" line2 = f"{back_icon}{_('Cancel')} {go_icon}{_('Go ')} -{_('Del')}" self.draw.text((2, bar_y + 2), line1, font=self.base.font, fill=self.red) - self.draw.text((2, bar_y + 12), line2, font=self.base.font, fill=self.red) + self.draw.text( + (2, bar_y + self.base.height + 1), line2, font=self.base.font, fill=self.red + ) def validate_field(self, field_index, value): """Validate the entered value for the given field""" diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index fe92660a3..c9be54aa0 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -100,7 +100,7 @@ def update(self, force=False): font=self.fonts.base.font, fill=self.colors.get(128), ) - draw_pos += 15 + draw_pos += self.fonts.base.height + 4 self.draw.text( (0, draw_pos), @@ -108,7 +108,7 @@ def update(self, force=False): font=self.fonts.bold.font, fill=self.colors.get(128), ) - draw_pos += 10 + draw_pos += self.fonts.bold.height - 3 self.draw.text( (10, draw_pos), @@ -116,7 +116,7 @@ def update(self, force=False): font=self.fonts.bold.font, fill=self.colors.get(192), ) - draw_pos += 16 + draw_pos += self.fonts.bold.height + 3 self.draw.text( (0, draw_pos), @@ -124,7 +124,7 @@ def update(self, force=False): font=self.fonts.bold.font, fill=self.colors.get(128), ) - draw_pos += 10 + draw_pos += self.fonts.bold.height - 3 self.draw.text( (10, draw_pos), @@ -133,15 +133,21 @@ def update(self, force=False): fill=self.colors.get(192), ) + # The two-line status / action message is anchored up from the bottom + # so it clears the (taller-font) info block on larger displays. + msg_pitch = self.fonts.large.height + msg_top = self.display_class.resY - 2 * msg_pitch - 6 + msg_bottom = msg_top + msg_pitch + if self._wifi_mode != "Client": self.draw.text( - (10, 90), + (10, msg_top), _("WiFi must be"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 105), + (10, msg_bottom), _("client mode"), font=self.fonts.large.font, fill=self.colors.get(255), @@ -154,13 +160,13 @@ def update(self, force=False): if self._elipsis_count > 30: self.get_release_version() self.draw.text( - (10, 90), + (10, msg_top), _("Checking for"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 105), + (10, msg_bottom), _("updates{elipsis}").format( elipsis="." * int(self._elipsis_count / 10) ), @@ -176,13 +182,13 @@ def update(self, force=False): self._software_version.strip(), self._release_version.strip() ): self.draw.text( - (10, 90), + (10, msg_top), _("No Update"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( - (10, 105), + (10, msg_bottom), _("needed"), font=self.fonts.large.font, fill=self.colors.get(255), @@ -204,9 +210,9 @@ def update(self, force=False): fill=self.colors.get(255), ) if self._option_select == "Update": - ind_pos = 90 + ind_pos = msg_top else: - ind_pos = 105 + ind_pos = msg_bottom self.draw.text( (0, ind_pos), self._RIGHT_ARROW, diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index 9b07f0e22..ba4042af5 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -39,11 +39,15 @@ def __init__(self, *args, **kwargs): # Marking menu definition self.marking_menu = MarkingMenu( left=MarkingMenuOption( - label=_("CAL"), # TRANSLATORS: Marking menu option to launch SQM calibration wizard + label=_( + "CAL" + ), # TRANSLATORS: Marking menu option to launch SQM calibration wizard callback=self._launch_calibration, ), down=MarkingMenuOption( - label=_("CORRECT"), # TRANSLATORS: Marking menu option to launch SQM correction sweep tool + label=_( + "CORRECT" + ), # TRANSLATORS: Marking menu option to launch SQM correction sweep tool callback=self._launch_sqm_sweep, ), right=MarkingMenuOption(), @@ -52,7 +56,7 @@ def __init__(self, *args, **kwargs): def update(self, force=False): # Show camera image in background (same processing as preview) image_obj = self.camera_image.copy() - image_obj = image_obj.resize((128, 128)) + image_obj = image_obj.resize((self.display_class.resX, self.display_class.resY)) image_obj = subtract_background(image_obj, percent=0.5) image_obj = image_obj.convert("RGB") image_obj = ImageChops.multiply(image_obj, self.colors.red_image) @@ -63,16 +67,37 @@ def update(self, force=False): # Draw semi-transparent dark overlay for text readability overlay_draw = ImageDraw.Draw(self.screen, "RGBA") overlay_draw.rectangle( - [(0, 0), (128, 128)], + [(0, 0), (self.display_class.resX, self.display_class.resY)], fill=(0, 0, 0, 180), # Black with 70% opacity ) + # Dashboard layout anchors derived from the title bar + font heights so + # the value / detail rows fill the panel instead of clustering in the + # top-left of a larger display (was hand-placed for the 128 panel). + resX = self.display_class.resX + resY = self.display_class.resY + tb = self.display_class.titlebar_height + base_h = self.fonts.base.height + left = round(resX * 10 / 128) # ~10px left margin on the 128 panel + info_y = tb + 3 # top status row (time / stars / exposure) + value_y = info_y + base_h # big SQM value (huge font) + units_y = value_y + self.fonts.huge.height # units line under the value + detail_y = units_y + base_h # altitude-corrected value + bortle_y = detail_y + base_h # Bortle class + legend_y = resY - base_h - 3 # bottom legend row + desc_y = info_y + self.fonts.bold.height + 5 # scrollable description top + # right-hand columns scale with width + stars_x = round(resX * 60 / 128) + exp_x = round(resX * 95 / 128) + cal_x = round(resX * 105 / 128) + ncal_x = round(resX * 98 / 128) + # Get SQM from shared state sqm_state = self.shared_state.sqm() if sqm_state.last_update is None: self.draw.text( - (10, 30), + (left, value_y), _("NO SQM DATA"), font=self.fonts.bold.font, fill=self.colors.get(128), @@ -94,13 +119,13 @@ def update(self, force=False): # If no details found, show SQM value only if details is None: self.draw.text( - (10, 30), + (left, value_y), f"{sqm:.2f}", font=self.fonts.huge.font, fill=self.colors.get(192), ) self.draw.text( - (12, 68), + (left + 2, units_y), _("mag/arcsec²"), font=self.fonts.base.font, fill=self.colors.get(64), @@ -113,24 +138,26 @@ def update(self, force=False): desc_lines.append("─" * self.fonts.base.line_length) # End marker desc_text = "\n".join(desc_lines) self.text_layout.set_text(desc_text, reset_pointer=False) - self.text_layout.set_available_lines(7) + self.text_layout.set_available_lines( + max(1, (legend_y - desc_y) // (base_h + 1)) + ) # Title self.draw.text( - (0, 20), + (0, info_y), _("Bortle {bc}").format(bc=details["bortle_class"]), font=self.fonts.bold.font, fill=self.colors.get(255), ) # Scrollable description - self.text_layout.draw((0, 38)) + self.text_layout.draw((0, desc_y)) # Legend back_text = _("BACK") scroll_text = _("SCROLL") self.draw.text( - (0, 115), + (0, legend_y), f"{self._SQUARE_} {back_text} {self._PLUSMINUS_} {scroll_text}", font=self.fonts.base.font, fill=self.colors.get(128), @@ -145,7 +172,7 @@ def update(self, force=False): else: time_str = _("{m}m ago").format(m=elapsed // 60) self.draw.text( - (10, 20), + (left, info_y), time_str, font=self.fonts.base.font, fill=self.colors.get(64), @@ -156,7 +183,7 @@ def update(self, force=False): if sqm_details: n_stars = sqm_details.get("n_matched_stars", 0) self.draw.text( - (60, 20), + (stars_x, info_y), f"{n_stars}★", font=self.fonts.base.font, fill=self.colors.get(64), @@ -170,14 +197,14 @@ def update(self, force=False): else: exp_str = f"{exp_ms:.0f}ms" self.draw.text( - (95, 20), + (exp_x, info_y), exp_str, font=self.fonts.base.font, fill=self.colors.get(64), ) self.draw.text( - (10, 30), + (left, value_y), f"{sqm:.2f}", font=self.fonts.huge.font, fill=self.colors.get(192), @@ -185,7 +212,7 @@ def update(self, force=False): # Units in small, subtle text self.draw.text( - (12, 68), + (left + 2, units_y), _("mag/arcsec²"), font=self.fonts.base.font, fill=self.colors.get(64), @@ -194,14 +221,14 @@ def update(self, force=False): # Calibration indicator (right side of units line) if self._is_calibrated(): self.draw.text( - (105, 68), + (cal_x, units_y), "CAL", font=self.fonts.base.font, fill=self.colors.get(128), ) else: self.draw.text( - (98, 68), + (ncal_x, units_y), "!CAL", font=self.fonts.base.font, fill=self.colors.get(64), @@ -212,7 +239,7 @@ def update(self, force=False): sqm_alt = sqm_details.get("sqm_altitude_corrected") if sqm_alt: self.draw.text( - (12, 80), + (left + 2, detail_y), f"alt: {sqm_alt:.2f}", font=self.fonts.base.font, fill=self.colors.get(64), @@ -221,7 +248,7 @@ def update(self, force=False): # Bortle class if details: self.draw.text( - (10, 92), + (left, bortle_y), _("Bortle {bc}").format(bc=details["bortle_class"]), font=self.fonts.base.font, fill=self.colors.get(128), @@ -230,7 +257,7 @@ def update(self, force=False): # Legend details_text = _("DETAILS") self.draw.text( - (10, 110), + (left, legend_y), f"{self._SQUARE_} {details_text}", font=self.fonts.base.font, fill=self.colors.get(64), diff --git a/python/PiFinder/ui/sqm_calibration.py b/python/PiFinder/ui/sqm_calibration.py index 3f9dabbbc..ca2d1b91d 100644 --- a/python/PiFinder/ui/sqm_calibration.py +++ b/python/PiFinder/ui/sqm_calibration.py @@ -176,8 +176,9 @@ def update(self, force=False): def _draw_intro(self): """Draw introduction screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 20), + (10, tb + 3), "SQM CAL", font=self.fonts.bold.font, fill=self.colors.get(255), @@ -191,7 +192,7 @@ def _draw_intro(self): "• ~3 minutes", ] - y = 40 + y = tb + 23 for line in lines: self.draw.text( (10, y), line, font=self.fonts.base.font, fill=self.colors.get(192) @@ -200,7 +201,7 @@ def _draw_intro(self): # Legend self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_} START 0 CANCEL", font=self.fonts.base.font, fill=self.colors.get(192), @@ -208,35 +209,24 @@ def _draw_intro(self): def _draw_cap_on_instruction(self): """Draw lens cap on instruction""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 30), + (10, tb + 13), "PUT LENS CAP ON", font=self.fonts.bold.font, fill=self.colors.get(255), ) - self.draw.text( - (10, 50), - "Cover the camera", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 62), - "lens completely to", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 74), - "block all light.", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) + y = tb + 33 + for line in ("Cover the camera", "lens completely to", "block all light."): + self.draw.text( + (10, y), line, font=self.fonts.base.font, fill=self.colors.get(192) + ) + y += self.fonts.base.height + 1 # Legend self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_} READY 0 CANCEL", font=self.fonts.base.font, fill=self.colors.get(192), @@ -244,41 +234,32 @@ def _draw_cap_on_instruction(self): def _draw_cap_off_instruction(self): """Draw lens cap off instruction""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 30), + (10, tb + 13), "REMOVE LENS CAP", font=self.fonts.bold.font, fill=self.colors.get(255), ) - self.draw.text( - (10, 50), - "Remove the cap and", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 62), - "point at dark sky.", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 74), - "Wait for solve.", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) + y = tb + 33 + for line in ("Remove the cap and", "point at dark sky.", "Wait for solve."): + self.draw.text( + (10, y), line, font=self.fonts.base.font, fill=self.colors.get(192) + ) + y += self.fonts.base.height + 1 - # Legend - show skip option for indoor calibration + # Legend - show skip option for indoor calibration, anchored to bottom + base_h = self.fonts.base.height + skip_y = self.display_class.resY - base_h - 7 self.draw.text( - (10, 100), + (10, skip_y - (base_h + 2)), f"{self._SQUARE_} READY", font=self.fonts.base.font, fill=self.colors.get(192), ) self.draw.text( - (10, 112), + (10, skip_y), "0 SKIP (indoor cal)", font=self.fonts.base.font, fill=self.colors.get(192), @@ -286,8 +267,9 @@ def _draw_cap_off_instruction(self): def _draw_progress(self, label: str, current: int, total: int): """Draw progress bar for frame capture""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 20), + (10, tb + 3), f"{label} FRAMES", font=self.fonts.bold.font, fill=self.colors.get(255), @@ -295,17 +277,19 @@ def _draw_progress(self, label: str, current: int, total: int): # Progress text self.draw.text( - (10, 40), + (10, tb + 23), f"{current} / {total}", font=self.fonts.large.font, fill=self.colors.get(192), ) - # Progress bar - bar_x = 10 - bar_y = 70 - bar_width = 108 - bar_height = 12 + # Progress bar spans the width with a symmetric margin + bar_x = round(self.display_class.resX * 10 / 128) + bar_y = tb + 53 + bar_width = self.display_class.resX - 2 * bar_x + bar_height = round(self.display_class.resY * 12 / 128) + # message row sits just below the bar + msg_y = bar_y + bar_height + 8 # Background self.draw.rectangle( @@ -330,35 +314,35 @@ def _draw_progress(self, label: str, current: int, total: int): remaining = int(self.sky_capture_timeout - elapsed) if remaining > 0: self.draw.text( - (10, 90), + (10, msg_y), f"Wait for solve: {remaining}s", font=self.fonts.base.font, fill=self.colors.get(128), ) else: self.draw.text( - (10, 90), + (10, msg_y), "No solve detected", font=self.fonts.base.font, fill=self.colors.get(128), ) else: self.draw.text( - (10, 90), + (10, msg_y), "Hold steady...", font=self.fonts.base.font, fill=self.colors.get(64), ) # Show skip option self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), "0: SKIP SKY", font=self.fonts.base.font, fill=self.colors.get(128), ) else: self.draw.text( - (10, 90), + (10, msg_y), "Keep cap on...", font=self.fonts.base.font, fill=self.colors.get(64), @@ -366,36 +350,32 @@ def _draw_progress(self, label: str, current: int, total: int): def _draw_analyzing(self): """Draw analyzing screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 40), + (10, tb + 23), "ANALYZING...", font=self.fonts.bold.font, fill=self.colors.get(255), ) - self.draw.text( - (10, 60), - "Computing noise", - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - self.draw.text( - (10, 72), - "parameters...", - font=self.fonts.base.font, - fill=self.colors.get(128), - ) + y = tb + 43 + for line in ("Computing noise", "parameters..."): + self.draw.text( + (10, y), line, font=self.fonts.base.font, fill=self.colors.get(128) + ) + y += self.fonts.base.height + 1 def _draw_results(self): """Draw final results - both processed and raw""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 18), + (10, tb + 1), "CAL COMPLETE", font=self.fonts.bold.font, fill=self.colors.get(255), ) - y = 36 + y = tb + 19 # Header row self.draw.text( @@ -458,7 +438,7 @@ def _draw_results(self): # Legend self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_} DONE", font=self.fonts.base.font, fill=self.colors.get(192), @@ -466,20 +446,21 @@ def _draw_results(self): def _draw_error(self): """Draw error screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 30), + (10, tb + 13), "ERROR", font=self.fonts.bold.font, fill=self.colors.get(255), ) # Wrap error message - y = 50 + y = tb + 33 words = self.error_message.split() line = "" for word in words: test_line = line + " " + word if line else word - if len(test_line) <= 18: # Rough character limit + if len(test_line) <= self.fonts.base.line_length: line = test_line else: self.draw.text( @@ -495,7 +476,7 @@ def _draw_error(self): # Legend self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_} EXIT", font=self.fonts.base.font, fill=self.colors.get(192), diff --git a/python/PiFinder/ui/sqm_correction.py b/python/PiFinder/ui/sqm_correction.py index 6732c7915..76c924e3a 100644 --- a/python/PiFinder/ui/sqm_correction.py +++ b/python/PiFinder/ui/sqm_correction.py @@ -92,10 +92,20 @@ def update(self, force=False): fill=self.colors.get(255), ) + # Stack the rows below the title bar; offsets derive from font heights + # so the block fills a larger panel instead of clustering at the top + # (reproduces the 128 panel's 25 / 45 / 60 / 85 positions). + tb = self.display_class.titlebar_height + base_h = self.fonts.base.height + original_y = tb + 8 + corrected_y = original_y + base_h + 9 + entry_y = corrected_y + base_h + 4 + message_y = entry_y + self.fonts.large.height + 9 + # Show original SQM value original_text = _("Original: {sqm:.2f}").format(sqm=self.original_sqm) self.draw.text( - (0, 25), + (0, original_y), original_text, font=self.fonts.base.font, fill=self.colors.get(128), @@ -104,17 +114,16 @@ def update(self, force=False): # Show correction input label corrected_label = _("Corrected:") self.draw.text( - (0, 45), + (0, corrected_y), corrected_label, font=self.fonts.base.font, fill=self.colors.get(192), ) - # Calculate centered position for entry field - entry_y = 60 + # Center the entry field on the actual screen width char_width = self.fonts.large.width total_width = char_width * len(self.entry_field.positions) - entry_x = (128 - total_width) // 2 + entry_x = (self.display_class.resX - total_width) // 2 # Draw numeric entry field using component with blinking cursor self.entry_field.draw( @@ -130,7 +139,6 @@ def update(self, force=False): ) # Show error or success message - message_y = 85 if self.error_message and self.message_time: # Show error for 3 seconds if (datetime.now() - self.message_time).total_seconds() < 3: @@ -161,8 +169,8 @@ def update(self, force=False): # Draw legend at bottom using component self.legend.draw( draw=self.draw, - screen_width=128, - screen_height=128, + screen_width=self.display_class.resX, + screen_height=self.display_class.resY, font=self.fonts.base.font, font_height=self.fonts.base.height, separator_color=self.colors.get(128), diff --git a/python/PiFinder/ui/sqm_sweep.py b/python/PiFinder/ui/sqm_sweep.py index ac40ed9c0..9449d469a 100644 --- a/python/PiFinder/ui/sqm_sweep.py +++ b/python/PiFinder/ui/sqm_sweep.py @@ -78,25 +78,20 @@ def update(self, force=False): def _draw_ask_sqm(self): """Draw SQM input screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 15), + (10, tb + 3), "REFERENCE SQM", font=self.fonts.bold.font, fill=self.colors.get(255), ) - self.draw.text( - (10, 35), - "Enter SQM from", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 47), - "external meter:", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) + y = tb + 18 + for line in ("Enter SQM from", "external meter:"): + self.draw.text( + (10, y), line, font=self.fonts.base.font, fill=self.colors.get(192) + ) + y += self.fonts.base.height + 1 # Show current input with decimal separator (XX.XX format) if self.sqm_input: @@ -115,21 +110,23 @@ def _draw_ask_sqm(self): display = "__.__" self.draw.text( - (10, 65), + (10, tb + 48), f"SQM: {display}", font=self.fonts.large.font, fill=self.colors.get(255), ) - # Legend + # Legend (two rows anchored to the bottom) + base_h = self.fonts.base.height + legend_y = self.display_class.resY - base_h - 7 self.draw.text( - (10, 95), + (10, legend_y - (base_h + 1)), "0-9: Enter -: Del", font=self.fonts.base.font, fill=self.colors.get(128), ) self.draw.text( - (10, 107), + (10, legend_y), f"{self._SQUARE_}: OK 0: Skip", font=self.fonts.base.font, fill=self.colors.get(192), @@ -137,8 +134,9 @@ def _draw_ask_sqm(self): def _draw_confirm(self): """Draw confirmation screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 20), + (10, tb + 3), "READY?", font=self.fonts.bold.font, fill=self.colors.get(255), @@ -146,35 +144,29 @@ def _draw_confirm(self): if self.reference_sqm: self.draw.text( - (10, 45), + (10, tb + 28), f"Ref SQM: {self.reference_sqm:.2f}", font=self.fonts.base.font, fill=self.colors.get(192), ) else: self.draw.text( - (10, 45), + (10, tb + 28), "No reference SQM", font=self.fonts.base.font, fill=self.colors.get(128), ) - self.draw.text( - (10, 65), - "20 images", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - self.draw.text( - (10, 77), - "~1 minute", - font=self.fonts.base.font, - fill=self.colors.get(192), - ) + y = tb + 48 + for line in ("20 images", "~1 minute"): + self.draw.text( + (10, y), line, font=self.fonts.base.font, fill=self.colors.get(192) + ) + y += self.fonts.base.height + 1 # Legend self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_}: START 0: CANCEL", font=self.fonts.base.font, fill=self.colors.get(192), @@ -194,8 +186,9 @@ def _draw_capturing(self): # Wait a moment for sweep directory to be created time.sleep(0.2) + tb = self.display_class.titlebar_height self.draw.text( - (10, 15), + (10, tb + 3), "CAPTURING...", font=self.fonts.bold.font, fill=self.colors.get(255), @@ -207,17 +200,17 @@ def _draw_capturing(self): # Show actual file count self.draw.text( - (10, 40), + (10, tb + 23), f"{file_count} / {self.total_images}", font=self.fonts.large.font, fill=self.colors.get(192), ) - # Progress bar - bar_x = 10 - bar_y = 65 - bar_width = 108 - bar_height = 12 + # Progress bar spans the width with a symmetric margin + bar_x = round(self.display_class.resX * 10 / 128) + bar_y = tb + 48 + bar_width = self.display_class.resX - 2 * bar_x + bar_height = round(self.display_class.resY * 12 / 128) self.draw.rectangle( [bar_x, bar_y, bar_x + bar_width, bar_y + bar_height], @@ -242,7 +235,7 @@ def _draw_capturing(self): mins = remaining // 60 secs = remaining % 60 self.draw.text( - (10, 85), + (10, bar_y + bar_height + 8), f"~{mins}:{secs:02d} remaining", font=self.fonts.base.font, fill=self.colors.get(128), @@ -385,22 +378,23 @@ def _add_detailed_metadata(self): def _draw_complete(self): """Draw completion screen""" + tb = self.display_class.titlebar_height self.draw.text( - (10, 40), + (10, tb + 23), "SWEEP COMPLETE!", font=self.fonts.bold.font, fill=self.colors.get(255), ) self.draw.text( - (10, 70), + (10, tb + 53), "Metadata saved", font=self.fonts.base.font, fill=self.colors.get(192), ) self.draw.text( - (10, 110), + (10, self.display_class.resY - self.fonts.base.height - 7), f"{self._SQUARE_}: EXIT", font=self.fonts.base.font, fill=self.colors.get(192), diff --git a/python/PiFinder/ui/status.py b/python/PiFinder/ui/status.py index 167dcce72..68a9e54f0 100644 --- a/python/PiFinder/ui/status.py +++ b/python/PiFinder/ui/status.py @@ -11,6 +11,7 @@ from PiFinder import calc_utils from PiFinder import utils from PiFinder.ui.ui_utils import TextLayouter, SpaceCalculatorFixed +from PiFinder.ui.layout import rows_below_titlebar sys_utils = utils.get_sys_utils() @@ -57,7 +58,9 @@ def __init__(self, *args, **kwargs): color=self.colors.get(255), colors=self.colors, font=self.fonts.base, - available_lines=9, + # As many base-font rows as fit below the title bar (9 on the 128 + # panel, more on taller displays). + available_lines=rows_below_titlebar(self.display_class, gap=1).max_visible, ) def update_status_dict(self): @@ -120,12 +123,8 @@ def update_status_dict(self): mtext = "Static" self.status_dict["IMU"] = f"{mtext : >11}" + " " + str(imu.status) - self.status_dict["IMU qw,qx"] = ( - f"{imu.quat.w:>.2f},{imu.quat.x : >.2f}" - ) - self.status_dict["IMU qy,qz"] = ( - f"{imu.quat.y:>.2f},{imu.quat.z : >.2f}" - ) + self.status_dict["IMU qw,qx"] = f"{imu.quat.w:>.2f},{imu.quat.x : >.2f}" + self.status_dict["IMU qy,qz"] = f"{imu.quat.y:>.2f},{imu.quat.z : >.2f}" else: self.status_dict["IMU"] = "--" self.status_dict["IMU qw,qx"] = "--" @@ -170,7 +169,7 @@ def update_status_dict(self): def update(self, force=False): self.update_status_dict() - self.draw.rectangle([0, 0, 128, 128], fill=self.colors.get(0)) + self.clear_screen() lines = [] # Insert IP address here... for k, v in self.status_dict.items(): diff --git a/python/PiFinder/ui/text_menu.py b/python/PiFinder/ui/text_menu.py index 6a07180cf..a4f38f81e 100644 --- a/python/PiFinder/ui/text_menu.py +++ b/python/PiFinder/ui/text_menu.py @@ -7,6 +7,7 @@ from typing import Union from PiFinder.ui.base import UIModule +from PiFinder.ui.layout import carousel_layout from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu @@ -73,76 +74,49 @@ def __init__( def update(self, force=False): # clear screen self.clear_screen() - # Draw current selection hint - self.draw.rectangle((-1, 60, 129, 80), outline=self.colors.get(128), width=1) - - line_number = 0 - line_horiz_pos = 13 - - for i in range(self._current_item_index - 3, self._current_item_index + 4): - if i >= 0 and i < self.get_nr_of_menu_items(): - # figure out line position / color / font - line_font = self.fonts.base - if line_number == 0: - line_color = 96 - line_pos = 0 - if line_number == 1: - line_color = 128 - line_pos = 13 - if line_number == 2: - line_color = 192 - line_font = self.fonts.bold - line_pos = 25 - if line_number == 3: - line_color = 256 - line_font = self.fonts.large - line_pos = 40 - if line_number == 4: - line_color = 192 - line_font = self.fonts.bold - line_pos = 60 - if line_number == 5: - line_color = 128 - line_pos = 76 - if line_number == 6: - line_color = 96 - line_pos = 89 - - # Offset for title - line_pos += 20 - - # figure out line text - item_text = str(self._menu_items[i]) - - # Check if this item has a name_suffix_callback for dynamic display - item_def = self.get_item(item_text) - suffix = "" - if item_def and item_def.get("name_suffix_callback"): - try: - suffix = item_def["name_suffix_callback"](self) - except Exception: - suffix = "" + # Resolution-flexible carousel layout: row positions, per-row font and + # brightness, and the focus-line selection box all derive from the + # display's resolution, title-bar height and font metrics. + layout = carousel_layout(self.display_class) + half = layout.center_index + + # Draw current selection hint around the focus (centre) line + self.draw.rectangle(layout.selection_box, outline=self.colors.get(128), width=1) + + for slot, row in enumerate(layout.rows): + i = self._current_item_index - half + slot + if i < 0 or i >= self.get_nr_of_menu_items(): + continue + + # figure out line text + item_text = str(self._menu_items[i]) + + # Check if this item has a name_suffix_callback for dynamic display + item_def = self.get_item(item_text) + suffix = "" + if item_def and item_def.get("name_suffix_callback"): + try: + suffix = item_def["name_suffix_callback"](self) + except Exception: + suffix = "" + + self.draw.text( + (layout.text_x, row.y), + _(item_text) + suffix, # I18N: translate item for display, add suffix + font=row.font.font, + fill=self.colors.get(row.brightness), + ) + if ( + item_def is not None + and item_def.get("value", "--") in self._selected_values + ): self.draw.text( - (line_horiz_pos, line_pos), - _(item_text) - + suffix, # I18N: translate item for display, add suffix - font=line_font.font, - fill=self.colors.get(line_color), + (layout.check_x, row.y), + self._CHECKMARK, + font=row.font.font, + fill=self.colors.get(row.brightness), ) - if ( - self.get_item(item_text) is not None - and self.get_item(item_text).get("value", "--") - in self._selected_values - ): - self.draw.text( - (3, line_pos), - self._CHECKMARK, - font=line_font.font, - fill=self.colors.get(line_color), - ) - - line_number += 1 return self.screen_update() diff --git a/python/PiFinder/ui/textentry.py b/python/PiFinder/ui/textentry.py index 4f3d78063..037a50753 100644 --- a/python/PiFinder/ui/textentry.py +++ b/python/PiFinder/ui/textentry.py @@ -90,8 +90,8 @@ def __init__(self, *args, **kwargs) -> None: self.current_text = self.item_definition.get("initial_text", "") self.callback = self.item_definition.get("callback") - self.width = 128 - self.height = 128 + self.width = self.display_class.resX + self.height = self.display_class.resY self.red = self.colors.get(255) self.black = self.colors.get(0) self.half_red = self.colors.get(128) @@ -114,7 +114,7 @@ def __init__(self, *args, **kwargs) -> None: self.cursor_width = self.fonts.bold.width self.cursor_height = self.fonts.bold.height self.text_x = 7 - self.text_x_end = 128 - self.text_x + self.text_x_end = self.width - self.text_x self.text_y = self.display_class.titlebar_height + 2 # Async search state @@ -129,7 +129,7 @@ def t9_search_enabled(self) -> bool: return bool(self.config_object.get_option("t9_search", False)) def draw_text_entry(self): - line_text_y = self.text_y + 15 + line_text_y = self.text_y + self.bold.height + 2 self.draw.line( [(self.text_x, line_text_y), (self.text_x_end, line_text_y)], fill=self.half_red, @@ -175,21 +175,25 @@ def draw_text_entry(self): ) def draw_keypad(self): - key_size = (38, 23) - padding = 0 - start_x, start_y = self.text_x, 32 + # 3-column x 4-row T9 grid filling the width below the text line; key + # size derives from the screen so it scales (38x23 on the 128 panel). + start_x = self.text_x + start_y = self.text_y + self.bold.height + key_w = (self.width - 2 * self.text_x) // 3 + key_h = (self.height - start_y - 4) // 4 + letter_dy = self.fonts.base.height - 3 for i, (num, letters) in enumerate(self.keys): - x = start_x + (i % 3) * (key_size[0] + padding) - y = start_y + (i // 3) * (key_size[1] + padding) + x = start_x + (i % 3) * key_w + y = start_y + (i // 3) * key_h self.draw.rectangle( - [x, y, x + key_size[0], y + key_size[1]], outline=self.half_red, width=1 + [x, y, x + key_w, y + key_h], outline=self.half_red, width=1 ) self.draw.text( (x + 2, y), str(num), font=self.fonts.base.font, fill=self.half_red ) self.draw.text( - (x + 2, y + 8), + (x + 2, y + letter_dy), letters[1], font=self.fonts.bold.font, fill=self.colors.get(192), @@ -215,7 +219,7 @@ def draw_search_result_len(self): formatted_len = format_number(result_count, 4).strip() self.text_x_end = ( - 128 - 2 - self.text_x - self.bold.font.getbbox(formatted_len)[2] + self.width - 2 - self.text_x - self.bold.font.getbbox(formatted_len)[2] ) self.draw.text( (self.text_x_end + 2, self.text_y), @@ -391,7 +395,7 @@ def inactive(self): self._search_version += 1 def update(self, force=False): - self.draw.rectangle((0, 0, 128, 128), fill=self.colors.get(0)) + self.draw.rectangle((0, 0, self.width, self.height), fill=self.colors.get(0)) # Set title based on mode (will be drawn by screen_update()) if self.text_entry_mode: diff --git a/python/PiFinder/ui/timeentry.py b/python/PiFinder/ui/timeentry.py index 8d6f4fc86..917f123fc 100644 --- a/python/PiFinder/ui/timeentry.py +++ b/python/PiFinder/ui/timeentry.py @@ -5,6 +5,7 @@ import PiFinder.ui.callbacks as callbacks from PiFinder.ui.base import UIModule from PiFinder.ui.dateentry import UIDateEntry +from PiFinder.ui.layout import center_box_row if TYPE_CHECKING: @@ -29,8 +30,8 @@ def __init__(self, *args, **kwargs): ] # TRANSLATORS: Place holders for hours, minutes, seconds in time entry # Screen setup - self.width = 128 - self.height = 128 + self.width = self.display_class.resX + self.height = self.display_class.resY self.red = self.colors.get(255) self.black = self.colors.get(0) self.half_red = self.colors.get(128) @@ -38,15 +39,22 @@ def __init__(self, *args, **kwargs): self.draw = ImageDraw.Draw(self.screen) self.bold = self.fonts.bold - # Layout constants - updated to center the boxes - self.text_y = 25 - self.box_width = 25 - self.box_height = 20 - self.box_spacing = 15 - - # Calculate start_x to center the boxes on screen - total_width = (3 * self.box_width) + (2 * self.box_spacing) - self.start_x = (self.width - total_width) // 2 + # Layout constants - box dimensions derive from the bold font so the + # two-digit boxes scale with the display (25 / 20 / 15 on the 128 panel). + self.text_y = self.display_class.titlebar_height + 8 + self.box_width = self.bold.width * 2 + 11 + self.box_height = self.bold.height + 7 + self.box_spacing = round(self.display_class.resX * 15 / 128) + + # Center the three boxes on the actual screen width + box_row = center_box_row( + self.display_class, + [self.box_width] * 3, + self.box_spacing, + self.text_y, + self.box_height, + ) + self.start_x = box_row.xs[0] def draw_time_boxes(self): # Draw the three boxes with colons between them @@ -128,14 +136,14 @@ def draw_legend(self, start_y): font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 self.draw.text( (10, legend_y), _("\uf053 Cancel"), font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 + legend_y += self.fonts.base.height + 1 self.draw.text( (10, legend_y), _("\U000f0374 Delete/Previous"), @@ -218,7 +226,7 @@ def inactive(self): self.custom_callback(self, time_str) def update(self, force=False): - self.draw.rectangle((0, 0, 128, 128), fill=self.black) + self.draw.rectangle((0, 0, self.width, self.height), fill=self.black) self.draw_time_boxes() diff --git a/python/PiFinder/ui/ui_utils.py b/python/PiFinder/ui/ui_utils.py index 37e860537..1147fa068 100644 --- a/python/PiFinder/ui/ui_utils.py +++ b/python/PiFinder/ui/ui_utils.py @@ -195,9 +195,13 @@ def set_available_lines(self, available_lines: int): self.updated = True def _draw_pos(self, pos): - xpos = 127 + # Derive the scrollbar extent from the display resolution. TextLayouter + # isn't handed a display_class, but its ``colors`` carries a red_image + # sized to the display resolution, so read the size from there. + resX, resY = self.colors.red_image.size + xpos = resX - 1 starty = pos[1] + 1 - endy = 127 + endy = resY - 1 therange = endy - starty blockextent = math.floor((self.available_lines / self.nr_lines) * therange) blockstart = ((self.pointer) / self.nr_lines) * therange