mindvest-atlas 0.50.0 → 0.50.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.50.0",
3
+ "version": "0.50.2",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -99,6 +99,16 @@ const OPS = {
99
99
  // command beats a key chord, which goes wherever the focus is and reports
100
100
  // success either way.
101
101
  windows: ['windows', 0],
102
+ // A console Windows put on top of everything is machinery, not the desk.
103
+ 'hide-consoles': ['hide_consoles', 0],
104
+ // ── A JOB THAT IS MOSTLY WAITING ──────────────────────────────────────
105
+ watch: ['watch', 1],
106
+ // ── ONE CALL FOR WHAT USED TO BE FOUR ─────────────────────────────────
107
+ 'scroll-x': ['scroll_x', 1],
108
+ 'shift-scroll': ['shift_scroll', 1],
109
+ swipe: ['swipe', 4],
110
+ zoom: ['zoom', 1],
111
+ page: ['page', 1],
102
112
  minimize: ['minimize_window', 1],
103
113
  maximize: ['maximize_window', 1],
104
114
  restore: ['restore_window', 1],
@@ -114,7 +124,8 @@ const OPS = {
114
124
  // that takes numbers in one runtime and strings in the other is a verb nobody
115
125
  // can rely on.
116
126
  const NUMERIC = new Set(['use-screen', 'click', 'double-click', 'right-click',
117
- 'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region']);
127
+ 'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region',
128
+ 'scroll-x', 'shift-scroll', 'swipe', 'zoom']);
118
129
 
119
130
  /** This shell's holding id, made once and remembered. Shared with `atlas run`
120
131
  * so everything typed at one prompt lands in ONE session: the log reads as a
@@ -198,20 +209,34 @@ const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
198
209
  * Best effort and quiet: a machine with no network or no pip is one that runs
199
210
  * without it, and every call site already says what it cannot do.
200
211
  */
212
+ /**
213
+ * NOTHING THIS DOES MAY PUT A WINDOW ON THE SCREEN.
214
+ *
215
+ * On Windows a console program started by a process with no console of its own
216
+ * gets a NEW one, drawn on top of everything. Every action here runs python, so
217
+ * a black console box appeared over the desktop at exactly the moment the screen
218
+ * was photographed — and the agent, looking at its own picture, then spent the
219
+ * run trying to work out what the black window was (owner, 2026-09-06: "the AI
220
+ * complains of a black console every time; that console is just the popup taking
221
+ * screenshots"). A picture of the desktop must not contain the machinery that
222
+ * took it.
223
+ */
224
+ const HIDDEN = { windowsHide: true };
225
+
201
226
  export function ensureDeps(python, cwd, env) {
202
227
  const check = `import ${NEEDED.map(([, mod]) => mod).join(', ')}`;
203
228
  const has = () => spawnSync(python[0], [...python.slice(1), '-c', check],
204
- { cwd, env, stdio: 'ignore' }).status === 0;
229
+ { cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
205
230
  if (has()) return true;
206
231
  println(color.dim(' Getting this computer ready (one time)…'));
207
232
  // --user is wrong inside a venv and right outside one, so ask the interpreter
208
233
  // which it is in rather than guessing.
209
234
  const inVenv = spawnSync(python[0],
210
235
  [...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
211
- { cwd, env, stdio: 'ignore' }).status === 0;
236
+ { cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
212
237
  const args = ['-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user'])];
213
238
  spawnSync(python[0], [...python.slice(1), ...args, ...NEEDED.map(([name]) => name)],
214
- { cwd, env, stdio: 'ignore', timeout: 300000 });
239
+ { cwd, env, stdio: 'ignore', timeout: 300000, ...HIDDEN });
215
240
  return has();
216
241
  }
217
242
 
@@ -239,7 +264,7 @@ export function runInProject(job, code, root) {
239
264
  env.ATLAS_COMPUTER_PID = '0';
240
265
  env.PYTHONPATH = [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
241
266
  const out = spawnSync(python[0], [...python.slice(1), '-c', code], {
242
- cwd: dir, env, stdio: 'inherit',
267
+ cwd: dir, env, stdio: 'inherit', ...HIDDEN,
243
268
  });
244
269
  if (out.error) {
245
270
  eprintln(color.red('This computer has no Python, and using it needs one.'));
package/src/config.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
- export const VERSION = '0.50.0';
5
+ export const VERSION = '0.50.2';
6
6
  export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
7
7
  export const DEFAULT_SCOPE = 'atlas broker';
8
8
  export const CLIENT_NAME = 'Atlas CLI';
@@ -1390,6 +1390,11 @@ def screen(path=None, grid=None):
1390
1390
 
1391
1391
  def take():
1392
1392
  import hashlib
1393
+ # NOTHING OF OURS IN THE PICTURE. A console that Windows put on top a
1394
+ # moment ago is machinery, not the desk, and a run that photographs one
1395
+ # spends its time on it instead of on the work. Swept before the shutter,
1396
+ # so no picture can contain one.
1397
+ hide_consoles()
1393
1398
  img, raw = _grab(m)
1394
1399
  digest = hashlib.md5(raw).hexdigest() if raw is not None else None
1395
1400
  seen = _last_look()
@@ -1725,6 +1730,11 @@ def do(*steps, **kw):
1725
1730
  """
1726
1731
  settle = float(kw.get("settle", 1.2))
1727
1732
  look = kw.get("look", True)
1733
+ # AND NOTHING OF OURS IN FRONT OF IT. A console Windows put on top has the
1734
+ # KEYBOARD, so a run of steps types its password into a black box instead of
1735
+ # into the form it was aiming at, and every shortcut goes nowhere. Swept
1736
+ # before the first step rather than discovered after the last.
1737
+ hide_consoles()
1728
1738
  # What it looked like before any of this, so the wait afterwards can tell
1729
1739
  # "nothing has happened yet" from "it happened and is done". Only worth
1730
1740
  # measuring when there is going to be a wait: settle=0 says do not wait, and
@@ -1962,6 +1972,111 @@ def _one_window(title):
1962
1972
  return got
1963
1973
 
1964
1974
 
1975
+ #: A CONSOLE NOBODY NAMED, recognised by SHAPE rather than by a list of names.
1976
+ #:
1977
+ #: The title of a console window is the command line of the program in it until
1978
+ #: something sets one. So "C:\...\python3.11.exe" is machinery, and so is
1979
+ #: "C:\...\Atlas Remote Control.exe" — which a list of interpreter names would
1980
+ #: have walked straight past, and that one was the window sitting over the
1981
+ #: owner's chart (owner, 2026-09-06: "screen for any console interpreter and
1982
+ #: let's stick with background processes for all of it").
1983
+ #:
1984
+ #: A window somebody is WORKING in has a title about their work: "trading notes
1985
+ #: - bash" does not end in .exe and is left alone. That is the whole boundary,
1986
+ #: and it is deliberately the conservative side of the line.
1987
+ _EXE_TITLE = __import__("re").compile(r"\.exe$", __import__("re").I)
1988
+
1989
+
1990
+ def _looks_like_a_console(title):
1991
+ """True for a window whose title is just the program running in it."""
1992
+ name = str(title or "").strip()
1993
+ if not name:
1994
+ return False
1995
+ # `"C:\path\codex.exe" app-server` — a quoted program plus its arguments.
1996
+ if name.startswith('"') and '"' in name[1:]:
1997
+ return bool(_EXE_TITLE.search(name[1:name.index('"', 1)]))
1998
+ # Windows Terminal titles a tab "<something> - powershell.exe".
1999
+ return bool(_EXE_TITLE.search(name) or _EXE_TITLE.search(name.rsplit(" - ", 1)[-1].strip()))
2000
+
2001
+
2002
+ def _put_out_of_sight(win):
2003
+ """Hide it outright where the platform allows, and minimise where it does not.
2004
+
2005
+ HIDDEN, NOT MINIMISED. A minimised console is still a window: it holds a
2006
+ taskbar button, it can be restored by a stray click, and it flashes on its
2007
+ way down — and the ask was for these to be BACKGROUND PROCESSES, which is
2008
+ what a program with no window on screen actually is. ShowWindow(SW_HIDE)
2009
+ takes it off the screen entirely without touching the process, so whatever is
2010
+ running in it carries on with its output still going where it always went.
2011
+ """
2012
+ try:
2013
+ import ctypes
2014
+ handle = getattr(win, "_hWnd", None)
2015
+ if handle and os.name == "nt":
2016
+ ctypes.windll.user32.ShowWindow(int(handle), 0) # SW_HIDE
2017
+ return True
2018
+ except Exception:
2019
+ pass
2020
+ win.minimize()
2021
+ return True
2022
+
2023
+
2024
+ def hide_consoles():
2025
+ """Put the machinery's own console windows out of the picture.
2026
+
2027
+ WHY THIS EXISTS AT ALL. Running anything on Windows can allocate a console,
2028
+ and Windows draws a new one on top of everything and gives it the keyboard.
2029
+ It should not happen — every spawn here asks for no window — but with
2030
+ Windows Terminal set as the default console host that request is not always
2031
+ honoured, and the result is a black box sitting over the person's charts,
2032
+ cascading down-right each time it is focused, eating the keystrokes meant
2033
+ for the app being driven.
2034
+
2035
+ An agent then photographs it and, quite reasonably, spends the run trying to
2036
+ work out what it is (owner, 2026-09-06: "can we make bash, powershell or any
2037
+ terminal invisible or minimized so the agent does not need to see this ...
2038
+ the best is to auto minimise or auto layer to the back").
2039
+
2040
+ IT IS HIDDEN, NEVER CLOSED. That window is the process running the commands:
2041
+ closing it ends the session issuing them. Hiding takes it off the screen
2042
+ without touching what is inside, and hands the keyboard back to whatever was
2043
+ in front, which is the other half of the damage it does.
2044
+
2045
+ Hidden rather than minimised, because a minimised console is still a window
2046
+ — a taskbar button, one stray click from being back, and a flash on the way
2047
+ down. A background process is one with no window on screen.
2048
+
2049
+ Identified by TITLE, and only the ones nobody named: a console's title is
2050
+ the path of the program in it until something sets one, so
2051
+ a path ending in python3.11.exe is machinery, and "trading notes - bash" is
2052
+ somebody's work and is left alone.
2053
+
2054
+ Quiet and best effort: a machine that cannot list its windows is one this
2055
+ does nothing on.
2056
+ """
2057
+ hidden = []
2058
+ try:
2059
+ wins = _all_windows()
2060
+ except Exception:
2061
+ return hidden
2062
+ for w in wins:
2063
+ title = str(getattr(w, "title", "") or "")
2064
+ if not _looks_like_a_console(title):
2065
+ continue
2066
+ try:
2067
+ # ALREADY GONE IS ALREADY DONE. The test is whether it is on the
2068
+ # screen, not whether it is minimised: a minimised console still has
2069
+ # a taskbar button and is one click from being back, so it is still
2070
+ # something to put away.
2071
+ if getattr(w, "visible", True) is False:
2072
+ continue
2073
+ _put_out_of_sight(w)
2074
+ hidden.append(title[:120])
2075
+ except Exception:
2076
+ continue
2077
+ return hidden
2078
+
2079
+
1965
2080
  def windows(title=""):
1966
2081
  """Every window that is open, with where it is and what state it is in.
1967
2082
 
@@ -2122,6 +2237,357 @@ def scan_page(x=None, y=None, most=10, amount=None, grid=None):
2122
2237
  return _do("scan_page", go, {"x": x, "y": y, "step": step})
2123
2238
 
2124
2239
 
2240
+ #: What a round of watching may wait, in minutes. A model asked how long to
2241
+ #: leave it will occasionally say 0 (a busy loop, billed per turn) or 600 (an
2242
+ #: afternoon in which anything could happen). Its judgement inside these bounds
2243
+ #: is better than a fixed interval; outside them it is a mistake, not a choice.
2244
+ _WAIT_FLOOR_MIN = 0.5
2245
+ _WAIT_CEILING_MIN = 120.0
2246
+
2247
+
2248
+ def _next_wait(answer, fallback):
2249
+ """How long the model asked to wait, in minutes, or `fallback` if it did not.
2250
+
2251
+ Pass fallback=None to tell the two apart: "it asked for this" and "it said
2252
+ nothing" lead to different behaviour, and a default that quietly stands in
2253
+ for silence is how a dynamic interval becomes a fixed one without anybody
2254
+ noticing.
2255
+
2256
+ Read from a `NEXT: <minutes>` line rather than by parsing prose. A model
2257
+ writing a paragraph will mention several numbers and the last thing you want
2258
+ is to have picked one of them out of a sentence.
2259
+ """
2260
+ import re as _re
2261
+ hit = None
2262
+ for m in _re.finditer(r"^\s*NEXT\s*:\s*([0-9]+(?:\.[0-9]+)?)", str(answer or ""),
2263
+ _re.I | _re.M):
2264
+ hit = m.group(1)
2265
+ if hit is None:
2266
+ return fallback
2267
+ try:
2268
+ want = float(hit)
2269
+ except ValueError:
2270
+ return fallback
2271
+ return max(_WAIT_FLOOR_MIN, min(_WAIT_CEILING_MIN, want))
2272
+
2273
+
2274
+ def _watch_done(answer):
2275
+ """The model saying it is finished, on a line of its own."""
2276
+ import re as _re
2277
+ return bool(_re.search(r"^\s*DONE\s*$", str(answer or ""), _re.I | _re.M))
2278
+
2279
+
2280
+ def _still_mine():
2281
+ """Are we still the ones holding the computer?
2282
+
2283
+ STOP DELETES THE CLAIM. That is how Atlas ends a run, and it is a signal any
2284
+ loop can read: no claim, or somebody else's, means this run is over. A long
2285
+ sleep that does not check it is a run that carries on for another twenty
2286
+ minutes after Stop, and then acts (owner, 2026-09-06: "if I click stop, the
2287
+ entire thing stops, no lingering data or wait").
2288
+ """
2289
+ held = _claim_holder()
2290
+ if not held:
2291
+ return False
2292
+ mine = os.environ.get("ATLAS_COMPUTER_SESSION") or ""
2293
+ return not mine or str(held.get("session") or "") == mine
2294
+
2295
+
2296
+ def _sleep_or_stop(seconds, slice_s=1.0):
2297
+ """Wait, in slices, and give up the moment the run is stopped.
2298
+
2299
+ Returns False when it was stopped, True when it slept the whole way. One
2300
+ long sleep cannot be interrupted, so it is many short ones — the cost is a
2301
+ file read a second, which next to a model call is nothing.
2302
+ """
2303
+ import time as _time
2304
+ left = max(0.0, float(seconds))
2305
+ while left > 0:
2306
+ if not _still_mine():
2307
+ return False
2308
+ nap = min(slice_s, left)
2309
+ _time.sleep(nap)
2310
+ left -= nap
2311
+ return _still_mine()
2312
+
2313
+
2314
+ def watch(instruction, every=5, most=200, hours=8, look=True):
2315
+ """Watch the screen and act when it is time. Runs until it is done.
2316
+
2317
+ THE SHAPE OF A JOB THAT IS MOSTLY WAITING (owner, 2026-09-06: "watch this
2318
+ market, wait until you see a setup, when you see it put a trade, keep
2319
+ watching, check every 5 minutes"). Most of what somebody wants from a
2320
+ computer that can see is not a task with a beginning and an end; it is a
2321
+ watch. Written as a script it is a loop nobody gets right: too fast and it
2322
+ burns a model call a second, too slow and it misses the thing.
2323
+
2324
+ watch("Watch the SPY 5-minute chart. When price breaks above the "
2325
+ "opening range high on rising volume, buy 1 call and say so.")
2326
+
2327
+ Each round: it looks, then asks YOUR model what it sees and what to do. The
2328
+ model has the same computer this does, so when it decides to act it acts —
2329
+ there is no separate step where something else carries out its decision.
2330
+
2331
+ THE WAIT IS THE MODEL'S TO CHOOSE, and that is the point. A fixed interval is
2332
+ wrong in both directions all day: nothing is happening at lunch and
2333
+ everything is happening at the open. So each round ends by asking how long to
2334
+ leave it, and the answer sets the next sleep — "quiet, look again in 20" or
2335
+ "this is setting up, 2 minutes" (owner: "each loop wait can update, so
2336
+ dynamic based on what the agent said"). `every` is only the FIRST wait and
2337
+ the fallback when it does not say.
2338
+
2339
+ IT HOLDS THE COMPUTER THE WHOLE TIME. There is one mouse, and something that
2340
+ may need to act at any moment cannot hand it back between looks. Nothing else
2341
+ can drive while this runs, which is a real cost and the reason for `hours`.
2342
+
2343
+ IT COSTS A MODEL CALL PER ROUND. That is what the interval is really
2344
+ choosing, and why a model that says "nothing is happening, look again in an
2345
+ hour" is doing its job rather than being lazy.
2346
+
2347
+ Ends when the model writes DONE on a line by itself, when `most` rounds have
2348
+ passed, or when `hours` have. Pressing Stop ends it like anything else.
2349
+ """
2350
+ import time as _time
2351
+ began = _time.time()
2352
+ deadline = began + max(0.0, float(hours)) * 3600.0
2353
+ wait_min = max(_WAIT_FLOOR_MIN, min(_WAIT_CEILING_MIN, float(every)))
2354
+ rounds = 0
2355
+ seen = []
2356
+ waits = []
2357
+ quiet = 0
2358
+ changed = None
2359
+ note("watching: %s" % str(instruction)[:160])
2360
+ stopped = False
2361
+ done = False
2362
+ try:
2363
+ while rounds < int(most) and _time.time() < deadline:
2364
+ # STOP MEANS STOPPED, AT EVERY POINT IT COULD MEAN ANYTHING ELSE.
2365
+ # Atlas ends a run by deleting the claim; a loop that does not read
2366
+ # it carries on for another twenty minutes and then ACTS (owner,
2367
+ # 2026-09-06: "if I click stop, the entire thing stops, no lingering
2368
+ # data or wait").
2369
+ if not _still_mine():
2370
+ stopped = True
2371
+ break
2372
+ rounds += 1
2373
+ shot = None
2374
+ if look:
2375
+ try:
2376
+ got = screen()
2377
+ shot = got if isinstance(got, str) else got.get("path")
2378
+ # Whether anything moved, which decides the backoff when the
2379
+ # model does not choose.
2380
+ changed = _screen_changed(shot)
2381
+ except Exception as err:
2382
+ # A look that fails is not a reason to stop watching; the
2383
+ # model is told, and decides whether that matters.
2384
+ shot = None
2385
+ seen.append({"round": rounds, "error": str(err)})
2386
+ asked = (
2387
+ "%s\n\n"
2388
+ "You are WATCHING, not running a task to completion. This is round %d.\n"
2389
+ "%s\n"
2390
+ "Decide whether anything has changed that matters, and if it is time "
2391
+ "to act, ACT NOW using the computer yourself - you have the same "
2392
+ "`atlas computer-use` this does.\n\n"
2393
+ "End your reply with exactly two things on their own lines:\n"
2394
+ " NEXT: <minutes until the next look>\n"
2395
+ " DONE (only if there is nothing left to watch for)\n"
2396
+ "CHOOSE THE INTERVAL YOURSELF and change it as things change: two or "
2397
+ "three minutes when something is developing, fifteen or sixty when it "
2398
+ "is quiet. The same number every round is a fixed poll, which is the "
2399
+ "one thing this is not. A model call is spent every round."
2400
+ % (instruction, rounds,
2401
+ ("The screen right now: %s" % shot) if shot
2402
+ else "The screen could not be photographed this round.")
2403
+ )
2404
+ try:
2405
+ answer = ask(asked)
2406
+ except Exception as err:
2407
+ answer = ""
2408
+ seen.append({"round": rounds, "error": str(err)})
2409
+ if answer:
2410
+ note(str(answer).strip()[:400])
2411
+ seen.append({"round": rounds, "said": str(answer)[:2000]})
2412
+ if _watch_done(answer):
2413
+ note("done watching after %d rounds" % rounds)
2414
+ done = True
2415
+ break
2416
+ # THE MODEL'S JUDGEMENT WINS WHENEVER IT GIVES ONE.
2417
+ asked_for = _next_wait(answer, None)
2418
+ if asked_for is not None:
2419
+ wait_min = asked_for
2420
+ quiet = 0
2421
+ else:
2422
+ # AND WHEN IT DOES NOT, THE LOOP BACKS OFF BY ITSELF.
2423
+ #
2424
+ # The whole point is that the interval MOVES; a model that
2425
+ # answers nothing round after round has quietly turned this back
2426
+ # into a fixed poll and nothing would say so. Silence is not
2427
+ # agreement: with the screen unchanged too, the wait grows.
2428
+ # Something happening resets it.
2429
+ #
2430
+ # It only ever fills a gap the model left. An explicit NEXT is
2431
+ # obeyed, including a short one every round, because a model
2432
+ # watching something develop is right to keep looking.
2433
+ if changed is False:
2434
+ quiet += 1
2435
+ if quiet >= 2:
2436
+ grown = min(_WAIT_CEILING_MIN, wait_min * 1.5)
2437
+ if grown > wait_min:
2438
+ note("nothing is changing and no interval was given - "
2439
+ "waiting %d minutes instead of %d"
2440
+ % (round(grown), round(wait_min)))
2441
+ wait_min = grown
2442
+ else:
2443
+ quiet = 0
2444
+ waits.append(wait_min)
2445
+ # NO SLEEP AFTER THE LAST ROUND. It is over: waiting five more
2446
+ # minutes to discover that is five minutes holding the computer for
2447
+ # nothing.
2448
+ if rounds >= int(most):
2449
+ break
2450
+ left = deadline - _time.time()
2451
+ if left <= 0:
2452
+ break
2453
+ # SLICED, so Stop lands in a second rather than at the end of a
2454
+ # twenty-minute wait.
2455
+ if not _sleep_or_stop(min(wait_min * 60.0, left)):
2456
+ stopped = True
2457
+ break
2458
+ finally:
2459
+ # WHATEVER HAPPENED, LET GO. A watch holds the computer for hours, so a
2460
+ # run that ends any other way than tidily — an error, Stop, the ceiling —
2461
+ # would leave the one mouse claimed and nothing else able to drive.
2462
+ try:
2463
+ release_computer()
2464
+ except Exception:
2465
+ pass
2466
+ note("%s after %d rounds" % ("stopped" if stopped else
2467
+ "finished" if done else "ran out", rounds))
2468
+ # `waits` is every interval it used and `varied` says whether they moved at
2469
+ # all - so "the model just echoes one number back" is something anybody can
2470
+ # SEE rather than suspect.
2471
+ return {"rounds": rounds, "done": done, "stopped": stopped, "seen": seen,
2472
+ "waits": waits, "varied": len(set(waits)) > 1}
2473
+
2474
+
2475
+ def scroll_x(amount, x=None, y=None):
2476
+ """Scroll SIDEWAYS. Positive goes right, negative left.
2477
+
2478
+ NOT EVERYWHERE, AND SAID RATHER THAN SILENTLY IGNORED. Windows has no
2479
+ horizontal wheel at the API this uses; macOS and Linux do. Where it is
2480
+ missing, the ways that DO work on every platform are named, because a wide
2481
+ table nobody can reach sideways is a wall (owner, 2026-09-06: "add left
2482
+ scrolling, right scrolling").
2483
+
2484
+ On Windows: shift+wheel is what applications themselves map to sideways,
2485
+ which is what shift_scroll() does — or drag the scrollbar, which always
2486
+ works because it is just a thing on the screen.
2487
+ """
2488
+ def go():
2489
+ g = _gui()
2490
+ if x is not None and y is not None:
2491
+ g.moveTo(int(x), int(y))
2492
+ if not hasattr(g, "hscroll") or os_name() == "windows":
2493
+ raise RuntimeError(
2494
+ "This computer has no sideways wheel. Use shift_scroll(%d) — "
2495
+ "applications map shift+wheel to sideways — or drag the "
2496
+ "scrollbar, which works anywhere." % int(amount)
2497
+ )
2498
+ g.hscroll(int(amount))
2499
+ return _do("scroll_x", go, {"amount": amount, "x": x, "y": y})
2500
+
2501
+
2502
+ def shift_scroll(amount, x=None, y=None):
2503
+ """Sideways the way applications actually do it: shift and the wheel.
2504
+
2505
+ WORKS ON ALL THREE, which the real horizontal wheel does not. Nearly every
2506
+ program that scrolls sideways binds this, so it is the first thing to try
2507
+ rather than the fallback. Positive is right, negative is left, same as
2508
+ scroll_x.
2509
+ """
2510
+ def go():
2511
+ g = _gui()
2512
+ if x is not None and y is not None:
2513
+ g.moveTo(int(x), int(y))
2514
+ g.keyDown("shift")
2515
+ try:
2516
+ # The wheel's own sign is inverted under shift in most applications:
2517
+ # a positive (up) wheel with shift held goes LEFT. Negated here so
2518
+ # that positive means right, as it says on the tin.
2519
+ g.scroll(-int(amount))
2520
+ finally:
2521
+ g.keyUp("shift")
2522
+ return _do("shift_scroll", go, {"amount": amount, "x": x, "y": y})
2523
+
2524
+
2525
+ def swipe(x, y, dx=0, dy=0, seconds=0.35):
2526
+ """Drag from a point BY an amount — a flick, a pan, a slider.
2527
+
2528
+ ONE CALL FOR SOMETHING THAT WAS FOUR. Panning a chart or moving a slider was
2529
+ move, mouse_down, move, mouse_up, and four calls is four round trips and four
2530
+ chances to leave the button held down if one of them is refused. The button
2531
+ is released in a `finally`, so a refusal in the middle does not leave the
2532
+ mouse stuck.
2533
+
2534
+ swipe(*middle, dx=-400) # drag the chart left by 400
2535
+ """
2536
+ def go():
2537
+ g = _gui()
2538
+ g.moveTo(int(x), int(y))
2539
+ g.mouseDown()
2540
+ try:
2541
+ g.dragTo(int(x) + int(dx), int(y) + int(dy), duration=max(0.0, float(seconds)))
2542
+ finally:
2543
+ g.mouseUp()
2544
+ return _do("swipe", go, {"x": x, "y": y, "dx": dx, "dy": dy},
2545
+ points=((x, y), (int(x) + int(dx), int(y) + int(dy))))
2546
+
2547
+
2548
+ def zoom(steps=1, x=None, y=None):
2549
+ """Zoom in (positive) or out (negative), the way every platform does it.
2550
+
2551
+ ctrl and the wheel on Windows and Linux, command and the wheel on a Mac.
2552
+ One call rather than a chord the agent has to remember differently per
2553
+ machine — and getting that wrong is a keystroke into whatever had focus.
2554
+ """
2555
+ def go():
2556
+ import sys as _sys
2557
+ g = _gui()
2558
+ if x is not None and y is not None:
2559
+ g.moveTo(int(x), int(y))
2560
+ holder = "command" if _sys.platform == "darwin" else "ctrl"
2561
+ g.keyDown(holder)
2562
+ try:
2563
+ g.scroll(int(steps))
2564
+ finally:
2565
+ g.keyUp(holder)
2566
+ return _do("zoom", go, {"steps": steps, "x": x, "y": y})
2567
+
2568
+
2569
+ def page(way="down", times=1):
2570
+ """Page down or up, the whole screenful, without a wheel.
2571
+
2572
+ What a keyboard does and a wheel approximates. `way` is "down", "up",
2573
+ "top" or "bottom" — the last two are one keystroke to either end, which is
2574
+ the fastest way to the bottom of a long page by a distance.
2575
+ """
2576
+ keys = {"down": "pagedown", "up": "pageup", "top": "home", "bottom": "end"}
2577
+ which = keys.get(str(way).lower())
2578
+ if not which:
2579
+ raise RuntimeError('page() takes "down", "up", "top" or "bottom", not "%s".' % way)
2580
+
2581
+ def go():
2582
+ g = _gui()
2583
+ for _ in range(max(1, int(times))):
2584
+ if which in ("home", "end"):
2585
+ g.hotkey("ctrl", which)
2586
+ else:
2587
+ g.press(which)
2588
+ return _do("page", go, {"way": str(way), "times": times})
2589
+
2590
+
2125
2591
  def wait(ms):
2126
2592
  """Wait, and say so. A pause nobody recorded looks like a hang in a replay.
2127
2593