"""Screens, target sizes, and what "easy to reach" means for each access method. These are models, stated so they can be argued with; none is a measurement of a person. DESIGN-motor.md gives the reasoning and shows the trade-offs. Screens (CSS pixels and millimetres per CSS pixel, from the devices' published resolutions): tablet: a 10.2-inch tablet (iPad 9th generation class), 2160x1620 px at 264 ppi, 1080x810 CSS px -> 0.1924 mm per CSS px. phone: a 6.1-inch phone (iPhone 14 class), 1170x2532 px at 460 ppi, 390x844 CSS px -> 0.1657 mm per CSS px; safe areas 47 px top and 34 px bottom in portrait, 47 px each side and 21 px bottom in landscape. The app's chrome (aac-web style/main.css): message bar 66 px, suggestion row 52 px (3.25em), navigation bar 54 px; board padding 4 px; 4 px between cells (the default cell_spacing_px). Text mode and the word-finding bar are off. Target sizes are compared with the app's own recommendations (access-core input.rs recommended_target_mm): touch 10 mm, switch 15 mm, head 18 mm, eye gaze 20 mm. """ from __future__ import annotations import math from dataclasses import dataclass from .grid import Size CHROME_PX = 66 + 52 + 54 PAD_PX = 4 GAP_PX = 4 RECOMMENDED_MM = {"touch": 10.0, "switch": 15.0, "head": 18.0, "gaze": 20.0} @dataclass(frozen=True) class Screen: name: str width_px: int height_px: int mm_per_px: float safe_x: int = 0 # both sides together safe_y: int = 0 # top and bottom together def board_px(self) -> tuple[float, float]: w = self.width_px - self.safe_x - 2 * PAD_PX h = self.height_px - self.safe_y - CHROME_PX - 2 * PAD_PX return w, h def cell_mm(self, size: Size) -> tuple[float, float]: w, h = self.board_px() cw = (w - (size.cols - 1) * GAP_PX) / size.cols ch = (h - (size.rows - 1) * GAP_PX) / size.rows return cw * self.mm_per_px, ch * self.mm_per_px SCREENS = { "tablet-landscape": Screen("10.2-inch tablet, landscape", 1080, 810, 0.1924), "tablet-portrait": Screen("10.2-inch tablet, portrait", 810, 1080, 0.1924), "phone-portrait": Screen("6.1-inch phone, portrait", 390, 844, 0.1657, safe_y=47 + 34), "phone-landscape": Screen("6.1-inch phone, landscape", 844, 390, 0.1657, safe_x=47 + 47, safe_y=21), } # The screen the motor costs are computed on. DESIGN_SCREEN = SCREENS["tablet-landscape"] def touch_cost(cell: tuple[int, int], size: Size, screen: Screen = DESIGN_SCREEN) -> float: """Fitts' index of difficulty, log2(1 + D/W), from a hand resting at the bottom centre below the board (the navigation bar's height under it) to the cell's centre; W is the smaller side of the cell.""" w_px, h_px = screen.board_px() cw = (w_px - (size.cols - 1) * GAP_PX) / size.cols ch = (h_px - (size.rows - 1) * GAP_PX) / size.rows r, c = cell x = c * (cw + GAP_PX) + cw / 2 y = r * (ch + GAP_PX) + ch / 2 rest_x, rest_y = w_px / 2, h_px + 54 d = math.hypot(x - rest_x, y - rest_y) return math.log2(1 + d / min(cw, ch)) def scan_steps(cell: tuple[int, int], size: Size) -> int: """Row-column scanning from the top left: highlights until the cell is chosen (rows stepped through, then cells in the row), the selection presses included. The app's default places the bar and the navigation after the board (SPEC §5.5, §5.6), so they add nothing before it.""" r, c = cell return (r + 1) + (c + 1) def gaze_cost(cell: tuple[int, int], size: Size) -> float: """Distance of the cell's centre from the middle of the board, 0 at the centre and 1 at a corner: gaze estimates (a webcam's above all) are least accurate towards the edges, and a target at the edge has nowhere to put the overshoot.""" r, c = cell y = (r + 0.5) / size.rows - 0.5 x = (c + 0.5) / size.cols - 0.5 return math.hypot(x, y) / math.hypot(0.5, 0.5) METHODS = {"touch": touch_cost, "scan": scan_steps, "gaze": gaze_cost} def cost_table(size: Size, method: str) -> dict[tuple[int, int], float]: fn = METHODS[method] return {(r, c): float(fn((r, c), size)) for r in range(size.rows) for c in range(size.cols)} def blended(size: Size, weights: dict[str, float]) -> dict[tuple[int, int], float]: """Each method's cost scaled to 0..1 over the grid, then weighted.""" out = {(r, c): 0.0 for r in range(size.rows) for c in range(size.cols)} for m, w in weights.items(): t = cost_table(size, m) lo, hi = min(t.values()), max(t.values()) for k, v in t.items(): out[k] += w * ((v - lo) / (hi - lo) if hi > lo else 0.0) return out def target_sizes(size: Size) -> dict[str, dict]: out = {} for key, screen in SCREENS.items(): w, h = screen.cell_mm(size) smaller = min(w, h) out[key] = { "width_mm": round(w, 1), "height_mm": round(h, 1), "smaller_mm": round(smaller, 1), "meets": [m for m, mm in RECOMMENDED_MM.items() if smaller >= mm], } return out