tapwright 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tapwright/__init__.py +2 -0
- tapwright/__main__.py +5 -0
- tapwright/airplay.py +103 -0
- tapwright/changes.py +264 -0
- tapwright/cli.py +374 -0
- tapwright/client.py +151 -0
- tapwright/control.py +189 -0
- tapwright/daemon.py +2401 -0
- tapwright/descriptions.py +186 -0
- tapwright/device.py +495 -0
- tapwright/errors.py +13 -0
- tapwright/mcp_server.py +266 -0
- tapwright/mirror.py +91 -0
- tapwright/rtp.py +109 -0
- tapwright/server.py +435 -0
- tapwright/session.py +672 -0
- tapwright/shot.py +42 -0
- tapwright/signing.py +201 -0
- tapwright/skill/SKILL.md +203 -0
- tapwright/skill/agents/openai.yaml +4 -0
- tapwright/skill/references/setup.md +102 -0
- tapwright/skills.py +60 -0
- tapwright/store.py +130 -0
- tapwright/stream.py +405 -0
- tapwright/tapline.py +118 -0
- tapwright/ui.py +1242 -0
- tapwright/video.py +486 -0
- tapwright/viewer.html +2060 -0
- tapwright-1.0.0.dist-info/METADATA +254 -0
- tapwright-1.0.0.dist-info/RECORD +34 -0
- tapwright-1.0.0.dist-info/WHEEL +5 -0
- tapwright-1.0.0.dist-info/entry_points.txt +2 -0
- tapwright-1.0.0.dist-info/licenses/LICENSE +21 -0
- tapwright-1.0.0.dist-info/top_level.txt +1 -0
tapwright/__init__.py
ADDED
tapwright/__main__.py
ADDED
tapwright/airplay.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The AirPlay receiver for the H.264 live view: UxPlay, an open-source program run as its own process. It takes
|
|
2
|
+
the phone's Screen Mirroring and passes the phone's own H.264 on, undecoded, as RTP to video.VideoHub on
|
|
3
|
+
localhost. Tapwright doesn't bundle it: `find()` looks for an installed one.
|
|
4
|
+
|
|
5
|
+
How it is run (the size, 60 frames a second, a fixed device id) follows phoneuse (MIT), where it was worked
|
|
6
|
+
out on Windows; the numbers were measured again here on an iPhone 14 Pro Max, iOS 26.5 (2026-11-10)."""
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from . import store
|
|
15
|
+
|
|
16
|
+
NAME = "Tapwright" # what the phone lists under Screen Mirroring
|
|
17
|
+
WINDOWS_BUILD = Path(r"C:\msys64\ucrt64\bin\uxplay.exe") # where an MSYS2 build of UxPlay lands
|
|
18
|
+
STARTED = ("begin streaming", "starting mirroring") # UxPlay's log lines when the phone starts mirroring...
|
|
19
|
+
ENDED = ("no longer true", "connection closed") # ...and when it stops
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find() -> str | None:
|
|
23
|
+
"""The UxPlay to run: $TAPWRIGHT_UXPLAY, else `uxplay` on the PATH, else the MSYS2 build on Windows."""
|
|
24
|
+
named = os.environ.get("TAPWRIGHT_UXPLAY")
|
|
25
|
+
if named:
|
|
26
|
+
return named if Path(named).is_file() else None
|
|
27
|
+
found = shutil.which("uxplay")
|
|
28
|
+
if found:
|
|
29
|
+
return found
|
|
30
|
+
return str(WINDOWS_BUILD) if os.name == "nt" and WINDOWS_BUILD.is_file() else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def device_id() -> str:
|
|
34
|
+
"""A fixed AirPlay device id for this computer (a random, locally administered MAC address, made once), so
|
|
35
|
+
the phone sees one receiver across runs. UxPlay would otherwise take an adapter's address, which can change."""
|
|
36
|
+
path = store.HOME / "airplay-id"
|
|
37
|
+
try:
|
|
38
|
+
mac = path.read_text().strip()
|
|
39
|
+
if len(mac) == 17:
|
|
40
|
+
return mac
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
raw = bytearray(os.urandom(6))
|
|
44
|
+
raw[0] = (raw[0] & 0xFC) | 0x02 # unicast, locally administered
|
|
45
|
+
mac = ":".join(f"{b:02x}" for b in raw)
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
path.write_text(mac)
|
|
48
|
+
return mac
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def command(uxplay: str, rtp_port: int, pixels: tuple[int, int], name: str = NAME) -> list[str]:
|
|
52
|
+
"""UxPlay's command line: fixed ports, our name, the phone's H.264 as RTP to `rtp_port`, no audio."""
|
|
53
|
+
return [uxplay, "-p", "-n", name, "-nh",
|
|
54
|
+
"-vrtp", f"config-interval=1 pt=96 mtu=60000 ! udpsink host=127.0.0.1 port={rtp_port} sync=false",
|
|
55
|
+
"-vsync", "no", "-s", f"{pixels[0]}x{pixels[1]}@60", "-fps", "60", "-m", device_id(), "-a"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Receiver:
|
|
59
|
+
"""UxPlay as a child process. `mirroring` follows what its log says about the phone's connection."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, uxplay: str, rtp_port: int, pixels: tuple[int, int],
|
|
62
|
+
log: Callable[[str], None] | None = None, name: str = NAME) -> None:
|
|
63
|
+
self.args = command(uxplay, rtp_port, pixels, name)
|
|
64
|
+
self.name = name
|
|
65
|
+
self.mirroring = False
|
|
66
|
+
self._log = log or (lambda message: None)
|
|
67
|
+
self._process: subprocess.Popen | None = None
|
|
68
|
+
|
|
69
|
+
def start(self) -> None:
|
|
70
|
+
env = dict(os.environ)
|
|
71
|
+
env["PATH"] = os.path.dirname(self.args[0]) + os.pathsep + env.get("PATH", "") # its DLLs sit beside it
|
|
72
|
+
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
73
|
+
self._process = subprocess.Popen(self.args, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.STDOUT, creationflags=flags)
|
|
75
|
+
threading.Thread(target=self._follow, args=(self._process,), name="uxplay-log", daemon=True).start()
|
|
76
|
+
|
|
77
|
+
def _follow(self, process: subprocess.Popen) -> None:
|
|
78
|
+
for raw in process.stdout:
|
|
79
|
+
line = raw.decode(errors="replace").strip().lower()
|
|
80
|
+
if any(mark in line for mark in STARTED):
|
|
81
|
+
if not self.mirroring:
|
|
82
|
+
self._log("video: the phone is mirroring")
|
|
83
|
+
self.mirroring = True
|
|
84
|
+
elif any(mark in line for mark in ENDED):
|
|
85
|
+
if self.mirroring:
|
|
86
|
+
self._log("video: the phone stopped mirroring")
|
|
87
|
+
self.mirroring = False
|
|
88
|
+
self.mirroring = False
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def running(self) -> bool:
|
|
92
|
+
return self._process is not None and self._process.poll() is None
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
process, self._process = self._process, None
|
|
96
|
+
self.mirroring = False
|
|
97
|
+
if process is None or process.poll() is not None:
|
|
98
|
+
return
|
|
99
|
+
process.terminate()
|
|
100
|
+
try:
|
|
101
|
+
process.wait(5)
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
process.kill()
|
tapwright/changes.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Refs that survive an action, and the change list an action answers with (docs/AGENT-TOOLS.md
|
|
2
|
+
§3.1 and §3.4). Pure functions over element lists, so they're testable without a phone."""
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
|
|
6
|
+
from .ui import Element, clip
|
|
7
|
+
|
|
8
|
+
NO_CHANGE = "No visible change."
|
|
9
|
+
SAME_SCREEN = "Same screen. Changes:"
|
|
10
|
+
MOVE_PT = 1.0 # a frame moved when x, y, width or height differ by more than this many points (WDA's
|
|
11
|
+
# XML has whole points, so rounding alone can move a value by 1)
|
|
12
|
+
MOVED_LISTED = 5 # up to this many moved elements get a line each; more become a count
|
|
13
|
+
FULL_MIN = 8 # the full list replaces the changes above max(FULL_MIN, half the new list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def highest(items: list[Element]) -> int:
|
|
17
|
+
"""The largest number among the refs e1, e2 ... of a list; 0 for none."""
|
|
18
|
+
return max((int(e.ref[1:]) for e in items if e.ref[1:].isdigit()), default=0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _key(e: Element) -> tuple[str, str, str]:
|
|
22
|
+
"""What makes two elements "the same one" on different reads: the identifier, else the text (label,
|
|
23
|
+
else value)."""
|
|
24
|
+
return (e.role, "id", e.identifier) if e.identifier else (e.role, "text", e.text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _stayed(a: Element, b: Element) -> bool:
|
|
28
|
+
return math.hypot(a.center[0] - b.center[0], a.center[1] - b.center[1]) <= MOVE_PT
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _shift(a: Element, b: Element) -> tuple[float, float]:
|
|
32
|
+
return b.x - a.x, b.y - a.y
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _moved_by(a: Element, b: Element, shift: tuple[float, float]) -> bool:
|
|
36
|
+
dx, dy = _shift(a, b)
|
|
37
|
+
return abs(dx - shift[0]) <= MOVE_PT and abs(dy - shift[1]) <= MOVE_PT
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _content_shift(pairs: list[tuple[Element, Element]]) -> tuple[float, float] | None:
|
|
41
|
+
"""How far the content moved: the displacement most unique elements with an identifier or text agree on
|
|
42
|
+
(within a point), if at least three do and it is a real move; else None."""
|
|
43
|
+
shifts = [_shift(a, b) for a, b in pairs if a.identifier or a.text]
|
|
44
|
+
best = max(shifts, key=lambda s: sum(abs(s[0] - t[0]) <= MOVE_PT and abs(s[1] - t[1]) <= MOVE_PT for t in shifts),
|
|
45
|
+
default=None)
|
|
46
|
+
if best is None:
|
|
47
|
+
return None
|
|
48
|
+
agreeing = sum(abs(best[0] - t[0]) <= MOVE_PT and abs(best[1] - t[1]) <= MOVE_PT for t in shifts)
|
|
49
|
+
if agreeing < 3 or (abs(best[0]) <= MOVE_PT and abs(best[1]) <= MOVE_PT):
|
|
50
|
+
return None # too few agree, or they all stayed (fixed bars): nothing says the content moved
|
|
51
|
+
return round(best[0]), round(best[1])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def carry_refs(old: list[Element], new: list[Element], used: int = 0,
|
|
55
|
+
moves_content: bool | None = False) -> list[Element]:
|
|
56
|
+
"""`new` (a fresh read, in reading order) with the refs of the elements still on screen, and new
|
|
57
|
+
numbers for the others, counted on from the highest ref in use. `used` is the highest number this
|
|
58
|
+
agent was ever given, so a ref that went away is never handed to another element.
|
|
59
|
+
|
|
60
|
+
`moves_content` None is a read with no action of the agent's before it (the re-read a target is
|
|
61
|
+
resolved on): the content counts as moved only if the unique elements agree that it did. Calling
|
|
62
|
+
every such read a move gave each element without an identifier a new ref on almost every answer.
|
|
63
|
+
|
|
64
|
+
A key (see _key) that is unique in both lists keeps its ref however far the element moved, except
|
|
65
|
+
an element with neither identifier nor text, which keeps it only if it stayed put. A key shared by
|
|
66
|
+
several elements keeps its refs only if the group is the same size in both lists, every member
|
|
67
|
+
stayed put, the action wasn't one that moves content (`moves_content`: scroll, swipe, back, open_app,
|
|
68
|
+
open_url) and no unique element moved: positions alone can't tell "Edit" of row A from "Edit" of row B
|
|
69
|
+
after a scroll by one row, and a wrong guess presses the wrong row. Else every member is new."""
|
|
70
|
+
before: dict[tuple[str, str, str], list[Element]] = {}
|
|
71
|
+
after: dict[tuple[str, str, str], list[Element]] = {}
|
|
72
|
+
for e in old:
|
|
73
|
+
before.setdefault(_key(e), []).append(e)
|
|
74
|
+
for e in new:
|
|
75
|
+
after.setdefault(_key(e), []).append(e)
|
|
76
|
+
kept: dict[int, str] = {} # id(new element) -> the ref it keeps
|
|
77
|
+
pairs = [(before[key][0], group[0]) for key, group in after.items()
|
|
78
|
+
if len(group) == 1 and len(before.get(key, [])) == 1]
|
|
79
|
+
unknown = moves_content is None
|
|
80
|
+
if unknown:
|
|
81
|
+
moves_content = _content_shift(pairs) is not None
|
|
82
|
+
content_moved = moves_content
|
|
83
|
+
shift = _content_shift(pairs) if moves_content else None
|
|
84
|
+
for was, now in pairs:
|
|
85
|
+
if moves_content and not was.identifier:
|
|
86
|
+
# no identifier: a same-named element elsewhere may be another row's. It keeps its ref only
|
|
87
|
+
# if it moved the way the content did; with no estimate of that, never.
|
|
88
|
+
ok = shift is not None and _moved_by(was, now, shift)
|
|
89
|
+
elif unknown:
|
|
90
|
+
# nothing says the content moved, and no action says it shouldn't have: the same text somewhere
|
|
91
|
+
# else may be another row's (a list that scrolled by itself, by less than three rows we can match)
|
|
92
|
+
ok = bool(_key(was)[1] == "id" or _stayed(was, now))
|
|
93
|
+
else:
|
|
94
|
+
ok = bool(_key(was)[1] == "id" or was.text or _stayed(was, now))
|
|
95
|
+
if ok:
|
|
96
|
+
kept[id(now)] = was.ref
|
|
97
|
+
content_moved = content_moved or not _stayed(was, now)
|
|
98
|
+
for key, group in after.items():
|
|
99
|
+
was = before.get(key, [])
|
|
100
|
+
if (not content_moved and not (len(was) == 1 and len(group) == 1) and len(was) == len(group)
|
|
101
|
+
and all(_stayed(o, n) for o, n in zip(was, group))):
|
|
102
|
+
kept.update((id(n), o.ref) for o, n in zip(was, group))
|
|
103
|
+
last = max(highest(old), used)
|
|
104
|
+
out: list[Element] = []
|
|
105
|
+
for e in new:
|
|
106
|
+
ref = kept.get(id(e))
|
|
107
|
+
if ref is None:
|
|
108
|
+
last += 1
|
|
109
|
+
ref = f"e{last}"
|
|
110
|
+
out.append(replace(e, ref=ref))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _flag(v: bool) -> str:
|
|
115
|
+
return f'"{str(v).lower()}"'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _was(old: Element, new: Element) -> list[str]:
|
|
119
|
+
"""`field=<old value>` for each of text, value, checked, selected, disabled and actions that changed.
|
|
120
|
+
A value that only mirrors the text, and a switch's value (it is `checked`), aren't fields of their own.
|
|
121
|
+
The frame is never one: a moved or resized element is a `↕`."""
|
|
122
|
+
out = []
|
|
123
|
+
if old.text != new.text:
|
|
124
|
+
out.append(("text", clip(old.text)))
|
|
125
|
+
if (old.value != new.value and old.checked is None and new.checked is None
|
|
126
|
+
and (old.value != old.text or new.value != new.text)):
|
|
127
|
+
# an input that showed its placeholder: `(was placeholder "…")` (AGENT-TOOLS v1.7)
|
|
128
|
+
out.append(("value", f"placeholder {clip(old.placeholder)}" if old.shows_placeholder else clip(old.value)))
|
|
129
|
+
if old.checked != new.checked:
|
|
130
|
+
out.append(("checked", "null" if old.checked is None else _flag(old.checked)))
|
|
131
|
+
if old.selected != new.selected:
|
|
132
|
+
out.append(("selected", _flag(old.selected)))
|
|
133
|
+
if old.enabled != new.enabled:
|
|
134
|
+
out.append(("disabled", _flag(not old.enabled)))
|
|
135
|
+
if old.actions != new.actions:
|
|
136
|
+
out.append(("actions", clip(", ".join(old.actions))))
|
|
137
|
+
return [f"{name}={v}" for name, v in out] if len(out) > 1 else [v for _, v in out]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _gone(e: Element) -> str:
|
|
141
|
+
"""`- <role> "<text>"`, else `<role> id=<id>`, else the role alone: no ref, no position."""
|
|
142
|
+
if e.text:
|
|
143
|
+
return f"- {e.role} {clip(e.text)}"
|
|
144
|
+
return f"- {e.role} id={e.identifier}" if e.identifier else f"- {e.role}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _frame_moved(old: Element, new: Element) -> bool:
|
|
148
|
+
return any(abs(a - b) > MOVE_PT for a, b in zip(old.rect, new.rect))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _changes(old: list[Element], new: list[Element]) -> tuple[list[str], int]:
|
|
152
|
+
"""(the change lines, how many elements changed) between two lists whose refs agree (see carry_refs)."""
|
|
153
|
+
before, after = {e.ref: e for e in old}, {e.ref: e for e in new}
|
|
154
|
+
added = [f"+ {e.line()}" for e in new if e.ref not in before]
|
|
155
|
+
gone = [_gone(e) for e in old if e.ref not in after]
|
|
156
|
+
modified, moved = [], []
|
|
157
|
+
for e in new:
|
|
158
|
+
o = before.get(e.ref)
|
|
159
|
+
if o is None:
|
|
160
|
+
continue
|
|
161
|
+
was = _was(o, e)
|
|
162
|
+
if was:
|
|
163
|
+
modified.append(f"~ {e.line()} (was {', '.join(was)})")
|
|
164
|
+
elif _frame_moved(o, e):
|
|
165
|
+
moved.append(e)
|
|
166
|
+
lines = added + gone + modified
|
|
167
|
+
if len(moved) > MOVED_LISTED:
|
|
168
|
+
lines.append(f"↕ {len(moved)} elements moved")
|
|
169
|
+
else:
|
|
170
|
+
lines += [f"↕ {e.line()}" for e in moved]
|
|
171
|
+
return lines or [NO_CHANGE], len(added) + len(gone) + len(modified) + len(moved)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
OFF_LISTED = 5 # up to this many changed off-screen elements get a line each; more become a count
|
|
175
|
+
OFF_SCREEN = "Changes off screen:" # heads those lines when they follow a full list (in a change block they need none)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def off_screen(old: list[Element], new: list[Element], by_place: bool = True,
|
|
179
|
+
listed: list[Element] | None = None) -> list[str]:
|
|
180
|
+
"""`~` lines for the elements off screen whose text, value, checked, selected or disabled changed between two
|
|
181
|
+
reads (AGENT-TOOLS v1.6): a tap whose effect lands on something scrolled out of view answered "No visible
|
|
182
|
+
change.", which read as "nothing happened". No refs (what is off screen has none), and only `~`: off-screen
|
|
183
|
+
elements that are new, gone or moved aren't listed, since a scroll moves all of them.
|
|
184
|
+
|
|
185
|
+
Off screen an element is the same one when its identifier is, if that is unique among what is off screen in
|
|
186
|
+
both reads; else, with `by_place`, when its role and place are (not after an action that moves content: the
|
|
187
|
+
next row then sits where this one sat).
|
|
188
|
+
|
|
189
|
+
`listed` is the agent's last list: an element that was on it and is off screen now (a scroll answered without
|
|
190
|
+
a list came between) is compared with what it was there, by its identifier."""
|
|
191
|
+
def unique(items: list[Element]) -> dict[tuple[str, str], Element]:
|
|
192
|
+
seen: dict[tuple[str, str], list[Element]] = {}
|
|
193
|
+
for e in items:
|
|
194
|
+
if e.identifier:
|
|
195
|
+
seen.setdefault((e.role, e.identifier), []).append(e)
|
|
196
|
+
return {key: group[0] for key, group in seen.items() if len(group) == 1}
|
|
197
|
+
|
|
198
|
+
def place(e: Element) -> tuple:
|
|
199
|
+
return (e.role, e.identifier, *(round(v) for v in e.rect))
|
|
200
|
+
was_off, placed = unique(old), {place(e): e for e in old}
|
|
201
|
+
named, now_named = unique([*old, *(listed or [])]), unique(new)
|
|
202
|
+
# Another screen of the same app can reuse an identifier ("title", "footer"): when fewer than half of the
|
|
203
|
+
# identifiers that were off screen still are, this isn't the same content, and nothing is said about it.
|
|
204
|
+
if was_off and len(was_off.keys() & now_named.keys()) * 2 < len(was_off):
|
|
205
|
+
return []
|
|
206
|
+
# A place names an element only while every element off screen is where it was. A row added or removed, or a
|
|
207
|
+
# form that slid up for the keyboard, moves them all by one row, and each would look like its neighbour changed.
|
|
208
|
+
by_place = by_place and placed.keys() == {place(e) for e in new}
|
|
209
|
+
lines = []
|
|
210
|
+
for e in new:
|
|
211
|
+
key = (e.role, e.identifier)
|
|
212
|
+
was = named.get(key) if now_named.get(key) is e else (placed.get(place(e)) if by_place else None)
|
|
213
|
+
if was is None:
|
|
214
|
+
continue
|
|
215
|
+
fields = _was(was, replace(e, actions=was.actions)) # what it can do isn't news off screen
|
|
216
|
+
if fields:
|
|
217
|
+
lines.append(f"~ {e.line(ref=False, actions=False)} (off screen; was {', '.join(fields)})")
|
|
218
|
+
if len(lines) > OFF_LISTED:
|
|
219
|
+
lines = [*lines[:OFF_LISTED], f"~ {len(lines) - OFF_LISTED} more changed off screen"]
|
|
220
|
+
return lines
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diff(old: list[Element], new: list[Element]) -> list[str]:
|
|
224
|
+
"""The change lines from `old` to `new`, whose refs came from carry_refs: `+` new, `-` gone (no ref),
|
|
225
|
+
`~` changed (with the old value), `↕` moved; or just "No visible change."."""
|
|
226
|
+
return _changes(old, new)[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class Update:
|
|
231
|
+
"""What an action answers with, and the list the agent holds afterwards."""
|
|
232
|
+
items: list[Element]
|
|
233
|
+
full: bool # the full list replaces the changes
|
|
234
|
+
lines: list[str] # the change block, headed by "Same screen. Changes:"; empty when `full`
|
|
235
|
+
top: int # the highest ref number issued so far
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def update(old: list[Element], new: list[Element], app_changed: bool = False, used: int = 0,
|
|
239
|
+
shown: list[Element] | None = None, full: bool = False, header: list[str] | None = None,
|
|
240
|
+
moves_content: bool | None = False) -> Update:
|
|
241
|
+
"""After an action: carry the refs over, and say what changed. The full list instead when the app changed,
|
|
242
|
+
when there was no list before, or when more than max(8, half the new list) of the elements changed, or
|
|
243
|
+
when nothing is left of a list that had elements (a column of `-` lines would only hide that the screen is
|
|
244
|
+
covered). A full list keeps numbering too (AGENT-TOOLS v1.4): what stayed keeps its ref and everything else
|
|
245
|
+
counts on from the highest ref given, so an older ref never lands on another element. Only `look` starts
|
|
246
|
+
again at e1. After another app came to the front, or when `full` is forced, nothing is carried.
|
|
247
|
+
`old` is the list whose refs carry; `shown` is what the agent was last told, when a silent re-read
|
|
248
|
+
moved `old` past it: the changes are measured against that, so nothing it hasn't heard of is lost.
|
|
249
|
+
`full` forces the full list (a person acted, the line dropped, the agent never looked).
|
|
250
|
+
`header` lines (an alert or the keyboard changed) come first in the change block."""
|
|
251
|
+
base = old if shown is None else shown
|
|
252
|
+
if full or app_changed:
|
|
253
|
+
fresh = carry_refs([], new, max(used, highest(old)))
|
|
254
|
+
return Update(fresh, True, [], max(used, highest(old), highest(fresh)))
|
|
255
|
+
carried = carry_refs(old, new, used, moves_content)
|
|
256
|
+
top = max(used, highest(old), highest(carried))
|
|
257
|
+
if (not base and bool(new)) or (bool(base) and not new):
|
|
258
|
+
return Update(carried, True, [], top)
|
|
259
|
+
lines, count = _changes(base, carried)
|
|
260
|
+
if count > max(FULL_MIN, len(new) / 2):
|
|
261
|
+
return Update(carried, True, [], top)
|
|
262
|
+
if header:
|
|
263
|
+
lines = [*header, *([] if lines == [NO_CHANGE] else lines)]
|
|
264
|
+
return Update(carried, False, [SAME_SCREEN, *lines], top)
|