From 2dae2713e6dc1b8dcee4ba27fe7bbe78e742546d Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 10 Mar 2024 23:47:34 -0500 Subject: [PATCH 01/15] Pyodide working! --- runtimepy/data/js/main.js | 37 ++++++++++++++++------------- runtimepy/data/js/shared.js | 3 +++ runtimepy/data/js/worker.js | 22 ++++++++++++++--- runtimepy/net/server/app/base.py | 11 +++++++-- runtimepy/net/server/app/pyodide.py | 23 ++++++++++++++++++ 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 runtimepy/data/js/shared.js create mode 100644 runtimepy/net/server/app/pyodide.py diff --git a/runtimepy/data/js/main.js b/runtimepy/data/js/main.js index 3307bb0e..72b209a8 100644 --- a/runtimepy/data/js/main.js +++ b/runtimepy/data/js/main.js @@ -2,18 +2,6 @@ function worker_message(event) { console.log(`Main thread received: ${event.data}.`); } -/* - * Do some heinous sh*t to create a worker from our 'text/js-worker' element. - */ -const worker = new Worker(window.URL.createObjectURL(new Blob( - Array.prototype.map.call( - document.querySelectorAll("script[type='text\/js-worker']"), - (script) => script.textContent, - ), - {type : "text/javascript"}, - ))); -worker.onmessage = worker_message; - function worker_config(config) { let worker_cfg = {}; @@ -35,11 +23,28 @@ function worker_config(config) { return worker_cfg; } -function main(config) { +/* + * Do some heinous sh*t to create a worker from our 'text/js-worker' element. + */ +const worker = new Worker(window.URL.createObjectURL(new Blob( + Array.prototype.map.call( + document.querySelectorAll("script[type='text\/js-worker']"), + (script) => script.textContent, + ), + {type : "text/javascript"}, + ))); +worker.onmessage = worker_message; + +async function main(config) { + /* Send configuration data to the worker. */ config["worker"] = worker_config(config); worker.postMessage(config); + /* Run pyodide. */ + let pyodide = await loadPyodide(); + pyodide.runPython(`print("MAIN THREAD.")`); + /* Canvas. */ // let ctx = document.getElementById("canvas").getContext("2d"); // ctx.lineWidth = 10; @@ -47,8 +52,6 @@ function main(config) { } /* Load configuration data then run application entry. */ -window.onload = () => { - fetch(window.location.origin + "/json") - .then((value) => { return value.json(); }) - .then((value) => { main(value); }); +window.onload = async () => { + await main(await (await fetch(window.location.origin + "/json")).json()); }; diff --git a/runtimepy/data/js/shared.js b/runtimepy/data/js/shared.js new file mode 100644 index 00000000..2dedd1b5 --- /dev/null +++ b/runtimepy/data/js/shared.js @@ -0,0 +1,3 @@ +function import_pyodide() { + importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"); +} diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 7ae92bd5..6d79dcb3 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -1,3 +1,5 @@ +import_pyodide(); + function create_connections(config) { /* This business logic could use some work. */ const conn_factories = {"json" : JsonConnection, "data" : DataConnection}; @@ -12,18 +14,32 @@ function create_connections(config) { } /* Worker entry. */ -function start(config) { +async function start(config) { let conns = create_connections(config); // console.log(conns); + + /* Run pyodide. */ + let pyodide = await loadPyodide(); + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + + /* Install packages. */ + await micropip.install("runtimepy"); + + pyodide.runPython(` + from runtimepy.primitives import create + + print(create("uint8")) + `); } started = false; /* Handle messages from the main thread. */ -onmessage = (event) => { +onmessage = async (event) => { /* First message.*/ if (!started) { - start(event.data); + await start(event.data); started = true; } else { /* Additional messages not handled. */ diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index 79360f10..5adb836a 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -17,6 +17,7 @@ ) from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.files import append_kind +from runtimepy.net.server.app.pyodide import add_pyodide_js TabPopulater = Callable[[TabbedContent], None] @@ -24,8 +25,13 @@ class WebApplication: """A simple web-application interface.""" - worker_source_paths = ["JsonConnection", "DataConnection", "worker"] - main_source_paths = ["main"] + worker_source_paths = [ + "shared", + "JsonConnection", + "DataConnection", + "worker", + ] + main_source_paths = ["shared", "main"] css_paths = ["main"] def __init__(self, app: AppInfo) -> None: @@ -46,6 +52,7 @@ def populate(self, document: Html, app: TabPopulater) -> None: # Third-party dependencies. add_bootstrap_js(document.body) + add_pyodide_js(document.body) # Worker code. append_kind(document.body, *self.worker_source_paths)[ diff --git a/runtimepy/net/server/app/pyodide.py b/runtimepy/net/server/app/pyodide.py new file mode 100644 index 00000000..f3932c2a --- /dev/null +++ b/runtimepy/net/server/app/pyodide.py @@ -0,0 +1,23 @@ +""" +A module implementing interfaces to the pyodide project. +""" + +# third-party +from svgen.element import Element + +PYODIDE_VERSION = "0.25.0" + + +def add_pyodide_js(element: Element) -> Element: + """Add bootstrap JavaScript as a child of element.""" + + elem = Element( + tag="script", + src=( + "https://cdn.jsdelivr.net/pyodide/" + f"v{PYODIDE_VERSION}/full/pyodide.js" + ), + text="/* null */", + ) + element.children.append(elem) + return elem From ebee3ea520ca8e1dbfb857eae2f6e2e17d40a4cd Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Mon, 11 Mar 2024 00:00:29 -0500 Subject: [PATCH 02/15] Somewhat interesting pyodide tech demo --- runtimepy/data/js/worker.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 6d79dcb3..8e224a74 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -13,6 +13,16 @@ function create_connections(config) { return conns; } +const script = ` +import js +from runtimepy.primitives import create + +print(create("uint8")) + +# can send messages to main thread +js.postMessage("What's good bud!") +`; + /* Worker entry. */ async function start(config) { let conns = create_connections(config); @@ -26,11 +36,7 @@ async function start(config) { /* Install packages. */ await micropip.install("runtimepy"); - pyodide.runPython(` - from runtimepy.primitives import create - - print(create("uint8")) - `); + await pyodide.runPythonAsync(script); } started = false; From ecae9627f62bf2fdfe4c3adb176500b7aa7bf36a Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Mon, 11 Mar 2024 00:10:30 -0500 Subject: [PATCH 03/15] Trying more things via Python --- runtimepy/data/js/worker.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 8e224a74..32591e51 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -15,19 +15,20 @@ function create_connections(config) { const script = ` import js +import js_local from runtimepy.primitives import create print(create("uint8")) # can send messages to main thread js.postMessage("What's good bud!") + +# can access connection and config state +print(js_local.config.config.app) `; /* Worker entry. */ async function start(config) { - let conns = create_connections(config); - // console.log(conns); - /* Run pyodide. */ let pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); @@ -36,6 +37,10 @@ async function start(config) { /* Install packages. */ await micropip.install("runtimepy"); + /* Register namespace for local state. */ + pyodide.registerJsModule( + "js_local", {config : config, conns : create_connections(config)}); + await pyodide.runPythonAsync(script); } From b4e3b8024f809f2d61415bd4839d15430c9d9dd8 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Wed, 13 Mar 2024 22:14:29 -0500 Subject: [PATCH 04/15] Initial WebAssembly tinkering online --- runtimepy/data/css/bootstrap_extra.css | 12 +++ runtimepy/data/css/main.css | 13 --- runtimepy/data/server.yaml | 16 +--- runtimepy/data/server_base.yaml | 17 ++++ runtimepy/net/http/header.py | 2 +- runtimepy/net/http/response.py | 2 +- runtimepy/net/server/__init__.py | 73 ++++++++++++++- runtimepy/net/server/app/base.py | 2 +- runtimepy/net/server/app/create.py | 8 +- tasks/app.py | 125 +++++++++++++++++++++++++ tasks/default.yaml | 14 +++ wasm/src/.gitignore | 3 + wasm/src/hello_world.c | 6 ++ 13 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 runtimepy/data/css/bootstrap_extra.css create mode 100644 runtimepy/data/server_base.yaml create mode 100644 tasks/app.py create mode 100644 tasks/default.yaml create mode 100644 wasm/src/.gitignore create mode 100644 wasm/src/hello_world.c diff --git a/runtimepy/data/css/bootstrap_extra.css b/runtimepy/data/css/bootstrap_extra.css new file mode 100644 index 00000000..bb4c997b --- /dev/null +++ b/runtimepy/data/css/bootstrap_extra.css @@ -0,0 +1,12 @@ +.flex-column-scroll-bodge { + height: 100%; + flex-wrap: nowrap; + overflow: scroll; + flex-shrink: 0; +} + +.tab-content-scroll-bodge { + width: 100%; + height: 100%; + overflow: scroll; +} diff --git a/runtimepy/data/css/main.css b/runtimepy/data/css/main.css index 013a9891..0f16e342 100644 --- a/runtimepy/data/css/main.css +++ b/runtimepy/data/css/main.css @@ -12,16 +12,3 @@ body { body > :first-child { height: 100%; } - -.flex-column-scroll-bodge { - height: 100%; - flex-wrap: nowrap; - overflow: scroll; - flex-shrink: 0; -} - -.tab-content-scroll-bodge { - width: 100%; - height: 100%; - overflow: scroll; -} diff --git a/runtimepy/data/server.yaml b/runtimepy/data/server.yaml index f90106b4..8704aa3b 100644 --- a/runtimepy/data/server.yaml +++ b/runtimepy/data/server.yaml @@ -2,21 +2,7 @@ includes: # Un-comment while developing. # - server_dev.yaml - - package://runtimepy/factories.yaml + - package://runtimepy/server_base.yaml app: - runtimepy.net.server.app.setup - -ports: - - {name: runtimepy_http_server, type: tcp} - - {name: runtimepy_websocket_json_server, type: tcp} - - {name: runtimepy_websocket_data_server, type: tcp} - -servers: - - factory: runtimepy_http - kwargs: {port: "$runtimepy_http_server"} - - - factory: runtimepy_websocket_json - kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_json_server"} - - factory: runtimepy_websocket_data - kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_data_server"} diff --git a/runtimepy/data/server_base.yaml b/runtimepy/data/server_base.yaml new file mode 100644 index 00000000..aca9f7f3 --- /dev/null +++ b/runtimepy/data/server_base.yaml @@ -0,0 +1,17 @@ +--- +includes: + - package://runtimepy/factories.yaml + +ports: + - {name: runtimepy_http_server, type: tcp} + - {name: runtimepy_websocket_json_server, type: tcp} + - {name: runtimepy_websocket_data_server, type: tcp} + +servers: + - factory: runtimepy_http + kwargs: {port: "$runtimepy_http_server"} + + - factory: runtimepy_websocket_json + kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_json_server"} + - factory: runtimepy_websocket_data + kwargs: {host: "0.0.0.0", port: "$runtimepy_websocket_data_server"} diff --git a/runtimepy/net/http/header.py b/runtimepy/net/http/header.py index 98ab9fe6..64355acf 100644 --- a/runtimepy/net/http/header.py +++ b/runtimepy/net/http/header.py @@ -56,7 +56,7 @@ def from_lines(self, lines: list[str]) -> None: def log(self, logger: LoggerType, out: bool) -> None: """Log information about this request header.""" - logger.info( + logger.debug( "(%s request) %s - %s", "outgoing" if out else "incoming", self.request_line, diff --git a/runtimepy/net/http/response.py b/runtimepy/net/http/response.py index 0a9cab51..355e61c7 100644 --- a/runtimepy/net/http/response.py +++ b/runtimepy/net/http/response.py @@ -76,7 +76,7 @@ def log(self, logger: LoggerType, out: bool) -> None: """Log information about this response header.""" level = logging.INFO if (200 <= self.status <= 299) else logging.ERROR - logger.log( + logger.debug( level, "(%s response) %s - %s", "outgoing" if out else "incoming", diff --git a/runtimepy/net/server/__init__.py b/runtimepy/net/server/__init__.py index 72f5a1df..bbab229d 100644 --- a/runtimepy/net/server/__init__.py +++ b/runtimepy/net/server/__init__.py @@ -4,20 +4,25 @@ # built-in from io import StringIO +import mimetypes +from pathlib import Path from typing import Optional # third-party from vcorelib.io import JsonObject -from vcorelib.paths import find_file +from vcorelib.paths import Pathlike, find_file, normalize # internal from runtimepy import PKG_NAME from runtimepy.net.http.header import RequestHeader +from runtimepy.net.http.request_target import PathMaybeQuery from runtimepy.net.http.response import ResponseHeader from runtimepy.net.server.html import HtmlApp, HtmlApps, html_handler from runtimepy.net.server.json import json_handler from runtimepy.net.tcp.http import HttpConnection +MIMETYPES_INIT = False + class RuntimepyServerConnection(HttpConnection): """A class implementing a server-connection interface for this package.""" @@ -31,11 +36,42 @@ class RuntimepyServerConnection(HttpConnection): favicon_data: bytes + paths: list[Path] + class_paths: list[Pathlike] = [Path()] + + def add_path(self, path: Pathlike, front: bool = False) -> None: + """Add a path.""" + + resolved = normalize(path).resolve() + if not front: + self.paths.append(resolved) + else: + self.paths.insert(0, resolved) + + self.log_paths() + + def log_paths(self) -> None: + """Log search paths.""" + + self.logger.info( + "New path: %s.", ", ".join(str(x) for x in self.paths) + ) + def init(self) -> None: """Initialize this instance.""" + global MIMETYPES_INIT # pylint: disable=global-statement + if not MIMETYPES_INIT: + mimetypes.init() + MIMETYPES_INIT = True + super().init() + # Initialize paths. + self.paths = [] + for path in type(self).class_paths: + self.add_path(path) + # Load favicon if necessary. if not hasattr(type(self), "favicon_data"): with self.log_time("Loading favicon"): @@ -44,6 +80,36 @@ def init(self) -> None: with favicon.open("rb") as favicon_fd: type(self).favicon_data = favicon_fd.read() + def try_file( + self, path: PathMaybeQuery, response: ResponseHeader + ) -> Optional[bytes]: + """Try serving this path as a file directly from the file-system.""" + + result = None + + # Try serving the path as a file. + for search in self.paths: + candidate = search.joinpath(path[0][1:]) + if candidate.is_file(): + mime, encoding = mimetypes.guess_type(candidate, strict=False) + + # Set MIME type if it can be determined. + if mime: + response["Content-Type"] = mime + + # We don't handle this yet. + assert not encoding, (candidate, mime, encoding) + + self.logger.info("Serving '%s' (MIME: %s)", candidate, mime) + + # Return the file data. + with candidate.open("rb") as path_fd: + result = path_fd.read() + + break + + return result + async def get_handler( self, response: ResponseHeader, @@ -56,6 +122,11 @@ async def get_handler( with StringIO() as stream: if request.target.origin_form: + # Try serving the path as a file. + result = self.try_file(request.target.origin_form, response) + if result is not None: + return result + path = request.target.path # Handle favicon (for browser clients). diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index 5adb836a..c04176f6 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -32,7 +32,7 @@ class WebApplication: "worker", ] main_source_paths = ["shared", "main"] - css_paths = ["main"] + css_paths = ["main", "bootstrap_extra"] def __init__(self, app: AppInfo) -> None: """Initialize this instance.""" diff --git a/runtimepy/net/server/app/create.py b/runtimepy/net/server/app/create.py index cd3874dd..a0eeb6c8 100644 --- a/runtimepy/net/server/app/create.py +++ b/runtimepy/net/server/app/create.py @@ -20,13 +20,19 @@ T = TypeVar("T") -def config_param(app: AppInfo, key: str, default: T) -> T: +def config_param( + app: AppInfo, key: str, default: T, strict: bool = False +) -> T: """Attempt to get a configuration parameter.""" config: dict[str, T] = app.config["root"].setdefault( # type: ignore "config", {}, ) + + if strict: + assert key in config, (key, config) + return config.get(key, default) diff --git a/tasks/app.py b/tasks/app.py new file mode 100644 index 00000000..3776cc82 --- /dev/null +++ b/tasks/app.py @@ -0,0 +1,125 @@ +""" +A module implementing a simple web application. +""" + +# built-in +from pathlib import Path +from typing import Iterable, Optional + +# third-party +from svgen.element import Element +from svgen.element.html import Html +from vcorelib.asyncio.cli import run_command +from vcorelib.logging import LoggerType +from vcorelib.paths import Pathlike, modified_after, normalize + +# internal +from runtimepy.net.arbiter.info import AppInfo +from runtimepy.net.http.header import RequestHeader +from runtimepy.net.http.response import ResponseHeader +from runtimepy.net.server import RuntimepyServerConnection +from runtimepy.net.server.app.create import config_param +from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.files import append_kind +from runtimepy.net.server.html import HtmlApp + + +def search_paths( + path: Path, paths: list[Path], suffixes: Iterable[str] +) -> Optional[Path]: + """Search for a file given a list of paths.""" + + result = None + candidate = None + for search in paths: + if result is not None: + break + + for suffix in suffixes: + candidate = search.joinpath(path).with_suffix(suffix) + if candidate.is_file(): + result = candidate + break + + return result + + +DEFAULT_APP = "hello_world" + + +async def build_app( + app: Pathlike, paths: list[Path], logger: LoggerType, is_async: bool = True +) -> Element: + """Build the application.""" + + # Find the source file. + app = normalize(app) + source = search_paths(app, paths, {".c", ".cc"}) + assert source is not None, (app, paths) + + # Find a possible existing script file. + script = app.with_suffix(".js") + + script_dest = source.parent.joinpath(script.name) + + # Run emcc. + sources = [source] + if not script_dest.is_file() or modified_after(script_dest, sources): + await run_command(logger, "emcc", str(source), "-o", str(script_dest)) + + # Manifest the script file in the application. + elem = Element(tag="script", src=str(script), text="/* null */") + if is_async: + elem.booleans.add("async") + return elem + + +def create(app: AppInfo, paths: list[Path]) -> HtmlApp: + """Create a web-application handler.""" + + async def main( + document: Html, + request: RequestHeader, + response: ResponseHeader, + request_data: Optional[bytes], + ) -> Html: + """A simple 'Hello, world!' application.""" + + # Not currently used. + del request + del response + del request_data + + # Create the application. + append_kind(document.head, "main", kind="css", tag="style") + + # Remove at some point. + div(text="Hello, world!", parent=document.body) + + # Add WebAssembly application. + document.body.children.append( + await build_app( + config_param(app, "wasm_app", "", strict=True), + paths, + app.logger, + ) + ) + + return document + + return main + + +async def setup(app: AppInfo) -> int: + """Perform server application setup steps.""" + + # Add WASM root directory. + paths = RuntimepyServerConnection.class_paths + paths.insert(0, config_param(app, "wasm_root", Path(), strict=True)) + + # Set default application. + RuntimepyServerConnection.default_app = create( + app, list(normalize(x).resolve() for x in paths) + ) + + return 0 diff --git a/tasks/default.yaml b/tasks/default.yaml new file mode 100644 index 00000000..21fc9738 --- /dev/null +++ b/tasks/default.yaml @@ -0,0 +1,14 @@ +--- +includes: + - package://runtimepy/server_base.yaml + +port_overrides: + runtimepy_http_server: 8000 + +config: + wasm_app: hello_world + wasm_root: wasm/src + +app: + - tasks.app.setup + - runtimepy.net.apps.wait_for_stop diff --git a/wasm/src/.gitignore b/wasm/src/.gitignore new file mode 100644 index 00000000..51b54348 --- /dev/null +++ b/wasm/src/.gitignore @@ -0,0 +1,3 @@ +*.js +*.wasm +*.html diff --git a/wasm/src/hello_world.c b/wasm/src/hello_world.c new file mode 100644 index 00000000..ce0cca46 --- /dev/null +++ b/wasm/src/hello_world.c @@ -0,0 +1,6 @@ +#include + +int main() { + printf("hello, world!\n"); + return 0; +} From e17d7aa56ecd2fe7cf984d896276803aaab533c5 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Wed, 13 Mar 2024 23:58:58 -0500 Subject: [PATCH 05/15] More WASM dev --- .gitignore | 1 + .vimrc | 3 + runtimepy/net/arbiter/info.py | 20 +++++ runtimepy/net/server/app/create.py | 11 +-- tasks/app.py | 53 +++++-------- tasks/compile_commands.py | 121 +++++++++++++++++++++++++++++ tasks/default.yaml | 2 +- wasm/src/dev/console.c | 12 +++ 8 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 tasks/compile_commands.py create mode 100644 wasm/src/dev/console.c diff --git a/.gitignore b/.gitignore index e1ff9206..d8972513 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ coverage*.xml tags mklocal docs +compile_commands.json diff --git a/.vimrc b/.vimrc index 760fc0c1..e28da4bb 100644 --- a/.vimrc +++ b/.vimrc @@ -1,2 +1,5 @@ let g:ale_linters["javascript"] = ["eslint"] let g:ale_fixers["javascript"] = ["clang-format"] + +let g:ale_c_cc_executable = 'emcc' +let g:ale_c_cc_options = '' diff --git a/runtimepy/net/arbiter/info.py b/runtimepy/net/arbiter/info.py index bfdc709d..3b81f7a5 100644 --- a/runtimepy/net/arbiter/info.py +++ b/runtimepy/net/arbiter/info.py @@ -7,6 +7,7 @@ from contextlib import AsyncExitStack as _AsyncExitStack from dataclasses import dataclass from logging import getLogger as _getLogger +from pathlib import Path from re import compile as _compile from typing import Any, Dict from typing import Iterator as _Iterator @@ -30,6 +31,7 @@ ConnectionMap = _MutableMapping[str, _Connection] T = _TypeVar("T", bound=_Connection) V = _TypeVar("V", bound=PeriodicTask) +Z = _TypeVar("Z") @dataclass @@ -181,3 +183,21 @@ def original_config(self) -> dict[str, Any]: result[key] = val return result + + def config_param(self, key: str, default: Z, strict: bool = False) -> Z: + """Attempt to get a configuration parameter.""" + + config: dict[str, Z] = self.config["root"].setdefault( # type: ignore + "config", + {}, + ) + + if strict: + assert key in config, (key, config) + + return config.get(key, default) + + @property + def wasm_root(self) -> Path: + """Get the WebAssembly root directory.""" + return Path(self.config_param("wasm_root", str(Path()), strict=True)) diff --git a/runtimepy/net/server/app/create.py b/runtimepy/net/server/app/create.py index a0eeb6c8..167f495d 100644 --- a/runtimepy/net/server/app/create.py +++ b/runtimepy/net/server/app/create.py @@ -24,16 +24,7 @@ def config_param( app: AppInfo, key: str, default: T, strict: bool = False ) -> T: """Attempt to get a configuration parameter.""" - - config: dict[str, T] = app.config["root"].setdefault( # type: ignore - "config", - {}, - ) - - if strict: - assert key in config, (key, config) - - return config.get(key, default) + return app.config_param(key, default, strict=strict) def create_app( diff --git a/tasks/app.py b/tasks/app.py index 3776cc82..19ca1c6e 100644 --- a/tasks/app.py +++ b/tasks/app.py @@ -9,19 +9,17 @@ # third-party from svgen.element import Element from svgen.element.html import Html -from vcorelib.asyncio.cli import run_command -from vcorelib.logging import LoggerType -from vcorelib.paths import Pathlike, modified_after, normalize +from vcorelib.paths import normalize # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.http.header import RequestHeader from runtimepy.net.http.response import ResponseHeader from runtimepy.net.server import RuntimepyServerConnection -from runtimepy.net.server.app.create import config_param from runtimepy.net.server.app.elements import div from runtimepy.net.server.app.files import append_kind from runtimepy.net.server.html import HtmlApp +from tasks.compile_commands import EmscriptenBuilder def search_paths( @@ -44,28 +42,22 @@ def search_paths( return result -DEFAULT_APP = "hello_world" - - async def build_app( - app: Pathlike, paths: list[Path], logger: LoggerType, is_async: bool = True + app: AppInfo, paths: list[Path], is_async: bool = True ) -> Element: """Build the application.""" # Find the source file. - app = normalize(app) - source = search_paths(app, paths, {".c", ".cc"}) - assert source is not None, (app, paths) + app_path = normalize(app.config_param("wasm_app", "", strict=True)) + source = search_paths(app_path, paths, {".c", ".cc"}) + assert source is not None, (app_path, paths) # Find a possible existing script file. - script = app.with_suffix(".js") - - script_dest = source.parent.joinpath(script.name) + script = app_path.with_suffix(".js") - # Run emcc. - sources = [source] - if not script_dest.is_file() or modified_after(script_dest, sources): - await run_command(logger, "emcc", str(source), "-o", str(script_dest)) + # Build if necessary. Should we get the root directory a different way? + builder = EmscriptenBuilder(app.logger, Path()) + await builder.handle(source, source.parent.joinpath(script.name)) # Manifest the script file in the application. elem = Element(tag="script", src=str(script), text="/* null */") @@ -74,9 +66,15 @@ async def build_app( return elem -def create(app: AppInfo, paths: list[Path]) -> HtmlApp: +def create(app: AppInfo) -> HtmlApp: """Create a web-application handler.""" + # Add WASM root directory. + paths = RuntimepyServerConnection.class_paths + paths.insert(0, app.wasm_root) + + paths = list(normalize(x).resolve() for x in paths) + async def main( document: Html, request: RequestHeader, @@ -97,13 +95,7 @@ async def main( div(text="Hello, world!", parent=document.body) # Add WebAssembly application. - document.body.children.append( - await build_app( - config_param(app, "wasm_app", "", strict=True), - paths, - app.logger, - ) - ) + document.body.children.append(await build_app(app, paths)) return document @@ -113,13 +105,6 @@ async def main( async def setup(app: AppInfo) -> int: """Perform server application setup steps.""" - # Add WASM root directory. - paths = RuntimepyServerConnection.class_paths - paths.insert(0, config_param(app, "wasm_root", Path(), strict=True)) - # Set default application. - RuntimepyServerConnection.default_app = create( - app, list(normalize(x).resolve() for x in paths) - ) - + RuntimepyServerConnection.default_app = create(app) return 0 diff --git a/tasks/compile_commands.py b/tasks/compile_commands.py new file mode 100644 index 00000000..57f4665b --- /dev/null +++ b/tasks/compile_commands.py @@ -0,0 +1,121 @@ +""" +A module implementing some compile_commands.json management interfaces. +""" + +# built-in +import asyncio +import json +from pathlib import Path +from typing import NamedTuple + +# third-party +from vcorelib import DEFAULT_ENCODING +from vcorelib.asyncio.cli import ProcessResult, run_command +from vcorelib.logging import LoggerMixin, LoggerType +from vcorelib.paths import modified_after, rel + + +class CompileCommand(NamedTuple): + """A class implementing a compile-command instance.""" + + directory: Path + args: list[str] + file: str + output: str + + @property + def command(self) -> str: + """Get the command string for this instance""" + return " ".join(self.args) + + async def run(self, logger: LoggerType, **kwargs) -> ProcessResult: + """Run command.""" + + # Make sure we switch to the correct directory first? + await run_command(logger, *self.args, **kwargs) + + def as_dict(self) -> dict[str, str]: + """Get this instance as a dictionary.""" + + return { + "directory": str(self.directory), + "command": self.command, + "file": self.file, + "output": self.output, + } + + +class CompileCommands: + """A class implementing a 'compile_commands.json' interface.""" + + def __init__(self, directory: Path | str) -> None: + """Initialize this instance.""" + + self.directory = str(directory) + + self.commands: list[CompileCommand] = [] + self.by_file: dict[str, CompileCommand] = {} + + # load file if it exists, update current commands + self.output = Path(self.directory).joinpath("compile_commands.json") + + # Load existing data. + if self.output.is_file(): + pass + + def write(self) -> None: + """Write the output file.""" + + with self.output.open("w", encoding=DEFAULT_ENCODING) as out_fd: + json.dump( + list(x.as_dict() for x in self.commands), out_fd, indent=2 + ) + + def add( + self, source: Path | str, dest: Path | str, cc: str = "emcc" + ) -> CompileCommand: + """Add a compile command.""" + + directory = Path(self.directory).resolve() + + dest_str = str(rel(dest, directory)) + src_str = str(rel(source, directory)) + + # Need to be able to source / specify additional flags. Load from + # configuration file as well? + cmd_args = [cc, src_str, "-o", dest_str] + + result = CompileCommand(directory, cmd_args, src_str, dest_str) + + # Update Tracking. + assert dest_str not in self.by_file, (dest_str, self.by_file[dest_str]) + self.by_file[dest_str] = result + self.commands.append(result) + + return result + + +class EmscriptenBuilder(LoggerMixin): + """A class implementing a simple emscripten-project buliding interface.""" + + def __init__(self, logger: LoggerType, root: Path) -> None: + """Initialize this instances.""" + + super().__init__(logger=logger) + self.root = root + self.compdb = CompileCommands(self.root) + + async def handle(self, source: Path | str, dest: Path | str) -> None: + """Add a compile command.""" + + command = self.compdb.add(source, dest) + + commands = [] + + # Build if necessary. + if not dest.is_file() or modified_after(dest, [source]): + commands.append(command.run(self.logger)) + + if commands: + await asyncio.gather(*commands) + self.compdb.write() diff --git a/tasks/default.yaml b/tasks/default.yaml index 21fc9738..43dbd380 100644 --- a/tasks/default.yaml +++ b/tasks/default.yaml @@ -6,7 +6,7 @@ port_overrides: runtimepy_http_server: 8000 config: - wasm_app: hello_world + wasm_app: dev/console wasm_root: wasm/src app: diff --git a/wasm/src/dev/console.c b/wasm/src/dev/console.c new file mode 100644 index 00000000..d11dbbaf --- /dev/null +++ b/wasm/src/dev/console.c @@ -0,0 +1,12 @@ +#include +#include + +int main() { + + emscripten_log(EM_LOG_INFO, "Hello, world! (info) %d.", 345); + emscripten_log(EM_LOG_DEBUG, "Hello, world! (debug) %d.", 456); + + printf("(printf) Console test.\n"); + + return 0; +} From 48dfe583208fddab7664736fe1fb724fb3f27518 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sat, 16 Mar 2024 12:56:58 -0500 Subject: [PATCH 06/15] Pruning --- tasks/app.py | 110 ---------------------------------- tasks/compile_commands.py | 121 -------------------------------------- tasks/default.yaml | 13 +--- 3 files changed, 3 insertions(+), 241 deletions(-) delete mode 100644 tasks/app.py delete mode 100644 tasks/compile_commands.py diff --git a/tasks/app.py b/tasks/app.py deleted file mode 100644 index 19ca1c6e..00000000 --- a/tasks/app.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -A module implementing a simple web application. -""" - -# built-in -from pathlib import Path -from typing import Iterable, Optional - -# third-party -from svgen.element import Element -from svgen.element.html import Html -from vcorelib.paths import normalize - -# internal -from runtimepy.net.arbiter.info import AppInfo -from runtimepy.net.http.header import RequestHeader -from runtimepy.net.http.response import ResponseHeader -from runtimepy.net.server import RuntimepyServerConnection -from runtimepy.net.server.app.elements import div -from runtimepy.net.server.app.files import append_kind -from runtimepy.net.server.html import HtmlApp -from tasks.compile_commands import EmscriptenBuilder - - -def search_paths( - path: Path, paths: list[Path], suffixes: Iterable[str] -) -> Optional[Path]: - """Search for a file given a list of paths.""" - - result = None - candidate = None - for search in paths: - if result is not None: - break - - for suffix in suffixes: - candidate = search.joinpath(path).with_suffix(suffix) - if candidate.is_file(): - result = candidate - break - - return result - - -async def build_app( - app: AppInfo, paths: list[Path], is_async: bool = True -) -> Element: - """Build the application.""" - - # Find the source file. - app_path = normalize(app.config_param("wasm_app", "", strict=True)) - source = search_paths(app_path, paths, {".c", ".cc"}) - assert source is not None, (app_path, paths) - - # Find a possible existing script file. - script = app_path.with_suffix(".js") - - # Build if necessary. Should we get the root directory a different way? - builder = EmscriptenBuilder(app.logger, Path()) - await builder.handle(source, source.parent.joinpath(script.name)) - - # Manifest the script file in the application. - elem = Element(tag="script", src=str(script), text="/* null */") - if is_async: - elem.booleans.add("async") - return elem - - -def create(app: AppInfo) -> HtmlApp: - """Create a web-application handler.""" - - # Add WASM root directory. - paths = RuntimepyServerConnection.class_paths - paths.insert(0, app.wasm_root) - - paths = list(normalize(x).resolve() for x in paths) - - async def main( - document: Html, - request: RequestHeader, - response: ResponseHeader, - request_data: Optional[bytes], - ) -> Html: - """A simple 'Hello, world!' application.""" - - # Not currently used. - del request - del response - del request_data - - # Create the application. - append_kind(document.head, "main", kind="css", tag="style") - - # Remove at some point. - div(text="Hello, world!", parent=document.body) - - # Add WebAssembly application. - document.body.children.append(await build_app(app, paths)) - - return document - - return main - - -async def setup(app: AppInfo) -> int: - """Perform server application setup steps.""" - - # Set default application. - RuntimepyServerConnection.default_app = create(app) - return 0 diff --git a/tasks/compile_commands.py b/tasks/compile_commands.py deleted file mode 100644 index 57f4665b..00000000 --- a/tasks/compile_commands.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -A module implementing some compile_commands.json management interfaces. -""" - -# built-in -import asyncio -import json -from pathlib import Path -from typing import NamedTuple - -# third-party -from vcorelib import DEFAULT_ENCODING -from vcorelib.asyncio.cli import ProcessResult, run_command -from vcorelib.logging import LoggerMixin, LoggerType -from vcorelib.paths import modified_after, rel - - -class CompileCommand(NamedTuple): - """A class implementing a compile-command instance.""" - - directory: Path - args: list[str] - file: str - output: str - - @property - def command(self) -> str: - """Get the command string for this instance""" - return " ".join(self.args) - - async def run(self, logger: LoggerType, **kwargs) -> ProcessResult: - """Run command.""" - - # Make sure we switch to the correct directory first? - await run_command(logger, *self.args, **kwargs) - - def as_dict(self) -> dict[str, str]: - """Get this instance as a dictionary.""" - - return { - "directory": str(self.directory), - "command": self.command, - "file": self.file, - "output": self.output, - } - - -class CompileCommands: - """A class implementing a 'compile_commands.json' interface.""" - - def __init__(self, directory: Path | str) -> None: - """Initialize this instance.""" - - self.directory = str(directory) - - self.commands: list[CompileCommand] = [] - self.by_file: dict[str, CompileCommand] = {} - - # load file if it exists, update current commands - self.output = Path(self.directory).joinpath("compile_commands.json") - - # Load existing data. - if self.output.is_file(): - pass - - def write(self) -> None: - """Write the output file.""" - - with self.output.open("w", encoding=DEFAULT_ENCODING) as out_fd: - json.dump( - list(x.as_dict() for x in self.commands), out_fd, indent=2 - ) - - def add( - self, source: Path | str, dest: Path | str, cc: str = "emcc" - ) -> CompileCommand: - """Add a compile command.""" - - directory = Path(self.directory).resolve() - - dest_str = str(rel(dest, directory)) - src_str = str(rel(source, directory)) - - # Need to be able to source / specify additional flags. Load from - # configuration file as well? - cmd_args = [cc, src_str, "-o", dest_str] - - result = CompileCommand(directory, cmd_args, src_str, dest_str) - - # Update Tracking. - assert dest_str not in self.by_file, (dest_str, self.by_file[dest_str]) - self.by_file[dest_str] = result - self.commands.append(result) - - return result - - -class EmscriptenBuilder(LoggerMixin): - """A class implementing a simple emscripten-project buliding interface.""" - - def __init__(self, logger: LoggerType, root: Path) -> None: - """Initialize this instances.""" - - super().__init__(logger=logger) - self.root = root - self.compdb = CompileCommands(self.root) - - async def handle(self, source: Path | str, dest: Path | str) -> None: - """Add a compile command.""" - - command = self.compdb.add(source, dest) - - commands = [] - - # Build if necessary. - if not dest.is_file() or modified_after(dest, [source]): - commands.append(command.run(self.logger)) - - if commands: - await asyncio.gather(*commands) - self.compdb.write() diff --git a/tasks/default.yaml b/tasks/default.yaml index 43dbd380..576fcfbd 100644 --- a/tasks/default.yaml +++ b/tasks/default.yaml @@ -1,14 +1,7 @@ --- -includes: - - package://runtimepy/server_base.yaml - -port_overrides: - runtimepy_http_server: 8000 - -config: - wasm_app: dev/console - wasm_root: wasm/src +includes_left: + - package://runtimepy/server.yaml + - package://runtimepy/server_dev.yaml app: - - tasks.app.setup - runtimepy.net.apps.wait_for_stop From f2ef6bc65cad272258fdfcdd03ac357acd8393b2 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sat, 16 Mar 2024 21:36:19 -0500 Subject: [PATCH 07/15] Initial tab application interface --- runtimepy/data/css/bootstrap_extra.css | 2 +- runtimepy/data/css/main.css | 4 + runtimepy/data/js/main.js | 49 +-------- runtimepy/data/js/pyodide.js | 38 +++++++ runtimepy/data/js/setup_worker.js | 39 +++++++ runtimepy/data/js/shared.js | 3 - runtimepy/data/js/worker.js | 30 +----- runtimepy/net/server/app/base.py | 21 ++-- runtimepy/net/server/app/bootstrap/tabs.py | 1 + runtimepy/net/server/app/tab.py | 116 +++++++++++++++++++++ tasks/html.py | 42 +++++--- wasm/src/.gitignore | 3 - wasm/src/dev/console.c | 12 --- wasm/src/hello_world.c | 6 -- 14 files changed, 243 insertions(+), 123 deletions(-) create mode 100644 runtimepy/data/js/pyodide.js create mode 100644 runtimepy/data/js/setup_worker.js delete mode 100644 runtimepy/data/js/shared.js create mode 100644 runtimepy/net/server/app/tab.py delete mode 100644 wasm/src/.gitignore delete mode 100644 wasm/src/dev/console.c delete mode 100644 wasm/src/hello_world.c diff --git a/runtimepy/data/css/bootstrap_extra.css b/runtimepy/data/css/bootstrap_extra.css index bb4c997b..0454fa28 100644 --- a/runtimepy/data/css/bootstrap_extra.css +++ b/runtimepy/data/css/bootstrap_extra.css @@ -1,7 +1,7 @@ .flex-column-scroll-bodge { height: 100%; flex-wrap: nowrap; - overflow: scroll; + overflow-y: scroll; flex-shrink: 0; } diff --git a/runtimepy/data/css/main.css b/runtimepy/data/css/main.css index 0f16e342..c6d4d2a5 100644 --- a/runtimepy/data/css/main.css +++ b/runtimepy/data/css/main.css @@ -12,3 +12,7 @@ body { body > :first-child { height: 100%; } + +#runtimepy { + height: 100%; +} diff --git a/runtimepy/data/js/main.js b/runtimepy/data/js/main.js index 72b209a8..22a4c685 100644 --- a/runtimepy/data/js/main.js +++ b/runtimepy/data/js/main.js @@ -1,54 +1,15 @@ -function worker_message(event) { - console.log(`Main thread received: ${event.data}.`); -} - -function worker_config(config) { - let worker_cfg = {}; - - /* Look for connections to establish. */ - let ports = config["config"]["ports"]; - for (let port_idx in ports) { - let port = ports[port_idx]; - - /* This business logic could use some work. */ - if (port["name"].includes("runtimepy_websocket")) { - if (port["name"].includes("data")) { - worker_cfg["data"] = "ws://localhost:" + port["port"]; - } else { - worker_cfg["json"] = "ws://localhost:" + port["port"]; - } - } - } - - return worker_cfg; -} - /* - * Do some heinous sh*t to create a worker from our 'text/js-worker' element. + * Application entry. */ -const worker = new Worker(window.URL.createObjectURL(new Blob( - Array.prototype.map.call( - document.querySelectorAll("script[type='text\/js-worker']"), - (script) => script.textContent, - ), - {type : "text/javascript"}, - ))); -worker.onmessage = worker_message; - async function main(config) { - /* Send configuration data to the worker. */ config["worker"] = worker_config(config); worker.postMessage(config); - /* Run pyodide. */ - let pyodide = await loadPyodide(); - pyodide.runPython(`print("MAIN THREAD.")`); - - /* Canvas. */ - // let ctx = document.getElementById("canvas").getContext("2d"); - // ctx.lineWidth = 10; - // ctx.strokeRect(20, 20, 40, 40); + /* Run tab initialization. */ + for await (const init of inits) { + await init(); + } } /* Load configuration data then run application entry. */ diff --git a/runtimepy/data/js/pyodide.js b/runtimepy/data/js/pyodide.js new file mode 100644 index 00000000..49951d92 --- /dev/null +++ b/runtimepy/data/js/pyodide.js @@ -0,0 +1,38 @@ +/* Just an example. */ + +function import_pyodide() { + importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"); +} + +import_pyodide(); + +const script = ` +import js +import js_local +from runtimepy.primitives import create + +print(create("uint8")) + +# can send messages to main thread +js.postMessage("What's good bud!") + +# can access connection and config state +print(js_local.config.config.app) +`; + +/* Worker entry. */ +async function start(config) { + /* Run pyodide. */ + let pyodide = await loadPyodide(); + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + + /* Install packages. */ + await micropip.install("runtimepy"); + + /* Register namespace for local state. */ + pyodide.registerJsModule( + "js_local", {config : config, conns : create_connections(config)}); + + await pyodide.runPythonAsync(script); +} diff --git a/runtimepy/data/js/setup_worker.js b/runtimepy/data/js/setup_worker.js new file mode 100644 index 00000000..c4daa811 --- /dev/null +++ b/runtimepy/data/js/setup_worker.js @@ -0,0 +1,39 @@ +function worker_config(config) { + let worker_cfg = {}; + + /* Look for connections to establish. */ + let ports = config["config"]["ports"]; + for (let port_idx in ports) { + let port = ports[port_idx]; + + /* This business logic could use some work. */ + if (port["name"].includes("runtimepy_websocket")) { + if (port["name"].includes("data")) { + worker_cfg["data"] = "ws://localhost:" + port["port"]; + } else { + worker_cfg["json"] = "ws://localhost:" + port["port"]; + } + } + } + + return worker_cfg; +} + +/* + * Do some heinous sh*t to create a worker from our 'text/js-worker' element. + */ +const worker = new Worker(window.URL.createObjectURL(new Blob( + Array.prototype.map.call( + document.querySelectorAll("script[type='text\/js-worker']"), + (script) => script.textContent, + ), + {type : "text/javascript"}, + ))); + +/* + * Worker message handler. + */ +function worker_message(event) { + console.log(`Main thread received: ${event.data}.`); +} +worker.onmessage = worker_message; diff --git a/runtimepy/data/js/shared.js b/runtimepy/data/js/shared.js deleted file mode 100644 index 2dedd1b5..00000000 --- a/runtimepy/data/js/shared.js +++ /dev/null @@ -1,3 +0,0 @@ -function import_pyodide() { - importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"); -} diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 32591e51..dbe0ffae 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -1,5 +1,3 @@ -import_pyodide(); - function create_connections(config) { /* This business logic could use some work. */ const conn_factories = {"json" : JsonConnection, "data" : DataConnection}; @@ -13,35 +11,11 @@ function create_connections(config) { return conns; } -const script = ` -import js -import js_local -from runtimepy.primitives import create - -print(create("uint8")) - -# can send messages to main thread -js.postMessage("What's good bud!") - -# can access connection and config state -print(js_local.config.config.app) -`; - /* Worker entry. */ async function start(config) { - /* Run pyodide. */ - let pyodide = await loadPyodide(); - await pyodide.loadPackage("micropip"); - const micropip = pyodide.pyimport("micropip"); - - /* Install packages. */ - await micropip.install("runtimepy"); - - /* Register namespace for local state. */ - pyodide.registerJsModule( - "js_local", {config : config, conns : create_connections(config)}); + console.log(config); - await pyodide.runPythonAsync(script); + let conns = create_connections(config); } started = false; diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index c04176f6..21a19de3 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -16,8 +16,8 @@ add_bootstrap_js, ) from runtimepy.net.server.app.bootstrap.tabs import TabbedContent +from runtimepy.net.server.app.elements import div from runtimepy.net.server.app.files import append_kind -from runtimepy.net.server.app.pyodide import add_pyodide_js TabPopulater = Callable[[TabbedContent], None] @@ -25,13 +25,8 @@ class WebApplication: """A simple web-application interface.""" - worker_source_paths = [ - "shared", - "JsonConnection", - "DataConnection", - "worker", - ] - main_source_paths = ["shared", "main"] + worker_source_paths = ["JsonConnection", "DataConnection", "worker"] + main_source_paths = ["setup_worker", "main"] css_paths = ["main", "bootstrap_extra"] def __init__(self, app: AppInfo) -> None: @@ -47,13 +42,12 @@ def populate(self, document: Html, app: TabPopulater) -> None: # Internal CSS. append_kind(document.head, *self.css_paths, kind="css", tag="style") + # Add a startup script. + div(tag="script", parent=document.body, text="let inits = [];") + # Populate applicaton elements. app(TabbedContent(PKG_NAME, document.body)) - # Third-party dependencies. - add_bootstrap_js(document.body) - add_pyodide_js(document.body) - # Worker code. append_kind(document.body, *self.worker_source_paths)[ "type" @@ -61,3 +55,6 @@ def populate(self, document: Html, app: TabPopulater) -> None: # Main-thread code. append_kind(document.body, *self.main_source_paths) + + # Third-party dependencies. + add_bootstrap_js(document.body) diff --git a/runtimepy/net/server/app/bootstrap/tabs.py b/runtimepy/net/server/app/bootstrap/tabs.py index 3692dd3b..c99dd2e4 100644 --- a/runtimepy/net/server/app/bootstrap/tabs.py +++ b/runtimepy/net/server/app/bootstrap/tabs.py @@ -61,6 +61,7 @@ def __init__(self, name: str, parent: Element) -> None: # Create application container self.container = div(parent=parent) + self.container["id"] = name self.container["data-bs-theme"] = "dark" self.container["class"] = "d-flex align-items-start" diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py new file mode 100644 index 00000000..f9f8dd80 --- /dev/null +++ b/runtimepy/net/server/app/tab.py @@ -0,0 +1,116 @@ +""" +A module implementing an application tab interface. +""" + +# built-in +from io import StringIO +from typing import cast + +# third-party +from svgen.element import Element +from vcorelib.io.file_writer import IndentedFileWriter + +# internal +from runtimepy import PKG_NAME +from runtimepy.net.arbiter.info import AppInfo +from runtimepy.net.server.app.bootstrap.tabs import TabbedContent +from runtimepy.net.server.app.elements import div + + +class Tab: + """A simple application-tab interface class.""" + + def __init__(self, name: str, app: AppInfo, tabs: TabbedContent) -> None: + """Initialize this instance.""" + + self.name = name + self.app = app + self.button, self.content = tabs.create(self.name) + + # What should we put here? + self.button.text = self.name + + self.populate_elements(self.content) + + def populate_elements(self, parent: Element) -> None: + """Populate tab elements.""" + + for idx in range(100): + div( + parent=parent, + text=f"Hello, world! ({idx})" * 10, + style="white-space: nowrap;", + ) + + def populate_shown(self, writer: IndentedFileWriter) -> None: + """Populate the tab-shown handler.""" + + # where should we source this from? + writer.write(f"console.log('SHOWN HANDLER FOR {self.name}');") + + def populate_hidden(self, writer: IndentedFileWriter) -> None: + """Populate the tab-hidden handler.""" + + # where should we source this from? + writer.write(f"console.log('HIDDEN HANDLER FOR {self.name}');") + + @property + def element_id(self) -> str: + """Get this tab's element identifier.""" + return f"{PKG_NAME}-{self.name}-tab" + + def populate_app(self, writer: IndentedFileWriter) -> None: + """Write a tab's application wrapper.""" + + # Tab shown handler. + shown_handler = f"{self.name}_shown_handler" + writer.write(f"async function {shown_handler}() " + "{") + with writer.indented(): + self.populate_shown(writer) + writer.write("}") + + writer.write( + f'let elem = document.getElementById("{self.element_id}");' + ) + + # If this tab is already/currently shown, run the handler now. + writer.write("let tab = bootstrap.Tab.getInstance(elem);") + writer.write("if (tab) {") + with writer.indented(): + writer.write(f"await {shown_handler}();") + writer.write("}") + + # Tab hidden handler. + hidden_handler = f"{self.name}_hidden_handler" + writer.write(f"async function {hidden_handler}() " + "{") + with writer.indented(): + self.populate_hidden(writer) + writer.write("}") + + # Register handlers. + writer.write("elem.addEventListener('shown.bs.tab', async event => {") + with writer.indented(): + writer.write(f"await {shown_handler}();") + writer.write("});") + writer.write("elem.addEventListener('hidden.bs.tab', async event => {") + with writer.indented(): + writer.write(f"await {hidden_handler}();") + writer.write("});") + + def entry(self) -> None: + """Tab overall script entry.""" + + with IndentedFileWriter.string(per_indent=2) as writer: + # Write initialization-method wrapper. + writer.write("inits.push(async () => {") + + with writer.indented(): + self.populate_app(writer) + + writer.write("});") + + div( + tag="script", + parent=self.content, + text=cast(StringIO, writer.stream).getvalue(), + ) diff --git a/tasks/html.py b/tasks/html.py index f14e2acb..082140a2 100644 --- a/tasks/html.py +++ b/tasks/html.py @@ -2,28 +2,42 @@ A module for working on HTML ideas. """ +# third-party +from svgen.element import Element +from vcorelib.io.file_writer import IndentedFileWriter + # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.tab import Tab -def sample(app: AppInfo, tabs: TabbedContent) -> None: - """Populate application elements.""" +class DevTab(Tab): + """A developmental tab.""" + + def populate_elements(self, parent: Element) -> None: + """Populate tab elements.""" + + div(parent=parent, text=f"Dev tab {self.name}.") - for idx in range(10): - item = f"test{idx}" + def populate_shown(self, writer: IndentedFileWriter) -> None: + """Populate the tab-shown handler.""" - button, content = tabs.create(item) + writer.write(f"console.log('shown dev tab {self.name}');") - # what should we put here? - button.text = item + def populate_hidden(self, writer: IndentedFileWriter) -> None: + """Populate the tab-hidden handler.""" + + writer.write(f"console.log('hidden dev tab {self.name}');") + + +def sample(app: AppInfo, tabs: TabbedContent) -> None: + """Populate application elements.""" - for idx in range(100): - div( - parent=content, - text=f"Hello, world! ({idx})", - style="white-space: nowrap;", - ) + # Add dev tab. + DevTab("dev", app, tabs).entry() - del app + for idx in range(100): + tab = Tab(f"test{idx}", app, tabs) + tab.entry() diff --git a/wasm/src/.gitignore b/wasm/src/.gitignore deleted file mode 100644 index 51b54348..00000000 --- a/wasm/src/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*.js -*.wasm -*.html diff --git a/wasm/src/dev/console.c b/wasm/src/dev/console.c deleted file mode 100644 index d11dbbaf..00000000 --- a/wasm/src/dev/console.c +++ /dev/null @@ -1,12 +0,0 @@ -#include -#include - -int main() { - - emscripten_log(EM_LOG_INFO, "Hello, world! (info) %d.", 345); - emscripten_log(EM_LOG_DEBUG, "Hello, world! (debug) %d.", 456); - - printf("(printf) Console test.\n"); - - return 0; -} diff --git a/wasm/src/hello_world.c b/wasm/src/hello_world.c deleted file mode 100644 index ce0cca46..00000000 --- a/wasm/src/hello_world.c +++ /dev/null @@ -1,6 +0,0 @@ -#include - -int main() { - printf("hello, world!\n"); - return 0; -} From a53986fd890fed578d0d2c10167cc865274ac1fa Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 17 Mar 2024 00:29:40 -0500 Subject: [PATCH 08/15] More UI dev --- runtimepy/data/html/dev.html | 5 +++ runtimepy/data/js/dev.js | 5 +++ runtimepy/net/server/app/tab.py | 68 +++++++++++++++++++-------------- tasks/html.py | 21 ++++++---- 4 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 runtimepy/data/html/dev.html create mode 100644 runtimepy/data/js/dev.js diff --git a/runtimepy/data/html/dev.html b/runtimepy/data/html/dev.html new file mode 100644 index 00000000..cc3611b3 --- /dev/null +++ b/runtimepy/data/html/dev.html @@ -0,0 +1,5 @@ +
+ Hello, world! (dev tab) +
+ + diff --git a/runtimepy/data/js/dev.js b/runtimepy/data/js/dev.js new file mode 100644 index 00000000..d7db17ba --- /dev/null +++ b/runtimepy/data/js/dev.js @@ -0,0 +1,5 @@ +document.getElementById("dev-button").onclick = async event => { + /**/ + console.log("button pressed"); + /**/ +}; diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index f9f8dd80..1de875c1 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -42,37 +42,21 @@ def populate_elements(self, parent: Element) -> None: style="white-space: nowrap;", ) - def populate_shown(self, writer: IndentedFileWriter) -> None: + def populate_shown_inner(self, writer: IndentedFileWriter) -> None: """Populate the tab-shown handler.""" # where should we source this from? writer.write(f"console.log('SHOWN HANDLER FOR {self.name}');") - def populate_hidden(self, writer: IndentedFileWriter) -> None: - """Populate the tab-hidden handler.""" - - # where should we source this from? - writer.write(f"console.log('HIDDEN HANDLER FOR {self.name}');") - - @property - def element_id(self) -> str: - """Get this tab's element identifier.""" - return f"{PKG_NAME}-{self.name}-tab" - - def populate_app(self, writer: IndentedFileWriter) -> None: - """Write a tab's application wrapper.""" + def populate_shown(self, writer: IndentedFileWriter) -> None: + """Populate the tab-shown handler.""" - # Tab shown handler. shown_handler = f"{self.name}_shown_handler" writer.write(f"async function {shown_handler}() " + "{") with writer.indented(): - self.populate_shown(writer) + self.populate_shown_inner(writer) writer.write("}") - writer.write( - f'let elem = document.getElementById("{self.element_id}");' - ) - # If this tab is already/currently shown, run the handler now. writer.write("let tab = bootstrap.Tab.getInstance(elem);") writer.write("if (tab) {") @@ -80,23 +64,51 @@ def populate_app(self, writer: IndentedFileWriter) -> None: writer.write(f"await {shown_handler}();") writer.write("}") - # Tab hidden handler. + # Add event listener. + writer.write("elem.addEventListener('shown.bs.tab', async event => {") + with writer.indented(): + writer.write(f"await {shown_handler}();") + writer.write("});") + + def populate_hidden_inner(self, writer: IndentedFileWriter) -> None: + """Populate the tab-hidden handler.""" + + # where should we source this from? + writer.write(f"console.log('HIDDEN HANDLER FOR {self.name}');") + + def populate_hidden(self, writer: IndentedFileWriter) -> None: + """Populate the tab-hidden handler.""" + hidden_handler = f"{self.name}_hidden_handler" writer.write(f"async function {hidden_handler}() " + "{") with writer.indented(): - self.populate_hidden(writer) + self.populate_hidden_inner(writer) writer.write("}") - # Register handlers. - writer.write("elem.addEventListener('shown.bs.tab', async event => {") - with writer.indented(): - writer.write(f"await {shown_handler}();") - writer.write("});") + # Add event listener. writer.write("elem.addEventListener('hidden.bs.tab', async event => {") with writer.indented(): writer.write(f"await {hidden_handler}();") writer.write("});") + @property + def element_id(self) -> str: + """Get this tab's element identifier.""" + return f"{PKG_NAME}-{self.name}-tab" + + def init_script(self, writer: IndentedFileWriter) -> None: + """Initialize script code.""" + + def _init_script(self, writer: IndentedFileWriter) -> None: + """Create this tab's initialization script.""" + + writer.write( + f'let elem = document.getElementById("{self.element_id}");' + ) + self.init_script(writer) + self.populate_shown(writer) + self.populate_hidden(writer) + def entry(self) -> None: """Tab overall script entry.""" @@ -105,7 +117,7 @@ def entry(self) -> None: writer.write("inits.push(async () => {") with writer.indented(): - self.populate_app(writer) + self._init_script(writer) writer.write("});") diff --git a/tasks/html.py b/tasks/html.py index 082140a2..a68ea93a 100644 --- a/tasks/html.py +++ b/tasks/html.py @@ -9,7 +9,11 @@ # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent -from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.files import ( + append_kind, + kind_url, + write_found_file, +) from runtimepy.net.server.app.tab import Tab @@ -19,17 +23,18 @@ class DevTab(Tab): def populate_elements(self, parent: Element) -> None: """Populate tab elements.""" - div(parent=parent, text=f"Dev tab {self.name}.") + append_kind(parent, self.name, kind="html", tag="div") - def populate_shown(self, writer: IndentedFileWriter) -> None: - """Populate the tab-shown handler.""" + def init_script(self, writer: IndentedFileWriter) -> None: + """Initialize script code.""" - writer.write(f"console.log('shown dev tab {self.name}');") + write_found_file(writer, kind_url("js", self.name)) - def populate_hidden(self, writer: IndentedFileWriter) -> None: - """Populate the tab-hidden handler.""" + def populate_shown_inner(self, writer: IndentedFileWriter) -> None: + """Populate the tab-shown handler.""" - writer.write(f"console.log('hidden dev tab {self.name}');") + def populate_hidden_inner(self, writer: IndentedFileWriter) -> None: + """Populate the tab-hidden handler.""" def sample(app: AppInfo, tabs: TabbedContent) -> None: From a504e3647d775d96da83018b175481064b474937 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 17 Mar 2024 14:25:59 -0500 Subject: [PATCH 09/15] Solid progress on tab interface --- .gitignore | 1 + config | 2 +- runtimepy/data/js/dev.js | 4 +-- runtimepy/data/js/setup_worker.js | 3 ++ runtimepy/net/server/app/base.py | 16 +++++----- runtimepy/net/server/app/tab.py | 52 ++++++++++++++++++++++--------- tasks/html.py | 8 +---- 7 files changed, 53 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index d8972513..ce1e313e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ tags mklocal docs compile_commands.json +src diff --git a/config b/config index 6b9977e7..064ed618 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 6b9977e7d2132e33d09b6f5bd45b661cf66802d5 +Subproject commit 064ed618bd0e410933a70b95d579d009724705fb diff --git a/runtimepy/data/js/dev.js b/runtimepy/data/js/dev.js index d7db17ba..e60ae5ed 100644 --- a/runtimepy/data/js/dev.js +++ b/runtimepy/data/js/dev.js @@ -1,5 +1,5 @@ -document.getElementById("dev-button").onclick = async event => { +document.getElementById(name + "-button").onclick = async event => { /**/ - console.log("button pressed"); + send_message({kind : "button.pressed"}); /**/ }; diff --git a/runtimepy/data/js/setup_worker.js b/runtimepy/data/js/setup_worker.js index c4daa811..1ea17db5 100644 --- a/runtimepy/data/js/setup_worker.js +++ b/runtimepy/data/js/setup_worker.js @@ -37,3 +37,6 @@ function worker_message(event) { console.log(`Main thread received: ${event.data}.`); } worker.onmessage = worker_message; + +/* An array of initialization methods to run. */ +let inits = []; diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index 21a19de3..fabc1d62 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -16,7 +16,6 @@ add_bootstrap_js, ) from runtimepy.net.server.app.bootstrap.tabs import TabbedContent -from runtimepy.net.server.app.elements import div from runtimepy.net.server.app.files import append_kind TabPopulater = Callable[[TabbedContent], None] @@ -26,7 +25,6 @@ class WebApplication: """A simple web-application interface.""" worker_source_paths = ["JsonConnection", "DataConnection", "worker"] - main_source_paths = ["setup_worker", "main"] css_paths = ["main", "bootstrap_extra"] def __init__(self, app: AppInfo) -> None: @@ -42,19 +40,19 @@ def populate(self, document: Html, app: TabPopulater) -> None: # Internal CSS. append_kind(document.head, *self.css_paths, kind="css", tag="style") - # Add a startup script. - div(tag="script", parent=document.body, text="let inits = [];") - - # Populate applicaton elements. - app(TabbedContent(PKG_NAME, document.body)) - # Worker code. append_kind(document.body, *self.worker_source_paths)[ "type" ] = "text/js-worker" + # Set up worker. + append_kind(document.body, "setup_worker") + + # Populate applicaton elements. + app(TabbedContent(PKG_NAME, document.body)) + # Main-thread code. - append_kind(document.body, *self.main_source_paths) + append_kind(document.body, "main") # Third-party dependencies. add_bootstrap_js(document.body) diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index 1de875c1..63b3e9d1 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -35,7 +35,7 @@ def __init__(self, name: str, app: AppInfo, tabs: TabbedContent) -> None: def populate_elements(self, parent: Element) -> None: """Populate tab elements.""" - for idx in range(100): + for idx in range(10): div( parent=parent, text=f"Hello, world! ({idx})" * 10, @@ -45,8 +45,7 @@ def populate_elements(self, parent: Element) -> None: def populate_shown_inner(self, writer: IndentedFileWriter) -> None: """Populate the tab-shown handler.""" - # where should we source this from? - writer.write(f"console.log('SHOWN HANDLER FOR {self.name}');") + self.send_message(writer, 'kind: "tab.shown"') def populate_shown(self, writer: IndentedFileWriter) -> None: """Populate the tab-shown handler.""" @@ -58,8 +57,7 @@ def populate_shown(self, writer: IndentedFileWriter) -> None: writer.write("}") # If this tab is already/currently shown, run the handler now. - writer.write("let tab = bootstrap.Tab.getInstance(elem);") - writer.write("if (tab) {") + writer.write("if (bootstrap.Tab.getInstance(elem)) {") with writer.indented(): writer.write(f"await {shown_handler}();") writer.write("}") @@ -70,11 +68,15 @@ def populate_shown(self, writer: IndentedFileWriter) -> None: writer.write(f"await {shown_handler}();") writer.write("});") + def send_message(self, writer: IndentedFileWriter, data: str) -> None: + """Send a message to the worker thread.""" + + writer.write("send_message({" + data + "});") + def populate_hidden_inner(self, writer: IndentedFileWriter) -> None: """Populate the tab-hidden handler.""" - # where should we source this from? - writer.write(f"console.log('HIDDEN HANDLER FOR {self.name}');") + self.send_message(writer, 'kind: "tab.hidden"') def populate_hidden(self, writer: IndentedFileWriter) -> None: """Populate the tab-hidden handler.""" @@ -91,22 +93,44 @@ def populate_hidden(self, writer: IndentedFileWriter) -> None: writer.write(f"await {hidden_handler}();") writer.write("});") - @property - def element_id(self) -> str: - """Get this tab's element identifier.""" - return f"{PKG_NAME}-{self.name}-tab" - def init_script(self, writer: IndentedFileWriter) -> None: """Initialize script code.""" def _init_script(self, writer: IndentedFileWriter) -> None: """Create this tab's initialization script.""" + writer.c_comment("Useful constants.") + + # Declare useful variables. + writer.write(f'const name = "{self.name}";') writer.write( - f'let elem = document.getElementById("{self.element_id}");' + ( + "const elem = document.getElementById(" + f'"{PKG_NAME}-" + name + "-tab");' + ) ) + + # Declare a function for sending messages. + with writer.padding(): + writer.c_comment("Worker/server messaging interface.") + writer.write("function send_message(data) {") + with writer.indented(): + writer.write( + 'worker.postMessage({name: "' + + self.name + + '", event: data});' + ) + writer.write("}") + + writer.c_comment("Tab-specific code start.") self.init_script(writer) - self.populate_shown(writer) + writer.c_comment("Tab-specific code end.") + + with writer.padding(): + writer.c_comment("Tab-shown handling.") + self.populate_shown(writer) + + writer.c_comment("Tab-hidden handling.") self.populate_hidden(writer) def entry(self) -> None: diff --git a/tasks/html.py b/tasks/html.py index a68ea93a..7f914e86 100644 --- a/tasks/html.py +++ b/tasks/html.py @@ -30,12 +30,6 @@ def init_script(self, writer: IndentedFileWriter) -> None: write_found_file(writer, kind_url("js", self.name)) - def populate_shown_inner(self, writer: IndentedFileWriter) -> None: - """Populate the tab-shown handler.""" - - def populate_hidden_inner(self, writer: IndentedFileWriter) -> None: - """Populate the tab-hidden handler.""" - def sample(app: AppInfo, tabs: TabbedContent) -> None: """Populate application elements.""" @@ -43,6 +37,6 @@ def sample(app: AppInfo, tabs: TabbedContent) -> None: # Add dev tab. DevTab("dev", app, tabs).entry() - for idx in range(100): + for idx in range(10): tab = Tab(f"test{idx}", app, tabs) tab.entry() From 02ad174415634e73be7b88c609adf27df0adf1fd Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 17 Mar 2024 17:16:47 -0500 Subject: [PATCH 10/15] Moving the needle --- runtimepy/data/js/dev.js | 2 +- runtimepy/data/js/setup_tabs.js | 15 +++++++++++++++ runtimepy/data/js/setup_worker.js | 3 --- runtimepy/net/server/app/base.py | 2 +- runtimepy/net/server/app/tab.py | 13 +++++-------- 5 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 runtimepy/data/js/setup_tabs.js diff --git a/runtimepy/data/js/dev.js b/runtimepy/data/js/dev.js index e60ae5ed..31bb1ba3 100644 --- a/runtimepy/data/js/dev.js +++ b/runtimepy/data/js/dev.js @@ -1,5 +1,5 @@ document.getElementById(name + "-button").onclick = async event => { /**/ - send_message({kind : "button.pressed"}); + tab.send_message({kind : "button.pressed"}); /**/ }; diff --git a/runtimepy/data/js/setup_tabs.js b/runtimepy/data/js/setup_tabs.js new file mode 100644 index 00000000..83b1a8e6 --- /dev/null +++ b/runtimepy/data/js/setup_tabs.js @@ -0,0 +1,15 @@ +/* An array of initialization methods to run. */ +let inits = []; + +/* + * Define class for generated code to use (instead of generating so many + * methods). + */ +class TabInterface { + constructor(name) { + this.name = name; + this.element = document.getElementById("runtimepy-" + this.name + "-tab"); + } + + send_message(data) { worker.postMessage({name : this.name, event : data}); } +} diff --git a/runtimepy/data/js/setup_worker.js b/runtimepy/data/js/setup_worker.js index 1ea17db5..c4daa811 100644 --- a/runtimepy/data/js/setup_worker.js +++ b/runtimepy/data/js/setup_worker.js @@ -37,6 +37,3 @@ function worker_message(event) { console.log(`Main thread received: ${event.data}.`); } worker.onmessage = worker_message; - -/* An array of initialization methods to run. */ -let inits = []; diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index fabc1d62..2a4823b2 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -46,7 +46,7 @@ def populate(self, document: Html, app: TabPopulater) -> None: ] = "text/js-worker" # Set up worker. - append_kind(document.body, "setup_worker") + append_kind(document.body, "setup_worker", "setup_tabs") # Populate applicaton elements. app(TabbedContent(PKG_NAME, document.body)) diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index 63b3e9d1..00fcbafe 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -11,7 +11,6 @@ from vcorelib.io.file_writer import IndentedFileWriter # internal -from runtimepy import PKG_NAME from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.elements import div @@ -102,13 +101,11 @@ def _init_script(self, writer: IndentedFileWriter) -> None: writer.c_comment("Useful constants.") # Declare useful variables. - writer.write(f'const name = "{self.name}";') - writer.write( - ( - "const elem = document.getElementById(" - f'"{PKG_NAME}-" + name + "-tab");' - ) - ) + writer.write(f'const tab = new TabInterface("{self.name}");') + + # GET RID OF THESE + writer.write("const name = tab.name;") + writer.write("const elem = tab.element;") # Declare a function for sending messages. with writer.padding(): From 44da663bccb58706821cf422a3dafaddcac86b27 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 17 Mar 2024 20:08:38 -0500 Subject: [PATCH 11/15] Some JavaScript startup refactoring --- runtimepy/data/js/main.js | 67 +++++++++++++++++++++++++------ runtimepy/data/js/setup_worker.js | 29 ------------- runtimepy/data/js/worker.js | 20 ++++++--- 3 files changed, 69 insertions(+), 47 deletions(-) diff --git a/runtimepy/data/js/main.js b/runtimepy/data/js/main.js index 22a4c685..359e7956 100644 --- a/runtimepy/data/js/main.js +++ b/runtimepy/data/js/main.js @@ -1,18 +1,61 @@ -/* - * Application entry. - */ -async function main(config) { - /* Send configuration data to the worker. */ - config["worker"] = worker_config(config); - worker.postMessage(config); - - /* Run tab initialization. */ - for await (const init of inits) { - await init(); +function worker_config(config) { + let worker_cfg = {}; + + /* Look for connections to establish. */ + let ports = config["config"]["ports"]; + for (let port_idx in ports) { + let port = ports[port_idx]; + + /* This business logic could use some work. */ + if (port["name"].includes("runtimepy_websocket")) { + if (port["name"].includes("data")) { + worker_cfg["data"] = "ws://localhost:" + port["port"]; + } else { + worker_cfg["json"] = "ws://localhost:" + port["port"]; + } + } + } + + return worker_cfg; +} + +class App { + constructor(config, worker) { + this.config = config; + this.worker = worker; + + this.config["worker"] = worker_config(this.config); + } + + async main() { + /* + * Run application initialization when the worker thread responds with an + * expected value. + */ + worker.addEventListener("message", async (event) => { + if (event.data == 0) { + /* Run tab initialization. */ + for await (const init of inits) { + await init(); + } + + /* Prepare worker message handler. */ + this.worker.onmessage = async (event) => { + /**/ + console.log(`Main thread received: ${event.data}.`); + /**/ + }; + } + }, {once : true}); + + /* Start worker. */ + this.worker.postMessage(this.config); } } /* Load configuration data then run application entry. */ window.onload = async () => { - await main(await (await fetch(window.location.origin + "/json")).json()); + await (new App(await (await fetch(window.location.origin + "/json")).json(), + worker)) + .main(); }; diff --git a/runtimepy/data/js/setup_worker.js b/runtimepy/data/js/setup_worker.js index c4daa811..57b85625 100644 --- a/runtimepy/data/js/setup_worker.js +++ b/runtimepy/data/js/setup_worker.js @@ -1,24 +1,3 @@ -function worker_config(config) { - let worker_cfg = {}; - - /* Look for connections to establish. */ - let ports = config["config"]["ports"]; - for (let port_idx in ports) { - let port = ports[port_idx]; - - /* This business logic could use some work. */ - if (port["name"].includes("runtimepy_websocket")) { - if (port["name"].includes("data")) { - worker_cfg["data"] = "ws://localhost:" + port["port"]; - } else { - worker_cfg["json"] = "ws://localhost:" + port["port"]; - } - } - } - - return worker_cfg; -} - /* * Do some heinous sh*t to create a worker from our 'text/js-worker' element. */ @@ -29,11 +8,3 @@ const worker = new Worker(window.URL.createObjectURL(new Blob( ), {type : "text/javascript"}, ))); - -/* - * Worker message handler. - */ -function worker_message(event) { - console.log(`Main thread received: ${event.data}.`); -} -worker.onmessage = worker_message; diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index dbe0ffae..4ad3b037 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -11,14 +11,21 @@ function create_connections(config) { return conns; } +started = false; + +let conns; + /* Worker entry. */ async function start(config) { console.log(config); - let conns = create_connections(config); -} + conns = create_connections(config); -started = false; + /* Wait for both connections to be established. */ + + /* Tell main thread we're ready to go. */ + postMessage(0); +} /* Handle messages from the main thread. */ onmessage = async (event) => { @@ -27,8 +34,9 @@ onmessage = async (event) => { await start(event.data); started = true; } else { - /* Additional messages not handled. */ - console.log("MESSAGE NOT HANDLED."); - console.log(event.data); + console.log(`Worker thread received: ${event.data}.`); + + /* Need to make this actually work. */ + // conns["json"].send_json({"ui" : event.data}); } }; From e7bde6f012e89c8a1929e10824f9317913493260 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Sun, 17 Mar 2024 20:23:16 -0500 Subject: [PATCH 12/15] JavaScript refactoring for server messages --- runtimepy/data/js/JsonConnection.js | 10 ++++++---- runtimepy/data/js/worker.js | 25 ++++++++----------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/runtimepy/data/js/JsonConnection.js b/runtimepy/data/js/JsonConnection.js index c77b037f..1f88c5a4 100644 --- a/runtimepy/data/js/JsonConnection.js +++ b/runtimepy/data/js/JsonConnection.js @@ -11,7 +11,10 @@ class JsonConnection { this.conn.binaryType = "arraybuffer"; /* State. */ - this.connected = false; + this.connected = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject + }); /* Register handlers. */ this.conn.onclose = this.onclose.bind(this); @@ -82,13 +85,12 @@ class JsonConnection { onopen(event) { console.log(`Connection ${this.toString()} open.`); - this.connected = true; - /* Should run some initialization method here. */ + this.resolve(); } onclose(event) { console.log(`Connection ${this.toString()} closed.`); - this.connected = false; + this.reject(); } onerror(event) { diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 4ad3b037..641064a5 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -11,10 +11,6 @@ function create_connections(config) { return conns; } -started = false; - -let conns; - /* Worker entry. */ async function start(config) { console.log(config); @@ -22,21 +18,16 @@ async function start(config) { conns = create_connections(config); /* Wait for both connections to be established. */ + for (const key in conns) { + await conns[key].connected; + } + + /* Forward all other messages to the server. */ + onmessage = async (event) => { conns["json"].send_json({"ui" : event.data}); } /* Tell main thread we're ready to go. */ postMessage(0); } -/* Handle messages from the main thread. */ -onmessage = async (event) => { - /* First message.*/ - if (!started) { - await start(event.data); - started = true; - } else { - console.log(`Worker thread received: ${event.data}.`); - - /* Need to make this actually work. */ - // conns["json"].send_json({"ui" : event.data}); - } -}; +/* Handle first message from the main thread. */ +onmessage = async (event) => { await start(event.data); }; From ae0d763cfb3d91d064975c04d1cf094478a3878f Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Mon, 18 Mar 2024 00:33:11 -0500 Subject: [PATCH 13/15] More UI integration --- runtimepy/data/js/JsonConnection.js | 15 ++- runtimepy/data/js/dev.js | 5 - runtimepy/data/js/main.js | 9 +- runtimepy/data/js/setup_tabs.js | 32 ++++- runtimepy/data/js/tabs/dev.js | 11 ++ runtimepy/data/js/worker.js | 6 +- runtimepy/net/server/app/files.py | 54 ++++++--- runtimepy/net/server/app/tab.py | 129 +++++---------------- runtimepy/net/server/websocket/__init__.py | 12 +- runtimepy/net/stream/json/base.py | 3 + tasks/html.py | 28 +---- 11 files changed, 147 insertions(+), 157 deletions(-) delete mode 100644 runtimepy/data/js/dev.js create mode 100644 runtimepy/data/js/tabs/dev.js diff --git a/runtimepy/data/js/JsonConnection.js b/runtimepy/data/js/JsonConnection.js index 1f88c5a4..d48eb5f4 100644 --- a/runtimepy/data/js/JsonConnection.js +++ b/runtimepy/data/js/JsonConnection.js @@ -21,6 +21,9 @@ class JsonConnection { this.conn.onerror = this.onerror.bind(this); this.conn.onmessage = this.onmessage.bind(this); this.conn.onopen = this.onopen.bind(this); + + /* Individual message handlers. */ + this.message_handlers = {}; } /* @@ -42,12 +45,18 @@ class JsonConnection { } } - // handle any keys we haven't handled yet - console.log(data); + for (const key in data) { + if (key in this.message_handlers) { + this.message_handlers[key](data[key]); + } else { + console.log(`(not handled) ${key}: ${data[key]}`); + } + } /* Send our response. */ - if (response) { + for (const _ in response) { this.send_json(response); + return; } } diff --git a/runtimepy/data/js/dev.js b/runtimepy/data/js/dev.js deleted file mode 100644 index 31bb1ba3..00000000 --- a/runtimepy/data/js/dev.js +++ /dev/null @@ -1,5 +0,0 @@ -document.getElementById(name + "-button").onclick = async event => { - /**/ - tab.send_message({kind : "button.pressed"}); - /**/ -}; diff --git a/runtimepy/data/js/main.js b/runtimepy/data/js/main.js index 359e7956..7b19bda9 100644 --- a/runtimepy/data/js/main.js +++ b/runtimepy/data/js/main.js @@ -41,9 +41,12 @@ class App { /* Prepare worker message handler. */ this.worker.onmessage = async (event) => { - /**/ - console.log(`Main thread received: ${event.data}.`); - /**/ + for (const key in event.data) { + /* Handle forwarding messages to individual tabs. */ + if (key in tabs) { + tabs[key].onmessage(event.data[key]); + } + } }; } }, {once : true}); diff --git a/runtimepy/data/js/setup_tabs.js b/runtimepy/data/js/setup_tabs.js index 83b1a8e6..07378116 100644 --- a/runtimepy/data/js/setup_tabs.js +++ b/runtimepy/data/js/setup_tabs.js @@ -1,15 +1,43 @@ /* An array of initialization methods to run. */ let inits = []; +let tabs = {}; /* * Define class for generated code to use (instead of generating so many * methods). */ class TabInterface { - constructor(name) { + constructor(name, _worker) { this.name = name; + this.worker = _worker; + this.element = document.getElementById("runtimepy-" + this.name + "-tab"); + + this.message_handlers = []; + + this.element.addEventListener("hidden.bs.tab", + this.hidden_handler.bind(this)); + this.element.addEventListener("shown.bs.tab", + this.shown_handler.bind(this)); + + tabs[this.name] = this; + + if (bootstrap.Tab.getInstance(this.element)) { + this.shown_handler(); + } } - send_message(data) { worker.postMessage({name : this.name, event : data}); } + send_message(data) { + this.worker.postMessage({name : this.name, event : data}); + } + + shown_handler() { this.send_message({kind : "tab.shown"}); } + + hidden_handler() { this.send_message({kind : "tab.hidden"}); } + + onmessage(data) { + for (const handler of this.message_handlers) { + handler(data); + } + } } diff --git a/runtimepy/data/js/tabs/dev.js b/runtimepy/data/js/tabs/dev.js new file mode 100644 index 00000000..871e8de3 --- /dev/null +++ b/runtimepy/data/js/tabs/dev.js @@ -0,0 +1,11 @@ +document.getElementById(tab.name + "-button").onclick = async event => { + /**/ + tab.send_message({kind : "button.pressed"}); + /**/ +}; + +tab.message_handlers.push((data) => { + console.log("-----" + tab.name + "-----"); + console.log(data); + console.log("---------------------------"); +}); diff --git a/runtimepy/data/js/worker.js b/runtimepy/data/js/worker.js index 641064a5..3d0b8649 100644 --- a/runtimepy/data/js/worker.js +++ b/runtimepy/data/js/worker.js @@ -23,7 +23,11 @@ async function start(config) { } /* Forward all other messages to the server. */ - onmessage = async (event) => { conns["json"].send_json({"ui" : event.data}); } + onmessage = + async (event) => { conns["json"].send_json({"ui" : event.data}); }; + + /* Add message handler to forward UI messages to the main thread. */ + conns["json"].message_handlers["ui"] = (data) => { postMessage(data); }; /* Tell main thread we're ready to go. */ postMessage(0); diff --git a/runtimepy/net/server/app/files.py b/runtimepy/net/server/app/files.py index d96d90a0..95aab1be 100644 --- a/runtimepy/net/server/app/files.py +++ b/runtimepy/net/server/app/files.py @@ -4,6 +4,7 @@ # built-in from io import StringIO +from typing import Optional # third-party from svgen.element import Element @@ -15,29 +16,48 @@ from runtimepy import PKG_NAME -def write_found_file(writer: IndentedFileWriter, *args, **kwargs) -> None: +def write_found_file(writer: IndentedFileWriter, *args, **kwargs) -> bool: """Write a file's contents to the file-writer's stream.""" + result = False + entry = find_file(*args, **kwargs) - assert entry is not None - with entry.open(encoding=DEFAULT_ENCODING) as path_fd: - for line in path_fd: - writer.write(line) + if entry is not None: + with entry.open(encoding=DEFAULT_ENCODING) as path_fd: + for line in path_fd: + writer.write(line) + + result = True + + return result def set_text_to_file(element: Element, *args, **kwargs) -> None: """Set an element's text to the contents of a file.""" with StringIO() as stream: - write_found_file( + result = write_found_file( IndentedFileWriter(stream, per_indent=2), *args, **kwargs ) - element.text = stream.getvalue() + if result: + element.text = stream.getvalue() + return result -def kind_url(kind: str, name: str, package: str = PKG_NAME) -> str: + +def kind_url( + kind: str, name: str, subdir: str = None, package: str = PKG_NAME +) -> str: """Return a URL to find a package resource.""" - return f"package://{package}/{kind}/{name}.{kind}" + + path = kind + + if subdir is not None: + path += "/" + subdir + + path += f"/{name}" + + return f"package://{package}/{path}.{kind}" def set_text_to_kind( @@ -54,16 +74,22 @@ def append_kind( package: str = PKG_NAME, kind: str = "js", tag: str = "script", -) -> Element: +) -> Optional[Element]: """Append a new script element.""" elem = Element(tag=tag) with StringIO() as stream: writer = IndentedFileWriter(stream, per_indent=2) + found_count = 0 for name in names: - write_found_file(writer, kind_url(kind, name, package=package)) - elem.text = stream.getvalue() + if write_found_file(writer, kind_url(kind, name, package=package)): + found_count += 1 + + if found_count: + elem.text = stream.getvalue() + + if found_count: + element.children.append(elem) - element.children.append(elem) - return elem + return elem if found_count else None diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index 00fcbafe..481fd8e2 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -14,6 +14,11 @@ from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.files import ( + append_kind, + kind_url, + write_found_file, +) class Tab: @@ -29,106 +34,21 @@ def __init__(self, name: str, app: AppInfo, tabs: TabbedContent) -> None: # What should we put here? self.button.text = self.name - self.populate_elements(self.content) + self.compose(self.content) - def populate_elements(self, parent: Element) -> None: - """Populate tab elements.""" + def compose(self, parent: Element) -> None: + """Compose the tab's HTML elements.""" - for idx in range(10): - div( - parent=parent, - text=f"Hello, world! ({idx})" * 10, - style="white-space: nowrap;", - ) + if append_kind(parent, self.name, kind="html", tag="div") is None: + for idx in range(100): + div(parent=parent, text=f"Hello, world! ({idx})") - def populate_shown_inner(self, writer: IndentedFileWriter) -> None: - """Populate the tab-shown handler.""" + def write_js(self, writer: IndentedFileWriter) -> bool: + """Write JavaScript code for the tab.""" - self.send_message(writer, 'kind: "tab.shown"') - - def populate_shown(self, writer: IndentedFileWriter) -> None: - """Populate the tab-shown handler.""" - - shown_handler = f"{self.name}_shown_handler" - writer.write(f"async function {shown_handler}() " + "{") - with writer.indented(): - self.populate_shown_inner(writer) - writer.write("}") - - # If this tab is already/currently shown, run the handler now. - writer.write("if (bootstrap.Tab.getInstance(elem)) {") - with writer.indented(): - writer.write(f"await {shown_handler}();") - writer.write("}") - - # Add event listener. - writer.write("elem.addEventListener('shown.bs.tab', async event => {") - with writer.indented(): - writer.write(f"await {shown_handler}();") - writer.write("});") - - def send_message(self, writer: IndentedFileWriter, data: str) -> None: - """Send a message to the worker thread.""" - - writer.write("send_message({" + data + "});") - - def populate_hidden_inner(self, writer: IndentedFileWriter) -> None: - """Populate the tab-hidden handler.""" - - self.send_message(writer, 'kind: "tab.hidden"') - - def populate_hidden(self, writer: IndentedFileWriter) -> None: - """Populate the tab-hidden handler.""" - - hidden_handler = f"{self.name}_hidden_handler" - writer.write(f"async function {hidden_handler}() " + "{") - with writer.indented(): - self.populate_hidden_inner(writer) - writer.write("}") - - # Add event listener. - writer.write("elem.addEventListener('hidden.bs.tab', async event => {") - with writer.indented(): - writer.write(f"await {hidden_handler}();") - writer.write("});") - - def init_script(self, writer: IndentedFileWriter) -> None: - """Initialize script code.""" - - def _init_script(self, writer: IndentedFileWriter) -> None: - """Create this tab's initialization script.""" - - writer.c_comment("Useful constants.") - - # Declare useful variables. - writer.write(f'const tab = new TabInterface("{self.name}");') - - # GET RID OF THESE - writer.write("const name = tab.name;") - writer.write("const elem = tab.element;") - - # Declare a function for sending messages. - with writer.padding(): - writer.c_comment("Worker/server messaging interface.") - writer.write("function send_message(data) {") - with writer.indented(): - writer.write( - 'worker.postMessage({name: "' - + self.name - + '", event: data});' - ) - writer.write("}") - - writer.c_comment("Tab-specific code start.") - self.init_script(writer) - writer.c_comment("Tab-specific code end.") - - with writer.padding(): - writer.c_comment("Tab-shown handling.") - self.populate_shown(writer) - - writer.c_comment("Tab-hidden handling.") - self.populate_hidden(writer) + return write_found_file( + writer, kind_url("js", self.name, subdir="tabs") + ) def entry(self) -> None: """Tab overall script entry.""" @@ -138,12 +58,17 @@ def entry(self) -> None: writer.write("inits.push(async () => {") with writer.indented(): - self._init_script(writer) + writer.write( + f'const tab = new TabInterface("{self.name}", worker);' + ) + writer.empty() + result = self.write_js(writer) writer.write("});") - div( - tag="script", - parent=self.content, - text=cast(StringIO, writer.stream).getvalue(), - ) + if result: + div( + tag="script", + parent=self.content, + text=cast(StringIO, writer.stream).getvalue(), + ) diff --git a/runtimepy/net/server/websocket/__init__.py b/runtimepy/net/server/websocket/__init__.py index facb1f29..54747090 100644 --- a/runtimepy/net/server/websocket/__init__.py +++ b/runtimepy/net/server/websocket/__init__.py @@ -4,6 +4,7 @@ # internal from runtimepy.net.arbiter.tcp.json import WebsocketJsonMessageConnection +from runtimepy.net.stream.json.types import JsonMessage from runtimepy.net.websocket import WebsocketConnection @@ -14,7 +15,16 @@ def _register_handlers(self) -> None: """Register connection-specific command handlers.""" super()._register_handlers() - print("TODO") + + async def ui_handler(outbox: JsonMessage, inbox: JsonMessage) -> None: + """A simple loopback handler.""" + + self.logger.info("Got UI message: %s.", inbox) + + # Connect tabs to tab messaging somehow. + outbox[inbox["name"]] = {"GOOD_SHIT": "BUD"} + + self.basic_handler("ui", ui_handler) class RuntimepyDataWebsocketConnection(WebsocketConnection): diff --git a/runtimepy/net/stream/json/base.py b/runtimepy/net/stream/json/base.py index ba8a776c..b469c0df 100644 --- a/runtimepy/net/stream/json/base.py +++ b/runtimepy/net/stream/json/base.py @@ -363,6 +363,9 @@ async def process_json( if keys_ignored: response["keys_ignored"] = sorted(keys_ignored) + self.logger.warning( + "Ignored incoming message keys: %s.", ", ".join(keys_ignored) + ) if self._handle_reserved(data, response) and response: self.send_json(response, addr=addr) diff --git a/tasks/html.py b/tasks/html.py index 7f914e86..ac1097d4 100644 --- a/tasks/html.py +++ b/tasks/html.py @@ -2,41 +2,17 @@ A module for working on HTML ideas. """ -# third-party -from svgen.element import Element -from vcorelib.io.file_writer import IndentedFileWriter - # internal from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent -from runtimepy.net.server.app.files import ( - append_kind, - kind_url, - write_found_file, -) from runtimepy.net.server.app.tab import Tab -class DevTab(Tab): - """A developmental tab.""" - - def populate_elements(self, parent: Element) -> None: - """Populate tab elements.""" - - append_kind(parent, self.name, kind="html", tag="div") - - def init_script(self, writer: IndentedFileWriter) -> None: - """Initialize script code.""" - - write_found_file(writer, kind_url("js", self.name)) - - def sample(app: AppInfo, tabs: TabbedContent) -> None: """Populate application elements.""" # Add dev tab. - DevTab("dev", app, tabs).entry() + Tab("dev", app, tabs).entry() for idx in range(10): - tab = Tab(f"test{idx}", app, tabs) - tab.entry() + Tab(f"test{idx}", app, tabs).entry() From 11fbe97f4ff8dbd3c205bb7c74d9ce47ded6e90d Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Mon, 18 Mar 2024 12:13:07 -0500 Subject: [PATCH 14/15] Full test coverage --- runtimepy/data/html/dev.html | 7 +-- runtimepy/data/html/example.html | 4 +- runtimepy/data/js/JsonConnection.js | 5 +- runtimepy/data/js/setup_tabs.js | 13 ++-- runtimepy/data/js/tabs/dev.js | 2 +- runtimepy/data/server.yaml | 2 +- runtimepy/data/server_dev.yaml | 2 +- runtimepy/net/arbiter/info.py | 6 -- runtimepy/net/server/app/__init__.py | 19 ++---- runtimepy/net/server/app/base.py | 6 +- runtimepy/net/server/app/files.py | 6 +- runtimepy/net/server/app/tab.py | 25 ++++---- runtimepy/net/server/websocket/__init__.py | 2 +- tasks/html.py | 18 ------ .../connection_arbiter/runtimepy_http.yaml | 3 + tests/data/valid/http/sample.json | 1 + tests/net/server/__init__.py | 61 +++++++++++++++++++ tests/net/server/app/test_elements.py | 6 +- tests/net/stream/__init__.py | 33 +++------- 19 files changed, 123 insertions(+), 98 deletions(-) delete mode 100644 tasks/html.py create mode 100644 tests/data/valid/http/sample.json diff --git a/runtimepy/data/html/dev.html b/runtimepy/data/html/dev.html index cc3611b3..b2e22d03 100644 --- a/runtimepy/data/html/dev.html +++ b/runtimepy/data/html/dev.html @@ -1,5 +1,2 @@ -
- Hello, world! (dev tab) -
- - +
Hello, world!
+ diff --git a/runtimepy/data/html/example.html b/runtimepy/data/html/example.html index 04f871a5..5f99c0d8 100644 --- a/runtimepy/data/html/example.html +++ b/runtimepy/data/html/example.html @@ -1 +1,3 @@ -
Hello, world!
+
Hello, world! (1)
+
Hello, world! (2)
+
Hello, world! (3)
diff --git a/runtimepy/data/js/JsonConnection.js b/runtimepy/data/js/JsonConnection.js index d48eb5f4..d1e3171c 100644 --- a/runtimepy/data/js/JsonConnection.js +++ b/runtimepy/data/js/JsonConnection.js @@ -48,8 +48,9 @@ class JsonConnection { for (const key in data) { if (key in this.message_handlers) { this.message_handlers[key](data[key]); - } else { - console.log(`(not handled) ${key}: ${data[key]}`); + } else if (!(key in response)) { + console.log(`(not handled) ${key}:`); + console.log(data[key]); } } diff --git a/runtimepy/data/js/setup_tabs.js b/runtimepy/data/js/setup_tabs.js index 07378116..091708ce 100644 --- a/runtimepy/data/js/setup_tabs.js +++ b/runtimepy/data/js/setup_tabs.js @@ -11,18 +11,19 @@ class TabInterface { this.name = name; this.worker = _worker; - this.element = document.getElementById("runtimepy-" + this.name + "-tab"); + /* Relevant elements. */ + this.button = document.getElementById("runtimepy-" + this.name + "-tab"); + this.container = document.getElementById("runtimepy-" + this.name); this.message_handlers = []; - this.element.addEventListener("hidden.bs.tab", - this.hidden_handler.bind(this)); - this.element.addEventListener("shown.bs.tab", - this.shown_handler.bind(this)); + this.button.addEventListener("hidden.bs.tab", + this.hidden_handler.bind(this)); + this.button.addEventListener("shown.bs.tab", this.shown_handler.bind(this)); tabs[this.name] = this; - if (bootstrap.Tab.getInstance(this.element)) { + if (bootstrap.Tab.getInstance(this.button)) { this.shown_handler(); } } diff --git a/runtimepy/data/js/tabs/dev.js b/runtimepy/data/js/tabs/dev.js index 871e8de3..5262a47a 100644 --- a/runtimepy/data/js/tabs/dev.js +++ b/runtimepy/data/js/tabs/dev.js @@ -1,4 +1,4 @@ -document.getElementById(tab.name + "-button").onclick = async event => { +tab.container.querySelector("button").onclick = async event => { /**/ tab.send_message({kind : "button.pressed"}); /**/ diff --git a/runtimepy/data/server.yaml b/runtimepy/data/server.yaml index 8704aa3b..90e76a43 100644 --- a/runtimepy/data/server.yaml +++ b/runtimepy/data/server.yaml @@ -1,6 +1,6 @@ --- includes: - # Un-comment while developing. + # Can un-comment while developing. # - server_dev.yaml - package://runtimepy/server_base.yaml diff --git a/runtimepy/data/server_dev.yaml b/runtimepy/data/server_dev.yaml index a41fdce5..afe27d65 100644 --- a/runtimepy/data/server_dev.yaml +++ b/runtimepy/data/server_dev.yaml @@ -3,8 +3,8 @@ config: localhost: true caching: false + # This is the default. # html_method: runtimepy.net.server.app.sample - html_method: tasks.html.sample port_overrides: runtimepy_http_server: 8000 diff --git a/runtimepy/net/arbiter/info.py b/runtimepy/net/arbiter/info.py index 3b81f7a5..0fabd890 100644 --- a/runtimepy/net/arbiter/info.py +++ b/runtimepy/net/arbiter/info.py @@ -7,7 +7,6 @@ from contextlib import AsyncExitStack as _AsyncExitStack from dataclasses import dataclass from logging import getLogger as _getLogger -from pathlib import Path from re import compile as _compile from typing import Any, Dict from typing import Iterator as _Iterator @@ -196,8 +195,3 @@ def config_param(self, key: str, default: Z, strict: bool = False) -> Z: assert key in config, (key, config) return config.get(key, default) - - @property - def wasm_root(self) -> Path: - """Get the WebAssembly root directory.""" - return Path(self.config_param("wasm_root", str(Path()), strict=True)) diff --git a/runtimepy/net/server/app/__init__.py b/runtimepy/net/server/app/__init__.py index 85604175..e6cc9043 100644 --- a/runtimepy/net/server/app/__init__.py +++ b/runtimepy/net/server/app/__init__.py @@ -11,26 +11,17 @@ from runtimepy.net.server import RuntimepyServerConnection from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.create import config_param, create_app -from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.tab import Tab def sample(app: AppInfo, tabs: TabbedContent) -> None: """Populate application elements.""" - for idx in range(10): - item = f"test{idx}" - - button, content = tabs.create(item) - - # what should we put here? - button.text = item + # Add dev tab. + Tab("example", app, tabs, source="dev").entry() - for idx in range(100): - msg = f"Hello, world! ({idx})" - text = ", ".join(list(msg for _ in range(20))) - div(parent=content, text=text, style="white-space: nowrap;") - - del app + for idx in range(10): + Tab(f"test{idx}", app, tabs, source="example").entry() async def setup(app: AppInfo) -> int: diff --git a/runtimepy/net/server/app/base.py b/runtimepy/net/server/app/base.py index 2a4823b2..fe3a9ae7 100644 --- a/runtimepy/net/server/app/base.py +++ b/runtimepy/net/server/app/base.py @@ -41,9 +41,9 @@ def populate(self, document: Html, app: TabPopulater) -> None: append_kind(document.head, *self.css_paths, kind="css", tag="style") # Worker code. - append_kind(document.body, *self.worker_source_paths)[ - "type" - ] = "text/js-worker" + worker = append_kind(document.body, *self.worker_source_paths) + if worker is not None: + worker["type"] = "text/js-worker" # Set up worker. append_kind(document.body, "setup_worker", "setup_tabs") diff --git a/runtimepy/net/server/app/files.py b/runtimepy/net/server/app/files.py index 95aab1be..b593fd59 100644 --- a/runtimepy/net/server/app/files.py +++ b/runtimepy/net/server/app/files.py @@ -32,7 +32,7 @@ def write_found_file(writer: IndentedFileWriter, *args, **kwargs) -> bool: return result -def set_text_to_file(element: Element, *args, **kwargs) -> None: +def set_text_to_file(element: Element, *args, **kwargs) -> bool: """Set an element's text to the contents of a file.""" with StringIO() as stream: @@ -62,10 +62,10 @@ def kind_url( def set_text_to_kind( element: Element, kind: str, name: str, package: str = PKG_NAME -) -> None: +) -> bool: """Set text to HTML-file contents at a predictable path.""" - set_text_to_file(element, kind_url(kind, name, package=package)) + return set_text_to_file(element, kind_url(kind, name, package=package)) def append_kind( diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index 481fd8e2..c4b54481 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -24,10 +24,14 @@ class Tab: """A simple application-tab interface class.""" - def __init__(self, name: str, app: AppInfo, tabs: TabbedContent) -> None: + def __init__( + self, name: str, app: AppInfo, tabs: TabbedContent, source: str = None + ) -> None: """Initialize this instance.""" self.name = name + self.source = source if source else self.name + self.app = app self.button, self.content = tabs.create(self.name) @@ -39,15 +43,13 @@ def __init__(self, name: str, app: AppInfo, tabs: TabbedContent) -> None: def compose(self, parent: Element) -> None: """Compose the tab's HTML elements.""" - if append_kind(parent, self.name, kind="html", tag="div") is None: - for idx in range(100): - div(parent=parent, text=f"Hello, world! ({idx})") + append_kind(parent, self.source, kind="html", tag="div") def write_js(self, writer: IndentedFileWriter) -> bool: """Write JavaScript code for the tab.""" return write_found_file( - writer, kind_url("js", self.name, subdir="tabs") + writer, kind_url("js", self.source, subdir="tabs") ) def entry(self) -> None: @@ -62,13 +64,12 @@ def entry(self) -> None: f'const tab = new TabInterface("{self.name}", worker);' ) writer.empty() - result = self.write_js(writer) + self.write_js(writer) writer.write("});") - if result: - div( - tag="script", - parent=self.content, - text=cast(StringIO, writer.stream).getvalue(), - ) + div( + tag="script", + parent=self.content, + text=cast(StringIO, writer.stream).getvalue(), + ) diff --git a/runtimepy/net/server/websocket/__init__.py b/runtimepy/net/server/websocket/__init__.py index 54747090..1b6f6157 100644 --- a/runtimepy/net/server/websocket/__init__.py +++ b/runtimepy/net/server/websocket/__init__.py @@ -22,7 +22,7 @@ async def ui_handler(outbox: JsonMessage, inbox: JsonMessage) -> None: self.logger.info("Got UI message: %s.", inbox) # Connect tabs to tab messaging somehow. - outbox[inbox["name"]] = {"GOOD_SHIT": "BUD"} + outbox[inbox["name"]] = inbox["event"] self.basic_handler("ui", ui_handler) diff --git a/tasks/html.py b/tasks/html.py deleted file mode 100644 index ac1097d4..00000000 --- a/tasks/html.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -A module for working on HTML ideas. -""" - -# internal -from runtimepy.net.arbiter.info import AppInfo -from runtimepy.net.server.app.bootstrap.tabs import TabbedContent -from runtimepy.net.server.app.tab import Tab - - -def sample(app: AppInfo, tabs: TabbedContent) -> None: - """Populate application elements.""" - - # Add dev tab. - Tab("dev", app, tabs).entry() - - for idx in range(10): - Tab(f"test{idx}", app, tabs).entry() diff --git a/tests/data/valid/connection_arbiter/runtimepy_http.yaml b/tests/data/valid/connection_arbiter/runtimepy_http.yaml index feb1253c..0c642b6a 100644 --- a/tests/data/valid/connection_arbiter/runtimepy_http.yaml +++ b/tests/data/valid/connection_arbiter/runtimepy_http.yaml @@ -5,6 +5,9 @@ includes_left: app: - tests.net.stream.runtimepy_http_test +config: + foo: bar + clients: - factory: runtimepy_http name: runtimepy_http_client diff --git a/tests/data/valid/http/sample.json b/tests/data/valid/http/sample.json new file mode 100644 index 00000000..dce8ca0c --- /dev/null +++ b/tests/data/valid/http/sample.json @@ -0,0 +1 @@ +{"a": 1, "b": 2, "c": 3} diff --git a/tests/net/server/__init__.py b/tests/net/server/__init__.py index e69de29b..a2012e1f 100644 --- a/tests/net/server/__init__.py +++ b/tests/net/server/__init__.py @@ -0,0 +1,61 @@ +""" +Test HTTP server interactions. +""" + +# built-in +import asyncio + +# module under test +from runtimepy.net.http.header import RequestHeader +from runtimepy.net.server import RuntimepyServerConnection +from runtimepy.net.server.websocket import RuntimepyWebsocketConnection + +# internal +from tests.resources import resource + + +async def runtimepy_websocket_client( + client: RuntimepyWebsocketConnection, +) -> None: + """Test client interactions via WebSocket.""" + + client.send_json( + {"ui": {"name": "test", "event": {"a": 1, "b": 2, "c": 3}}} + ) + + +async def runtimepy_http_client_server( + client: RuntimepyServerConnection, server: RuntimepyServerConnection +) -> None: + """Test HTTP client and server interactions.""" + + # Add another path to server. + server.add_path(resource("http"), front=True) + + env = "connection_metrics_poller" + env_path = f"/json/environments/{env}" + + # Make requests in parallel. + await asyncio.gather( + *( + # Application. + client.request(RequestHeader(target="/")), + client.request(RequestHeader(target="/index.html")), + # Files from file-system. + client.request(RequestHeader(target="/sample.json")), + client.request(RequestHeader(target="/manifest.yaml")), + client.request(RequestHeader(target="/pyproject.toml")), + # favicon.ico. + client.request(RequestHeader(target="/favicon.ico")), + # JSON queries. + client.request_json(RequestHeader(target="/json")), + client.request_json(RequestHeader(target="/json//////")), + client.request_json(RequestHeader(target="/json/a")), + client.request_json(RequestHeader(target="/json/test")), + client.request_json(RequestHeader(target="/json/test/a")), + client.request_json(RequestHeader(target="/json/test/a/b")), + client.request_json(RequestHeader(target="/json/test/d")), + client.request_json(RequestHeader(target=env_path)), + client.request_json(RequestHeader(target=f"{env_path}/values")), + ) + ) diff --git a/tests/net/server/app/test_elements.py b/tests/net/server/app/test_elements.py index 14c61b9d..23ee841a 100644 --- a/tests/net/server/app/test_elements.py +++ b/tests/net/server/app/test_elements.py @@ -4,9 +4,13 @@ # module under test from runtimepy.net.server.app.elements import kind +from runtimepy.net.server.app.pyodide import add_pyodide_js def test_html_kind_basic(): """Test basic HTML-loading scenarios.""" - assert kind("example") + elem = kind("example") + assert elem + + assert add_pyodide_js(elem) diff --git a/tests/net/stream/__init__.py b/tests/net/stream/__init__.py index 2d7bb7e0..11869e73 100644 --- a/tests/net/stream/__init__.py +++ b/tests/net/stream/__init__.py @@ -17,10 +17,15 @@ from runtimepy.net.http.header import RequestHeader from runtimepy.net.http.response import ResponseHeader from runtimepy.net.server import RuntimepyServerConnection +from runtimepy.net.server.websocket import RuntimepyWebsocketConnection from runtimepy.net.stream import StringMessageConnection from runtimepy.net.stream.json import JsonMessage, JsonMessageConnection from runtimepy.net.tcp.http import HttpConnection from runtimepy.net.udp import UdpConnection +from tests.net.server import ( + runtimepy_http_client_server, + runtimepy_websocket_client, +) # internal from tests.resources import SampleArbiterTask @@ -83,6 +88,8 @@ async def http_test(app: AppInfo) -> int: async def runtimepy_http_test(app: AppInfo) -> int: """A network application that tests this package's HTTP connection.""" + assert app.config_param("foo", "baz", strict=True) == "bar" + client = app.single(pattern="client", kind=RuntimepyServerConnection) conns = list(app.search(kind=RuntimepyServerConnection)) @@ -93,31 +100,11 @@ async def runtimepy_http_test(app: AppInfo) -> int: server = conn assert server is not None - env = "connection_metrics_poller" - env_path = f"/json/environments/{env}" - - # Make requests in parallel. - await asyncio.gather( - *( - # Application. - client.request(RequestHeader(target="/")), - client.request(RequestHeader(target="/index.html")), - # favicon.ico. - client.request(RequestHeader(target="/favicon.ico")), - # JSON queries. - client.request_json(RequestHeader(target="/json")), - client.request_json(RequestHeader(target="/json//////")), - client.request_json(RequestHeader(target="/json/a")), - client.request_json(RequestHeader(target="/json/test")), - client.request_json(RequestHeader(target="/json/test/a")), - client.request_json(RequestHeader(target="/json/test/a/b")), - client.request_json(RequestHeader(target="/json/test/d")), - client.request_json(RequestHeader(target=env_path)), - client.request_json(RequestHeader(target=f"{env_path}/values")), - ) + await runtimepy_websocket_client( + app.single(pattern="client", kind=RuntimepyWebsocketConnection) ) - del server + await runtimepy_http_client_server(client, server) return 0 From 787783263ba0f3bc4fb0d1f996a571e16273c4a8 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Mon, 18 Mar 2024 23:03:18 -0500 Subject: [PATCH 15/15] 3.11.1 - More HTML UI dev --- .github/workflows/python-package.yml | 2 +- README.md | 4 +- local/configs/package.yaml | 2 +- local/variables/package.yaml | 2 +- pyproject.toml | 2 +- runtimepy/__init__.py | 4 +- runtimepy/data/css/bootstrap_extra.css | 8 +++ runtimepy/data/html/dev.html | 2 - runtimepy/data/html/example.html | 3 - runtimepy/data/js/{tabs/dev.js => env.js} | 8 ++- runtimepy/data/js/main.js | 12 ++++ runtimepy/data/js/{ => unused}/pyodide.js | 0 runtimepy/data/server_dev.yaml | 2 +- runtimepy/net/server/app/__init__.py | 32 ++++++---- .../net/server/app/bootstrap/__init__.py | 58 ++++++++++++------- runtimepy/net/server/app/bootstrap/tabs.py | 50 ++++++++++++++-- runtimepy/net/server/app/env.py | 42 ++++++++++++++ runtimepy/net/server/app/tab.py | 14 +---- runtimepy/requirements.txt | 2 +- tasks/default.yaml | 3 + tests/net/server/app/test_files.py | 18 ++++++ 21 files changed, 206 insertions(+), 64 deletions(-) delete mode 100644 runtimepy/data/html/dev.html delete mode 100644 runtimepy/data/html/example.html rename runtimepy/data/js/{tabs/dev.js => env.js} (86%) rename runtimepy/data/js/{ => unused}/pyodide.js (100%) create mode 100644 runtimepy/net/server/app/env.py create mode 100644 tests/net/server/app/test_files.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a0c64ea2..f78ea61d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -68,7 +68,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=3.11.0 + repo=runtimepy version=3.11.1 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index 63833379..05b262c0 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=16b2cf0b2122fb98367ad624135dcbb3 + hash=bedc870d38c09f3340b08f2d190a45f6 ===================================== --> -# runtimepy ([3.11.0](https://pypi.org/project/runtimepy/)) +# runtimepy ([3.11.1](https://pypi.org/project/runtimepy/)) [![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/) ![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg) diff --git a/local/configs/package.yaml b/local/configs/package.yaml index 0fe67d26..75895e80 100644 --- a/local/configs/package.yaml +++ b/local/configs/package.yaml @@ -6,7 +6,7 @@ entry: {{entry}} requirements: - vcorelib>=3.2.0 - - svgen>=0.5.2 + - svgen>=0.6.0 - websockets - "windows-curses; sys_platform == 'win32' and python_version < '3.12'" diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 41d829c4..e6b0c648 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 3 minor: 11 -patch: 0 +patch: 1 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index 1180bb3b..a9781a21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "3.11.0" +version = "3.11.1" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index 59b999b3..a0b03544 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=b07cee45d00e3ed8152c5fef04b7d2c9 +# hash=d7d74df416629b2e2b52a6f7acab3ddc # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "3.11.0" +VERSION = "3.11.1" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/data/css/bootstrap_extra.css b/runtimepy/data/css/bootstrap_extra.css index 0454fa28..5fad6829 100644 --- a/runtimepy/data/css/bootstrap_extra.css +++ b/runtimepy/data/css/bootstrap_extra.css @@ -10,3 +10,11 @@ height: 100%; overflow: scroll; } + +.button-bodge { + text-align: left; +} + +.collapsing { + transition: none !important; +} diff --git a/runtimepy/data/html/dev.html b/runtimepy/data/html/dev.html deleted file mode 100644 index b2e22d03..00000000 --- a/runtimepy/data/html/dev.html +++ /dev/null @@ -1,2 +0,0 @@ -
Hello, world!
- diff --git a/runtimepy/data/html/example.html b/runtimepy/data/html/example.html deleted file mode 100644 index 5f99c0d8..00000000 --- a/runtimepy/data/html/example.html +++ /dev/null @@ -1,3 +0,0 @@ -
Hello, world! (1)
-
Hello, world! (2)
-
Hello, world! (3)
diff --git a/runtimepy/data/js/tabs/dev.js b/runtimepy/data/js/env.js similarity index 86% rename from runtimepy/data/js/tabs/dev.js rename to runtimepy/data/js/env.js index 5262a47a..cfc06151 100644 --- a/runtimepy/data/js/tabs/dev.js +++ b/runtimepy/data/js/env.js @@ -1,7 +1,10 @@ +console.log("env.js"); + +/* tab.container.querySelector("button").onclick = async event => { - /**/ + // tab.send_message({kind : "button.pressed"}); - /**/ + // }; tab.message_handlers.push((data) => { @@ -9,3 +12,4 @@ tab.message_handlers.push((data) => { console.log(data); console.log("---------------------------"); }); + */ diff --git a/runtimepy/data/js/main.js b/runtimepy/data/js/main.js index 7b19bda9..5e6e22b3 100644 --- a/runtimepy/data/js/main.js +++ b/runtimepy/data/js/main.js @@ -19,6 +19,16 @@ function worker_config(config) { return worker_cfg; } +function bootstrap_init() { + /* + * Enable tooltips. + * https://getbootstrap.com/docs/5.3/components/tooltips/#overview + */ + const tooltipTriggerList = document.querySelectorAll(".has-tooltip"); + const tooltipList = [...tooltipTriggerList ].map( + tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); +} + class App { constructor(config, worker) { this.config = config; @@ -53,6 +63,8 @@ class App { /* Start worker. */ this.worker.postMessage(this.config); + + bootstrap_init(); } } diff --git a/runtimepy/data/js/pyodide.js b/runtimepy/data/js/unused/pyodide.js similarity index 100% rename from runtimepy/data/js/pyodide.js rename to runtimepy/data/js/unused/pyodide.js diff --git a/runtimepy/data/server_dev.yaml b/runtimepy/data/server_dev.yaml index afe27d65..01123c16 100644 --- a/runtimepy/data/server_dev.yaml +++ b/runtimepy/data/server_dev.yaml @@ -4,7 +4,7 @@ config: caching: false # This is the default. - # html_method: runtimepy.net.server.app.sample + # html_method: runtimepy.net.server.app.channel_environments port_overrides: runtimepy_http_server: 8000 diff --git a/runtimepy/net/server/app/__init__.py b/runtimepy/net/server/app/__init__.py index e6cc9043..3410c88e 100644 --- a/runtimepy/net/server/app/__init__.py +++ b/runtimepy/net/server/app/__init__.py @@ -11,17 +11,7 @@ from runtimepy.net.server import RuntimepyServerConnection from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.create import config_param, create_app -from runtimepy.net.server.app.tab import Tab - - -def sample(app: AppInfo, tabs: TabbedContent) -> None: - """Populate application elements.""" - - # Add dev tab. - Tab("example", app, tabs, source="dev").entry() - - for idx in range(10): - Tab(f"test{idx}", app, tabs, source="example").entry() +from runtimepy.net.server.app.env import ChannelEnvironmentTab async def setup(app: AppInfo) -> int: @@ -29,10 +19,28 @@ async def setup(app: AppInfo) -> int: # Set default application. module, method = import_str_and_item( - config_param(app, "html_method", "runtimepy.net.server.app.sample") + config_param( + app, "html_method", "runtimepy.net.server.app.channel_environments" + ) ) RuntimepyServerConnection.default_app = create_app( app, getattr(_import_module(module), method) ) return 0 + + +def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: + """Populate application elements.""" + + # Connection tabs. + for name, conn in app.connections.items(): + ChannelEnvironmentTab( + name, conn.command, app, tabs, icon="ethernet" + ).entry() + + # Task tabs. + for name, task in app.tasks.items(): + ChannelEnvironmentTab( + name, task.command, app, tabs, icon="arrow-repeat" + ).entry() diff --git a/runtimepy/net/server/app/bootstrap/__init__.py b/runtimepy/net/server/app/bootstrap/__init__.py index 84815696..ffe66f53 100644 --- a/runtimepy/net/server/app/bootstrap/__init__.py +++ b/runtimepy/net/server/app/bootstrap/__init__.py @@ -6,36 +6,54 @@ # third-party from svgen.element import Element +# internal +from runtimepy.net.server.app.elements import div + +CDN = "cdn.jsdelivr.net" BOOTSTRAP_VERSION = "5.3.3" +ICONS_VERSION = "1.11.3" + + +def icon_str(icon: str) -> str: + """Get a boostrap icon string.""" + return f'' def add_bootstrap_css(element: Element) -> None: """Add boostrap CSS sources as a child of element.""" - element.children.append( - Element( - tag="link", - href=( - "https://cdn.jsdelivr.net/npm/bootstrap" - f"@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css" - ), - rel="stylesheet", - crossorigin="anonymous", - ) + div( + tag="link", + rel="stylesheet", + href=( + f"https://{CDN}/npm/" + f"bootstrap-icons@{ICONS_VERSION}/font/bootstrap-icons.min.css" + ), + parent=element, + ) + + div( + tag="link", + href=( + f"https://{CDN}/npm/bootstrap" + f"@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css" + ), + rel="stylesheet", + crossorigin="anonymous", + parent=element, ) def add_bootstrap_js(element: Element) -> None: """Add bootstrap JavaScript as a child of element.""" - element.children.append( - Element( - tag="script", - src=( - "https://cdn.jsdelivr.net/npm/bootstrap" - f"@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js" - ), - crossorigin="anonymous", - text="/* null */", - ) + div( + tag="script", + src=( + "https://cdn.jsdelivr.net/npm/bootstrap" + f"@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js" + ), + crossorigin="anonymous", + text="/* null */", + parent=element, ) diff --git a/runtimepy/net/server/app/bootstrap/tabs.py b/runtimepy/net/server/app/bootstrap/tabs.py index c99dd2e4..7423da7d 100644 --- a/runtimepy/net/server/app/bootstrap/tabs.py +++ b/runtimepy/net/server/app/bootstrap/tabs.py @@ -6,8 +6,12 @@ from svgen.element import Element # internal +from runtimepy import PKG_NAME +from runtimepy.net.server.app.bootstrap import icon_str from runtimepy.net.server.app.elements import div +BOOTSTRAP_BUTTON = "rounded-0 font-monospace button-bodge" + def create_nav_button( parent: Element, name: str, item: str, active_tab: bool @@ -24,7 +28,7 @@ def create_nav_button( button["aria-controls"] = f"{name}-{item}" button["aria-selected"] = str(active_tab).lower() - class_str = "nav-link rounded-0 font-monospace" + class_str = "nav-link " + BOOTSTRAP_BUTTON if active_tab: class_str += " active" @@ -51,6 +55,35 @@ def create_nav_container( return content +def set_tooltip(element: Element, data: str, placement: str = "right") -> None: + """Set a tooltip on an element.""" + + element["data-bs-title"] = data + element["data-bs-placement"] = placement + + # Should we use another mechanism for this? + element["class"] += " has-tooltip" + + +def collapse_button(tooltip: str = None, **kwargs) -> Element: + """Create a collapse button.""" + + collapse = div( + tag="button", + type="button", + text=icon_str("arrows-collapse-vertical"), + **kwargs, + ) + collapse["class"] = "btn btn-secondary " + BOOTSTRAP_BUTTON + if tooltip: + set_tooltip(collapse, tooltip) + + collapse["data-bs-toggle"] = "collapse" + collapse["data-bs-target"] = f"#{PKG_NAME}-tabs" + + return collapse + + class TabbedContent: """A tabbed-content container.""" @@ -65,13 +98,22 @@ def __init__(self, name: str, parent: Element) -> None: self.container["data-bs-theme"] = "dark" self.container["class"] = "d-flex align-items-start" + # Collapse button. + self.button_column = div(parent=self.container) + self.button_column["class"] = "d-flex flex-column bg-dark h-100" + self.collapse = collapse_button( + parent=self.button_column, tooltip="Collapse tabs." + ) + + # Placeholder. + collapse_button(parent=self.button_column, tooltip="YUP 2") + # Create tab container. - self.tabs = div(parent=self.container) - tabs_class = "bg-dark nav flex-column nav-pills" + self.tabs = div(id=f"{PKG_NAME}-tabs", parent=self.container) + tabs_class = "bg-dark nav flex-column nav-pills show" # Created a custom class to fix scroll behavior. tabs_class += " flex-column-scroll-bodge" - self.tabs["class"] = tabs_class # Create content container. diff --git a/runtimepy/net/server/app/env.py b/runtimepy/net/server/app/env.py new file mode 100644 index 00000000..5c25a06d --- /dev/null +++ b/runtimepy/net/server/app/env.py @@ -0,0 +1,42 @@ +""" +A module implementing a channel-environment tab HTML interface. +""" + +# third-party +from svgen.element import Element + +# internal +from runtimepy.channel.environment.command.processor import ( + ChannelCommandProcessor, +) +from runtimepy.net.arbiter.info import AppInfo +from runtimepy.net.server.app.bootstrap import icon_str +from runtimepy.net.server.app.bootstrap.tabs import TabbedContent +from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.tab import Tab + + +class ChannelEnvironmentTab(Tab): + """A channel-environment tab interface.""" + + def __init__( + self, + name: str, + command: ChannelCommandProcessor, + app: AppInfo, + tabs: TabbedContent, + icon: str = "alarm", + ) -> None: + """Initialize this instance.""" + + self.command = command + super().__init__(name, app, tabs, source="env") + + # Use an icon as the start of the button. + self.button.text = icon_str(icon) + " " + self.name + + def compose(self, parent: Element) -> None: + """Compose the tab's HTML elements.""" + + for name in self.command.env.names: + div(text=name, parent=parent) diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index c4b54481..9d1a99b6 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -14,11 +14,7 @@ from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.elements import div -from runtimepy.net.server.app.files import ( - append_kind, - kind_url, - write_found_file, -) +from runtimepy.net.server.app.files import kind_url, write_found_file class Tab: @@ -43,14 +39,10 @@ def __init__( def compose(self, parent: Element) -> None: """Compose the tab's HTML elements.""" - append_kind(parent, self.source, kind="html", tag="div") - - def write_js(self, writer: IndentedFileWriter) -> bool: + def write_js(self, writer: IndentedFileWriter, **kwargs) -> bool: """Write JavaScript code for the tab.""" - return write_found_file( - writer, kind_url("js", self.source, subdir="tabs") - ) + return write_found_file(writer, kind_url("js", self.source, **kwargs)) def entry(self) -> None: """Tab overall script entry.""" diff --git a/runtimepy/requirements.txt b/runtimepy/requirements.txt index acd3c8d9..8d17493c 100644 --- a/runtimepy/requirements.txt +++ b/runtimepy/requirements.txt @@ -1,4 +1,4 @@ vcorelib>=3.2.0 -svgen>=0.5.2 +svgen>=0.6.0 websockets windows-curses; sys_platform == 'win32' and python_version < '3.12' diff --git a/tasks/default.yaml b/tasks/default.yaml index 576fcfbd..ebd72dcc 100644 --- a/tasks/default.yaml +++ b/tasks/default.yaml @@ -3,5 +3,8 @@ includes_left: - package://runtimepy/server.yaml - package://runtimepy/server_dev.yaml +tasks: + - {name: wave, factory: sinusoid, period_s: 0.01} + app: - runtimepy.net.apps.wait_for_stop diff --git a/tests/net/server/app/test_files.py b/tests/net/server/app/test_files.py new file mode 100644 index 00000000..69938c8d --- /dev/null +++ b/tests/net/server/app/test_files.py @@ -0,0 +1,18 @@ +""" +Test the 'net.server.app.files' module. +""" + +# third-party +from svgen.element import Element + +# module under test +from runtimepy import PKG_NAME +from runtimepy.net.server.app.files import kind_url, set_text_to_file + + +def test_set_text_to_file_basic(): + """Test basic functionality of this method.""" + + assert kind_url("js", "test", subdir="subdir") + + assert set_text_to_file(Element(), f"package://{PKG_NAME}/js/env.js")