-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplatform_metrics.py
More file actions
249 lines (196 loc) · 8.77 KB
/
platform_metrics.py
File metadata and controls
249 lines (196 loc) · 8.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
"""Platform-level metrics shared between screen hosts and view handlers.
The screen host (`pythonnative.screen`) is the only place that knows
about native window/safe-area state because it is the only piece of
code that holds a reference to the native ``UIViewController``
(iOS) or ``Activity`` (Android). Native view handlers, however, need
that state to size themselves correctly:
- A bottom tab bar must claim both its visible 49 pt / 56 dp content
height **and** the bottom safe-area inset so its background reaches
the edge of the screen and the home indicator / gesture bar does
not draw on top of the labels.
- A future safe-area-aware container can read the same values instead
of asking each native view for window metrics.
Rather than threading those values through every
[`measure_intrinsic`][pythonnative.native_views.base.ViewHandler.measure_intrinsic]
call signature, the screen host writes them here and handlers read
them on demand. Values are in **dp on Android** and **pt on iOS** —
i.e., the same "layout units" the layout engine uses on each
platform, so handlers can add them to other layout-unit values
without further conversion. On iOS the screen host consumes the top
safe-area inset by positioning the root view below it, then publishes
``top=0`` here; Android publishes the raw system-bar insets because
the host view normally remains full-screen.
Example:
>>> from pythonnative.platform_metrics import (
... get_safe_area_insets, set_safe_area_insets,
... )
>>> set_safe_area_insets(top=44.0, left=0.0, bottom=34.0, right=0.0)
>>> get_safe_area_insets().bottom
34.0
"""
from __future__ import annotations
import threading
from typing import Callable, List, NamedTuple
class SafeAreaInsets(NamedTuple):
"""Safe-area insets in layout units (pt on iOS, dp on Android)."""
top: float
left: float
bottom: float
right: float
class WindowDimensions(NamedTuple):
"""Viewport size in layout units (pt on iOS, dp on Android)."""
width: float
height: float
_safe_area_insets: SafeAreaInsets = SafeAreaInsets(0.0, 0.0, 0.0, 0.0)
_window_dimensions: WindowDimensions = WindowDimensions(0.0, 0.0)
_keyboard_height: float = 0.0
_subscribers: List[Callable[[], None]] = []
_subscribers_lock = threading.Lock()
def _notify_subscribers() -> None:
"""Invoke every registered subscriber, swallowing exceptions.
Called from every metric setter so subscriber callbacks (the React
hooks ``use_window_dimensions``/``use_safe_area_insets``/
``use_keyboard_height`` for example) can re-render in response to
any metric change. Subscribers are called serially in registration
order; any callback that raises is logged-and-skipped so a single
misbehaving subscriber can't take down the whole notification
cycle.
"""
with _subscribers_lock:
callbacks = list(_subscribers)
for cb in callbacks:
try:
cb()
except Exception:
pass
def subscribe(callback: Callable[[], None]) -> Callable[[], None]:
"""Register *callback* to fire whenever any metric changes.
Returns an unsubscribe function. Hooks pass a state setter so a
component re-renders whenever the platform reports a new value.
Threadsafe — multiple subscribers may register/unregister
concurrently.
"""
with _subscribers_lock:
_subscribers.append(callback)
def _unsub() -> None:
with _subscribers_lock:
try:
_subscribers.remove(callback)
except ValueError:
pass
return _unsub
def set_safe_area_insets(top: float, left: float, bottom: float, right: float) -> None:
"""Publish the current safe-area insets.
Called by the platform-specific screen host whenever it learns a
new value (e.g., on first layout, on rotation, on multitasking
split-view changes). Negative inputs are clamped to ``0.0`` so
handlers don't have to defend against bad data from native
callers.
Args:
top: Distance in layout units from the top of the host
container to the safe area (status bar / dynamic island /
navigation bar).
left: Inset from the left edge.
bottom: Inset from the bottom edge (home indicator / gesture
bar).
right: Inset from the right edge.
"""
global _safe_area_insets
new_insets = SafeAreaInsets(
max(0.0, float(top)),
max(0.0, float(left)),
max(0.0, float(bottom)),
max(0.0, float(right)),
)
if new_insets == _safe_area_insets:
return
_safe_area_insets = new_insets
_notify_subscribers()
def get_safe_area_insets() -> SafeAreaInsets:
"""Return the current safe-area insets.
The default value is ``(0, 0, 0, 0)`` — handlers should still
function correctly on a desktop / unit-test environment where no
screen host has published insets.
"""
return _safe_area_insets
def reset_safe_area_insets() -> None:
"""Reset the insets back to ``(0, 0, 0, 0)``.
Intended for unit tests that need a clean slate between cases.
Production code should use
[`set_safe_area_insets`][pythonnative.platform_metrics.set_safe_area_insets]
instead.
"""
global _safe_area_insets
_safe_area_insets = SafeAreaInsets(0.0, 0.0, 0.0, 0.0)
def set_window_dimensions(width: float, height: float) -> None:
"""Publish the viewport size in layout units.
Called by the screen host on initial layout, rotation, and split-
view changes. Notifies subscribers (and therefore re-renders
components using ``use_window_dimensions``) only when the size
actually changes.
"""
global _window_dimensions
new_dims = WindowDimensions(max(0.0, float(width)), max(0.0, float(height)))
if new_dims == _window_dimensions:
return
_window_dimensions = new_dims
_notify_subscribers()
def get_window_dimensions() -> WindowDimensions:
"""Return the current viewport size, or ``(0, 0)`` before first layout."""
return _window_dimensions
def reset_window_dimensions() -> None:
"""Reset window dimensions back to ``(0, 0)``. Intended for tests."""
global _window_dimensions
_window_dimensions = WindowDimensions(0.0, 0.0)
def set_keyboard_height(height: float) -> None:
"""Publish the on-screen keyboard height in layout units.
Negative inputs are clamped to ``0.0``. Notifies subscribers only
when the value actually changes.
"""
global _keyboard_height
new_h = max(0.0, float(height))
if new_h == _keyboard_height:
return
_keyboard_height = new_h
_notify_subscribers()
def get_keyboard_height() -> float:
"""Return the current on-screen keyboard height, or ``0.0`` if hidden."""
return _keyboard_height
def reset_keyboard_height() -> None:
"""Reset the keyboard height back to ``0.0``. Intended for tests."""
global _keyboard_height
_keyboard_height = 0.0
# ======================================================================
# Per-platform tab-bar defaults
# ======================================================================
#
# Only iOS exposes an explicit constant here. The iOS handler can't
# trust ``UITabBar.sizeThatFits_`` (it has historically returned 0 in
# some configurations) and the screen host deliberately extends the
# root view past the bottom safe area so the bar reaches the home
# indicator — both pieces conspire to require a single source of
# truth for the height formula.
#
# Android intentionally has no equivalent: ``BottomNavigationView``
# reports a reliable natural height via ``measure(…)`` once attached
# to the window, and the active-indicator pill is positioned against
# that natural height. Forcing our own height threw off the pill
# geometry, so the Android handler defers entirely to the system.
#: UIKit HIG tab-bar content height in points. The total bar reaches
#: ``IOS_TAB_BAR_BASE_HEIGHT_PT + safe_area_insets.bottom`` so the
#: pill background can extend over the home indicator. Apple's HIG
#: places the tab bar flush with the screen edge and lets UIKit
#: render its own internal padding for the home indicator.
IOS_TAB_BAR_BASE_HEIGHT_PT: float = 49.0
def ios_tab_bar_height() -> float:
"""Return the iOS tab-bar intrinsic height in points.
Equal to ``IOS_TAB_BAR_BASE_HEIGHT_PT + safe_area_insets.bottom``
so the bar reaches the home indicator. The iOS screen host
deliberately extends the root view past the bottom safe area for
this very reason — the tab bar absorbs the inset and UIKit
renders the pill with internal padding for the home indicator.
Used by ``pythonnative.native_views.ios.TabBarHandler``; exposed
here so the formula is testable without importing the iOS
handler module (which requires ``rubicon-objc``).
"""
return IOS_TAB_BAR_BASE_HEIGHT_PT + get_safe_area_insets().bottom