A press that only brings the window to the front draws nothing

From the development repository at 62e0967.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Opus 4.8 committed 2026-10-11 19:32:13 +02:00
1 parent 2de843143d
commit 51e66db9a1
5 files changed
+78 -10

No files matched your search

+1 -1
View File
@@ -1223,7 +1223,7 @@ async function enterSession(invitationText, name, notebook) {
session.start(); session.start();
// For looking at the session from the browser's console during development. // For looking at the session from the browser's console during development.
Object.assign(window, { kokkoja: { Object.assign(window, { kokkoja: {
session, renderer, writer, loadImage, textEditor, session, renderer, writer, loadImage, textEditor, logText,
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage }, notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
} }); } });
} }
+32 -1
View File
@@ -16,6 +16,31 @@ const ERASER_BUTTON = 32;
*/ */
const NOMINAL_PRESSURE = 0.4; const NOMINAL_PRESSURE = 0.4;
/**
* A press this soon after the window gained focus is taken to be the
* click that brought the window to the front, and draws nothing (Andy,
* 2026-10-11): clicking the page to return from the terminal, with the
* Pen chosen, left a stray dot. Measured on Hollis (Firefox, Linux):
* the window's focus event fires about 4 ms before such a press, the
* document already reporting focus, so neither document.hasFocus() nor
* the pointer's movement (a pen hovers either way) can tell it from an
* ordinary press; only the time since the focus can. A quarter second
* is far longer than that press and far shorter than any deliberate
* stroke made as a separate act after focusing the window.
*/
export const FOCUS_SETTLE_MS = 250;
/**
* The end-to-end tests turn the focusing-click rule off with
* `?no_focus_settle` in the address: a browser driven by WebDriver gains
* window focus on the first pointer action after every load or tab
* switch, so it cannot tell such a switch from a click that raised the
* window, and the rule would refuse legitimate test strokes. The rule
* itself is tested with a dispatched focus event in one tab. A real
* user never sets this, and it disables nothing but this nicety.
*/
const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle');
/** /**
* @typedef {object} PenTarget * @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage * @property {(clientX: number, clientY: number) => [number, number]} toPage
@@ -100,6 +125,8 @@ export function attachPenInput(surface, target) {
let pressY = 0; let pressY = 0;
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */ /** @type {PointerEvent | null} The eraser's press, not yet erased with. */
let eraseWaiting = null; let eraseWaiting = null;
/** When the window last gained focus, for the focusing-click rule. */
let lastFocusAt = -Infinity;
/** The eraser's press erases now, if it was waiting. */ /** The eraser's press erases now, if it was waiting. */
function eraseFromPress() { function eraseFromPress() {
@@ -188,6 +215,10 @@ export function attachPenInput(surface, target) {
if (activePointer !== null) return refuse(`pointer ${activePointer} still active`); if (activePointer !== null) return refuse(`pointer ${activePointer} still active`);
if (event.target !== surface) return refuse('not on the page'); if (event.target !== surface) return refuse('not on the page');
if (event.pointerType === 'pen') penSeen = true; if (event.pointerType === 'pen') penSeen = true;
// A press that only brought the window to the front draws nothing.
if (focusSettleOn && performance.now() - lastFocusAt < FOCUS_SETTLE_MS) {
return refuse('it brought the window to the front');
}
// Once a pen has been used, a touch is taken to be the resting hand. // Once a pen has been used, a touch is taken to be the resting hand.
if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand'); if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand');
if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button'); if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button');
@@ -325,7 +356,7 @@ export function attachPenInput(surface, target) {
// learned on his machine before the rule is written (a window focus // learned on his machine before the rule is written (a window focus
// is faked by the test harness on every tab switch, so it cannot be // is faked by the test harness on every tab switch, so it cannot be
// learned here). The press trace already carries "in front"/"behind". // learned here). The press trace already carries "in front"/"behind".
window.addEventListener('focus', () => trace('window: focus')); window.addEventListener('focus', () => { lastFocusAt = performance.now(); trace('window: focus'); });
window.addEventListener('blur', () => trace('window: blur')); window.addEventListener('blur', () => trace('window: blur'));
document.addEventListener('visibilitychange', () => trace(`visibility: ${document.visibilityState}`)); document.addEventListener('visibilitychange', () => trace(`visibility: ${document.visibilityState}`));
+3 -1
View File
@@ -81,7 +81,9 @@ from the palette joins the row and stays there in this browser. White
is for painting over. The four lines to the right of the colours are is for painting over. The four lines to the right of the colours are
the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure. the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure.
If a pen is in use, a hand resting on a touch screen is ignored. If a pen is in use, a hand resting on a touch screen is ignored. A
click on the page that only brings the browser window to the front, when
you come back to it from another window, makes no mark.
### Text ### Text
+1 -1
View File
@@ -283,7 +283,7 @@ the zoom step of the wheel and the zoom per 100 pixels of a pen drag
(`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin (`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin
by which the page grows (`page_model.js`), the tooltip delay (1 s, by which the page grows (`page_model.js`), the tooltip delay (1 s,
`DELAY_MS` in `tooltip.js`) and how long after one tooltip the next `DELAY_MS` in `tooltip.js`) and how long after one tooltip the next
button's comes at once (1 s, `WARM_MS` there), the hold that moves an image (600 ms, with 6 pixels of button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js`), the hold that moves an image (600 ms, with 6 pixels of
slack, `pen_input.js`), the reach of an image's corner (24 pixels, slack, `pen_input.js`), the reach of an image's corner (24 pixels,
`renderer.js`), the smallest an image can be scaled to (1 cm, `renderer.js`), the smallest an image can be scaled to (1 cm,
`page_model.js`), how often a dragged image's place is sent (40 ms, `page_model.js`), how often a dragged image's place is sent (40 ms,
+41 -6
View File
@@ -570,12 +570,17 @@ class EndToEndTest(unittest.TestCase):
cls.stop() cls.stop()
raise raise
def address(self, name, join=None): def address(self, name, join=None, focus_settle=False):
"""The Kokkoja address of a participant's own local program, with """The Kokkoja address of a participant's own local program, with
its token, and an invitation to join if given.""" its token, and an invitation to join if given. By default it
carries `?no_focus_settle`, which turns off the focusing-click
rule: a driven browser gains focus on the first draw after a load
or tab switch, which the rule would take for a focusing click
(see test_a_focusing_click_draws_nothing, which keeps it on)."""
base, token = self.programs[name] base, token = self.programs[name]
query = "" if focus_settle else "?no_focus_settle"
joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}" joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}"
return f"{base}#token={token}{joining}" return f"{base}{query}#token={token}{joining}"
@classmethod @classmethod
def start(cls, command): def start(cls, command):
@@ -708,7 +713,7 @@ class EndToEndTest(unittest.TestCase):
# filled in, says where the notebooks are, and offers the installed # filled in, says where the notebooks are, and offers the installed
# program to anyone who has it. # program to anyone who has it.
guest_tab = firefox.new_tab() guest_tab = firefox.new_tab()
firefox.go(link) firefox.go(link.replace("#", "?no_focus_settle#", 1))
self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"), self.wait_until_true(lambda: firefox.run("return document.getElementById('invitation_entry').value;"),
lambda v: v and v.startswith("ws://"), "the invitation in the start screen") lambda v: v and v.startswith("ws://"), "the invitation in the start screen")
self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"), self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"),
@@ -1063,6 +1068,33 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(host_tab) firefox.switch_to(host_tab)
firefox.go("about:blank") firefox.go("about:blank")
def test_a_focusing_click_draws_nothing(self):
"""Andy, 2026-10-11: clicking the page to bring the browser to the
front from the terminal, with the Pen chosen, left a stray dot. A
press within 250 ms of the window gaining focus draws nothing.
(The window focus is dispatched here: the test harness raises the
real window on the first draw after a tab switch, so a two-tab
test cannot feign the terminal case; this does it in one tab.)"""
firefox = self.firefox
firefox.go(self.address("Late", focus_settle=True))
self.enter_name("Focuser", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
strokes = lambda: len(self.state()["layers"].get("Focuser", []))
# The window gains focus, then a press right after: nothing drawn.
firefox.run("window.dispatchEvent(new Event('focus'));")
firefox.drag("#page", 200, 300, 400, 320)
time.sleep(0.4)
self.assertEqual(strokes(), 0)
self.assertIn("refused: it brought the window to the front",
firefox.run("return window.kokkoja.logText();"))
# Once the window has been focused a while, a stroke draws.
firefox.run("window.dispatchEvent(new Event('focus'));")
time.sleep(0.4)
firefox.drag("#page", 200, 400, 400, 420)
self.wait_until(lambda s: len(s["layers"].get("Focuser", [])) == 1,
"a stroke once the window had been focused a while")
firefox.go("about:blank")
def test_undo(self): def test_undo(self):
"""Andy, 2026-10-11: one's latest gesture is taken back, then the """Andy, 2026-10-11: one's latest gesture is taken back, then the
one before, by the Undo button, Ctrl+Z, or two quick taps of the one before, by the Undo button, Ctrl+Z, or two quick taps of the
@@ -2024,8 +2056,11 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) self.assertEqual(len(self.kept_pages("Teacher notebook")), 2)
# A new session in the same notebook: the page the last session # A new session in the same notebook: the page the last session
# left there is kept, without anyone having asked. # left there is kept, without anyone having asked. (The param
firefox.go(self.programs["Teacher"][0]) # that turns off the focusing-click rule, as address() adds, so
# that drawing after this navigation is not taken for a focusing
# click by the driven browser.)
firefox.go(self.programs["Teacher"][0] + "?no_focus_settle")
firefox.run("location.reload();") firefox.run("location.reload();")
time.sleep(0.5) time.sleep(0.5)
self.enter_name("Teacher", "start_session") self.enter_name("Teacher", "start_session")