diff --git a/pyscriptjs/examples/pylist.py b/pyscriptjs/examples/pylist.py new file mode 100644 index 00000000..0dfa781d --- /dev/null +++ b/pyscriptjs/examples/pylist.py @@ -0,0 +1,22 @@ +from datetime import datetime as dt + +class PyItem(PyItemTemplate): + def on_click(self, evt=None): + self.data['done'] = not self.data['done'] + self.strike(self.data['done']) + + self.select('input').element.checked = self.data['done'] + +class PyList(PyListTemplate): + item_class = PyItem + +def add_task(*ags, **kws): + # create a new dictionary representing the new task + task = { "content": new_task_content.value, "done": False, "created_at": dt.now() } + + # add a new task to the list and tell it to use the `content` key to show in the UI + # and to use the key `done` to sync the task status with a checkbox element in the UI + myList.add(task, labels=['content'], state_key="done") + + # clear the inputbox element used to create the new task + new_task_content.clear() diff --git a/pyscriptjs/examples/todo-pylist.html b/pyscriptjs/examples/todo-pylist.html new file mode 100644 index 00000000..49c02b15 --- /dev/null +++ b/pyscriptjs/examples/todo-pylist.html @@ -0,0 +1,36 @@ + + + + + + Todo App + + + + + + + - paths: + - /utils.py + + + + + + To Do List + + + def on_keypress(e): + if (e.code == "Enter"): + add_task() + + + def on_click(evt): + add_task() + + + + + + + diff --git a/pyscriptjs/src/App.svelte b/pyscriptjs/src/App.svelte index 05a5a65f..7122ca7b 100644 --- a/pyscriptjs/src/App.svelte +++ b/pyscriptjs/src/App.svelte @@ -53,7 +53,7 @@ for (let initializer of $postInitializers){ initializer(); } - }, 5000); + }, 3000); } diff --git a/pyscriptjs/src/components/base.ts b/pyscriptjs/src/components/base.ts index 0ec2a411..8c2861db 100644 --- a/pyscriptjs/src/components/base.ts +++ b/pyscriptjs/src/components/base.ts @@ -149,5 +149,174 @@ export class BaseEvalElement extends HTMLElement { this.errorElement.hidden = false; this.errorElement.style.display = 'block'; } - } + } // end evaluate + + async eval(source: string): Promise { + let output; + let pyodide = await pyodideReadyPromise; + + try{ + output = await pyodide.runPythonAsync(source); + if (output !== undefined){ console.log(output); } + } catch (err) { + console.log(err); + } + } // end eval + } + + function createWidget(name: string, code: string, klass: string){ + + class CustomWidget extends HTMLElement{ + shadow: ShadowRoot; + wrapper: HTMLElement; + + name: string = name; + klass: string = klass; + code: string = code; + proxy: any; + proxyClass: any; + + constructor() { + super(); + + // attach shadow so we can preserve the element original innerHtml content + this.shadow = this.attachShadow({ mode: 'open'}); + + this.wrapper = document.createElement('slot'); + this.shadow.appendChild(this.wrapper); + } + + connectedCallback() { + // TODO: we are calling with a 2secs delay to allow pyodide to load + // ideally we can just wait for it to load and then run. To do + // so we need to replace using the promise and actually using + // the interpreter after it loads completely + setTimeout(() => { + this.eval(this.code).then(() => { + this.proxy = this.proxyClass(this); + console.log('proxy', this.proxy); + this.proxy.connect(); + this.registerWidget(); + }); + }, 2000); + } + + async registerWidget(){ + let pyodide = await pyodideReadyPromise; + console.log('new widget registered:', this.name); + pyodide.globals.set(this.id, this.proxy); + } + + async eval(source: string): Promise { + let output; + let pyodide = await pyodideReadyPromise; + try{ + output = await pyodide.runPythonAsync(source); + this.proxyClass = pyodide.globals.get(this.klass); + if (output !== undefined){ + console.log(output); + } + } catch (err) { + console.log(err); + } + } + } + let xPyWidget = customElements.define(name, CustomWidget); + } + + export class PyWidget extends HTMLElement { + shadow: ShadowRoot; + name: string; + klass: string; + outputElement: HTMLElement; + errorElement: HTMLElement; + wrapper: HTMLElement; + theme: string; + source: string; + code: string; + + constructor() { + super(); + + // attach shadow so we can preserve the element original innerHtml content + this.shadow = this.attachShadow({ mode: 'open'}); + + this.wrapper = document.createElement('slot'); + this.shadow.appendChild(this.wrapper); + + if (this.hasAttribute('src')) { + this.source = this.getAttribute('src'); + } + + if (this.hasAttribute('name')) { + this.name = this.getAttribute('name'); + } + + if (this.hasAttribute('klass')) { + this.klass = this.getAttribute('klass'); + } + } + + connectedCallback() { + if (this.id === undefined){ + throw new ReferenceError(`No id specified for component. Components must have an explicit id. Please use id="" to specify your component id.`) + return; + } + + let mainDiv = document.createElement('div'); + mainDiv.id = this.id + '-main'; + this.appendChild(mainDiv); + console.log('reading source') + this.getSourceFromFile(this.source).then((code:string) => { + this.code = code; + createWidget(this.name, code, this.klass); + }); + } + + initOutErr(): void { + if (this.hasAttribute('output')) { + this.errorElement = this.outputElement = document.getElementById(this.getAttribute('output')); + + // in this case, the default output-mode is append, if hasn't been specified + if (!this.hasAttribute('output-mode')) { + this.setAttribute('output-mode', 'append'); + } + }else{ + if (this.hasAttribute('std-out')){ + this.outputElement = document.getElementById(this.getAttribute('std-out')); + }else{ + // In this case neither output or std-out have been provided so we need + // to create a new output div to output to + this.outputElement = document.createElement('div'); + this.outputElement.classList.add("output"); + this.outputElement.hidden = true; + this.outputElement.id = this.id + "-" + this.getAttribute("exec-id"); + } + + if (this.hasAttribute('std-err')){ + this.outputElement = document.getElementById(this.getAttribute('std-err')); + }else{ + this.errorElement = this.outputElement; + } + } + } + + async getSourceFromFile(s: string): Promise{ + let pyodide = await pyodideReadyPromise; + let response = await fetch(s); + return await response.text(); + } + + async eval(source: string): Promise { + let output; + let pyodide = await pyodideReadyPromise; + try{ + output = await pyodide.runPythonAsync(source); + if (output !== undefined){ + console.log(output); + } + } catch (err) { + console.log(err); + } + } } diff --git a/pyscriptjs/src/components/pybox.ts b/pyscriptjs/src/components/pybox.ts index c92845c1..acd27e57 100644 --- a/pyscriptjs/src/components/pybox.ts +++ b/pyscriptjs/src/components/pybox.ts @@ -19,7 +19,7 @@ export class PyBox extends HTMLElement { connectedCallback() { let mainDiv = document.createElement('div'); - addClasses(mainDiv, ["flex"]) + addClasses(mainDiv, ["flex", "mx-8"]) // Hack: for some reason when moving children, the editor box duplicates children // meaning that we end up with 2 editors, if there's a inside the diff --git a/pyscriptjs/src/components/pybutton.ts b/pyscriptjs/src/components/pybutton.ts new file mode 100644 index 00000000..426b537b --- /dev/null +++ b/pyscriptjs/src/components/pybutton.ts @@ -0,0 +1,56 @@ +import { BaseEvalElement } from './base'; +import { addClasses, ltrim, htmlDecode } from '../utils'; + +export class PyButton extends BaseEvalElement { + shadow: ShadowRoot; + wrapper: HTMLElement; + theme: string; + widths: Array; + label: string; + mount_name: string; + constructor() { + super(); + + if (this.hasAttribute('label')) { + this.label = this.getAttribute('label'); + } + } + + connectedCallback() { + this.code = htmlDecode(this.innerHTML); + this.mount_name = this.id.split("-").join("_"); + this.innerHTML = ''; + + let mainDiv = document.createElement('button'); + mainDiv.innerHTML = this.label; + addClasses(mainDiv, ["p-2", "text-white", "bg-blue-600", "border", "border-blue-600", "rounded"]); + + mainDiv.id = this.id; + this.id = `${this.id}-container`; + + this.appendChild(mainDiv); + this.code = this.code.split("self").join(this.mount_name); + let registrationCode = `${this.mount_name} = Element("${ mainDiv.id }")`; + if (this.code.includes("def on_focus")){ + this.code = this.code.replace("def on_focus", `def on_focus_${this.mount_name}`); + registrationCode += `\n${this.mount_name}.element.onfocus = on_focus_${this.mount_name}` + } + + if (this.code.includes("def on_click")){ + this.code = this.code.replace("def on_click", `def on_click_${this.mount_name}`); + registrationCode += `\n${this.mount_name}.element.onclick = on_click_${this.mount_name}` + } + + // now that we appended and the element is attached, lets connect with the event handlers + // defined for this widget + setTimeout(() => { + this.eval(this.code).then(() => { + this.eval(registrationCode).then(() => { + console.log('registered handlers'); + }); + }); + }, 4000); + + console.log('py-button connected'); + } + } diff --git a/pyscriptjs/src/components/pyinputbox.ts b/pyscriptjs/src/components/pyinputbox.ts new file mode 100644 index 00000000..58fec149 --- /dev/null +++ b/pyscriptjs/src/components/pyinputbox.ts @@ -0,0 +1,54 @@ +import { BaseEvalElement } from './base'; +import { addClasses, ltrim, htmlDecode } from '../utils'; + +export class PyInputBox extends BaseEvalElement { + shadow: ShadowRoot; + wrapper: HTMLElement; + theme: string; + widths: Array; + label: string; + mount_name: string; + constructor() { + super(); + + if (this.hasAttribute('label')) { + this.label = this.getAttribute('label'); + } + } + + connectedCallback() { + this.code = htmlDecode(this.innerHTML); + this.mount_name = this.id.split("-").join("_"); + this.innerHTML = ''; + + let mainDiv = document.createElement('input'); + mainDiv.type = "text"; + addClasses(mainDiv, ["border", "flex-1", "w-full", "mr-3", "border-gray-300", "p-2", "rounded"]); + + mainDiv.id = this.id; + this.id = `${this.id}-container`; + this.appendChild(mainDiv); + + // now that we appended and the element is attached, lets connect with the event handlers + // defined for this widget + this.appendChild(mainDiv); + this.code = this.code.split("self").join(this.mount_name); + let registrationCode = `${this.mount_name} = Element("${ mainDiv.id }")`; + if (this.code.includes("def on_keypress")){ + this.code = this.code.replace("def on_keypress", `def on_keypress_${this.mount_name}`); + registrationCode += `\n${this.mount_name}.element.onkeypress = on_keypress_${this.mount_name}` + } + + // TODO: For now we delay execution to allow pyodide to load but in the future this + // should really wait for it to load.. + setTimeout(() => { + this.eval(this.code).then(() => { + this.eval(registrationCode).then(() => { + console.log('registered handlers'); + }); + }); + }, 4000); + } + } + + \ No newline at end of file diff --git a/pyscriptjs/src/components/pyrepl.ts b/pyscriptjs/src/components/pyrepl.ts index 5a9f9c18..2f9cd61e 100644 --- a/pyscriptjs/src/components/pyrepl.ts +++ b/pyscriptjs/src/components/pyrepl.ts @@ -98,7 +98,7 @@ export class PyRepl extends BaseEvalElement { }) let mainDiv = document.createElement('div'); - addClasses(mainDiv, ["parentBox", "group", "flex", "flex-col", "mt-2", "border-2", "border-gray-200", "rounded-lg"]) + addClasses(mainDiv, ["parentBox", "group", "flex", "flex-col", "mt-2", "border-2", "border-gray-200", "rounded-lg", "mx-8"]) // add Editor to main PyScript div // Butons DIV @@ -199,6 +199,10 @@ export class PyRepl extends BaseEvalElement { } postEvaluate(): void { + + this.outputElement.hidden = false; + this.outputElement.style.display = 'block'; + if (this.hasAttribute('auto-generate')) { let nextExecId = parseInt(this.getAttribute('exec-id')) + 1; const newPyRepl = document.createElement("py-repl"); diff --git a/pyscriptjs/src/components/pyscript.ts b/pyscriptjs/src/components/pyscript.ts index 4b0da39d..b1d1f6e6 100644 --- a/pyscriptjs/src/components/pyscript.ts +++ b/pyscriptjs/src/components/pyscript.ts @@ -7,7 +7,7 @@ import { defaultKeymap } from "@codemirror/commands"; import { oneDarkTheme } from "@codemirror/theme-one-dark"; import { pyodideLoaded, loadedEnvironments, componentDetailsNavOpen, currentComponentDetails, mode, addToScriptsQueue, addInitializer, addPostInitializer } from '../stores'; -import { addClasses } from '../utils'; +import { addClasses, htmlDecode } from '../utils'; import { BaseEvalElement } from './base'; // Premise used to connect to the first available pyodide interpreter @@ -41,11 +41,6 @@ function createCmdHandler(el){ return toggleCheckbox } -function htmlDecode(input) { - var doc = new DOMParser().parseFromString(input, "text/html"); - return doc.documentElement.textContent; -} - // TODO: use type declaractions type PyodideInterface = { registerJsModule(name: string, module: object): void @@ -296,7 +291,7 @@ async function mountElements() { for (var el of matches) { let mountName = el.getAttribute('py-mount'); if (!mountName){ - mountName = el.id.replace("-", "_"); + mountName = el.id.split("-").join("_"); } source += `\n${ mountName } = Element("${ el.id }")`; } diff --git a/pyscriptjs/src/components/pytitle.ts b/pyscriptjs/src/components/pytitle.ts new file mode 100644 index 00000000..de4890c4 --- /dev/null +++ b/pyscriptjs/src/components/pytitle.ts @@ -0,0 +1,34 @@ +import { BaseEvalElement } from './base'; +import { addClasses, ltrim, htmlDecode } from '../utils'; + +export class PyTitle extends BaseEvalElement { + shadow: ShadowRoot; + wrapper: HTMLElement; + theme: string; + widths: Array; + label: string; + mount_name: string; + constructor() { + super(); + } + + connectedCallback() { + this.label = htmlDecode(this.innerHTML); + this.mount_name = this.id.split("-").join("_"); + this.innerHTML = ''; + + let mainDiv = document.createElement('div'); + let divContent = document.createElement('h1') + + addClasses(mainDiv, ["text-center", "w-full", "mb-8"]); + addClasses(divContent, ["text-3xl", "font-bold", "text-gray-800", "uppercase", "tracking-tight"]); + divContent.innerHTML = this.label; + + mainDiv.id = this.id; + this.id = `${this.id}-container`; + mainDiv.appendChild(divContent); + this.appendChild(mainDiv); + } +} + + \ No newline at end of file diff --git a/pyscriptjs/src/interpreter.ts b/pyscriptjs/src/interpreter.ts index c2dbfdd7..48ddd620 100644 --- a/pyscriptjs/src/interpreter.ts +++ b/pyscriptjs/src/interpreter.ts @@ -6,8 +6,9 @@ let pyodideReadyPromise; let pyodide; let additional_definitions = ` -from js import document, setInterval, console +from js import document, setInterval, console, setTimeout import micropip +import time import asyncio import io, base64, sys @@ -51,6 +52,10 @@ class Element: self._id = element_id self._element = element + @property + def id(self): + return self._id + @property def element(self): """Return the dom element""" @@ -58,6 +63,14 @@ class Element: self._element = document.querySelector(f'#{self._id}'); return self._element + @property + def value(self): + return self.element.value + + @property + def innerHtml(self): + return self.element.innerHtml + def write(self, value, append=False): console.log(f"Element.write: {value} --> {append}") # TODO: it should be the opposite... pyscript.write should use the Element.write @@ -96,6 +109,176 @@ class Element: return Element(clone.id, clone) + + def remove_class(self, classname): + if isinstance(classname, list): + for cl in classname: + self.remove_class(cl) + else: + self.element.classList.remove(classname) + + def add_class(self, classname): + self.element.classList.add(classname) + +def add_classes(element, class_list): + for klass in class_list.split(' '): + element.classList.add(klass) + +def create(what, id_=None, classes=''): + element = document.createElement(what) + if id_: + element.id = id_ + add_classes(element, classes) + return Element(id_, element) + + +class PyWidgetTheme: + def __init__(self, main_style_classes): + self.main_style_classes = main_style_classes + + def theme_it(self, widget): + for klass in self.main_style_classes.split(' '): + widget.classList.add(klass) + + +class PyItemTemplate(Element): + label_fields = None + + def __init__(self, data, labels=None, state_key=None, parent=None): + self.data = data + + self.register_parent(parent) + + if not labels: + labels = list(self.data.keys()) + self.labels = labels + + self.state_key = state_key + + super().__init__(self._id) + + def register_parent(self, parent): + self._parent = parent + if parent: + self._id = f"{self._parent._id}-c-{len(self._parent._children)}" + self.data['id'] = self._id + else: + self._id = None + + def create(self): + console.log('creating section') + new_child = create('section', self._id, "task bg-white my-1") + console.log('creating values') + + console.log('creating innerHtml') + new_child._element.innerHTML = f""" + + """ + + console.log('returning') + return new_child + + def on_click(self, evt): + pass + + def pre_append(self): + pass + + def post_append(self): + self.element.click = self.on_click + self.element.onclick = self.on_click + + self._post_append() + + def _post_append(self): + pass + + def strike(self, value, extra=None): + if value: + self.add_class("line-through") + else: + self.remove_class("line-through") + + def render_content(self): + return ' - '.join([self.data[f] for f in self.labels]) + +class PyListTemplate: + theme = PyWidgetTheme("flex flex-col-reverse mt-8 mx-8") + item_class = PyItemTemplate + + def __init__(self, parent): + self.parent = parent + self._children = [] + self._id = self.parent.id + + @property + def children(self): + return self._children + + @property + def data(self): + return [c.data for c in self._children] + + def render_children(self): + out = [] + binds = {} + for i, c in enumerate(self._children): + txt = c.element.innerHTML + rnd = str(time.time()).replace(".", "")[-5:] + new_id = f"{c.element.id}-{i}-{rnd}" + binds[new_id] = c.element.id + txt = txt.replace(">", f" id='{new_id}'>") + print(txt) + + def foo(evt): + console.log(evt) + evtEl = evt.srcElement + srcEl = Element(binds[evtEl.id]) + srcEl.element.onclick() + evtEl.classList = srcEl.element.classList + + for new_id, old_id in binds.items(): + Element(new_id).element.onclick = foo + + def connect(self): + self.md = main_div = document.createElement('div') + main_div.id = self._id + "-list-tasks-container" + + if self.theme: + self.theme.theme_it(main_div) + + self.parent.appendChild(main_div) + + def add(self, *args, **kws): + if not isinstance(args[0], self.item_class): + child = self.item_class(*args, **kws) + else: + child = args[0] + child.register_parent(self) + return self._add(child) + + def _add(self, child_elem): + console.log("appending child", child_elem.element) + self.pre_child_append(child_elem) + child_elem.pre_append() + self._children.append(child_elem) + self.md.appendChild(child_elem.create().element) + child_elem.post_append() + self.child_appended(child_elem) + return child_elem + + def pre_child_append(self, child): + pass + + def child_appended(self, child): + """Overwrite me to define logic""" + pass + + + class OutputCtxManager: def __init__(self, out=None, output_to_console=True, append=True): self._out = out diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index bd4f69d6..321a79ee 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -4,12 +4,19 @@ import { PyScript } from "./components/pyscript"; import { PyRepl } from "./components/pyrepl"; import { PyEnv } from "./components/pyenv"; import { PyBox } from "./components/pybox"; - +import { PyButton } from "./components/pybutton"; +import { PyTitle } from "./components/pytitle"; +import { PyInputBox } from "./components/pyinputbox"; +import { PyWidget } from "./components/base"; let xPyScript = customElements.define('py-script', PyScript); let xPyRepl = customElements.define('py-repl', PyRepl); let xPyEnv = customElements.define('py-env', PyEnv); let xPyBox = customElements.define('py-box', PyBox); +let xPyButton = customElements.define('py-button', PyButton); +let xPyTitle = customElements.define('py-title', PyTitle); +let xPyInputBox = customElements.define('py-inputbox', PyInputBox); +let xPyWidget = customElements.define('py-register-widget', PyWidget); const app = new App({ diff --git a/pyscriptjs/src/utils.ts b/pyscriptjs/src/utils.ts index d9912bff..ac9f83d5 100644 --- a/pyscriptjs/src/utils.ts +++ b/pyscriptjs/src/utils.ts @@ -9,4 +9,29 @@ const getLastPath = function (str) { return str.split('\\').pop().split('/').pop(); } -export {addClasses, getLastPath} +function htmlDecode(input) { + var doc = new DOMParser().parseFromString(input, "text/html"); + return ltrim(doc.documentElement.textContent); +} + +function ltrim(code: string): string { + const lines = code.split("\n") + if (lines.length == 0) + return code + + const lengths = lines + .filter((line) => line.trim().length != 0) + .map((line) => { + const [prefix] = line.match(/^\s*/) + return prefix.length + }) + + const k = Math.min(...lengths) + + if (k != 0) + return lines.map((line) => line.substring(k)).join("\n") + else + return code +} + +export {addClasses, getLastPath, ltrim, htmlDecode}