Arranging: boxes on everything, handwriting movable, the Eraser's preview, a Circle shape, pixel-exact cues

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 20:38:29 +02:00
1 parent 03907da8c9
commit 24a6a2595b
10 files changed
+690 -126

No files matched your search

+295 -20
View File
@@ -44,10 +44,27 @@ HOVER_CUE = """
const page = document.getElementById('page').getBoundingClientRect();
const b = c.getBoundingClientRect();
const k = s.page.width / page.width;
const st = getComputedStyle(c);
return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k,
width: b.width * k, height: b.height * k};
width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1],
box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top],
scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top],
edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]};
"""
# Where an overlay's box must lie around a rectangle's edges in pixels
# (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and
# the line this far beyond them.
CUE_OFFSET_PX = 2
def overlay_box(left, top, right, bottom, line):
"""The pixel box an overlay with this line width takes around a
rectangle whose edges are at these pixel positions."""
beyond = CUE_OFFSET_PX - 1 + line
return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond]
# Whether anything at all is drawn on the writer's own layer canvas within
# two device pixels of the page point (x, y).
INK_AT = """
@@ -538,6 +555,12 @@ class Firefox:
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": moves}]})
def pointer_actions(self, actions):
"""Perform mouse actions (WebDriver's pointer actions) in sequence."""
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": actions}]})
def type_into(self, selector, text):
"""Type text into an element, as the keyboard would."""
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
@@ -661,17 +684,30 @@ class EndToEndTest(unittest.TestCase):
def state(self):
return self.firefox.run(PAGE_STATE)
def assert_hover_cue(self, x, y, rect):
"""A pointer hovering at the page point (x, y) shows the blue
corners over the rectangle (page units), and none once it leaves."""
def assert_hover_cue(self, x, y, rect, kind="hide"):
"""A pointer hovering at the page point (x, y) shows the blue cue
over the rectangle (page units), and none once it leaves: a text
rectangle or a Hide outlined, free text's top and left edges
(Andy, 2026-10-14)."""
state = self.state()
fx = (x - state["origin"][0]) / state["page"]["width"]
fy = (y - state["origin"][1]) / state["page"]["height"]
self.firefox.run(HOVER, fx, fy)
cue = self.firefox.run(HOVER_CUE)
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
for key, expected in zip(("x", "y", "width", "height"), rect):
self.assertAlmostEqual(cue[key], expected, delta=3, msg=key)
self.assertEqual(cue["kind"], kind)
line = cue["edges"][0]
self.assertNotEqual(line, "0px")
# The cue's box lies CUE_OFFSET_PX outside the rectangle.
px = lambda v: (v - cue["origin"][0]) * cue["scale"]
py = lambda v: (v - cue["origin"][1]) * cue["scale"]
expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2]))
for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
if kind == "free":
self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only")
else:
self.assertEqual(cue["edges"], [line] * 4, "outlined")
self.firefox.run(HOVER, 0.01, 0.01)
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
@@ -1517,20 +1553,39 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(after["layers"]["Arranger"], [])
self.assertEqual(rect(after, 0), rect(scaled, 0))
# A text rectangle of one's own and a Hide, marked by their own blue
# corners, get no cue; a corner drag widens the text box, a drag
# inside moves the Hide.
# A text rectangle of one's own, free text and a Hide get cues too
# (Andy, 2026-10-14), the free text's without corners; the hover
# cue still shows on the box and the Hide; a corner drag widens
# the text box, a drag inside moves the Hide.
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
firefox.type_into(".text_field", "Box")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box.
self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text.
firefox.type_into(".text_field", "Free")
choose("hide")
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box",
"the box and the Hide")
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
"the box, the free text and the Hide")
choose("arrange")
self.assertEqual(cues(), ["image"])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"),
[False, True], "free text's cue has no corners")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"),
["2px"] * 4, "every cue is a box, its line the grip's width")
# The image's cue lies CUE_OFFSET_PX outside the image.
im = scaled["images"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5")
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)")
box = with_all["texts"]["Arranger"][0]
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]])
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect")
h = with_all["hides"][0]
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
@@ -1542,14 +1597,51 @@ class EndToEndTest(unittest.TestCase):
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
self.assertEqual(len(cues()), 1)
self.assertEqual(len(cues()), 4)
# Undo takes the Hide's move back, as it does a hold's.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
# The watcher, without the right, is shown no cue; their own text
# rectangle has its blue corners as everyone's has.
# While something is held its blue cue gives way to the green grip:
# a press on the Hide, held down, then released.
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone")
self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)")
# Held for moving, all four corners are the moving size (Andy, 2026-10-14).
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4)
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back")
self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;"))
# Held at a corner, only that corner is the moving size.
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4)
# The square's position is a percentage of the grip's padding box.
self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');"
" return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"),
["14px", True, True], "the dragged corner, bottom right, larger")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press")
# Held free text shows the grip without corners, as its cue has none.
free = self.state()["texts"]["Arranger"][1]
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"),
["none"] * 4, "no corners on held free text")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or
# the Hide, only on their own text.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
@@ -1558,10 +1650,10 @@ class EndToEndTest(unittest.TestCase):
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
self.assertEqual(cues(), [])
self.assertEqual(cues(), ["text"])
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]])
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
def test_shapes(self):
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
@@ -1772,6 +1864,19 @@ class EndToEndTest(unittest.TestCase):
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
# Each cue's line lies just outside the shape's ink (the points plus
# half the line's width, taken out to whole pixels), one pixel
# beyond it on every side (Andy, 2026-10-14).
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();"
" return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });")
for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes):
xs, ys = stroke["points"][0::3], stroke["points"][1::3]
half = stroke["width"] / 2
self.assertEqual(line, 2)
expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line)
for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
before = strokes(filled)[4]["points"]
firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50)
moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
@@ -1814,7 +1919,7 @@ class EndToEndTest(unittest.TestCase):
box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0]
self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge")
self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas")
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]])
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: "<desc>Box</desc>" in text, "the box saved")
self.assertNotIn("data-ground", saved)
@@ -1827,7 +1932,177 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2,
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]])
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free")
# A circle (Andy, 2026-10-14): the centre first, then a drag out to
# the edge in any direction; every point at that distance from the
# centre. Resized by Arrange from a corner, it stays a circle.
choose("shape")
pick("circle")
self.assertEqual(label(), "Circle")
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"])
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"')
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"),
["rectangle", "circle", "ellipse", "line", "arrow"])
now = self.state()
count = len(strokes(now))
now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"])
firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34)
with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle")
circle = strokes(with_circle)[-1]
self.assertEqual(circle["shape"], "circle")
cx, cy = now_at(0.3, 0.3)
radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy))))
xs, ys = circle["points"][0::3], circle["points"][1::3]
for x, y in zip(xs, ys):
self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1)
self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed")
choose("arrange")
right, bottom = max(xs), max(ys)
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30)
resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized")
rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3]
ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2
nr = (max(rx) - min(rx)) / 2
self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag")
self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round")
for x, y in zip(rx, ry):
self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1)
def test_eraser_preview(self):
"""Andy, 2026-10-14: with the Eraser chosen, a red box around each
element a press under the hovering pointer would erase: a stroke by
the ink's extent, a text block, an image a tap would remove; none
on empty paper, none with another tool."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Eraser", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]"
".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });")
firefox.run(PEN_WAVE, 0.3, 0.5)
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8)
firefox.type_into(".text_field", "Box")
choose("pen")
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"]
and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Eraser"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
# With the pen, nothing; with the eraser, the stroke's ink boxed in red.
hover_at(p[0], p[1])
self.assertEqual(cues(), [])
choose("eraser")
hover_at(p[0], p[1])
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertEqual(boxes[0][4], "rgb(229, 57, 53)")
for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
# The text block, by its box; the image, by a tap's rule; empty
# paper, nothing; the cue goes when the pointer leaves the page.
box = state["texts"]["Eraser"][0]
hover_at(box["x"] + 20, box["y"] + 20)
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one")
for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
im = state["images"][0]
hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2)
boxes = cues()
self.assertEqual(len(boxes), 1)
for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"])
self.assertEqual(cues(), [])
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));")
self.assertEqual(cues(), [])
# Choosing another tool clears it.
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
choose("pen")
self.assertEqual(cues(), [])
def test_arrange_handwriting(self):
"""Andy, 2026-10-14: under Arrange a handwritten stroke gets its
box while the pointer hovers over its ink, kept while the pointer
stays within the box; a press on the ink moves it, on the box's
corner scales it; the hold with the pen leaves it alone."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name.
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
firefox.run(PEN_WAVE, 0.3, 0.5)
state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Scribe"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
choose("arrange")
self.assertEqual(cues(), [], "no box until the pointer is over the ink")
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(left), py(top), px(right), py(bottom), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
# Off the ink but within the box, the box stays; outside it, it goes.
hover_at(left + 2, top + 2)
self.assertEqual(cues(), ["stroke"], "kept within the box")
hover_at(right + 200, bottom + 200)
self.assertEqual(cues(), [], "gone outside the box")
# A drag on the ink moves the whole stroke; Undo takes it back.
firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60)
moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved")
q = moved["layers"]["Scribe"][0]["points"]
self.assertEqual(len(q), len(p))
self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2)
self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2)
self.assertEqual(q[2::3], p[2::3], "the pressures stay")
self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size")
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone")
# A drag on the shown box's bottom-right corner scales the stroke.
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40)
scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled")
q = scaled["layers"]["Scribe"][0]["points"]
self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays")
self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale)
self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged")
# The hold with the pen draws a dot and leaves the stroke alone.
choose("pen")
self.assertEqual(cues(), [])
firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700)
held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke")
self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold")
def test_page_menu_and_help(self):
"""Andy, 2026-10-14: the page and view commands are in a Page menu,