Skip to content

Commit

Permalink
Support viewport size
Browse files Browse the repository at this point in the history
  • Loading branch information
wwwillchen committed Jun 8, 2024
1 parent 5f3f594 commit d62b3cc
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 52 deletions.
21 changes: 21 additions & 0 deletions docs/api/viewport_size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Viewport size

## Overview

Viewport size enables you to create responsive or size-specific UIs.

## Examples

### Different styles

...

### Different layout

...

## API

::: mesop.features.viewport_size.viewport_size

::: mesop.features.viewport_size.Size
2 changes: 2 additions & 0 deletions mesop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mesop/examples/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions mesop/examples/viewport_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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}"
)
34 changes: 34 additions & 0 deletions mesop/features/viewport_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from dataclasses import dataclass

from mesop.runtime import runtime


@dataclass(kw_only=True)
class Size:
"""
This class represents the size of the viewport.
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:
"""
This function returns the current viewport size.
The viewport size is an object of the class Size, which has two properties: width and height.
These properties represent the current width and height of the viewport in pixels.
Returns:
Size: An object of class Size representing the current viewport size.
"""
pb_size = runtime().context().viewport_size()
return Size(
width=pb_size.width,
height=pb_size.height,
)
21 changes: 19 additions & 2 deletions mesop/protos/ui.proto
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ message UiRequest {
}

message InitRequest {

optional ViewportSize viewport_size = 1;
}

message UserEvent {
Expand All @@ -31,6 +31,7 @@ message UserEvent {
double double_value = 7;
int32 int_value = 8;
NavigationEvent navigation = 6;
ResizeEvent resize = 10;
bytes bytes_value = 9;
}
}
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions mesop/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

def __init__(
self,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,7 +141,13 @@ 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"):
if event.HasField("resize"):
self.set_viewport_size(event.resize.viewport_size)
if event.HasField("navigation") and event.navigation.HasField(
"viewport_size"
):
self.set_viewport_size(event.navigation.viewport_size)
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

Expand Down
1 change: 1 addition & 0 deletions mesop/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions mesop/tests/e2e/viewport_size_test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
22 changes: 12 additions & 10 deletions mesop/web/src/services/channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {Injectable, NgZone} from '@angular/core';
import {
InitRequest,
ServerError,
States,
UiRequest,
Expand All @@ -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.
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
98 changes: 60 additions & 38 deletions mesop/web/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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). */
Expand All @@ -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}];
Expand Down
8 changes: 8 additions & 0 deletions mesop/web/src/utils/viewport_size.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit d62b3cc

Please sign in to comment.