Skip to content

Commit 60dc807

Browse files
committed
Native iOS/tvOS text entry, streamlined Android string-edit dialog, quieter connect-outage logging.
1 parent ba986a6 commit 60dc807

30 files changed

Lines changed: 570 additions & 62 deletions

.efrocachemap

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
### 1.8.0 (build 22986, api 9, 2026-08-18)
1+
### 1.8.0 (build 22987, api 9, 2026-08-18)
22
- Fully implemented asset packages (more on this soon)
33
- App-config committing (dirty-tracking, debounced disk writes, and
44
suspend/shutdown flushes) now lives fully in `babase` instead of routing
@@ -99,6 +99,9 @@
9999
round trips cost the most. The app also now asks your permission before
100100
letting a console control it, and tells the console when it is quitting
101101
instead of leaving it waiting.
102+
- Android text editing has been streamlined - you can send chat messages
103+
directly from the keyboard instead of having to hit 'Done' and then 'Send',
104+
etc.
102105

103106
### 1.7.63 (build 22870, api 9, 2026-06-08)
104107
- Fixed mouse-wheel zooming in manual camera mode.

pconfig/projectconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"bauiv1lib": "buil"
2424
},
2525
"efrocache_repository_url": "https://files.ballistica.net/cache/ba1",
26-
"engine_build_number": 22986,
26+
"engine_build_number": 22987,
2727
"name": "BallisticaKit",
2828
"public": true,
2929
"python_paths": [
@@ -43,5 +43,5 @@
4343
"tests",
4444
"config"
4545
],
46-
"version": "1.8.0a91"
46+
"version": "1.8.0a92"
4747
}

src/assets/ba_data/python/babase/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,11 @@
238238
from babase._net import get_ip_address_type, NetworkSubsystem
239239
from babase._plugin import PluginSpec, Plugin, PluginSubsystem
240240
from babase._simpledialog import SimpleDialog
241-
from babase._stringedit import StringEditAdapter, StringEditSubsystem
241+
from babase._stringedit import (
242+
StringEditAdapter,
243+
StringEditKind,
244+
StringEditSubsystem,
245+
)
242246
from babase._text import timestring
243247
from babase._workspace import WorkspaceSubsystem
244248

@@ -439,6 +443,7 @@
439443
'SpecialChar',
440444
'storagename',
441445
'StringEditAdapter',
446+
'StringEditKind',
442447
'StringEditSubsystem',
443448
'supports_max_fps',
444449
'supports_vsync',

src/assets/ba_data/python/babase/_stringedit.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import time
1010
import logging
1111
import weakref
12+
from enum import Enum
1213
from typing import TYPE_CHECKING, final
1314

1415
from efro.util import empty_weakref
@@ -19,6 +20,23 @@
1920
pass
2021

2122

23+
class StringEditKind(Enum):
24+
"""Semantic kind for a string being edited.
25+
26+
Platform edit UIs can use this to tailor presentation: keyboard
27+
action buttons (send vs done), title/button visibility,
28+
suggestion behavior, etc. Purely a presentation hint; has no
29+
effect on the resulting value.
30+
"""
31+
32+
#: Generic text edit; no special treatment.
33+
DEFAULT = 'default'
34+
35+
#: A chat message; edit UIs may show a 'send' action and minimize
36+
#: chrome around the field.
37+
CHAT = 'chat'
38+
39+
2240
class StringEditSubsystem:
2341
"""Full string-edit state for the app.
2442
@@ -51,7 +69,9 @@ def __init__(
5169
initial_text: str,
5270
max_length: int | None,
5371
screen_space_center: tuple[float, float] | None,
72+
*,
5473
is_password: bool = False,
74+
kind: StringEditKind = StringEditKind.DEFAULT,
5575
) -> None:
5676
if not _babase.in_logic_thread():
5777
raise RuntimeError('This must be called from the logic thread.')
@@ -66,6 +86,8 @@ def __init__(
6686
self.screen_space_center = screen_space_center
6787
# Whether the platform editor should mask input (password entry).
6888
self.is_password = is_password
89+
# Semantic kind hint for platform edit UIs.
90+
self.kind = kind
6991

7092
# Attempt to register ourself as the active edit.
7193
subsys = _babase.app.stringedit
@@ -106,9 +128,15 @@ def can_be_replaced(self) -> bool:
106128
return False
107129

108130
@final
109-
def apply(self, new_text: str) -> None:
131+
def apply(self, new_text: str, submit: bool = False) -> None:
110132
"""Should be called by the owner when editing is complete.
111133
134+
Submit means the commit came from an enter-equivalent gesture
135+
(a keyboard send/done action key or the like) and should also
136+
trigger the edit target's return-press behavior, matching
137+
desktop inline editing where enter does both. A plain apply
138+
just stores the value.
139+
112140
Note that in some cases this call may be a no-op (such as if
113141
this adapter is no longer the globally active one).
114142
"""
@@ -129,6 +157,8 @@ def apply(self, new_text: str) -> None:
129157
new_text = new_text[: self.max_length]
130158

131159
self._do_apply(new_text)
160+
if submit:
161+
self._do_submit()
132162

133163
@final
134164
def cancel(self) -> None:
@@ -150,3 +180,11 @@ def _do_cancel(self) -> None:
150180
Will always be called in the logic thread.
151181
"""
152182
raise NotImplementedError('Subclasses must override this.')
183+
184+
def _do_submit(self) -> None:
185+
"""Trigger the edit target's return-press behavior, if any.
186+
187+
Called after :meth:`_do_apply` for submit-style applies.
188+
Optional override; the default does nothing. Will always be
189+
called in the logic thread.
190+
"""

src/assets/ba_data/python/baenv.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@
5555

5656
# Build number and version of the ballistica binary we expect to be
5757
# using.
58-
TARGET_BALLISTICA_BUILD = 22986
59-
TARGET_BALLISTICA_VERSION = '1.8.0a91'
58+
TARGET_BALLISTICA_BUILD = 22987
59+
TARGET_BALLISTICA_VERSION = '1.8.0a92'
6060

6161

6262
@dataclass

src/assets/ba_data/python/baplus/_automationsession.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@
9898
#: worth stalling the app's exit.
9999
_SHUTDOWN_CLOSE_TIMEOUT = 2.0
100100

101+
#: How long the recreate loop waits for the transport before looking
102+
#: again. The transport tells us when it reconnects, so this is only
103+
#: the self-heal path for a notification that never arrives; it must
104+
#: never be the *only* way we wake up, and nothing may depend on it
105+
#: being short.
106+
_TRANSPORT_RECHECK_SECONDS = 30.0
107+
108+
#: A channel that died sooner than this means something is wrong
109+
#: (an unreachable node) rather than a drive having finished.
110+
_CHANNEL_SHORT_LIFE_SECONDS = 2.0
111+
112+
#: Backoff bounds for re-offering after a channel dies immediately.
113+
_RECREATE_BACKOFF_MIN_SECONDS = 2.0
114+
_RECREATE_BACKOFF_MAX_SECONDS = 60.0
115+
101116

102117
def _make_key() -> str:
103118
"""Mint (or read) this run's automation key."""
@@ -125,6 +140,12 @@ def __init__(self) -> None:
125140
#: Set at app shutdown so the recreate loop stops offering
126141
#: fresh channels while the runtime is going away.
127142
self._shutting_down = False
143+
#: Wakes the recreate loop when the transport reconnects.
144+
#: A wakeup *only*: what gates the loop is asking the
145+
#: transport where it is, so a set() we miss costs a
146+
#: re-check delay rather than a device that never becomes
147+
#: drivable again.
148+
self._transport_connected = asyncio.Event()
128149
#: Supplied by the caller, which has the private-api access
129150
#: to read it (baplus may not reach ``_babase``).
130151
self._app_instance_id = ''
@@ -161,15 +182,23 @@ def on_transport_connected(
161182

162183
self._app_instance_id = app_instance_id
163184
ws_url = _ws_url_for(node_base_url)
185+
186+
# Before the early-out below, not after: a task that parked
187+
# because the transport was down is still very much running,
188+
# and this is the wakeup that un-parks it.
189+
self._transport_connected.set()
190+
164191
if ws_url == self._node_url and self._task is not None:
165-
# Same node, still running; nothing to do.
192+
# Same node, still running; nothing to do. (Also the
193+
# ordinary path back from a transport outage: the task
194+
# notices the node it holds is reachable again.)
166195
return
167196

168197
self._stop()
169198
self._node_url = ws_url
170199
if self._key is None:
171200
self._key = _make_key()
172-
self._task = asyncio.create_task(self._run(ws_url))
201+
self._task = asyncio.create_task(self._run())
173202

174203
def _stop(self) -> None:
175204
"""Tear down any live channel."""
@@ -213,7 +242,7 @@ async def shutdown(self) -> None:
213242
except BaseException: # pylint: disable=broad-except
214243
pass # Cancelled (or already failing) -- we're leaving.
215244

216-
async def _run(self, ws_url: str) -> None:
245+
async def _run(self) -> None:
217246
"""Offer a channel, and a fresh one each time one ends.
218247
219248
A SmartSocket channel is one device + one driver over its
@@ -224,8 +253,30 @@ async def _run(self, ws_url: str) -> None:
224253
new one under a new id + locator. A single driver's own wifi
225254
blip is invisible to this loop -- the endpoint resumes the
226255
same channel internally and only returns here on a real end.
256+
257+
Offering is gated on the transport being connected. Not the
258+
live channel -- an endpoint mid-resume keeps its full
259+
reconnect budget, since a brief drop is exactly what it is
260+
built to ride out and killing it would cost a driver its
261+
session. What stops is the *recreating*: with the device
262+
asleep or the network gone, its node is unreachable by
263+
definition, and re-offering into that produces a fresh
264+
locator and a fresh round of failures every couple of
265+
seconds, forever, for nobody.
227266
"""
267+
backoff = _RECREATE_BACKOFF_MIN_SECONDS
228268
while not self._shutting_down:
269+
ws_url = await self._await_transport()
270+
if ws_url is None:
271+
return # Shutting down.
272+
# Adopt whatever node the transport is on now. Normally
273+
# ``on_transport_connected`` restarts us on a node change
274+
# and this is simply the url we already had; taking it
275+
# from the transport each time means a notification we
276+
# somehow miss costs a recheck interval instead of
277+
# leaving us dialing a node nobody is on any more.
278+
self._node_url = ws_url
279+
229280
started = time.monotonic()
230281
channel_dead = await self._run_one_channel(ws_url)
231282
if not channel_dead or self._shutting_down:
@@ -237,10 +288,51 @@ async def _run(self, ws_url: str) -> None:
237288
# racing to read the fresh locator right after ending the
238289
# old one must not find a gap. Back off only when a
239290
# channel dies almost immediately, which means something
240-
# is wrong (an unreachable node) rather than a drive
241-
# having finished.
242-
if time.monotonic() - started < 2.0:
243-
await asyncio.sleep(2.0)
291+
# is wrong (a node that answers but won't hold a channel)
292+
# rather than a drive having finished.
293+
if time.monotonic() - started < _CHANNEL_SHORT_LIFE_SECONDS:
294+
await asyncio.sleep(backoff)
295+
backoff = min(backoff * 2.0, _RECREATE_BACKOFF_MAX_SECONDS)
296+
else:
297+
backoff = _RECREATE_BACKOFF_MIN_SECONDS
298+
299+
async def _await_transport(self) -> str | None:
300+
"""Wait until we have a node to offer a channel on.
301+
302+
Returns its attach url, or ``None`` if we're shutting down.
303+
304+
Level-triggered on purpose: we ask the transport where it is
305+
rather than trusting a remembered flag, and the wait always
306+
times out. Both halves are about recovery -- a gate that can
307+
only be re-opened by an event arriving is a gate that strands
308+
the device for the rest of the run if one ever doesn't, and
309+
that failure would look exactly like automation being broken.
310+
"""
311+
while not self._shutting_down:
312+
# Clear *before* looking, so a connect landing between
313+
# the two leaves the event set and the wait returns at
314+
# once. Clearing after would be the classic missed-wakeup
315+
# race, costing a full recheck interval.
316+
self._transport_connected.clear()
317+
ws_url = self._connected_node_ws_url()
318+
if ws_url is not None:
319+
return ws_url
320+
try:
321+
await asyncio.wait_for(
322+
self._transport_connected.wait(),
323+
timeout=_TRANSPORT_RECHECK_SECONDS,
324+
)
325+
except TimeoutError:
326+
pass
327+
return None
328+
329+
def _connected_node_ws_url(self) -> str | None:
330+
"""Our transport's current node as an attach url, if any."""
331+
plus = babase.app.plus
332+
if plus is None:
333+
return None
334+
base_url = plus.cloud.get_connected_node_base_url()
335+
return None if base_url is None else _ws_url_for(base_url)
244336

245337
async def _run_one_channel(self, ws_url: str) -> bool:
246338
"""Hold one channel until it dies. True if it died on its own.

src/assets/ba_data/python/bauiv1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
shutdown_suppress_begin,
109109
shutdown_suppress_end,
110110
SpecialChar,
111+
StringEditKind,
111112
supports_max_fps,
112113
supports_vsync,
113114
supports_unicode_display,
@@ -300,6 +301,7 @@
300301
'Sound',
301302
'SoundHandle',
302303
'SpecialChar',
304+
'StringEditKind',
303305
'spinnerwidget',
304306
'supports_max_fps',
305307
'supports_vsync',

0 commit comments

Comments
 (0)