diff --git a/frontend/code/components/componentBase.ts b/frontend/code/components/componentBase.ts index 757c4807..33ddaf64 100644 --- a/frontend/code/components/componentBase.ts +++ b/frontend/code/components/componentBase.ts @@ -13,8 +13,13 @@ import { ClickHandler, } from "../eventHandling"; import { ComponentId } from "../dataModels"; -import { insertWrapperElement, replaceElement } from "../utils"; -import { devToolsConnector } from "../app"; +import { + getAllocatedHeightInPx, + getAllocatedWidthInPx, + insertWrapperElement, + replaceElement, +} from "../utils"; +import { devToolsConnector, pixelsPerRem } from "../app"; export type Key = string | number; @@ -46,6 +51,8 @@ export type ComponentState = { // Debugging information: The dev tools may not display components to the // developer if they're considered internal _rio_internal_: boolean; + // Whether this component requested resize events + _on_resize_: boolean; }; export type DeltaState = Omit, "_type_">; @@ -76,6 +83,7 @@ export abstract class ComponentBase { private outerScrollElement: HTMLElement | null = null; private centerScrollElement: HTMLElement | null = null; private innerScrollElement: HTMLElement | null = null; + private sizeObserver: ResizeObserver | null = null; constructor( id: ComponentId, @@ -151,10 +159,29 @@ export abstract class ComponentBase { this.element.role = deltaState.accessibility_role; } } + + if (deltaState._on_resize_ !== undefined) { + if (deltaState._on_resize_ && this.sizeObserver === null) { + this.sizeObserver = new ResizeObserver( + this._onSizeChange.bind(this) + ); + this.sizeObserver.observe(this.element); + } + if (!deltaState._on_resize_ && this.sizeObserver !== null) { + this.sizeObserver.disconnect(); + this.sizeObserver = null; + } + } } onChildGrowChanged(): void {} + private _onSizeChange(): void { + let width = getAllocatedWidthInPx(this.element); + let height = getAllocatedHeightInPx(this.element); + this.triggerResizeEvent(width / pixelsPerRem, height / pixelsPerRem); + } + private _updateMaxSize(maxSize: [number | null, number | null]): void { let transform: string[] = []; @@ -558,6 +585,9 @@ export abstract class ComponentBase { for (let handler of this._eventHandlers) { handler.disconnect(); } + if (this.sizeObserver !== null) { + this.sizeObserver.disconnect(); + } } /// Send a message to the python instance corresponding to this component. The @@ -570,6 +600,14 @@ export abstract class ComponentBase { }); } + triggerResizeEvent(width: number, height: number): void { + callRemoteMethodDiscardResponse("onComponentSizeChange", { + component_id: this.id, + new_width: width, + new_height: height, + }); + } + _setStateDontNotifyBackend(deltaState: DeltaState): void { // Trigger an update this.updateElement( diff --git a/rio/event.py b/rio/event.py index 2e224e93..3932a741 100644 --- a/rio/event.py +++ b/rio/event.py @@ -39,6 +39,7 @@ class EventTag(enum.Enum): ON_MOUNT = enum.auto() ON_PAGE_CHANGE = enum.auto() ON_POPULATE = enum.auto() + ON_RESIZE = enum.auto() ON_UNMOUNT = enum.auto() ON_WINDOW_SIZE_CHANGE = enum.auto() PERIODIC = enum.auto() @@ -386,3 +387,10 @@ def periodic( return handler return decorator + + +def on_resize( + handler: t.Callable[[t.Any, float, float], None], +) -> t.Callable[[t.Any, float, float], None]: + _tag_as_event_handler(handler, EventTag.ON_RESIZE, None) + return handler diff --git a/rio/serialization.py b/rio/serialization.py index 1846b550..71de38c1 100644 --- a/rio/serialization.py +++ b/rio/serialization.py @@ -182,6 +182,9 @@ def serialize_and_host_component( result["_type_"] = "HighLevelComponent-builtin" result["_child_"] = component._build_data_.build_result._id_ # type: ignore + if rio.event.EventTag.ON_RESIZE in component._rio_event_handlers_: + result["_on_resize_"] = True + return result diff --git a/rio/session.py b/rio/session.py index 28fec037..c7ba4d9e 100644 --- a/rio/session.py +++ b/rio/session.py @@ -3905,6 +3905,25 @@ a.remove(); name="`on_on_window_size_change` event handler", ) + @unicall.local(name="onComponentSizeChange") + async def _on_component_size_change( + self, component_id: int, new_width: float, new_height: float + ) -> None: + """ + Called by the client when a component is resized. + """ + component = self._weak_components_by_id[component_id] + + for handler, _ in component._rio_event_handlers_[ + rio.event.EventTag.ON_RESIZE + ]: + # Since the whole point of this event is to fetch data and + # modify the component's state, wait for it to finish if it's + # synchronous. + self._call_event_handler_sync( + handler, component, new_width, new_height + ) + @unicall.local(name="onFullscreenChange") async def _on_fullscreen_change(self, fullscreen: bool) -> None: """ diff --git a/tests/test_frontend/test_layouting/test_on_resize.py b/tests/test_frontend/test_layouting/test_on_resize.py new file mode 100644 index 00000000..96d2974c --- /dev/null +++ b/tests/test_frontend/test_layouting/test_on_resize.py @@ -0,0 +1,74 @@ +import rio +from tests.utils.layouting import verify_layout + +recorded_events = [] + + +class MyComponent(rio.Component): + @rio.event.on_resize + def handle_resize(self, width, height) -> None: + global recorded_events + print(f"Component resized to {width}x{height}") + recorded_events.append((width, height)) + + def build(self): + return rio.Rectangle( + fill=rio.Color.BLUE, + min_width=5.0, + min_height=10.0, + ) + + +async def test_size_observer_reports_content_dimensions(): + global recorded_events + + # Compute layout + layout = await verify_layout(MyComponent) + + # Find components + session = layout.session + size_observer = next( + c + for c in session._weak_components_by_id.values() + if isinstance(c, MyComponent) + ) + rectangle = next( + c + for c in session._weak_components_by_id.values() + if isinstance(c, rio.Rectangle) + ) + + observer_layout = layout._layouts_are[size_observer._id_] + rectangle_layout = layout._layouts_are[rectangle._id_] + + print(f"Observer layout: {observer_layout}") + print(f"Rectangle layout: {rectangle_layout}") + + # Verify layout dimensions + observer_width = observer_layout.allocated_outer_width + observer_height = observer_layout.allocated_outer_height + rect_width = rectangle_layout.allocated_outer_width + rect_height = rectangle_layout.allocated_outer_height + + assert observer_width == rect_width, ( + f"Widths do not match: observer={observer_width}, rectangle={rect_width}" + ) + assert observer_height == rect_height, ( + f"Heights do not match: observer={observer_height}, rectangle={rect_height}" + ) + + print( + f"Observer dimensions: {observer_width}x{observer_height},{session.pixels_per_font_height=}" + ) + + # Verify size event + assert len(recorded_events) >= 1, "Expected at least one resize event" + observed_width = recorded_events[-1][0] + observed_height = recorded_events[-1][1] + + assert observed_width == observer_width, ( + f"Expected width {observer_width}, got {observed_width}" + ) + assert observed_height == observer_height, ( + f"Expected height {observer_height}, got {observed_height}" + )