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