Skip to content

Commit

Permalink
Support viewport size (#379)
Browse files Browse the repository at this point in the history
  • Loading branch information
wwwillchen committed Jun 8, 2024
1 parent 1b4ea47 commit 273591e
Show file tree
Hide file tree
Showing 13 changed files with 260 additions and 55 deletions.
53 changes: 53 additions & 0 deletions docs/api/viewport_size.md
Original file line number Diff line number Diff line change
@@ -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
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.
32 changes: 32 additions & 0 deletions mesop/examples/viewport_size.py
Original file line number Diff line number Diff line change
@@ -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")
29 changes: 29 additions & 0 deletions mesop/features/viewport_size.py
Original file line number Diff line number Diff line change
@@ -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,
)
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
20 changes: 17 additions & 3 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 = 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,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)
Expand Down
10 changes: 9 additions & 1 deletion 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 All @@ -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
):
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
Loading

0 comments on commit 273591e

Please sign in to comment.