diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9b4e9623..a124df9f 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.3 + repo=runtimepy version=3.12.0 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index 3003803a..03bd6952 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=9985f72e34bdca8221f1d8d33ff6531a + hash=48f32e0cb1550ad416362e58fb185182 ===================================== --> -# runtimepy ([3.11.3](https://pypi.org/project/runtimepy/)) +# runtimepy ([3.12.0](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/variables/package.yaml b/local/variables/package.yaml index 2e15ffef..aefdecaa 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 3 -minor: 11 -patch: 3 +minor: 12 +patch: 0 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index ee63cdf8..bac5b877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "3.11.3" +version = "3.12.0" 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 e5975392..37eaef76 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=4c241d22cd21592b757b41cc596fa46b +# hash=4978a703b8ecd45735226f67d3cd0bf1 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "3.11.3" +VERSION = "3.12.0" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/channel/environment/command/processor.py b/runtimepy/channel/environment/command/processor.py index f575d56e..06a2b0f2 100644 --- a/runtimepy/channel/environment/command/processor.py +++ b/runtimepy/channel/environment/command/processor.py @@ -37,7 +37,7 @@ def __init__( self.hooks: list[CommandHook] = [] self.parser_data: dict[str, Any] = {} - self.parser = CommandParser() + self.parser = CommandParser(prog="") self.parser.data = self.parser_data self.parser.initialize() diff --git a/runtimepy/data/css/bootstrap_extra.css b/runtimepy/data/css/bootstrap_extra.css index 4938a18c..fb12fac6 100644 --- a/runtimepy/data/css/bootstrap_extra.css +++ b/runtimepy/data/css/bootstrap_extra.css @@ -52,3 +52,7 @@ .collapse:not(.show) { display: none !important; } + +select.form-select { + width: min-content; +} diff --git a/runtimepy/data/js/classes/TabInterface.js b/runtimepy/data/js/classes/TabInterface.js index fec99139..4deb3ca3 100644 --- a/runtimepy/data/js/classes/TabInterface.js +++ b/runtimepy/data/js/classes/TabInterface.js @@ -7,6 +7,9 @@ class TabInterface { this.name = name; this.worker = new WorkerInterface(this.name, _worker); + /* Send an initialization message. */ + this.worker.send({kind : "init"}); + /* Relevant elements. */ this.container = document.getElementById("runtimepy-" + this.name); this.logs = this.query("#" + this.name + "-logs"); @@ -25,22 +28,60 @@ class TabInterface { shown_tab = this.name; } this.worker.send({kind : msg}); - - /* Could remove this. */ - this.log(msg); } ]; - /* Plot related. */ + this.initPlot(); + this.initCommand(); + this.initControls(); + this.initButton(); + } + + initCommand() { + let command = this.query("#" + this.name + "-command") + if (command) { + command.onkeypress = (event) => { + if (event.key == "Enter") { + let cmd = event.target.value.trim(); + + if (cmd == "cls" || cmd == "clear") { + this.clearLog(); + } else { + this.command(cmd); + } + + event.target.value = ""; + } + }; + } + } + + command(data) { this.worker.send({kind : "command", value : data}); } + + initControls() { + /* Initialize enumeration command drop downs. */ + for (let enums of this.queryAll("select")) { + enums.onchange = + (() => { this.command(`set ${enums.id} ${enums.value}`); }) + .bind(this); + } + + /* Initialize toggle buttons. */ + for (let toggle of this.queryAll("td>button")) { + toggle.onclick = + (() => { this.command(`toggle ${toggle.id}`); }).bind(this); + } + } + + initPlot() { let plot = this.query("#" + this.name + "-plot"); if (plot) { this.plot = new Plot(plot, this.worker); this.show_state_handlers.push(this.plot.handle_shown.bind(this.plot)); } - - this.initButton(); } query(data) { return this.container.querySelector(data); } + queryAll(data) { return this.container.querySelectorAll(data); } initButton() { let button = document.getElementById("runtimepy-" + this.name + "-tab"); @@ -52,12 +93,13 @@ class TabInterface { } log(message) { - console.log(`(${this.name}) ` + message); if (this.logs) { this.logs.value += message + "\n"; } } + clearLog() { this.logs.value = ""; } + show_state_handler(is_shown) { for (const handler of this.show_state_handlers) { handler(is_shown); @@ -69,6 +111,10 @@ class TabInterface { hidden_handler() { this.show_state_handler(false); } onmessage(data) { + if ("log_message" in data) { + this.log(data["log_message"]); + } + for (const handler of this.message_handlers) { handler(data); } diff --git a/runtimepy/net/server/app/bootstrap/elements.py b/runtimepy/net/server/app/bootstrap/elements.py index 45a07d7c..ad1ab6a3 100644 --- a/runtimepy/net/server/app/bootstrap/elements.py +++ b/runtimepy/net/server/app/bootstrap/elements.py @@ -67,7 +67,7 @@ def collapse_button( return collapse -def toggle_button(parent: Element) -> Element: +def toggle_button(parent: Element, **kwargs) -> Element: """Add a boolean-toggle button.""" return div( @@ -77,6 +77,7 @@ def toggle_button(parent: Element) -> Element: parent=parent, title="toggle value", class_str="btn " + BOOTSTRAP_BUTTON, + **kwargs, ) @@ -85,6 +86,7 @@ def input_box( label: str = "filter", pattern: str = ".*", description: str = None, + **kwargs, ) -> None: """Create command input box.""" @@ -103,5 +105,6 @@ def input_box( parent=container, name=label, title=label + " input", + **kwargs, ) box.add_class("form-control", "rounded-0", TEXT) diff --git a/runtimepy/net/server/app/env/__init__.py b/runtimepy/net/server/app/env/__init__.py index a5983035..eecf2403 100644 --- a/runtimepy/net/server/app/env/__init__.py +++ b/runtimepy/net/server/app/env/__init__.py @@ -21,9 +21,6 @@ def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: # Tab name filter. input_box(tabs.tabs, label="tab", description="Tab name filter.") - # Sound tab. - SoundTab("sound", app, tabs, source="sound", icon="boombox").entry() - # Connection tabs. for name, conn in app.connections.items(): ChannelEnvironmentTab( @@ -36,6 +33,9 @@ def channel_environments(app: AppInfo, tabs: TabbedContent) -> None: name, task.command, app, tabs, icon="arrow-repeat" ).entry() + # Sound tab. + SoundTab("sound", app, tabs, source="sound", icon="boombox").entry() + dummy_tabs(3, app, tabs) # Toggle channel-table button. diff --git a/runtimepy/net/server/app/env/tab/__init__.py b/runtimepy/net/server/app/env/tab/__init__.py new file mode 100644 index 00000000..4e58ea17 --- /dev/null +++ b/runtimepy/net/server/app/env/tab/__init__.py @@ -0,0 +1,25 @@ +""" +A module implementing a channel-environment tab HTML interface. +""" + +# internal +from runtimepy.net.server.app.env.tab.html import ChannelEnvironmentTabHtml +from runtimepy.net.server.app.env.tab.message import ( + ChannelEnvironmentTabMessaging, +) + + +class ChannelEnvironmentTab( + ChannelEnvironmentTabMessaging, ChannelEnvironmentTabHtml +): + """A class aggregating all channel-environment tab interfaces.""" + + all_tabs: dict[str, "ChannelEnvironmentTab"] = {} + + def init(self) -> None: + """Initialize this instance.""" + + super().init() + + # Update global mapping. + type(self).all_tabs[self.name] = self diff --git a/runtimepy/net/server/app/env/tab/base.py b/runtimepy/net/server/app/env/tab/base.py new file mode 100644 index 00000000..656e61c9 --- /dev/null +++ b/runtimepy/net/server/app/env/tab/base.py @@ -0,0 +1,28 @@ +""" +A module implementing a channel-environment tab HTML interface. +""" + +# internal +from runtimepy.channel.environment.command.processor import ( + ChannelCommandProcessor, +) +from runtimepy.net.arbiter.info import AppInfo +from runtimepy.net.server.app.bootstrap.tabs import TabbedContent +from runtimepy.net.server.app.tab import Tab + + +class ChannelEnvironmentTabBase(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", icon=icon) diff --git a/runtimepy/net/server/app/env/tab.py b/runtimepy/net/server/app/env/tab/html.py similarity index 65% rename from runtimepy/net/server/app/env/tab.py rename to runtimepy/net/server/app/env/tab/html.py index 33a0cf5f..aae83f87 100644 --- a/runtimepy/net/server/app/env/tab.py +++ b/runtimepy/net/server/app/env/tab/html.py @@ -3,18 +3,14 @@ """ # built-in -from typing import Optional +from typing import Optional, cast # third-party from svgen.element import Element # internal from runtimepy.channel import AnyChannel -from runtimepy.channel.environment.command.processor import ( - ChannelCommandProcessor, -) from runtimepy.enum import RuntimeEnum -from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.server.app.bootstrap.elements import ( TEXT, flex, @@ -22,54 +18,19 @@ set_tooltip, toggle_button, ) -from runtimepy.net.server.app.bootstrap.tabs import TabbedContent from runtimepy.net.server.app.elements import div +from runtimepy.net.server.app.env.tab.base import ChannelEnvironmentTabBase +from runtimepy.net.server.app.env.widgets import ( + channel_table_header, + enum_dropdown, + plot_checkbox, +) from runtimepy.net.server.app.placeholder import under_construction -from runtimepy.net.server.app.tab import Tab - - -def plot_checkbox(parent: Element, name: str) -> None: - """Add a checkbox for individual channel plot status.""" - - container = div(tag="td", parent=parent) - - set_tooltip( - div( - tag="input", - type="checkbox", - value="", - id=f"plot-{name}", - allow_no_end_tag=True, - parent=container, - class_str="form-check-input", - ), - f"Enable plotting channel '{name}'.", - ) - -def enum_dropdown(parent: Element, enum: Optional[RuntimeEnum]) -> None: - """Implement a drop down for enumeration options.""" - del enum - div(parent=parent, text="TODO") - - -class ChannelEnvironmentTab(Tab): +class ChannelEnvironmentTabHtml(ChannelEnvironmentTabBase): """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", icon=icon) - def add_channel( self, parent: Element, @@ -91,19 +52,19 @@ def add_channel( kind_str = enum_name # Add boolean/bit toggle button. - control = div(tag="td", parent=parent) + control = div(tag="td", parent=parent, class_str="text-center") chan_type = div(tag="td", text=kind_str, parent=parent) if enum: chan_type.add_class("fw-bold") if chan.commandable and not chan.type.is_boolean: - enum_dropdown(control, enum) + enum_dropdown(control, name, enum, cast(int, chan.raw.value)) if chan.type.is_boolean: chan_type.add_class("text-primary-emphasis") if chan.commandable: - toggle_button(control)["title"] = f"Toggle '{name}'." + toggle_button(control, id=name)["title"] = f"Toggle '{name}'." elif chan.type.is_float: chan_type.add_class("text-secondary-emphasis") @@ -131,7 +92,7 @@ def add_field(self, parent: Element, name: str) -> None: if field.is_enum: enum = env.enums[field.enum] - control = div(tag="td", parent=parent) + control = div(tag="td", parent=parent, class_str="text-center") # Add boolean/bit toggle button. is_bit = field.width == 1 @@ -145,9 +106,9 @@ def add_field(self, parent: Element, name: str) -> None: if field.commandable: name_elem.add_class("text-success") if is_bit: - toggle_button(control) + toggle_button(control, id=name) elif enum: - enum_dropdown(control, enum) + enum_dropdown(control, name, enum, field()) div(tag="td", text=str(env.value(name)), parent=parent) @@ -161,34 +122,7 @@ def channel_table(self, parent: Element) -> None: body = div(tag="tbody", parent=table) # Add header. - header_row = div(tag="tr", parent=header) - for heading, desc in [ - ("plot", "Toggle plotting for channels."), - ("ctl", "Type-specific channel controls."), - ("type", "Channel types."), - ("name", "Channel names."), - ("value", "Channel values."), - ]: - set_tooltip( - div( - tag="th", - scope="col", - parent=header_row, - text=heading, - class_str="text-secondary", - ), - desc, - placement="bottom", - ) - - # Add some controls. - ctl_row = div(tag="tr", parent=header) - for _ in range(3): - div(tag="th", parent=ctl_row) - input_box( - div(tag="th", parent=ctl_row), description="Channel name filter." - ) - div(tag="th", parent=ctl_row) + channel_table_header(header) env = self.command.env @@ -210,6 +144,10 @@ def channel_table(self, parent: Element) -> None: else: self.add_field(row, name) + def get_id(self, data: str) -> str: + """Get an HTML id for an element.""" + return f"{self.name}-{data}" + def compose(self, parent: Element) -> None: """Compose the tab's HTML elements.""" @@ -231,6 +169,7 @@ def compose(self, parent: Element) -> None: label="command", pattern="help", description="Send a string command via this environment.", + id=self.get_id("command"), ) # Text area. @@ -238,7 +177,7 @@ def compose(self, parent: Element) -> None: tag="textarea", parent=div(parent=vert_container, class_str="form-floating"), class_str=f"form-control rounded-0 {TEXT}", - id=f"{self.name}-logs", + id=self.get_id("logs"), title=f"Text logs for {self.name}.", ) logs.booleans.add("readonly") @@ -252,7 +191,7 @@ def compose(self, parent: Element) -> None: # Plot. div( tag="canvas", - id=f"{self.name}-plot", + id=self.get_id("plot"), parent=div(parent=container, class_str="w-100 h-100"), class_str="w-100 h-100", ) diff --git a/runtimepy/net/server/app/env/tab/logger.py b/runtimepy/net/server/app/env/tab/logger.py new file mode 100644 index 00000000..bac77adc --- /dev/null +++ b/runtimepy/net/server/app/env/tab/logger.py @@ -0,0 +1,34 @@ +""" +A module implementing a simple logger interface for tabs. +""" + +# built-in +import logging +from typing import Callable + +# third-party +from vcorelib.logging import DEFAULT_TIME_FORMAT + +# internal +from runtimepy.net.stream.json.types import JsonMessage + +TabMessageSender = Callable[[JsonMessage], None] + + +class TabLogger(logging.Handler): + """An interface facilitating sending log messages to browser tabs.""" + + send: TabMessageSender + + def emit(self, record): + """Send the log message.""" + self.send({"log_message": self.format(record)}) + + @staticmethod + def create(send: TabMessageSender) -> "TabLogger": + """Create a tab logger handler.""" + + handler = TabLogger() + handler.send = send + handler.setFormatter(logging.Formatter(DEFAULT_TIME_FORMAT)) + return handler diff --git a/runtimepy/net/server/app/env/tab/message.py b/runtimepy/net/server/app/env/tab/message.py new file mode 100644 index 00000000..2482219b --- /dev/null +++ b/runtimepy/net/server/app/env/tab/message.py @@ -0,0 +1,80 @@ +""" +A module implementing a channel-environment tab message-handling interface. +""" + +# built-in +import logging +from typing import Any + +# internal +from runtimepy.net.server.app.env.tab.base import ChannelEnvironmentTabBase +from runtimepy.net.server.app.env.tab.logger import TabLogger, TabMessageSender +from runtimepy.net.stream.json.types import JsonMessage + + +class ChannelEnvironmentTabMessaging(ChannelEnvironmentTabBase): + """A channel-environment tab interface.""" + + shown: bool + + def init(self) -> None: + """Initialize this instance.""" + + super().init() + self.shown = False + + def handle_shown_state(self, shown: bool, outbox: JsonMessage) -> None: + """Handle 'shown' state changing.""" + + self.shown = shown + + outbox["handle_shown_state"] = shown + + def handle_init(self, outbox: JsonMessage, send: TabMessageSender) -> None: + """Handle tab initialization.""" + + del outbox + + logger: logging.Logger = self.command.logger # type: ignore + + # Add a log handler. + logger.addHandler(TabLogger.create(send)) + + logger.info("Tab initialized.") + + async def handle_message( + self, data: dict[str, Any], send: TabMessageSender + ) -> JsonMessage: + """Handle a message from a tab.""" + + kind: str = data["kind"] + response: JsonMessage = {} + + # Respond to initialization. + if kind == "init": + self.handle_init(response, send) + + # Handle command-line commands. + elif kind == "command": + cmd = self.command + result = cmd.command(data["value"]) + + cmd.logger.log( + logging.INFO if result else logging.ERROR, + "%s: %s", + data["value"], + result, + ) + + # Handle tab-event messages. + elif kind.startswith("tab"): + if "shown" in kind: + self.handle_shown_state(True, response) + elif "hidden" in kind: + self.handle_shown_state(False, response) + + # Log when messages aren't handled. + else: + self.command.logger.warning("Message not handled: '%s'.", data) + + return response diff --git a/runtimepy/net/server/app/env/widgets.py b/runtimepy/net/server/app/env/widgets.py new file mode 100644 index 00000000..45fd8996 --- /dev/null +++ b/runtimepy/net/server/app/env/widgets.py @@ -0,0 +1,90 @@ +""" +Channel-environment tab widget interfaces. +""" + +# built-in +from typing import cast + +# third-party +from svgen.element import Element + +# internal +from runtimepy.enum import RuntimeEnum +from runtimepy.net.server.app.bootstrap.elements import input_box, set_tooltip +from runtimepy.net.server.app.elements import div + + +def plot_checkbox(parent: Element, name: str) -> None: + """Add a checkbox for individual channel plot status.""" + + container = div(tag="td", parent=parent) + + set_tooltip( + div( + tag="input", + type="checkbox", + value="", + id=f"plot-{name}", + allow_no_end_tag=True, + parent=container, + class_str="form-check-input", + ), + f"Enable plotting channel '{name}'.", + ) + + +def enum_dropdown( + parent: Element, name: str, enum: RuntimeEnum, current: int | bool +) -> None: + """Implement a drop down for enumeration options.""" + + title = f"Enumeration selection for '{name}'." + select = div( + tag="select", + parent=parent, + class_str="form-select", + title=title, + id=name, + ) + select["aria-label"] = title + + for key, val in cast(dict[str, dict[str, int | bool]], enum.asdict())[ + "items" + ].items(): + opt = div(tag="option", value=key, text=key, parent=select) + if current == val: + opt.booleans.add("selected") + + +def channel_table_header(parent: Element) -> None: + """Add header row to channel table..""" + + # Add header. + header_row = div(tag="tr", parent=parent) + for heading, desc in [ + ("plot", "Toggle plotting for channels."), + ("ctl", "Type-specific channel controls."), + ("type", "Channel types."), + ("name", "Channel names."), + ("value", "Channel values."), + ]: + set_tooltip( + div( + tag="th", + scope="col", + parent=header_row, + text=heading, + class_str="text-secondary", + ), + desc, + placement="bottom", + ) + + # Add some controls. + ctl_row = div(tag="tr", parent=parent) + for _ in range(3): + div(tag="th", parent=ctl_row) + input_box( + div(tag="th", parent=ctl_row), description="Channel name filter." + ) + div(tag="th", parent=ctl_row) diff --git a/runtimepy/net/server/app/tab.py b/runtimepy/net/server/app/tab.py index f5348eef..13a49e95 100644 --- a/runtimepy/net/server/app/tab.py +++ b/runtimepy/net/server/app/tab.py @@ -46,8 +46,13 @@ def __init__( button_str += self.name self.button.text = button_str + self.init() + self.compose(self.content) + def init(self) -> None: + """Initialize this instance.""" + def compose(self, parent: Element) -> None: """Compose the tab's HTML elements.""" diff --git a/runtimepy/net/server/websocket/__init__.py b/runtimepy/net/server/websocket/__init__.py index 1b6f6157..a35cb97c 100644 --- a/runtimepy/net/server/websocket/__init__.py +++ b/runtimepy/net/server/websocket/__init__.py @@ -4,6 +4,8 @@ # internal from runtimepy.net.arbiter.tcp.json import WebsocketJsonMessageConnection +from runtimepy.net.server.app.env.tab import ChannelEnvironmentTab +from runtimepy.net.server.app.env.tab.message import TabMessageSender from runtimepy.net.stream.json.types import JsonMessage from runtimepy.net.websocket import WebsocketConnection @@ -11,18 +13,40 @@ class RuntimepyWebsocketConnection(WebsocketJsonMessageConnection): """A class implementing a package-specific WebSocket connection.""" + send_interfaces: dict[str, TabMessageSender] + + def tab_sender(self, name: str) -> TabMessageSender: + """Get a tab message-sending interface.""" + + if name not in self.send_interfaces: + + def sender(data: JsonMessage) -> None: + """Tab-message sending interface.""" + self.send_json({"ui": {name: data}}) + + self.send_interfaces[name] = sender + + return self.send_interfaces[name] + def _register_handlers(self) -> None: """Register connection-specific command handlers.""" super()._register_handlers() + self.send_interfaces = {} 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"]] = inbox["event"] + # Handle messages from tabs. + if "name" in inbox and "event" in inbox: + name = inbox["name"] + tab = ChannelEnvironmentTab.all_tabs.get(name) + if tab is not None: + response = await tab.handle_message( + inbox["event"], self.tab_sender(name) + ) + if response: + outbox[name] = response self.basic_handler("ui", ui_handler) diff --git a/runtimepy/task/sample.py b/runtimepy/task/sample.py index d2d3872f..095b73cd 100644 --- a/runtimepy/task/sample.py +++ b/runtimepy/task/sample.py @@ -82,6 +82,7 @@ async def init(self, app: AppInfo) -> None: self.env.int_channel( "really_really_long_enum", enum="InsanelyLongEnumNameForTesting", + commandable=True, ) self.env.bool_channel("bool") self.env.int_channel("int", commandable=True)