From c6e229f88eafefec0059f486f2c5e8b728f36fc7 Mon Sep 17 00:00:00 2001 From: Aran-Fey Date: Sun, 4 Aug 2024 15:32:27 +0200 Subject: [PATCH] dialog improvements --- frontend/code/componentManagement.ts | 1 - frontend/code/components/componentBase.ts | 4 +- frontend/code/components/dialog_container.ts | 11 +- frontend/code/components/image.ts | 12 ++- frontend/code/rpcFunctions.ts | 1 - pyproject.toml | 1 + rio/app.py | 51 ++++++++- rio/components/component.py | 20 +++- rio/components/dialog_container.py | 8 +- rio/dialog.py | 13 ++- rio/session.py | 105 ++++++++++++++++--- 11 files changed, 185 insertions(+), 42 deletions(-) diff --git a/frontend/code/componentManagement.ts b/frontend/code/componentManagement.ts index efaa558b..77250240 100644 --- a/frontend/code/componentManagement.ts +++ b/frontend/code/componentManagement.ts @@ -390,7 +390,6 @@ export function recursivelyDeleteComponent(component: ComponentBase): void { // Inform Python about the destruction of the dialog callRemoteMethodDiscardResponse('dialogClosed', { - owningComponentId: this.state.owning_component_id, dialogRootComponentId: dialog_container.id, }); } diff --git a/frontend/code/components/componentBase.ts b/frontend/code/components/componentBase.ts index ddcd1a2f..fe357980 100644 --- a/frontend/code/components/componentBase.ts +++ b/frontend/code/components/componentBase.ts @@ -7,7 +7,7 @@ import { ClickHandlerArguments, ClickHandler, } from '../eventHandling'; -import { ComponentId, RioScrollBehavior } from '../dataModels'; +import { ComponentId } from '../dataModels'; import { insertWrapperElement, replaceElement } from '../utils'; import { devToolsConnector } from '../app'; import { DialogContainerComponent } from './dialog_container'; @@ -69,7 +69,7 @@ export abstract class ComponentBase { // Any dialogs attached to this component. When this component disappears, // the dialogs go with it. - ownedDialogs: DialogContainerComponent[] = []; + public ownedDialogs: DialogContainerComponent[] = []; constructor(id: ComponentId, state: Required) { this.id = id; diff --git a/frontend/code/components/dialog_container.ts b/frontend/code/components/dialog_container.ts index d720913d..27dcab81 100644 --- a/frontend/code/components/dialog_container.ts +++ b/frontend/code/components/dialog_container.ts @@ -1,5 +1,6 @@ import { recursivelyDeleteComponent } from '../componentManagement'; import { ComponentId } from '../dataModels'; +import { markEventAsHandled } from '../eventHandling'; import { callRemoteMethodDiscardResponse } from '../rpc'; import { commitCss } from '../utils'; import { ComponentBase, ComponentState } from './componentBase'; @@ -8,8 +9,8 @@ export type DialogContainerState = ComponentState & { _type_: 'DialogContainer-builtin'; content?: ComponentId; owning_component_id?: ComponentId; - modal?: boolean; - user_closable?: boolean; + is_modal?: boolean; + is_user_closable?: boolean; }; export class DialogContainerComponent extends ComponentBase { @@ -32,8 +33,10 @@ export class DialogContainerComponent extends ComponentBase { // Listen for outside clicks element.addEventListener('click', (event) => { + markEventAsHandled(event); + // Is the dialog user-closable? - if (!this.state.user_closable) { + if (!this.state.is_user_closable) { return; } @@ -83,7 +86,7 @@ export class DialogContainerComponent extends ComponentBase { this.replaceOnlyChild(latentComponents, deltaState.content); // Modal - if (deltaState.modal) { + if (deltaState.is_modal) { this.element.style.pointerEvents = 'auto'; this.element.style.removeProperty('background-color'); } else { diff --git a/frontend/code/components/image.ts b/frontend/code/components/image.ts index 1576dba7..e826ae74 100644 --- a/frontend/code/components/image.ts +++ b/frontend/code/components/image.ts @@ -33,9 +33,7 @@ export class ImageComponent extends ComponentBase { this.imageElement = document.createElement('img'); element.appendChild(this.imageElement); - this.imageElement.onload = () => { - this.imageElement.classList.remove('rio-content-loading'); - }; + this.imageElement.onload = this._onLoad.bind(this); this.imageElement.onerror = this._onError.bind(this); return element; @@ -80,6 +78,14 @@ export class ImageComponent extends ComponentBase { } } + private _onLoad(): void { + this.imageElement.classList.remove('rio-content-loading'); + + // Browsers are dumb and render content outside of the SVG viewbox if + // the element is too large. So we can't set `width/height: 100%` + // as we usually would. + } + private _onError(event: string | Event): void { this.imageElement.classList.remove('rio-content-loading'); diff --git a/frontend/code/rpcFunctions.ts b/frontend/code/rpcFunctions.ts index e19ff9a5..a0b86339 100644 --- a/frontend/code/rpcFunctions.ts +++ b/frontend/code/rpcFunctions.ts @@ -11,7 +11,6 @@ import { UnittestClientLayoutInfo, UnittestComponentLayout, } from './dataModels'; -import { DialogContainerComponent } from './components/dialog_container'; export async function registerFont( name: string, diff --git a/pyproject.toml b/pyproject.toml index de4f7470..275469d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ dev-dependencies = [ "polars>=0.20", "pre-commit~=3.1", "pytest~=8.2.1", + "pywebview", "requests~=2.31", "ruff>=0.4.7", "selenium>=4.22", diff --git a/rio/app.py b/rio/app.py index 0c4b1c14..cd6f931b 100644 --- a/rio/app.py +++ b/rio/app.py @@ -492,7 +492,12 @@ class App: def run_in_window( self, + *, quiet: bool = True, + maximized: bool = False, + fullscreen: bool = False, + width: float | None = None, + height: float | None = None, ) -> None: """ Runs the app in a local window. @@ -567,12 +572,54 @@ class App: # Wait for the server to start app_ready_event.wait() + # Problem: width and height are given in rem, but we need them in + # pixels. We'll use pywebview's execute_js to find out as soon as the + # window has been created, and then update the window size accordingly. + def update_window_size(): + if width is None and height is None: + return + + pixels_per_rem = window.evaluate_js(""" +let measure = document.createElement('div'); +measure.style.height = '1rem'; + +let pixels_per_rem = measure.getBoundingClientRect().height * +window.devicePixelRatio; + +measure.remove(); + +pixels_per_rem +""") + + if width is None: + width_in_pixels = window.width + else: + width_in_pixels = round(width * pixels_per_rem) + + if height is None: + height_in_pixels = window.height + else: + height_in_pixels = round(height * pixels_per_rem) + + window.set_window_size(width_in_pixels, height_in_pixels) + # Start the webview try: - webview.create_window(self.name, url) - webview.start(debug=os.environ.get("RIO_WEBVIEW_DEBUG") == "1") + window = webview.create_window( + self.name, + url, + maximized=maximized, + fullscreen=fullscreen, + ) + webview.start( + update_window_size, + debug=os.environ.get("RIO_WEBVIEW_DEBUG") == "1", + ) finally: + server = cast( + uvicorn.Server, server + ) # Prevents "unreachable code" warning assert isinstance(server, uvicorn.Server) server.should_exit = True diff --git a/rio/components/component.py b/rio/components/component.py index 29bc2d0e..67b8e4ac 100644 --- a/rio/components/component.py +++ b/rio/components/component.py @@ -474,12 +474,22 @@ class Component(abc.ABC, metaclass=ComponentMeta): and builder._is_in_component_tree(cache) ) - # Special case: DialogContainers are always considered to be part of the - # component tree + # Special case: DialogContainers are considered to be part of the + # component tree as long as their owning component is if not result and isinstance( self, rio.components.dialog_container.DialogContainer ): - result = True + try: + owning_component = self.session._weak_components_by_id[ + self.owning_component_id + ] + except KeyError: + result = False + else: + result = ( + owning_component._is_in_component_tree(cache) + and self._id in owning_component._owned_dialogs_ + ) # Cache the result and return cache[self] = result @@ -702,8 +712,8 @@ class Component(abc.ABC, metaclass=ComponentMeta): dialog_container = dialog_container.DialogContainer( build_content=build, owning_component_id=self._id, - modal=modal, - user_closeable=user_closeable, + is_modal=modal, + is_user_closeable=user_closeable, on_close=on_close, ) diff --git a/rio/components/dialog_container.py b/rio/components/dialog_container.py index 2c08caf9..eccd529d 100644 --- a/rio/components/dialog_container.py +++ b/rio/components/dialog_container.py @@ -16,8 +16,8 @@ __all__ = [ class DialogContainer(Component): build_content: Callable[[], Component] owning_component_id: int - modal: bool - user_closeable: bool + is_modal: bool + is_user_closeable: bool on_close: rio.EventHandler[[]] def build(self) -> Component: @@ -30,6 +30,6 @@ class DialogContainer(Component): def serialize(self) -> dict[str, Any]: return { "owning_component_id": self.owning_component_id, - "modal": self.modal, - "user_closable": self.user_closeable, + "is_modal": self.is_modal, + "is_user_closable": self.is_user_closeable, } diff --git a/rio/dialog.py b/rio/dialog.py index a9dd3dce..0116e2eb 100644 --- a/rio/dialog.py +++ b/rio/dialog.py @@ -15,11 +15,7 @@ class Dialog: "Dialogs cannot be instantiated directly. To create a dialog, call `self.show_custom_dialog` inside of a component's event handler." ) - async def close(self) -> None: - """ - Removes the dialog from the screen. Has no effect if the dialog has - already been previously closed. - """ + def _cleanup(self) -> None: # Try to remove the dialog from its owning component. This can fail if # the dialog has already been removed. try: @@ -27,6 +23,13 @@ class Dialog: except KeyError: return + async def close(self) -> None: + """ + Removes the dialog from the screen. Has no effect if the dialog has + already been previously closed. + """ + self._cleanup() + # The dialog was just discarded on the Python side. Tell the client to # also remove it. await self._root_component.session._remove_dialog( diff --git a/rio/session.py b/rio/session.py index 38cbba15..f3f97f09 100644 --- a/rio/session.py +++ b/rio/session.py @@ -41,7 +41,7 @@ from . import ( user_settings_module, utils, ) -from .components import fundamental_component, root_components +from .components import dialog_container, fundamental_component, root_components from .data_models import BuildData from .state_properties import AttributeBinding from .transports import AbstractTransport, TransportInterrupted @@ -160,6 +160,9 @@ class Session(unicall.Unicall): self.window_width = window_width self.window_height = window_height + self._is_maximized = False + self._is_fullscreen = False + self._base_url = base_url self.theme = theme_ @@ -485,6 +488,53 @@ class Session(unicall.Unicall): # useful in a window anymore. return self.http_headers.get("user-agent", "") + # @property + # def is_maximized(self) -> bool: + # return self._is_maximized + + # @is_maximized.setter + # def is_maximized(self, is_maximized: bool) -> None: + # self._is_maximized = is_maximized + # self.create_task(self._set_maximized(is_maximized)) + + async def _set_maximized(self, is_maximized: bool) -> None: + if self.running_in_window: + window = await self._get_webview_window() + + if is_maximized: + window.maximize() + else: + raise NotImplementedError # FIXME + else: + if is_maximized: + await self._evaluate_javascript_and_get_result(""" +window.moveTo(0, 0); +window.resizeTo(screen.availWidth, screen.availHeight); +""") + else: + raise NotImplementedError # FIXME + + # @property + # def is_fullscreen(self) -> bool: + # return self._is_fullscreen + + # @is_fullscreen.setter + # def is_fullscreen(self, fullscreen: bool): + # self._is_fullscreen = fullscreen + # self.create_task(self._set_fullscreen(fullscreen)) + + async def _set_fullscreen(self, fullscreen: bool) -> None: + if self.running_in_window: + window = await self._get_webview_window() + window.toggle_fullscreen() + else: + if fullscreen: + js_code = "await document.documentElement.requestFullscreen();" + else: + js_code = "document.exitFullscreen();" + + await self._evaluate_javascript_and_get_result(js_code) + @property def _is_connected(self) -> bool: """ @@ -2446,28 +2496,39 @@ a.remove(); await component._on_message(payload) @unicall.local(name="dialogClosed") - async def _dialog_closed( - self, - owning_component_id: int, - dialog_root_component_id: int, - ) -> None: - # Fetch the owning component - dialog = self._weak_components_by_id[owning_component_id] - - # Don't die to network lag - if dialog is None: - return - + async def _dialog_closed(self, dialog_root_component_id: int) -> None: # Fetch and remove the dialog itself, while still not succumbing to # network lag try: - dialog = dialog._owned_dialogs_.pop(dialog_root_component_id) + dialog_cont = self._weak_components_by_id[dialog_root_component_id] except KeyError: return + assert isinstance( + dialog_cont, dialog_container.DialogContainer + ), dialog_cont + + # Fetch the owning component + try: + dialog_owner = self._weak_components_by_id[ + dialog_cont.owning_component_id + ] + except KeyError: + # Don't die to network lag + return + + # Fetch the Dialog object and mark it as closed + try: + dialog = dialog_owner._owned_dialogs_[dialog_root_component_id] + except KeyError: + # Don't die to network lag + return + + dialog._cleanup() + # Trigger the dialog's close event await self._call_event_handler( - dialog._root_component.on_close, + dialog_cont.on_close, refresh=True, ) @@ -2537,6 +2598,20 @@ a.remove(); name="`on_on_window_size_change` event handler", ) + @unicall.local(name="onFullscreenChange") + async def _on_fullscreen_change(self, fullscreen: bool) -> None: + """ + Called by the client when the window is (un-)fullscreened. + """ + self._is_fullscreen = fullscreen + + @unicall.local(name="onMaximizedChange") + async def _on_maximized_change(self, maximized: bool) -> None: + """ + Called by the client when the window is (un-)maximized. + """ + self._is_maximized = maximized + @unicall.remote( name="setClipboard", parameter_format="dict",