dialog improvements

This commit is contained in:
Aran-Fey
2024-08-04 15:32:27 +02:00
parent 4e50965bb7
commit c6e229f88e
11 changed files with 185 additions and 42 deletions

View File

@@ -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,
});
}

View File

@@ -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<ComponentState>) {
this.id = id;

View File

@@ -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 {

View File

@@ -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 <img> 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');

View File

@@ -11,7 +11,6 @@ import {
UnittestClientLayoutInfo,
UnittestComponentLayout,
} from './dataModels';
import { DialogContainerComponent } from './components/dialog_container';
export async function registerFont(
name: string,

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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(

View File

@@ -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",