diff --git a/docs/_static/livecode/livecode.js b/docs/_static/livecode/livecode.js index 0389577..ed90497 100644 --- a/docs/_static/livecode/livecode.js +++ b/docs/_static/livecode/livecode.js @@ -4,6 +4,12 @@ // click, so a reader who never runs anything never downloads Pyodide. const COLUMNS = 80; +// Narrowest terminal the fit may produce. progressbar's default widget set +// needs about 57 columns before the bar itself gets any room, so a phone +// width host shrinks the font instead of the column count. +const MIN_COLUMNS = 60; +const FONT_SIZE = 13; +const MIN_FONT_SIZE = 6; const RUN_TIMEOUT_MS = 30000; // Read the Docs serves the site under `/en//`, so root-absolute @@ -133,8 +139,19 @@ function fitTerminalWidth(terminal, host) { terminal.loadAddon(addon); /** @type {() => void} */ const fit = () => { + // Measure at the full font first so a host that grows back gets its + // original size, then shrink the font until MIN_COLUMNS fit. + terminal.options.fontSize = FONT_SIZE; /** @type {{cols: number, rows: number} | undefined} */ - const dimensions = addon.proposeDimensions(); + let dimensions = addon.proposeDimensions(); + if (dimensions && Number.isInteger(dimensions.cols) + && dimensions.cols > 0 && dimensions.cols < MIN_COLUMNS) { + terminal.options.fontSize = Math.max( + MIN_FONT_SIZE, + Math.floor(FONT_SIZE * dimensions.cols / MIN_COLUMNS), + ); + dimensions = addon.proposeDimensions(); + } if (dimensions && Number.isInteger(dimensions.cols) && dimensions.cols > 0 && dimensions.cols !== terminal.cols) { terminal.resize(dimensions.cols, terminal.rows); @@ -181,7 +198,7 @@ function createPanel(container, source) { cols: COLUMNS, rows: container.closest('.home-quickstart') ? 3 : 12, convertEol: true, - fontSize: 13, + fontSize: FONT_SIZE, theme: {background: '#101418', foreground: '#d6e2ef'}, }); terminal.open(terminalHost); diff --git a/docs/_static/livecode/worker.js b/docs/_static/livecode/worker.js index 624ce06..1c2ec39 100644 --- a/docs/_static/livecode/worker.js +++ b/docs/_static/livecode/worker.js @@ -37,7 +37,7 @@ const THREAD_ERROR_MESSAGE = 'animation above is the real output. See the source for the pattern.'; const BRIDGE = ` -import os, sys, js, traceback +import os, sys, time, js, traceback from pyodide.ffi import to_js @@ -109,9 +109,22 @@ def _excepthook(exc_type, exc_value, exc_tb): traceback.print_exception(exc_type, exc_value, exc_tb, file=sys.stderr) +def _sleep(seconds): + # Pyodide's own time.sleep costs about 80ms per 10ms call in Chromium + # on macOS (measured 2026-09-15; Linux stays close to nominal), which + # pushed a 1000-item tutorial run past the console's 30 second stop. + # A perf_counter loop keeps to the asked duration on every platform. + # It burns the worker's CPU while waiting, which is the same thread + # the blocking sleep already held. + deadline = time.perf_counter() + max(0.0, seconds) + while time.perf_counter() < deadline: + pass + + def _install_bridge(columns): global _LAST_ERROR _LAST_ERROR = None + time.sleep = _sleep os.environ['TERM'] = 'xterm-256color' os.environ['COLORTERM'] = 'truecolor' os.environ['COLUMNS'] = str(columns) diff --git a/tests/console/test_console.py b/tests/console/test_console.py index cfeb2a7..1681596 100644 --- a/tests/console/test_console.py +++ b/tests/console/test_console.py @@ -357,3 +357,34 @@ def fail_wheels_manifest(route: Route) -> None: # and not a hang. `resetWorker()` nulling out the cached `booting` # promise is what makes this 2 instead of 1. assert _worker_count(browser_page) == 2 + + +def test_sleep_in_the_console_keeps_to_its_duration( + server: str, + page: tuple[Page, list[str]], +) -> None: + """Pyodide's own ``time.sleep`` costs about 80ms per 10ms call in + Chromium on macOS, which pushed a 1000-item tutorial run past the + console's 30s stop. The worker installs its own sleep, so a call + must cost close to what it asks for on every platform. + """ + browser_page, errors = page + browser_page.goto(f'{server}/tutorial/step2.html') + browser_page.get_by_role('button', name='Edit code').click() + browser_page.locator('.demo-editor').fill( + 'import time\n' + 'started = time.perf_counter()\n' + 'for _ in range(20):\n' + ' time.sleep(0.01)\n' + 'per_call_ms = (time.perf_counter() - started) / 20 * 1000\n' + "print(f'per call: {per_call_ms:.1f} ms')\n" + ) + browser_page.click('.demo-button') + _wait_for_terminal_text(browser_page, 'per call:', BOOT_TIMEOUT_MS) + text: str = browser_page.evaluate( + 'window.__consoleTestTerminal.buffer.active.getLine(0)' + '.translateToString(true)' + ) + per_call_ms: float = float(text.split('per call:')[1].split('ms')[0]) + assert 8 <= per_call_ms <= 25, text + assert not errors diff --git a/tests/console/test_homepage.py b/tests/console/test_homepage.py index 5a49a23..76e9e90 100644 --- a/tests/console/test_homepage.py +++ b/tests/console/test_homepage.py @@ -119,6 +119,11 @@ def test_homepage_output_has_colour_and_fits_after_resizing( for width in (1440, 768, 375): browser_page.set_viewport_size({'width': width, 'height': 1000}) browser_page.get_by_role('button', name='Run', exact=True).click() + # The default widget set needs about 57 columns before the bar + # gets any room, so the console must never fit narrower than that. + browser_page.wait_for_function( + '() => window.__consoleTestTerminal.cols >= 60', timeout=5000 + ) _wait_for_terminal_text(browser_page, '100%', BOOT_TIMEOUT_MS) browser_page.wait_for_function("""() => { const term = window.__consoleTestTerminal;