From a0585a3705ecdb396e4e15ea96774ccd17a63bd0 Mon Sep 17 00:00:00 2001 From: Richard To Date: Thu, 5 Sep 2024 18:08:45 -0700 Subject: [PATCH] Add hotkey shortcuts for text areas (#922) Also updates fancy_chat and textarea demos to show usage of shortcuts --- demo/fancy_chat.py | 15 ++++ demo/textarea.py | 45 +++++++++- mesop/__init__.py | 4 + mesop/components/input/e2e/__init__.py | 1 + mesop/components/input/e2e/input_test.ts | 89 +++++++++++++++++++ .../input/e2e/textarea_shortcut_app.py | 58 ++++++++++++ mesop/components/input/input.ng.html | 2 + mesop/components/input/input.proto | 16 +++- mesop/components/input/input.py | 76 ++++++++++++++++ mesop/components/input/input.ts | 37 +++++++- mesop/protos/ui.proto | 20 ++++- 11 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 mesop/components/input/e2e/textarea_shortcut_app.py diff --git a/demo/fancy_chat.py b/demo/fancy_chat.py index 8b64d8746..eeab30cee 100644 --- a/demo/fancy_chat.py +++ b/demo/fancy_chat.py @@ -349,6 +349,9 @@ def chat_input(): key="chat_input", min_rows=4, on_blur=on_chat_input, + shortcuts={ + me.Shortcut(shift=True, key="Enter"): on_submit_chat_msg, + }, placeholder="Enter your prompt", style=me.Style( background=me.theme_var("surface-container") @@ -458,6 +461,7 @@ def on_click_example_user_query(e: me.ClickEvent): state = me.state(State) _, example_index = e.key.split("-") state.input = _EXAMPLE_USER_QUERIES[int(example_index)] + me.focus_component(key="chat_input") def on_click_thumb_up(e: me.ClickEvent): @@ -550,7 +554,18 @@ def on_click_regenerate(e: me.ClickEvent): yield +def on_submit_chat_msg(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = e.value + yield + yield from _submit_chat_msg() + + def on_click_submit_chat_msg(e: me.ClickEvent): + yield from _submit_chat_msg() + + +def _submit_chat_msg(): """Handles submitting a chat message.""" state = me.state(State) if state.in_progress or not state.input: diff --git a/demo/textarea.py b/demo/textarea.py index 62dbc08eb..fde30fd28 100644 --- a/demo/textarea.py +++ b/demo/textarea.py @@ -4,11 +4,30 @@ @me.stateclass class State: input: str = "" + output: str = "" def on_blur(e: me.InputBlurEvent): state = me.state(State) state.input = e.value + state.output = e.value + + +def on_newline(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = e.value + "\n" + + +def on_submit(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = e.value + state.output = e.value + + +def on_clear(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = "" + state.output = "" @me.page( @@ -19,5 +38,27 @@ def on_blur(e: me.InputBlurEvent): ) def app(): s = me.state(State) - me.textarea(label="Basic input", on_blur=on_blur) - me.text(text=s.input) + with me.box(style=me.Style(margin=me.Margin.all(15))): + me.text( + "Press enter to submit.", + style=me.Style(margin=me.Margin(bottom=15)), + ) + me.text( + "Press shift+enter to create new line.", + style=me.Style(margin=me.Margin(bottom=15)), + ) + me.text( + "Press shift+meta+enter to clear text.", + style=me.Style(margin=me.Margin(bottom=15)), + ) + me.textarea( + label="Basic input", + value=s.input, + on_blur=on_blur, + shortcuts={ + me.Shortcut(key="enter"): on_submit, + me.Shortcut(shift=True, key="ENTER"): on_newline, + me.Shortcut(shift=True, meta=True, key="Enter"): on_clear, + }, + ) + me.text(text=s.output) diff --git a/mesop/__init__.py b/mesop/__init__.py index efabfe564..95ab35c7d 100644 --- a/mesop/__init__.py +++ b/mesop/__init__.py @@ -78,6 +78,10 @@ from mesop.components.input.input import EnterEvent as EnterEvent from mesop.components.input.input import InputBlurEvent as InputBlurEvent from mesop.components.input.input import InputEnterEvent as InputEnterEvent +from mesop.components.input.input import Shortcut as Shortcut +from mesop.components.input.input import ( + TextareaShortcutEvent as TextareaShortcutEvent, +) from mesop.components.input.input import input as input from mesop.components.input.input import native_textarea as native_textarea from mesop.components.input.input import textarea as textarea diff --git a/mesop/components/input/e2e/__init__.py b/mesop/components/input/e2e/__init__.py index 55c2a9613..394d1376b 100644 --- a/mesop/components/input/e2e/__init__.py +++ b/mesop/components/input/e2e/__init__.py @@ -1,2 +1,3 @@ from . import input_app as input_app from . import input_blur_app as input_blur_app +from . import textarea_shortcut_app as textarea_shortcut_app diff --git a/mesop/components/input/e2e/input_test.ts b/mesop/components/input/e2e/input_test.ts index 0638238c9..0dbad7a40 100644 --- a/mesop/components/input/e2e/input_test.ts +++ b/mesop/components/input/e2e/input_test.ts @@ -43,3 +43,92 @@ test('test input on_blur works', async ({page}) => { page.getByText('input_value_when_button_clicked: second_textarea'), ).toBeVisible(); }); + +test('test textarea shortcuts', async ({page}) => { + await page.goto('/components/input/e2e/textarea_shortcut_app'); + const textbox = page.getByLabel('Textarea'); + await textbox.fill('hi'); + await page.keyboard.press('Enter'); + await expect(await page.getByText('Submitted: hi')).toBeVisible(); + + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await expect(await page.getByText('Submitted: hi')).toBeVisible(); + + await textbox.pressSequentially('hi'); + await page.keyboard.press('Enter'); + await expect(await page.getByText('Submitted: hi hi')).toBeVisible(); + + await page.keyboard.down('Meta'); + await page.keyboard.press('s'); + await page.keyboard.up('Meta'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='S', shift=False, ctrl=False, alt=False, meta=True)", + ), + ).toBeVisible(); + + await page.keyboard.down('Control'); + await page.keyboard.down('Alt'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Control'); + await page.keyboard.up('Alt'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='Enter', shift=False, ctrl=True, alt=True, meta=False)", + ), + ).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='escape', shift=False, ctrl=False, alt=False, meta=False)", + ), + ).toBeVisible(); +}); + +test('test native textarea shortcuts', async ({page}) => { + await page.goto('/components/input/e2e/textarea_shortcut_app'); + const textbox = page.getByPlaceholder('Native textarea'); + + await textbox.fill('hi'); + await page.keyboard.press('Enter'); + await expect(await page.getByText('Submitted: hi')).toBeVisible(); + + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await expect(await page.getByText('Submitted: hi')).toBeVisible(); + + await textbox.pressSequentially('hi'); + await page.keyboard.press('Enter'); + await expect(await page.getByText('Submitted: hi hi')).toBeVisible(); + + await page.keyboard.down('Meta'); + await page.keyboard.press('s'); + await page.keyboard.up('Meta'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='S', shift=False, ctrl=False, alt=False, meta=True)", + ), + ).toBeVisible(); + + await page.keyboard.down('Control'); + await page.keyboard.down('Alt'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Control'); + await page.keyboard.up('Alt'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='Enter', shift=False, ctrl=True, alt=True, meta=False)", + ), + ).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect( + await page.getByText( + "Shortcut: Shortcut(key='escape', shift=False, ctrl=False, alt=False, meta=False)", + ), + ).toBeVisible(); +}); diff --git a/mesop/components/input/e2e/textarea_shortcut_app.py b/mesop/components/input/e2e/textarea_shortcut_app.py new file mode 100644 index 000000000..c68f6b589 --- /dev/null +++ b/mesop/components/input/e2e/textarea_shortcut_app.py @@ -0,0 +1,58 @@ +import mesop as me + + +@me.stateclass +class State: + input: str = "" + output: str = "" + shortcut: str = "" + + +def on_newline(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = e.value + "\n" + + +def on_submit(e: me.TextareaShortcutEvent): + state = me.state(State) + state.input = e.value + state.output = e.value + + +def on_shortcut(e: me.TextareaShortcutEvent): + state = me.state(State) + state.shortcut = str(e.shortcut) + + +@me.page(path="/components/input/e2e/textarea_shortcut_app") +def app(): + s = me.state(State) + with me.box(style=me.Style(margin=me.Margin.all(15))): + me.textarea( + label="Textarea", + value=s.input, + shortcuts={ + me.Shortcut(key="enter"): on_submit, + me.Shortcut(shift=True, key="ENTER"): on_newline, + me.Shortcut(ctrl=True, alt=True, key="Enter"): on_shortcut, + me.Shortcut(meta=True, key="S"): on_shortcut, + me.Shortcut(key="escape"): on_shortcut, + }, + ) + + me.native_textarea( + placeholder="Native textarea", + value=s.input, + autosize=True, + min_rows=5, + shortcuts={ + me.Shortcut(key="enter"): on_submit, + me.Shortcut(shift=True, key="ENTER"): on_newline, + me.Shortcut(ctrl=True, alt=True, key="Enter"): on_shortcut, + me.Shortcut(meta=True, key="S"): on_shortcut, + me.Shortcut(key="escape"): on_shortcut, + }, + ) + + me.text(text="Submitted: " + s.output) + me.text(text="Shortcut: " + s.shortcut) diff --git a/mesop/components/input/input.ng.html b/mesop/components/input/input.ng.html index 7666130ac..5565ba75e 100644 --- a/mesop/components/input/input.ng.html +++ b/mesop/components/input/input.ng.html @@ -7,6 +7,7 @@ [style]="getStyle()" (input)="onInput($event)" (blur)="onBlur($event)" + (keydown)="onKeyDown($event)" [cdkTextareaAutosize]="config().getAutosize()" [cdkAutosizeMinRows]="config().getMinRows()" [cdkAutosizeMaxRows]="config().getMaxRows()" @@ -35,6 +36,7 @@ (input)="onInput($event)" (blur)="onBlur($event)" [rows]="config().getRows()" + (keydown)="onKeyDown($event)" [cdkTextareaAutosize]="config().getAutosize()" [cdkAutosizeMinRows]="config().getMinRows()" [cdkAutosizeMaxRows]="config().getMaxRows()" diff --git a/mesop/components/input/input.proto b/mesop/components/input/input.proto index 86700fe27..71276ee16 100644 --- a/mesop/components/input/input.proto +++ b/mesop/components/input/input.proto @@ -3,7 +3,7 @@ syntax = "proto2"; package mesop.components.input; -// Next id: 26 +// Next id: 27 message InputType { optional bool disabled = 1; optional string id = 2; @@ -27,7 +27,21 @@ message InputType { optional bool autosize = 20; optional int32 min_rows = 21; optional int32 max_rows = 22; + repeated ShortcutHandler on_shortcut_handler = 26; // Not exposed as public API. optional bool is_textarea = 19; optional bool is_native_textarea = 23; } + +message ShortcutHandler { + optional Shortcut shortcut = 1; + optional string handler_id = 2; +} + +message Shortcut { + optional string key = 1; + optional bool shift = 2; + optional bool ctrl = 3; + optional bool alt = 4; + optional bool meta = 5; +} diff --git a/mesop/components/input/input.py b/mesop/components/input/input.py index 637094251..5970eabba 100644 --- a/mesop/components/input/input.py +++ b/mesop/components/input/input.py @@ -40,6 +40,51 @@ class InputEnterEvent(MesopEvent): ) +@dataclass(kw_only=True, frozen=True) +class Shortcut: + """Represents a keyboard shortcut combination. + + Key values are compared case-insensitively. For a list of possible key values, see + https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values. + """ + + key: str + shift: bool = False + ctrl: bool = False + alt: bool = False + meta: bool = False + + +@dataclass(kw_only=True) +class TextareaShortcutEvent(MesopEvent): + """Represents a shortcut keyboard event on a textarea component. + + Attributes: + value: Input value. + shortcut: Key combination pressed. + key (str): key of the component that emitted this event. + """ + + shortcut: Shortcut + value: str + + +register_event_mapper( + TextareaShortcutEvent, + lambda event, key: TextareaShortcutEvent( + key=key.key, + value=event.textarea_shortcut.string_value, + shortcut=Shortcut( + shift=event.textarea_shortcut.shortcut.shift, + ctrl=event.textarea_shortcut.shortcut.ctrl, + alt=event.textarea_shortcut.shortcut.alt, + meta=event.textarea_shortcut.shortcut.meta, + key=event.textarea_shortcut.shortcut.key, + ), + ), +) + + @dataclass(kw_only=True) class InputBlurEvent(MesopEvent): """Represents an inpur blur event (when a user loses focus of an input). @@ -83,6 +128,8 @@ def textarea( float_label: Literal["always", "auto"] = "auto", subscript_sizing: Literal["fixed", "dynamic"] = "fixed", hint_label: str = "", + shortcuts: dict[Shortcut, Callable[[TextareaShortcutEvent], Any]] + | None = None, key: str | None = None, ): """Creates a Textarea component. @@ -107,6 +154,7 @@ def textarea( float_label: Whether the label should always float or float as the user types. subscript_sizing: Whether the form field should reserve space for one line of hint/error text (default) or to have the spacing grow from 0px as needed based on the size of the hint/error content. Note that when using dynamic sizing, layout shifts will occur when hint/error text changes. hint_label: Text for the form field hint. + shortcuts: Shortcut events to listen for. key: The component [key](../components/index.md#component-key). """ @@ -138,6 +186,7 @@ def textarea( on_input_handler_id=register_event_handler(on_input, event=InputEvent) if on_input else "", + on_shortcut_handler=_to_on_shortcut_handler(shortcuts), ), style=style, ) @@ -250,6 +299,8 @@ def native_textarea( placeholder: str = "", value: str = "", readonly: bool = False, + shortcuts: dict[Shortcut, Callable[[TextareaShortcutEvent], Any]] + | None = None, key: str | None = None, ): """Creates a browser native Textarea component. Intended for advanced use cases with maximum UI control. @@ -265,6 +316,7 @@ def native_textarea( placeholder: Placeholder value value: Initial value. readonly: Whether the element is readonly. + shortcuts: Shortcut events to listen for. key: The component [key](../components/index.md#component-key). """ @@ -287,6 +339,30 @@ def native_textarea( on_input_handler_id=register_event_handler(on_input, event=InputEvent) if on_input else "", + on_shortcut_handler=_to_on_shortcut_handler(shortcuts), ), style=style, ) + + +def _to_on_shortcut_handler( + shortcuts: dict[Shortcut, Callable[[TextareaShortcutEvent], Any]] | None, +) -> list[input_pb.ShortcutHandler]: + if not shortcuts: + return [] + + return [ + input_pb.ShortcutHandler( + shortcut=input_pb.Shortcut( + key=shortcut.key, + shift=shortcut.shift, + ctrl=shortcut.ctrl, + alt=shortcut.alt, + meta=shortcut.meta, + ), + handler_id=register_event_handler( + event_handler, event=TextareaShortcutEvent + ), + ) + for shortcut, event_handler in shortcuts.items() + ] diff --git a/mesop/components/input/input.ts b/mesop/components/input/input.ts index 49f48ca75..99886ed58 100644 --- a/mesop/components/input/input.ts +++ b/mesop/components/input/input.ts @@ -5,7 +5,9 @@ import { UserEvent, Key, Type, + Shortcut, Style, + TextareaShortcutEvent, } from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; import {InputType} from 'mesop/mesop/components/input/input_jspb_proto_pb/mesop/components/input/input_pb'; import {Channel} from '../../web/src/services/channel'; @@ -111,6 +113,7 @@ export class InputComponent { } onKeyDown(event: Event): void { + const keyboardEvent = event as KeyboardEvent; // The isComposing field tells us if we are using an input method editor (IME) which // will display a menu of characters that can't be represented on a standard QWERTY // keyboard. The user can select from this menu by pressing "enter" or clicking the @@ -132,11 +135,43 @@ export class InputComponent { // // In order to work around this issue, we need to count expectedIsComposingCount // equals false twice on Safari rather than just once. - if ((event as KeyboardEvent).isComposing) { + if (keyboardEvent.isComposing) { this.isComposingCount = 0; } else if (this.isComposingCount < this.expectedIsComposingCount) { this.isComposingCount += 1; } + + // Handle keyboard shortcut events (textareas only) + for (const shortcutHandler of this._config.getOnShortcutHandlerList()) { + if ( + keyboardEvent.key.toLowerCase() === + shortcutHandler.getShortcut()!.getKey()!.toLowerCase() && + keyboardEvent.altKey === shortcutHandler.getShortcut()!.getAlt() && + keyboardEvent.ctrlKey === shortcutHandler.getShortcut()!.getCtrl() && + keyboardEvent.shiftKey === shortcutHandler.getShortcut()!.getShift() && + keyboardEvent.metaKey === shortcutHandler.getShortcut()!.getMeta() + ) { + // Prevent default behavior for cases where we want to override browser level + // commands, such as Cmd+S. + keyboardEvent.preventDefault(); + + const userEvent = new UserEvent(); + userEvent.setHandlerId(shortcutHandler.getHandlerId()!); + const shortcut = new Shortcut(); + shortcut.setKey(shortcutHandler.getShortcut()!.getKey()!); + shortcut.setAlt(shortcutHandler.getShortcut()!.getAlt()!); + shortcut.setCtrl(shortcutHandler.getShortcut()!.getCtrl()!); + shortcut.setShift(shortcutHandler.getShortcut()!.getShift()!); + shortcut.setMeta(shortcutHandler.getShortcut()!.getMeta()!); + const shortcutEvent = new TextareaShortcutEvent(); + shortcutEvent.setShortcut(shortcut); + shortcutEvent.setStringValue((event.target as HTMLInputElement).value); + userEvent.setTextareaShortcut(shortcutEvent); + userEvent.setKey(this.key); + this.channel.dispatch(userEvent); + break; + } + } } onBlur(event: Event): void { diff --git a/mesop/protos/ui.proto b/mesop/protos/ui.proto index b58e6c657..ef69844f1 100644 --- a/mesop/protos/ui.proto +++ b/mesop/protos/ui.proto @@ -24,7 +24,7 @@ message QueryParam { repeated string values = 2; } -// Next ID: 17 +// Next ID: 18 message UserEvent { optional States states = 1; @@ -47,6 +47,7 @@ message UserEvent { bytes bytes_value = 9; ChangePrefersColorScheme change_prefers_color_scheme = 14; ClickEvent click = 16; + TextareaShortcutEvent textarea_shortcut = 17; } optional string state_token = 12; @@ -84,6 +85,23 @@ message ClickEvent { optional bool is_target = 1; } +// Shortcut event for textareas. +message TextareaShortcutEvent { + optional Shortcut shortcut = 1; + optional string string_value = 2; +} + +// Shortcut key combination based on KeyboardEvent naming. +// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent +message Shortcut { + optional string key = 1; + optional bool shift = 2; + optional bool ctrl = 3; + optional bool alt = 4; + optional bool meta = 5; +} + + // This is a user-triggered navigation (e.g. go back/forwards) or a hot reload event. message NavigationEvent{ }