Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/adr/0009-resolution-flexible-ui-hybrid.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion docs/ax/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/ax/ui/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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).
Expand Down
64 changes: 63 additions & 1 deletion python/PiFinder/displays.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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()

Expand Down
23 changes: 18 additions & 5 deletions python/PiFinder/ui/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading