Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add on_keyup and value_input for code editor #6919

Merged
merged 16 commits into from
Aug 20, 2024
27 changes: 24 additions & 3 deletions examples/reference/widgets/CodeEditor.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@
" - `'type'`: type of annotation and the icon displayed {`warning` | `error`}\n",
"* **``filename``** (str): If filename is provided the file extension will be used to determine the language\n",
"* **``language``** (str): A string declaring which language to use for code syntax highlighting (default: 'text')\n",
"* **``on_keyup``** (bool): Whether to update the value on every key press or only upon loss of focus / hotkeys.\n",
"* **``print_margin``** (boolean): Whether to show a print margin in the editor\n",
"* **``theme``** (str): theme of the editor (default: 'chrome')\n",
"* **``readonly``** (boolean): Whether the editor should be opened in read-only mode\n",
"* **``value``** (str): A string with (initial) code to set in the editor\n",
"\n",
"* **``value``** (str): State of the current code in the editor if `on_keyup`. Otherwise, only upon loss of focus, i.e. clicking outside the editor, or pressing <Ctrl+Enter> or <Cmd+Enter>.\n",
"* **``value_input``** (str): State of the current code updated on every key press. Identical to `value` if `on_keyup`.\n",
"___"
]
},
Expand All @@ -50,7 +51,7 @@
"metadata": {},
"source": [
"To construct an `Ace` widget we must define it explicitly using `pn.widgets.Ace`. We can add some text as initial code.\n",
"Code inserted in the editor is automatically reflected in the `value`."
"Code inserted in the editor is automatically reflected in the `value_input` and `value`."
]
},
{
Expand Down Expand Up @@ -84,6 +85,26 @@
"\"\"\""
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default, the code editor will update the `value` on every key press, but you can set `on_keyup=False` to only update the `value` when the editor loses focus or pressing `<Ctrl+Enter>`/ `<Cmd+Enter>`. Here, the code is printed when `value` is changed."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def print_code(value):\n",
" print(value)\n",
"\n",
"editor = pn.widgets.CodeEditor(value=py_code, on_keyup=False)\n",
"pn.bind(print_code, editor.param.value)"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
4 changes: 4 additions & 0 deletions panel/models/ace.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def __js_skip__(cls):

code = String(default='')

code_input = String(default='')

on_keyup = Bool(default=True)

theme = Enum(ace_themes, default='chrome')

filename = Nullable(String())
Expand Down
25 changes: 24 additions & 1 deletion panel/models/ace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,20 @@ export class AcePlotView extends HTMLBoxView {
this._update_language()
this._editor.setReadOnly(this.model.readonly)
this._editor.setShowPrintMargin(this.model.print_margin)
this._editor.on("change", () => this._update_code_from_editor())
// if on keyup, update code from editor
if (this.model.on_keyup) {
this._editor.on("change", () => this._update_code_from_editor())
} else {
this._editor.on("blur", () => this._update_code_from_editor())
this._editor.commands.addCommand({
name: "updateCodeFromEditor",
bindKey: {win: "Ctrl-Enter", mac: "Command-Enter"},
exec: () => {
this._update_code_from_editor()
},
})
}
this._editor.on("change", () => this._update_code_input_from_editor())
}

_update_code_from_model(): void {
Expand All @@ -87,6 +100,12 @@ export class AcePlotView extends HTMLBoxView {
}
}

_update_code_input_from_editor(): void {
if (this._editor.getValue() != this.model.code_input) {
this.model.code_input = this._editor.getValue()
}
}

_update_theme(): void {
this._editor.setTheme(`ace/theme/${this.model.theme}`)
}
Expand Down Expand Up @@ -120,6 +139,8 @@ export namespace AcePlot {
export type Attrs = p.AttrsOf<Props>
export type Props = HTMLBox.Props & {
code: p.Property<string>
code_input: p.Property<string>
on_keyup: p.Property<boolean>
language: p.Property<string>
filename: p.Property<string | null>
theme: p.Property<string>
Expand All @@ -145,6 +166,8 @@ export class AcePlot extends HTMLBox {

this.define<AcePlot.Props>(({Any, List, Bool, Str, Nullable}) => ({
code: [ Str, "" ],
code_input: [ Str, "" ],
on_keyup: [ Bool, true ],
filename: [ Nullable(Str), null],
language: [ Str, "" ],
theme: [ Str, "chrome" ],
Expand Down
84 changes: 84 additions & 0 deletions panel/tests/ui/widgets/test_codeeditor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import sys

import pytest

pytest.importorskip("playwright")

from playwright.sync_api import expect

from panel.tests.util import serve_component, wait_until
from panel.widgets import CodeEditor

pytestmark = pytest.mark.ui


def test_code_editor_on_keyup(page):

editor = CodeEditor(value="print('Hello World!')", on_keyup=True)
serve_component(page, editor)
ace_input = page.locator(".ace_content")
expect(ace_input).to_have_count(1)
ace_input.click()

page.keyboard.press("Enter")
page.keyboard.type('print("Hello Panel!")')

expect(page.locator(".ace_content")).to_have_text("print('Hello World!')\nprint(\"Hello Panel!\")", use_inner_text=True)
wait_until(lambda: editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")")
assert editor.value == "print('Hello World!')\nprint(\"Hello Panel!\")"

# clear the editor
editor.value = ""
expect(page.locator(".ace_content")).to_have_text("", use_inner_text=True)
assert editor.value == ""
assert editor.value_input == ""

# enter Hello UI
ace_input.click()
page.keyboard.type('print("Hello UI!")')
expect(page.locator(".ace_content")).to_have_text("print(\"Hello UI!\")", use_inner_text=True)
assert editor.value == "print(\"Hello UI!\")"

Check failure on line 40 in panel/tests/ui/widgets/test_codeeditor.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:macos-latest

test_code_editor_on_keyup assert '' == 'print("Hello UI!")' - print("Hello UI!")


def test_code_editor_not_on_keyup(page):

editor = CodeEditor(value="print('Hello World!')", on_keyup=False)
serve_component(page, editor)
ace_input = page.locator(".ace_content")
expect(ace_input).to_have_count(1)
ace_input.click()

page.keyboard.press("Enter")
page.keyboard.type('print("Hello Panel!")')

expect(page.locator(".ace_content")).to_have_text("print('Hello World!')\nprint(\"Hello Panel!\")", use_inner_text=True)
wait_until(lambda: editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")")
assert editor.value == "print('Hello World!')"

# page click outside the editor; sync the value
page.locator("body").click()
assert editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")"
wait_until(lambda: editor.value == "print('Hello World!')\nprint(\"Hello Panel!\")")

# clear the editor
editor.value = ""
expect(page.locator(".ace_content")).to_have_text("", use_inner_text=True)
assert editor.value == ""
assert editor.value_input == ""

# enter Hello UI
ace_input.click()
page.keyboard.type('print("Hello UI!")')
expect(page.locator(".ace_content")).to_have_text("print(\"Hello UI!\")", use_inner_text=True)
assert editor.value == ""

# If windows: Ctrl+Enter to trigger value else if mac, Command+Enter
if sys.platform == "win32":
page.keyboard.down("Control")
page.keyboard.press("Enter")
page.keyboard.up("Control")
else:
page.keyboard.down("Meta")
page.keyboard.press("Enter")
page.keyboard.up("Meta")
wait_until(lambda: editor.value == "print(\"Hello UI!\")")

Check failure on line 84 in panel/tests/ui/widgets/test_codeeditor.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_code_editor_not_on_keyup TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 84 in panel/tests/ui/widgets/test_codeeditor.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_code_editor_not_on_keyup TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 84 in panel/tests/ui/widgets/test_codeeditor.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_code_editor_not_on_keyup TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 84 in panel/tests/ui/widgets/test_codeeditor.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_code_editor_not_on_keyup TimeoutError: wait_until timed out in 5000 milliseconds
11 changes: 11 additions & 0 deletions panel/tests/widgets/test_codeeditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ def test_ace(document, comm):
# Try changes
editor._process_events({"value": "Hi there!"})
assert editor.value == "Hi there!"


def test_ace_input(document, comm):
editor = CodeEditor(value="", language="python")
editor.value = "Hello World!"
assert editor.value == "Hello World!"
assert editor.value_input == "Hello World!"

editor.value = ""
assert editor.value == ""
assert editor.value_input == ""
16 changes: 14 additions & 2 deletions panel/widgets/codeeditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class CodeEditor(Widget):

language = param.String(default='text', doc="Language of the editor")

on_keyup = param.Boolean(default=True, doc="""
Whether to update the value on every key press or only upon loss of focus / hotkeys.""")

print_margin = param.Boolean(default=False, doc="""
Whether to show the a print margin.""")

Expand All @@ -49,9 +52,14 @@ class CodeEditor(Widget):
theme = param.ObjectSelector(default="chrome", objects=list(ace_themes),
doc="Theme of the editor")

value = param.String(default="", doc="State of the current code in the editor")
value = param.String(default="", doc="""
State of the current code in the editor if `on_keyup`. Otherwise, only upon loss of focus,
i.e. clicking outside the editor, or pressing <Ctrl+Enter> or <Cmd+Enter>.""")

value_input = param.String(default="", doc="""
State of the current code updated on every key press. Identical to `value` if `on_keyup`.""")

_rename: ClassVar[Mapping[str, str | None]] = {"value": "code", "name": None}
_rename: ClassVar[Mapping[str, str | None]] = {"value": "code", "value_input": "code_input", "name": None}

def __init__(self, **params):
if 'readonly' in params:
Expand All @@ -64,6 +72,10 @@ def __init__(self, **params):
)
self.jslink(self, readonly='disabled', bidirectional=True)

@param.depends("value", watch=True)
def _update_value_input(self):
self.value_input = self.value

def _get_model(
self, doc: Document, root: Optional[Model] = None,
parent: Optional[Model] = None, comm: Optional[Comm] = None
Expand Down
Loading