diff --git a/frontend/code/components/flowContainer.ts b/frontend/code/components/flowContainer.ts index f9afd3a0..8bf4fc4d 100644 --- a/frontend/code/components/flowContainer.ts +++ b/frontend/code/components/flowContainer.ts @@ -1,3 +1,4 @@ +import { componentsById } from '../componentManagement'; import { ComponentId } from '../dataModels'; import { ComponentBase, ComponentState } from './componentBase'; @@ -24,57 +25,62 @@ export class FlowComponent extends ComponentBase { ): void { super.updateElement(deltaState, latentComponents); - // Update the children - this.replaceChildren(latentComponents, deltaState.children); - } - - private processRow(row: ComponentBase[], rowWidth: number): void { - // Determine how to use the additional space - let spaceToTheLeft, spaceToGrow, spaceForGap; - let additionalWidth = this.allocatedWidth - rowWidth; - - if (this.state.justify === 'left') { - spaceToTheLeft = 0; - spaceToGrow = 0; - spaceForGap = 0; - } else if (this.state.justify === 'center') { - spaceToTheLeft = additionalWidth * 0.5; - spaceToGrow = 0; - spaceForGap = 0; - } else if (this.state.justify === 'right') { - spaceToTheLeft = additionalWidth; - spaceToGrow = 0; - spaceForGap = 0; - } else if (this.state.justify === 'justified') { - if (row.length === 1) { - spaceToTheLeft = additionalWidth * 0.5; - spaceToGrow = 0; - spaceForGap = 0; - } else { - spaceToTheLeft = 0; - spaceToGrow = 0; - spaceForGap = additionalWidth / (row.length - 1); - } - } else { - // 'grow' - spaceToTheLeft = 0; - spaceToGrow = additionalWidth / row.length; - spaceForGap = spaceToGrow; + if (deltaState.row_spacing !== undefined) { + this.element.style.rowGap = `${deltaState.row_spacing}rem`; } - // Assign the positions - for (let ii = 0; ii < row.length; ii++) { - let child = row[ii]; + if (deltaState.column_spacing !== undefined) { + this.element.style.columnGap = `${deltaState.column_spacing}rem`; + } - // Assign the position - let left = - (child as any)._flowContainer_posX + - spaceToTheLeft + - ii * spaceForGap; - child.element.style.left = `${left}rem`; + if (deltaState.justify !== undefined) { + this.element.style.justifyContent = { + left: 'start', + right: 'end', + center: 'center', + justified: 'space-between', + grow: 'stretch', + }[deltaState.justify]; + } - // Assign the width - child.allocatedWidth = child.requestedWidth + spaceToGrow; + if (deltaState.children !== undefined) { + this.replaceChildren( + latentComponents, + deltaState.children, + this.element, + true + ); + this.updateChildGrows( + deltaState.children, + deltaState.justify ?? this.state.justify + ); + } + } + + onChildGrowChanged(): void { + this.updateChildGrows(this.state.children, this.state.justify); + } + + private updateChildGrows(children: ComponentId[], justify: string): void { + // Set the children's `flex-grow` + let hasGrowers = false; + for (let [index, childId] of children.entries()) { + let childComponent = componentsById[childId]!; + let childWrapper = this.element.children[index] as HTMLElement; + + if (childComponent.state._grow_[0]) { + hasGrowers = true; + childWrapper.style.flexGrow = '1'; + } else { + childWrapper.style.flexGrow = '0'; + } + } + + // If nobody wants to grow, all of them do + if (justify === 'grow' && !hasGrowers) { + for (let childWrapper of this.element.children) { + (childWrapper as HTMLElement).style.flexGrow = '1'; + } } } } diff --git a/frontend/code/components/fundamentalRootComponent.ts b/frontend/code/components/fundamentalRootComponent.ts index 9ee1ce21..6479b4ee 100644 --- a/frontend/code/components/fundamentalRootComponent.ts +++ b/frontend/code/components/fundamentalRootComponent.ts @@ -15,7 +15,6 @@ export class FundamentalRootComponent extends ComponentBase { public overlaysContainer: HTMLElement; - private userContentScroller: HTMLElement; private userRootContainer: HTMLElement; private connectionLostPopupContainer: HTMLElement; private devToolsContainer: HTMLElement; @@ -25,8 +24,8 @@ export class FundamentalRootComponent extends ComponentBase { element.classList.add('rio-fundamental-root-component'); element.innerHTML = ` -
-
+
+
@@ -37,11 +36,8 @@ export class FundamentalRootComponent extends ComponentBase { '.rio-overlays-container' ) as HTMLElement; - this.userContentScroller = element.querySelector( - '.rio-user-content-scroller' - ) as HTMLElement; this.userRootContainer = element.querySelector( - '.rio-user-root-container' + '.rio-user-root-container-inner' ) as HTMLElement; this.connectionLostPopupContainer = element.querySelector( '.rio-connection-lost-popup-container' diff --git a/frontend/code/components/linearContainers.ts b/frontend/code/components/linearContainers.ts index 6c8a26bd..3cce109c 100644 --- a/frontend/code/components/linearContainers.ts +++ b/frontend/code/components/linearContainers.ts @@ -37,7 +37,7 @@ abstract class LinearContainer extends ComponentBase { true ); - this.updateChildFlexes(deltaState.children); + this.updateChildGrows(deltaState.children); } // Spacing @@ -58,10 +58,10 @@ abstract class LinearContainer extends ComponentBase { } onChildGrowChanged(): void { - this.updateChildFlexes(this.state.children); + this.updateChildGrows(this.state.children); } - private updateChildFlexes(children: ComponentId[]): void { + private updateChildGrows(children: ComponentId[]): void { // Set the children's `flex-grow` let hasGrowers = false; for (let [index, childId] of children.entries()) { diff --git a/frontend/code/utils.ts b/frontend/code/utils.ts index 0a4eb11d..99ce31b5 100644 --- a/frontend/code/utils.ts +++ b/frontend/code/utils.ts @@ -17,7 +17,7 @@ export function getPixelsPerRem(): number { // rem export function getUsableWindowSize(): [number, number] { let element = globalThis.RIO_DEBUG_MODE - ? document.querySelector('.rio-user-content-scroller')! + ? document.querySelector('.rio-user-content-container')! : document.documentElement; let rect = element.getBoundingClientRect(); diff --git a/frontend/css/style.scss b/frontend/css/style.scss index ebba56ed..f5e2d50c 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -36,6 +36,29 @@ height: 100%; } +/// Kills the child's size request and adds scroll bars if necessary +@mixin scroll-in-both-directions { + // NOTE: For some reason it makes a difference whether the scrolling is done + // on the parent or on the child. Initially we had `overflow: auto` and + // `width/height: 100%` on the child, but this led to very weird layouting + // in some situations. (To reproduce: Launch the rio website in dev mode and + // open the sidebar. The content of the scrolling element will be too + // small.) + + overflow: auto; + pointer-events: auto; + + position: relative; + + & > * { + position: absolute; + min-width: 100%; + min-height: 100%; + + @include single-container(); + } +} + // Light / Dark highlight.js themes // // Switch between these by setting the `data-theme` attribute on the `html` @@ -321,36 +344,27 @@ select { grid-template-columns: minmax(min-content, 1fr) min-content; // The user's root component - & > .rio-user-content-scroller { + & > .rio-user-root-container-outer { z-index: $z-index-user-root; grid-row: 1; grid-column: 1; } - &[data-has-dev-tools='true'] > .rio-user-content-scroller { + &[data-has-dev-tools='true'] > .rio-user-root-container-outer { // If the dev tools sidebar is present, we don't want the scrollbar to // be on their left side, so we must manually take care of scrolling // instead of letting the element do it. - position: relative; - - & > .rio-user-root-container { - @include kill-size-request(); - - overflow: auto; - pointer-events: auto; - - @include single-container(); - } + @include scroll-in-both-directions(); } - &[data-has-dev-tools='false'] > .rio-user-content-scroller { + &[data-has-dev-tools='false'] > .rio-user-root-container-outer { // If the dev tools sidebar is not present, we want to let the // element handle scrolling. This is important because mobile browsers // hide the URL bar when you scroll down. @include single-container(); - & > .rio-user-root-container { + & > .rio-user-root-container-inner { @include single-container(); } } @@ -377,7 +391,7 @@ select { &[data-has-dev-tools='false'] > .rio-overlays-container { // If there are no dev tools present, then scrolling is handled by the - // element instead our rio-user-content-scroller, so in this case + // element instead our rio-user-root-container, so in this case // overlays must be `position: fixed` and cover the whole screen. overflow: hidden; @@ -2270,12 +2284,6 @@ $rio-input-box-text-distance-from-bottom: 0.4rem; // To be aligned with the * { - transition: - left 0.2s ease-out, - top 0.2s ease-out; -} - // Connection lost popup .rio-connection-lost-popup { pointer-events: none; diff --git a/pyproject.toml b/pyproject.toml index bcfac74a..50453dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,13 +97,14 @@ dev-dependencies = [ "alt-pytest-asyncio~=0.7.2", "coverage~=7.2", "pandas>=2.2", + "playwright>=1.44", "plotly>=5.22", "polars>=0.20", "pre-commit~=3.1", "pytest~=8.2.1", "requests~=2.31", "ruff>=0.4.7", - "selenium>=4.22.0", + "selenium>=4.22", "hatch>=1.11.1", ] managed = true diff --git a/rio/app_server/fastapi_server.py b/rio/app_server/fastapi_server.py index c2ce47fb..479b9148 100644 --- a/rio/app_server/fastapi_server.py +++ b/rio/app_server/fastapi_server.py @@ -898,5 +898,11 @@ Sitemap: {request_url.with_path("/rio/sitemap")} def _after_session_closed(self, session: rio.Session) -> None: super()._after_session_closed(session) - session_token = self._active_tokens_by_session.pop(session) + try: + session_token = self._active_tokens_by_session.pop(session) + except KeyError: + # It must be a session created for a crawler. Those don't get unique + # session tokens and aren't registered. + return + del self._active_session_tokens[session_token] diff --git a/rio/debug/layouter.py b/rio/debug/layouter.py index 51b94f0b..efb4f684 100644 --- a/rio/debug/layouter.py +++ b/rio/debug/layouter.py @@ -262,7 +262,9 @@ class Layouter: # single component. Instead, fetch the natural sizes of leaf components, # and then make a pass over all parent components to determine the # correct layout. + print("Getting client layout info") client_info = await self.session._get_unittest_client_layout_info() + print(client_info) self.window_width = client_info.window_width self.window_height = client_info.window_height diff --git a/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py b/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py index 9306788c..8d357add 100644 --- a/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py +++ b/rio/patches_for_3rd_party_stuff/ProactorBasePipeTransport_call_connection_lost.py @@ -19,6 +19,11 @@ class SocketWrapper: self._socket = socket def shutdown(self, *args, **kwargs): + # Sometimes it's a `PipeHandle` object instead of a socket for some + # reason + if not hasattr(self._socket, "shutdown"): + return + try: self._socket.shutdown(*args, **kwargs) except ConnectionResetError: diff --git a/rio/session.py b/rio/session.py index 3d563dd1..ae9f2c59 100644 --- a/rio/session.py +++ b/rio/session.py @@ -872,7 +872,7 @@ window.location.href = {json.dumps(str(target_url))}; // Scroll to the top. This has to happen before we change the URL, because if // the URL has a #fragment then we will scroll to the corresponding ScrollTarget let element = { - 'document.querySelector(".rio-user-content-scroller")' + 'document.querySelector(".rio-user-root-container-inner")' if self._app_server.debug_mode else 'document.documentElement' }; diff --git a/tests/test_layouting.py b/tests/test_layouting.py index 38c778dc..d91dbf53 100644 --- a/tests/test_layouting.py +++ b/tests/test_layouting.py @@ -6,22 +6,38 @@ import rio.testing from tests.utils.headless_client import HeadlessClient -def row_with_extra_width(): +def row_with_no_extra_width(): + return rio.Row( + rio.Text("hi", width=100), + rio.Button("clicky", width=400), + ) + + +def row_with_extra_width_and_no_growers(): return rio.Row( rio.Text("hi", width=5), rio.Button("clicky", width=10), + width=25, + ) + + +def row_with_extra_width_and_one_grower(): + return rio.Row( + rio.Text("hi", width=5), + rio.Button("clicky", width="grow"), + width=20, ) @pytest.mark.parametrize( - "window_size, build", + "build", [ - ((25, 3), row_with_extra_width), + row_with_no_extra_width, + row_with_extra_width_and_no_growers, + row_with_extra_width_and_one_grower, ], ) -@pytest.mark.async_timeout(60) -async def test_layout( - window_size: tuple[float, float], build: Callable[[], rio.Component] -): - async with HeadlessClient(window_size, build) as test_client: +@pytest.mark.async_timeout(20) +async def test_layout(build: Callable[[], rio.Component]): + async with HeadlessClient(build) as test_client: await test_client.verify_dimensions() diff --git a/tests/utils/headless_client copy.py b/tests/utils/headless_client copy.py new file mode 100644 index 00000000..1ff733fe --- /dev/null +++ b/tests/utils/headless_client copy.py @@ -0,0 +1,129 @@ +import asyncio +from collections.abc import Callable + +import uvicorn +from selenium import webdriver +from typing_extensions import Self + +import rio +from rio.app_server import FastapiServer +from rio.debug.layouter import Layouter +from rio.utils import choose_free_port + +__all__ = ["HeadlessClient"] + + +class HeadlessClient: + def __init__( + self, + window_size: tuple[float, float], + build: Callable[[], rio.Component], + ): + self.window_size = window_size + self.build = build + + self._port = choose_free_port("localhost") + + self._session: rio.Session | None = None + self._uvicorn_server: uvicorn.Server | None = None + self._uvicorn_serve_task: asyncio.Task[None] | None = None + self._webdriver = None + + async def __aenter__(self) -> Self: + self._uvicorn_server, app_server = await self._start_uvicorn_server() + self._webdriver = self._start_webdriver() + self._session = await self._create_session(app_server) + + return self + + async def _start_uvicorn_server(self): + server_is_ready_event = asyncio.Event() + loop = asyncio.get_running_loop() + + def set_server_ready_event(): + loop.call_soon_threadsafe(server_is_ready_event.set) + + app = rio.App(build=self.build) + app_server = FastapiServer( + app, + debug_mode=False, + running_in_window=False, + internal_on_app_start=set_server_ready_event, + ) + + config = uvicorn.Config( + app_server, + port=self._port, + # log_level="critical", + ) + uvicorn_server = uvicorn.Server(config) + + current_task: asyncio.Task = asyncio.current_task() # type: ignore + self._uvicorn_serve_task = asyncio.create_task( + self._run_uvicorn(uvicorn_server, current_task) + ) + + await server_is_ready_event.wait() + + # Just because uvicorn says it's ready doesn't mean it's actually ready. + # Give it a bit more time. + await asyncio.sleep(1) + + return uvicorn_server, app_server + + async def _run_uvicorn( + self, uvicorn_server: uvicorn.Server, test_task: asyncio.Task + ): + try: + await uvicorn_server.serve() + except BaseException as error: + test_task.cancel(f"Uvicorn server crashed: {error}") + + def _start_webdriver(self): + # Start the browser and connect to the server + options = webdriver.ChromeOptions() + # options.add_argument("--headless=new") + options.add_argument( + f"--window-size={self.window_size[0]},{self.window_size[1]}" + ) + + # Silence annoying terminal output + options.add_argument("--log-level=3") + options.add_experimental_option("excludeSwitches", ["enable-logging"]) + options.set_capability("browserVersion", "117") + + return webdriver.Chrome(options=options) + + async def _create_session(self, app_server: FastapiServer): + assert self._webdriver is not None + + self._webdriver.get(f"http://localhost:{self._port}") + + while not app_server.sessions: + await asyncio.sleep(0.1) + + return app_server.sessions[0] + + async def __aexit__(self, *_) -> None: + print("Exiting") + + if self._webdriver is not None: + self._webdriver.quit() + + if self._session is not None: + await self._session._close(close_remote_session=False) + + if self._uvicorn_server is not None: + self._uvicorn_server.should_exit = True + + async def verify_dimensions(self) -> None: + assert self._session is not None + + print("Creating layouter") + layouter = await Layouter.create(self._session) + + for component_id, layout_should in layouter._layouts_should.items(): + print("Verifying layout of component", component_id) + layout_is = layouter._layouts_are[component_id] + + assert layout_is == layout_should diff --git a/tests/utils/headless_client.py b/tests/utils/headless_client.py index 558e70b0..86b2b2ed 100644 --- a/tests/utils/headless_client.py +++ b/tests/utils/headless_client.py @@ -1,9 +1,9 @@ import asyncio -import threading from collections.abc import Callable import uvicorn -from selenium import webdriver +from playwright.async_api import async_playwright +from playwright.sync_api import sync_playwright from typing_extensions import Self import rio @@ -14,28 +14,46 @@ from rio.utils import choose_free_port __all__ = ["HeadlessClient"] +# Make sure playwright is set up. +# +# playwright install --with-deps chromium +with sync_playwright() as p: + browser = p.chromium.launch() + browser.close() + + class HeadlessClient: - def __init__( - self, - window_size: tuple[float, float], - build: Callable[[], rio.Component], - ): - self.window_size = window_size + def __init__(self, build: Callable[[], rio.Component]): self.build = build self._port = choose_free_port("localhost") self._session: rio.Session | None = None self._uvicorn_server: uvicorn.Server | None = None - self._webdriver = None + self._uvicorn_serve_task: asyncio.Task[None] | None = None + self._playwright_context = None + self._browser = None async def __aenter__(self) -> Self: + app_server = await self._start_uvicorn_server() + await self._start_browser() + await self._create_session(app_server) + + return self + + async def _start_uvicorn_server(self): + server_is_ready_event = asyncio.Event() + loop = asyncio.get_running_loop() + + def set_server_ready_event(): + loop.call_soon_threadsafe(server_is_ready_event.set) + app = rio.App(build=self.build) app_server = FastapiServer( app, debug_mode=False, running_in_window=False, - internal_on_app_start=self._on_app_start, + internal_on_app_start=set_server_ready_event, ) config = uvicorn.Config( @@ -45,49 +63,59 @@ class HeadlessClient: ) self._uvicorn_server = uvicorn.Server(config) - threading.Thread( - target=self._run_uvicorn, - args=[self._uvicorn_server, asyncio.current_task()], - ).start() + current_task: asyncio.Task = asyncio.current_task() # type: ignore + self._uvicorn_serve_task = asyncio.create_task( + self._run_uvicorn(current_task) + ) - print("Uvicorn thread started") + await server_is_ready_event.wait() + + # Just because uvicorn says it's ready doesn't mean it's actually ready. + # Give it a bit more time. + await asyncio.sleep(1) + + return app_server + + async def _run_uvicorn(self, test_task: asyncio.Task): + assert self._uvicorn_server is not None + + try: + await self._uvicorn_server.serve() + except BaseException as error: + test_task.cancel(f"Uvicorn server crashed: {error}") + + async def _start_browser(self): + self._playwright_context = async_playwright() + + playwright = await self._playwright_context.__aenter__() + + browser = await playwright.chromium.launch() + + # With default settings, playwright gets detected as a crawler. So we + # need to emulate a real device. + self._browser = await browser.new_context( + **playwright.devices["Desktop Chrome"] + ) + + async def _create_session(self, app_server: FastapiServer): + assert self._browser is not None + + page = await self._browser.new_page() + await page.goto(f"http://localhost:{self._port}") while not app_server.sessions: await asyncio.sleep(0.1) self._session = app_server.sessions[0] - print("Session:", self._session) - - return self - - def _run_uvicorn( - self, uvicorn_server: uvicorn.Server, test_task: asyncio.Task - ): - try: - uvicorn_server.run() - except BaseException as error: - test_task.cancel(f"Uvicorn server crashed: {error}") - - def _on_app_start(self): - # Start the browser and connect to the server - options = webdriver.ChromeOptions() - options.add_argument("--headless=new") - options.add_argument( - f"--window-size={self.window_size[0]},{self.window_size[1]}" - ) - - # Silence annoying terminal output - options.add_argument("--log-level=3") - options.add_experimental_option("excludeSwitches", ["enable-logging"]) - options.set_capability("browserVersion", "117") - - self._webdriver = webdriver.Chrome(options=options) - - self._webdriver.get(f"localhost:{self._port}") async def __aexit__(self, *_) -> None: - if self._webdriver is not None: - self._webdriver.quit() + print("Exiting") + + if self._browser is not None: + await self._browser.close() + + if self._playwright_context is not None: + await self._playwright_context.__aexit__(*_) if self._session is not None: await self._session._close(close_remote_session=False) @@ -98,9 +126,11 @@ class HeadlessClient: async def verify_dimensions(self) -> None: assert self._session is not None + print("Creating layouter") layouter = await Layouter.create(self._session) for component_id, layout_should in layouter._layouts_should.items(): + print("Verifying layout of component", component_id) layout_is = layouter._layouts_are[component_id] assert layout_is == layout_should