diff --git a/docs/api/viewport_size.md b/docs/api/viewport_size.md new file mode 100644 index 000000000..2fa8f4068 --- /dev/null +++ b/docs/api/viewport_size.md @@ -0,0 +1,53 @@ +# Viewport size + +## Overview +The viewport size API allows you to access the current viewport size. This can be useful for creating responsive and adaptive designs that are suitable for the user's screen size. + +## Examples + +### Responsive Design + +Responsive design is having a single fluid layout that adapts to all screen sizes. + +You can use the viewport size to dynamically set the property of a style. This can be useful if you want to fit two boxes in a row for larger screens (e.g. desktop) and a single box for smaller screens (e.g. mobile) as shown in the example below: + +```py +import mesop as me + +@me.page() +def page(): + if me.viewport_size().width > 640: + width = me.viewport_size().width / 2 + else: + width = me.viewport_size().width + for i in range(8): + me.box(style=me.Style(width=width)) +``` + +> Tip: Responsive design tends to take less work and is usually a good starting point. + +### Adaptive Design + +Adaptive design is having multiple fixed layouts for specific device categories at specific breakpoints, typically viewport width. + +For example, oftentimes you will hide the nav component on a mobile device and instead show a hamburger menu, while for a larger device you will always show the nav component on the left side. + +```py +import mesop as me + +@me.page() +def page(): + if me.viewport_size().width > 480: + nav_component() + body() + else: + body(show_menu_button=True) +``` + +> Tip: Adaptive design tends to take more work and is best for optimizing complex mobile and desktop experiences. + +## API + +::: mesop.features.viewport_size.viewport_size + +::: mesop.features.viewport_size.Size diff --git a/mesop/__init__.py b/mesop/__init__.py index 3d8d195ed..0ad5e6594 100644 --- a/mesop/__init__.py +++ b/mesop/__init__.py @@ -149,6 +149,8 @@ MesopUserException as MesopUserException, ) from mesop.features import page as page +from mesop.features.viewport_size import Size as Size +from mesop.features.viewport_size import viewport_size as viewport_size from mesop.key import Key as Key from mesop.runtime import runtime from mesop.security.security_policy import SecurityPolicy as SecurityPolicy diff --git a/mesop/examples/__init__.py b/mesop/examples/__init__.py index b2324936b..e974b2631 100644 --- a/mesop/examples/__init__.py +++ b/mesop/examples/__init__.py @@ -26,4 +26,5 @@ from mesop.examples import scroll_into_view as scroll_into_view from mesop.examples import sxs as sxs from mesop.examples import testing as testing +from mesop.examples import viewport_size as viewport_size # Do not import error_state_missing_init_prop because it cause all examples to fail. diff --git a/mesop/examples/viewport_size.py b/mesop/examples/viewport_size.py new file mode 100644 index 000000000..85eeaa6d0 --- /dev/null +++ b/mesop/examples/viewport_size.py @@ -0,0 +1,32 @@ +import mesop as me + + +@me.page(path="/viewport_size") +def app(): + me.text( + f"viewport_size width={me.viewport_size().width} height={me.viewport_size().height}" + ) + + if me.viewport_size().width > 640: + width = round(me.viewport_size().width / 2) + else: + width = me.viewport_size().width + + me.box( + style=me.Style( + width=width, + height=40, + background="blue", + ) + ) + + # Example of adaptive design: + if me.viewport_size().width > 640: + with me.box( + style=me.Style( + width="100%", + height=100, + background="pink", + ) + ): + me.text("Only shown on large screens") diff --git a/mesop/features/viewport_size.py b/mesop/features/viewport_size.py new file mode 100644 index 000000000..2f2ab81d0 --- /dev/null +++ b/mesop/features/viewport_size.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + +from mesop.runtime import runtime + + +@dataclass(kw_only=True) +class Size: + """ + Attributes: + width: The width of the viewport in pixels. + height: The height of the viewport in pixels. + """ + + width: int + height: int + + +def viewport_size() -> Size: + """ + Returns the current viewport size. + + Returns: + Size: The current viewport size. + """ + pb_size = runtime().context().viewport_size() + return Size( + width=pb_size.width, + height=pb_size.height, + ) diff --git a/mesop/protos/ui.proto b/mesop/protos/ui.proto index 102fd6da2..bacb574d1 100644 --- a/mesop/protos/ui.proto +++ b/mesop/protos/ui.proto @@ -14,7 +14,7 @@ message UiRequest { } message InitRequest { - + optional ViewportSize viewport_size = 1; } message UserEvent { @@ -31,6 +31,7 @@ message UserEvent { double double_value = 7; int32 int_value = 8; NavigationEvent navigation = 6; + ResizeEvent resize = 10; bytes bytes_value = 9; } } @@ -106,7 +107,23 @@ message ArgPathSegment { } // This is a user-triggered navigation (e.g. go back/forwards) or a hot reload event. -message NavigationEvent{} +message NavigationEvent{ + // Set the viewport size when it's a hot reload. + optional ViewportSize viewport_size = 1; +} + +// Fired whenever a user resizes the viewport/browser. +message ResizeEvent { + optional ViewportSize viewport_size = 1; +} + +message ViewportSize { + // Viewport width, in pixels. + optional int32 width = 1; + + // Viewport height, in pixels. + optional int32 height = 2; +} // Sent from Python server to web client. // Multiple UiResponse messages may be sent in response to 1 UiRequest. diff --git a/mesop/runtime/context.py b/mesop/runtime/context.py index 87108dab7..6c74e749e 100644 --- a/mesop/runtime/context.py +++ b/mesop/runtime/context.py @@ -7,7 +7,10 @@ serialize_dataclass, update_dataclass_from_json, ) -from mesop.exceptions import MesopDeveloperException, MesopException +from mesop.exceptions import ( + MesopDeveloperException, + MesopException, +) FLAGS = flags.FLAGS @@ -29,6 +32,7 @@ class Context: _commands: list[pb.Command] _node_slot: pb.Component | None _node_slot_children_count: int | None + _viewport_size: pb.ViewportSize | None = None def __init__( self, @@ -59,6 +63,16 @@ def scroll_into_view(self, key: str) -> None: pb.Command(scroll_into_view=pb.ScrollIntoViewCommand(key=key)) ) + def set_viewport_size(self, size: pb.ViewportSize): + self._viewport_size = size + + def viewport_size(self) -> pb.ViewportSize: + if self._viewport_size is None: + raise MesopDeveloperException( + "Tried to retrieve viewport size before it was set." + ) + return self._viewport_size + def register_event_handler(self, fn_id: str, handler: Handler) -> None: if self._trace_mode: self._handlers[fn_id] = handler @@ -127,9 +141,9 @@ def update_state(self, states: pb.States) -> None: def run_event_handler( self, event: pb.UserEvent ) -> Generator[None, None, None]: - if event.HasField("navigation"): + if event.HasField("navigation") or event.HasField("resize"): yield # empty yield so there's one tick of the render loop - return # return early b/c there's no event handler for hot reload + return # return early b/c there's no event handler for these events. payload = cast(Any, event) handler = self._handlers.get(event.handler_id) diff --git a/mesop/server/server.py b/mesop/server/server.py index c590e42b4..2b2b5f999 100644 --- a/mesop/server/server.py +++ b/mesop/server/server.py @@ -114,6 +114,7 @@ def generate_data(ui_request: pb.UiRequest) -> Generator[str, None, None]: yield from yield_errors(runtime().get_loading_errors()[0]) if ui_request.HasField("init"): + runtime().context().set_viewport_size(ui_request.init.viewport_size) page_config = runtime().get_page_config(path=ui_request.path) if page_config and page_config.on_load: result = page_config.on_load(LoadEvent(path=ui_request.path)) @@ -132,7 +133,14 @@ def generate_data(ui_request: pb.UiRequest) -> Generator[str, None, None]: else: yield from render_loop(path=ui_request.path, init_request=True) elif ui_request.HasField("user_event"): - runtime().context().update_state(ui_request.user_event.states) + event = ui_request.user_event + if event.HasField("resize"): + runtime().context().set_viewport_size(event.resize.viewport_size) + elif event.HasField("navigation") and event.navigation.HasField( + "viewport_size" + ): + runtime().context().set_viewport_size(event.navigation.viewport_size) + runtime().context().update_state(event.states) for _ in render_loop( path=ui_request.path, keep_alive=True, trace_mode=True ): diff --git a/mesop/tests/e2e/viewport_size_test.ts b/mesop/tests/e2e/viewport_size_test.ts new file mode 100644 index 000000000..332e53713 --- /dev/null +++ b/mesop/tests/e2e/viewport_size_test.ts @@ -0,0 +1,15 @@ +import {test, expect} from '@playwright/test'; + +test('viewport_size', async ({page}) => { + await page.goto('/viewport_size'); + await page.setViewportSize({width: 400, height: 300}); + // For the following assertions, make sure the width has updated so that + // there isn't a race condition. + expect(await page.getByText('viewport_size width=400').textContent()).toEqual( + 'viewport_size width=400 height=300', + ); + await page.setViewportSize({width: 500, height: 200}); + expect(await page.getByText('viewport_size width=500').textContent()).toEqual( + 'viewport_size width=500 height=200', + ); +}); diff --git a/mesop/web/src/services/channel.ts b/mesop/web/src/services/channel.ts index cd0a7dcdc..085ba201e 100644 --- a/mesop/web/src/services/channel.ts +++ b/mesop/web/src/services/channel.ts @@ -1,6 +1,5 @@ import {Injectable, NgZone} from '@angular/core'; import { - InitRequest, ServerError, States, UiRequest, @@ -16,6 +15,7 @@ import {Logger} from '../dev_tools/services/logger'; import {Title} from '@angular/platform-browser'; import {SSE} from '../utils/sse'; import {applyComponentDiff} from '../utils/diff'; +import {getViewportSize} from '../utils/viewport_size'; // Pick 500ms as the minimum duration before showing a progress/busy indicator // for the channel. @@ -82,11 +82,7 @@ export class Channel { return this.componentConfigs; } - init(initParams: InitParams, request?: UiRequest) { - if (!request) { - request = new UiRequest(); - request.setInit(new InitRequest()); - } + init(initParams: InitParams, request: UiRequest) { this.eventSource = new SSE('/ui', { payload: generatePayloadString(request), }); @@ -174,9 +170,13 @@ export class Channel { } dispatch(userEvent: UserEvent) { - // Except for navigation user event, every user event should have - // an event handler. - if (!userEvent.getHandlerId() && !userEvent.getNavigation()) { + // Every user event should have an event handler, + // except for navigation and resize. + if ( + !userEvent.getHandlerId() && + !userEvent.getNavigation() && + !userEvent.getResize() + ) { // This is a no-op user event, so we don't send it. return; } @@ -246,7 +246,9 @@ export class Channel { const request = new UiRequest(); const userEvent = new UserEvent(); userEvent.setStates(this.states); - userEvent.setNavigation(new NavigationEvent()); + const navigationEvent = new NavigationEvent(); + navigationEvent.setViewportSize(getViewportSize()); + userEvent.setNavigation(navigationEvent); request.setUserEvent(userEvent); this.init(this.initParams, request); } diff --git a/mesop/web/src/shell/shell.ts b/mesop/web/src/shell/shell.ts index c0b49d25f..d61f97b08 100644 --- a/mesop/web/src/shell/shell.ts +++ b/mesop/web/src/shell/shell.ts @@ -13,6 +13,9 @@ import { UserEvent, ComponentConfig, NavigationEvent, + ResizeEvent, + UiRequest, + InitRequest, } from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; import {CommonModule} from '@angular/common'; import {ComponentRenderer} from '../component_renderer/component_renderer'; @@ -24,6 +27,7 @@ import {EditorService} from '../services/editor_service'; import {MatSidenavModule} from '@angular/material/sidenav'; import {ErrorBox} from '../error/error_box'; import {GlobalErrorHandlerService} from '../services/global_error_handler'; +import {getViewportSize} from '../utils/viewport_size'; @Component({ selector: 'mesop-shell', @@ -62,48 +66,57 @@ export class Shell { } ngOnInit() { - this.channel.init({ - zone: this.zone, - onRender: (rootComponent, componentConfigs) => { - this.rootComponent = rootComponent; - // Component configs are only sent for the first response. - // For subsequent reponses, use the component configs previously - if (componentConfigs.length) { - this.componentConfigs = componentConfigs; - } - this.error = undefined; - }, - onCommand: (command) => { - if (command.hasNavigate()) { - this.router.navigateByUrl(command.getNavigate()!.getUrl()!); - } else if (command.hasScrollIntoView()) { - // Scroll into view - const key = command.getScrollIntoView()!.getKey(); - const targetElements = document.querySelectorAll( - `[data-key="${key}"]`, - ); - if (!targetElements.length) { - console.error( - `Could not scroll to component with key ${key} because no component found`, - ); - return; + const request = new UiRequest(); + const initRequest = new InitRequest(); + initRequest.setViewportSize(getViewportSize()); + request.setInit(initRequest); + this.channel.init( + { + zone: this.zone, + onRender: (rootComponent, componentConfigs) => { + this.rootComponent = rootComponent; + // Component configs are only sent for the first response. + // For subsequent reponses, use the component configs previously + if (componentConfigs.length) { + this.componentConfigs = componentConfigs; } - if (targetElements.length > 1) { - console.warn( - 'Found multiple components', - targetElements, - 'to potentially scroll to for key', - key, - '. This is probably a bug and you should use a unique key identifier.', + this.error = undefined; + }, + onCommand: (command) => { + if (command.hasNavigate()) { + this.router.navigateByUrl(command.getNavigate()!.getUrl()!); + } else if (command.hasScrollIntoView()) { + // Scroll into view + const key = command.getScrollIntoView()!.getKey(); + const targetElements = document.querySelectorAll( + `[data-key="${key}"]`, ); + if (!targetElements.length) { + console.error( + `Could not scroll to component with key ${key} because no component found`, + ); + return; + } + if (targetElements.length > 1) { + console.warn( + 'Found multiple components', + targetElements, + 'to potentially scroll to for key', + key, + '. This is probably a bug and you should use a unique key identifier.', + ); + } + targetElements[0].parentElement!.scrollIntoView({ + behavior: 'smooth', + }); } - targetElements[0].parentElement!.scrollIntoView({behavior: 'smooth'}); - } + }, + onError: (error) => { + this.error = error; + }, }, - onError: (error) => { - this.error = error; - }, - }); + request, + ); } /** Listen to browser navigation events (go back/forward). */ @@ -117,6 +130,15 @@ export class Shell { showChannelProgressIndicator(): boolean { return this.channel.isBusy(); } + + @HostListener('window:resize') + onResize() { + const userEvent = new UserEvent(); + const resize = new ResizeEvent(); + resize.setViewportSize(getViewportSize()); + userEvent.setResize(resize); + this.channel.dispatch(userEvent); + } } const routes: Routes = [{path: '**', component: Shell}]; diff --git a/mesop/web/src/utils/viewport_size.ts b/mesop/web/src/utils/viewport_size.ts new file mode 100644 index 000000000..43fb536e8 --- /dev/null +++ b/mesop/web/src/utils/viewport_size.ts @@ -0,0 +1,8 @@ +import {ViewportSize} from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; + +export function getViewportSize(): ViewportSize { + const viewportSize = new ViewportSize(); + viewportSize.setWidth(window.innerWidth); + viewportSize.setHeight(window.innerHeight); + return viewportSize; +} diff --git a/mkdocs.yml b/mkdocs.yml index b4f1d3828..f8cd5bd54 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,7 +54,9 @@ nav: - Plot: components/plot.md - API: - Page: api/page.md - - Style: api/style.md + - UI Customization: + - Style: api/style.md + - Viewport Size: api/viewport_size.md - Commands: - Navigate: api/commands/navigate.md - Scroll into view: api/commands/scroll_into_view.md