Files
box/bin/hatch_menu/controls.py
T

249 lines
8.0 KiB
Python
Raw Normal View History

"""Generic control primitives (ws-level, no tab knowledge).
Radio/switch list + set with verify-then-fallback: synthetic click,
verify state flipped, else trusted real click, verify again. Tab
modules build their flows on these; toggles.py adds addressing.
"""
import time
from approvals import cdp_evaluate
from hatch_menu.mouse import real_click
JS_LIST_RADIOS = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('input[type="radio"]')).map(r => {
let head = '', el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim()) {
head = h.innerText.trim().slice(0, 60);
break;
}
el = el.parentElement;
depth += 1;
}
const lab = r.closest('label');
return {value: r.value, checked: !!r.checked, heading: head,
aria: r.getAttribute('aria-label') || '',
label: lab ? (lab.innerText || '').trim().slice(0, 80) : ''};
});
})()"""
JS_CLICK_RADIO = """((heading, value) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
const headOf = (r) => {
let el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim())
return h.innerText.trim().toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const t = radios.find(r => headOf(r) === heading.toLowerCase()
&& r.value === value);
if (!t) return 'NO_MATCH';
t.click();
return 'CLICKED';
})('%s', '%s')"""
JS_RADIO_RECT = """((heading, value) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
const headOf = (r) => {
let el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim())
return h.innerText.trim().toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const t = radios.find(r => headOf(r) === heading.toLowerCase()
&& r.value === value);
if (!t) return null;
const r = t.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s', '%s')"""
JS_CLICK_RADIO_ARIA = """((name) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const n = name.toLowerCase();
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|| (r.value || '').toLowerCase() === n);
if (!t) return 'NO_MATCH';
t.click();
return 'CLICKED';
})('%s')"""
JS_RADIO_ARIA_RECT = """((name) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const n = name.toLowerCase();
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|| (r.value || '').toLowerCase() === n);
if (!t) return null;
const r = t.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s')"""
JS_LIST_SWITCHES = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
let el = s.parentElement, label = '', depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
if (t && t.length < 250) { label = t; break; }
el = el.parentElement;
depth += 1;
}
return {label: label.slice(0, 200),
aria: s.getAttribute('aria-label') || '',
checked: s.getAttribute('aria-checked') === 'true'};
});
})()"""
JS_CLICK_SWITCH = """((label) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const rowOf = (s) => {
let el = s.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim();
if (t && t.length < 250) return t.toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const hits = Array.from(d.querySelectorAll('[role="switch"]'))
.filter(s => rowOf(s).includes(label.toLowerCase())
|| (s.getAttribute('aria-label') || '').toLowerCase()
.includes(label.toLowerCase()));
if (!hits.length) return 'NO_MATCH';
if (hits.length > 1) return 'AMBIGUOUS';
hits[0].click();
return 'CLICKED';
})('%s')"""
def _eval(ws, js, timeout=8.0):
try:
return cdp_evaluate(ws, js, timeout=timeout)
except Exception:
return None
VERIFY_TRIES = 10
VERIFY_PAUSE = 1.5
def _poll(check, tries=VERIFY_TRIES, pause=VERIFY_PAUSE):
"""Poll a state check until true. Fast exit; tolerates slow commits."""
for _ in range(tries):
try:
if check():
return True
except Exception:
pass
time.sleep(pause)
return False
def list_radios(ws):
"""All dialog radios with heading/label/value/checked (or [])."""
rows = _eval(ws, JS_LIST_RADIOS)
return rows if isinstance(rows, list) else []
def list_switches(ws):
"""All dialog switches with row label + checked (or [])."""
rows = _eval(ws, JS_LIST_SWITCHES)
return rows if isinstance(rows, list) else []
def radio_state(ws, heading, value):
"""Checked state of one heading-grouped radio, None if absent."""
for r in list_radios(ws):
if (r.get("heading") or "").lower() == heading.lower() \
and r.get("value") == value:
return bool(r.get("checked"))
return None
def set_radio_by_heading(ws, heading, value):
"""Set a heading-grouped radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
and _poll(lambda: radio_state(ws, heading, value) is True):
return True
rect = _eval(ws, JS_RADIO_RECT % (heading, value))
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
return _poll(lambda: radio_state(ws, heading, value) is True)
def radio_aria_state(ws, name):
"""Checked state of one aria-labeled radio, None if absent."""
n = name.lower()
for r in list_radios(ws):
if (r.get("aria") or "").lower() == n \
or (r.get("value") or "").lower() == n:
return bool(r.get("checked"))
return None
def set_radio_by_aria(ws, name):
"""Set an aria-labeled radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
and _poll(lambda: radio_aria_state(ws, name) is True):
return True
rect = _eval(ws, JS_RADIO_ARIA_RECT % name)
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
return _poll(lambda: radio_aria_state(ws, name) is True)
def switch_state(ws, label):
"""Checked state of one label-matched switch, None if not unique."""
matches = [s for s in list_switches(ws)
if label.lower() in (s.get("label") or "").lower()
or label.lower() in (s.get("aria") or "").lower()]
if len(matches) != 1:
return None
return bool(matches[0].get("checked"))
def set_switch(ws, label, on):
"""Set a switch by row-label/aria match; no-op when already there.
Single click only: a fallback re-click would UNDO a slow commit
(switches toggle). The fresh-session readback decides.
"""
state = switch_state(ws, label)
if state is None:
return False
if state == bool(on):
return True
if _eval(ws, JS_CLICK_SWITCH % label) != "CLICKED":
return False
return _poll(lambda: switch_state(ws, label) is bool(on))