diff --git a/README.md b/README.md index 5871556..bf5b46b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,28 @@ At some point we may publish packages to e.g. pypi. As above, plus also `pip install -Ur requirements/requirements-dev.txt` to get linters and whatnot. +## Config Editor + +[![asciicast](https://asciinema.org/a/67hELtUNkKCit4dFwYeAUa2fo.svg)](https://asciinema.org/a/67hELtUNkKCit4dFwYeAUa2fo) + +A terminal UI config editor, capable of viewing and editing existing configurations, can be launched via +**edit-config**. It supports navigation both via the keyboard or the mouse. + +Simple usage: + +| Key(s) | Action | +|-----------------------|--------------------------------------------------------| +| Up, Down | Move up and down the config tree | +| Left, Right | Scroll the tree left and right (when content is long) | +| Space | Expand a tree node to show its children | +| Enter | Expand a tree node, or edit a leaf node (bools toggle) | +| Tab (in edit popup) | Cycle highlight between input field and buttons | +| Enter (in edit popup) | Choose dropdown option or activate button | +| S | Save the config to the opened file | +| Q | Quit without saving | + +A quick demonstration of the editor is available [on asciinema.org](https://asciinema.org/a/67hELtUNkKCit4dFwYeAUa2fo). + ## Tools In all cases, online help can be retrieved by providing the `-h` or ``--help`` flags to the below programs. diff --git a/gp2040ce_bintools/config_tree.css b/gp2040ce_bintools/config_tree.css new file mode 100644 index 0000000..d49c847 --- /dev/null +++ b/gp2040ce_bintools/config_tree.css @@ -0,0 +1,50 @@ +Tree { + padding: 1; +} + +Button { + border: round gray; + content-align: center middle; + width: 100%; + height: 100%; + margin: 0 1; +} + +EditScreen, MessageScreen { + align: center middle; +} + +EditScreen Label { + width: 100%; + height: 100%; + content-align: center middle; +} + +.hidden { + visibility: hidden; +} + +#edit-dialog { + grid-size: 2; + grid-rows: 1fr 3fr 1fr 2fr; + padding: 0 1; + width: 50%; + height: 50%; + border: tall gray 100%; +} + +#message-dialog { + padding: 0 1; + grid-rows: 3fr 2fr; + width: 50%; + height: 50%; + border: tall gray 100%; +} + +#field-name, #field-input, #input-errors { + column-span: 2; +} + +#field-input { + border: solid white; +} diff --git a/gp2040ce_bintools/gui.py b/gp2040ce_bintools/gui.py new file mode 100644 index 0000000..55a899a --- /dev/null +++ b/gp2040ce_bintools/gui.py @@ -0,0 +1,265 @@ +"""GUI applications for working with binary files.""" +import argparse +import logging + +from google.protobuf import descriptor +from google.protobuf.message import Message +from rich.highlighter import ReprHighlighter +from rich.text import Text +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.logging import TextualHandler +from textual.screen import ModalScreen +from textual.validation import Number +from textual.widgets import Button, Footer, Header, Input, Label, Pretty, Select, Tree +from textual.widgets.tree import TreeNode + +from gp2040ce_bintools import core_parser, handler +from gp2040ce_bintools.builder import write_new_config_to_filename +from gp2040ce_bintools.storage import get_config_from_file + +logger = logging.getLogger(__name__) + + +class EditScreen(ModalScreen): + """Do an input prompt by way of an overlaid screen.""" + + def __init__(self, node: TreeNode, field_value: object, *args, **kwargs): + """Save the config field info for later usage.""" + logger.debug("constructing EditScreen for %s", node.label) + self.node = node + parent_config, field_descriptor = node.data + self.parent_config = parent_config + self.field_descriptor = field_descriptor + self.field_value = field_value + super().__init__(*args, **kwargs) + + def compose(self) -> ComposeResult: + """Build the pop-up window with this result.""" + if self.field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM: + options = [(d.name, v) for v, d in self.field_descriptor.enum_type.values_by_number.items()] + self.input_field = Select(options, value=self.field_value, id='field-input') + elif self.field_descriptor.type in (descriptor.FieldDescriptor.TYPE_INT32, + descriptor.FieldDescriptor.TYPE_INT64, + descriptor.FieldDescriptor.TYPE_UINT32, + descriptor.FieldDescriptor.TYPE_UINT64): + self.input_field = Input(value=repr(self.field_value), validators=[Number()], id='field-input') + elif self.field_descriptor.type == descriptor.FieldDescriptor.TYPE_STRING: + self.input_field = Input(value=self.field_value, id='field-input') + else: + # we don't handle whatever these are yet + self.input_field = Label(repr(self.field_value), id='field-input') + yield Grid( + Label(self.field_descriptor.full_name, id="field-name"), + self.input_field, + Pretty('', id='input-errors', classes='hidden'), + Button("Save", id='save-button'), + Button("Cancel", id='cancel-button'), + id='edit-dialog', + ) + + @on(Input.Changed) + def show_invalid_reasons(self, event: Input.Changed) -> None: + """Update the UI to show why validation failed.""" + if event.validation_result: + error_field = self.query_one(Pretty) + save_button = self.query_one('#save-button', Button) + if not event.validation_result.is_valid: + error_field.update(event.validation_result.failure_descriptions) + error_field.classes = '' + save_button.disabled = True + else: + error_field.update('') + error_field.classes = 'hidden' + save_button.disabled = False + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Process the button actions.""" + if event.button.id == 'save-button': + logger.debug("calling _save") + self._save() + self.app.pop_screen() + + def _save(self): + """Save the field value to the retained config item.""" + if not isinstance(self.input_field, Label): + if self.field_descriptor.type in (descriptor.FieldDescriptor.TYPE_INT32, + descriptor.FieldDescriptor.TYPE_INT64, + descriptor.FieldDescriptor.TYPE_UINT32, + descriptor.FieldDescriptor.TYPE_UINT64): + field_value = int(self.input_field.value) + else: + field_value = self.input_field.value + setattr(self.parent_config, self.field_descriptor.name, field_value) + logger.debug("parent config post-change: %s", self.parent_config) + self.node.set_label(pb_field_to_node_label(self.field_descriptor, field_value)) + + +class MessageScreen(ModalScreen): + """Simple screen for displaying messages.""" + + def __init__(self, text: str, *args, **kwargs): + """Store the message for later display.""" + self.text = text + super().__init__(*args, **kwargs) + + def compose(self) -> ComposeResult: + """Build the pop-up window with the desired message displayed.""" + yield Grid( + Label(self.text, id="message-text"), + Button("OK", id='ok-button'), + id='message-dialog', + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Process the button action (close the window).""" + self.app.pop_screen() + + +class ConfigEditor(App): + """Display the GP2040-CE configuration as a tree.""" + + BINDINGS = [ + ('s', 'save', "Save Config"), + ('q', 'quit', "Quit"), + ] + CSS_PATH = "config_tree.css" + TITLE = "GP2040-CE Configuration Editor" + + def __init__(self, *args, **kwargs): + """Initialize config.""" + self.config_filename = kwargs.pop('config_filename') + self.whole_board = kwargs.pop('whole_board', False) + super().__init__(*args, **kwargs) + + # disable normal logging and enable console logging if we're not headless + logger.debug("reconfiguring logging...") + root = logging.getLogger() + root.setLevel(logging.DEBUG) + root.removeHandler(handler) + root.addHandler(TextualHandler()) + + def compose(self) -> ComposeResult: + """Compose the UI.""" + yield Header() + yield Footer() + yield Tree("Root") + + def on_mount(self) -> None: + """Load the configuration object into the tree view.""" + self.config = get_config_from_file(self.config_filename, whole_board=self.whole_board) + tree = self.query_one(Tree) + + def add_node(parent_node: TreeNode, parent_config: Message, + field_descriptor: descriptor.FieldDescriptor, field_value: object) -> None: + """Add a node to the overall tree, recursively. + + Args: + parent_node: parent node to attach the new node(s) to + parent_config: the Config object parent. parent_config + field_descriptor.name = this node + field_descriptor: descriptor for the protobuf field + field_value: data to add to the parent node as new node(s) + """ + # all nodes relate to their parent and retain info about themselves + this_node = parent_node.add("") + this_node.data = (parent_config, field_descriptor) + this_node.set_label(pb_field_to_node_label(field_descriptor, field_value)) + + if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE: + # a message has stuff under it, recurse into it + this_config = getattr(parent_config, field_descriptor.name) + for child_field_descriptor, child_field_value in sorted(field_value.ListFields(), + key=lambda f: f[0].name): + add_node(this_node, this_config, child_field_descriptor, child_field_value) + else: + # leaf node, stop here + this_node.allow_expand = False + + tree.root.data = (None, self.config.DESCRIPTOR) + tree.root.set_label(self.config_filename) + for field_descriptor, field_value in sorted(self.config.ListFields(), key=lambda f: f[0].name): + add_node(tree.root, self.config, field_descriptor, field_value) + tree.root.expand() + + def on_tree_node_selected(self, node_event: Tree.NodeSelected) -> None: + """Take the appropriate action for this type of node.""" + self._modify_node(node_event.node) + + def action_save(self) -> None: + """Save the configuration.""" + write_new_config_to_filename(self.config, self.config_filename, inject=self.whole_board) + self.push_screen(MessageScreen(f"Configuration saved to {self.config_filename}.")) + + def action_quit(self) -> None: + """Quit the application.""" + self.exit() + + def _modify_node(self, node: TreeNode) -> None: + """Modify the selected node by context of what type of config item it is.""" + parent_config, field_descriptor = node.data + + # don't do anything special with selecting expandable nodes, since the framework already expands them + if (isinstance(field_descriptor, descriptor.Descriptor) or + field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE): + return + + field_value = getattr(parent_config, field_descriptor.name) + if field_descriptor.type == descriptor.FieldDescriptor.TYPE_BOOL: + # toggle bools inline + logger.debug("changing %s from %s...", field_descriptor.name, field_value) + field_value = not field_value + logger.debug("...to %s", field_value) + setattr(parent_config, field_descriptor.name, field_value) + node.data = (parent_config, field_descriptor) + node.set_label(pb_field_to_node_label(field_descriptor, field_value)) + logger.debug(self.config) + else: + logger.debug("opening edit screen for %s", field_descriptor.name) + self.push_screen(EditScreen(node, field_value)) + + +def pb_field_to_node_label(field_descriptor, field_value): + """Provide the pretty label for a tree node. + + Args: + field_descriptor: protobuf field for determining the type + field_value: value to render + Returns: + prettified text representation of the field + """ + highlighter = ReprHighlighter() + if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE: + label = Text.from_markup(f"[b]{field_descriptor.name}[/b]") + elif field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM: + enum_selection = field_descriptor.enum_type.values_by_number[field_value].name + label = Text.assemble( + Text.from_markup(f"{field_descriptor.name} = "), + highlighter(enum_selection), + ) + else: + label = Text.assemble( + Text.from_markup(f"{field_descriptor.name} = "), + highlighter(repr(field_value)), + ) + + return label + + +############ +# COMMANDS # +############ + + +def edit_config(): + """Edit the configuration in an interactive fashion.""" + parser = argparse.ArgumentParser( + description="Utilize a GUI to view and alter the contents of a GP2040-CE configuration.", + parents=[core_parser], + ) + parser.add_argument('--whole-board', action='store_true', help="indicate the binary file is a whole board dump") + parser.add_argument('filename', help=".bin file of a GP2040-CE board's config + footer or entire storage section, " + "or of a GP2040-CE's whole board dump if --whole-board is specified") + args, _ = parser.parse_known_args() + app = ConfigEditor(config_filename=args.filename, whole_board=args.whole_board) + app.run() diff --git a/pyproject.toml b/pyproject.toml index f3cc26f..128deff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,16 +10,18 @@ authors = [ {name = "Brian S. Stephan", email = "bss@incorporeal.org"}, ] requires-python = ">=3.9" -dependencies = ["grpcio-tools"] +dependencies = ["grpcio-tools", "textual"] dynamic = ["version"] [project.optional-dependencies] dev = ["bandit", "decorator", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings", "flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable", - "flake8-pyproject", "mypy", "pip-tools", "pytest", "pytest-cov", "setuptools-scm", "tox"] + "flake8-pyproject", "mypy", "pip-tools", "pytest", "pytest-asyncio", "pytest-cov", "setuptools-scm", + "tox"] [project.scripts] concatenate = "gp2040ce_bintools.builder:concatenate" +edit-config = "gp2040ce_bintools.gui:edit_config" visualize-storage = "gp2040ce_bintools.storage:visualize" [tool.flake8] @@ -39,6 +41,14 @@ ignore_missing_imports = true module = "google.protobuf.*" ignore_missing_imports = true +# there's a lot of class inheritance and so on going on in textual that I haven't figured out +# e.g. assigning Select or Input to the same variable is valid but not type-safe, bindings +# can just exit but mypy thinks they should return coroutines... better just to disable it for +# now until I can figure things out and learn more about doing proper type checking +[[tool.mypy.overrides]] +module = "gp2040ce_bintools.gui" +ignore_errors = true + [tool.pytest] python_files = ["*_tests.py", "tests.py", "test_*.py"] diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 0529d0f..c7bb514 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -59,21 +59,30 @@ gitdb==4.0.10 # via gitpython gitpython==3.1.31 # via bandit -grpcio==1.54.2 +grpcio==1.56.0 # via grpcio-tools -grpcio-tools==1.54.2 +grpcio-tools==1.56.0 # via gp2040ce-binary-tools (pyproject.toml) +importlib-metadata==6.7.0 + # via textual iniconfig==2.0.0 # via pytest isort==5.12.0 # via flake8-isort -markdown-it-py==3.0.0 - # via rich +linkify-it-py==2.0.2 + # via markdown-it-py +markdown-it-py[linkify,plugins]==2.2.0 + # via + # mdit-py-plugins + # rich + # textual mccabe==0.7.0 # via flake8 +mdit-py-plugins==0.4.0 + # via markdown-it-py mdurl==0.1.2 # via markdown-it-py -mypy==1.4.0 +mypy==1.4.1 # via gp2040ce-binary-tools (pyproject.toml) mypy-extensions==1.0.0 # via mypy @@ -88,7 +97,7 @@ pbr==5.11.1 # via stevedore pip-tools==6.13.0 # via gp2040ce-binary-tools (pyproject.toml) -platformdirs==3.7.0 +platformdirs==3.8.0 # via # tox # virtualenv @@ -110,16 +119,21 @@ pyproject-api==1.5.2 # via tox pyproject-hooks==1.0.0 # via build -pytest==7.3.2 +pytest==7.4.0 # via # gp2040ce-binary-tools (pyproject.toml) + # pytest-asyncio # pytest-cov +pytest-asyncio==0.21.0 + # via gp2040ce-binary-tools (pyproject.toml) pytest-cov==4.1.0 # via gp2040ce-binary-tools (pyproject.toml) pyyaml==6.0 # via bandit rich==13.4.2 - # via bandit + # via + # bandit + # textual setuptools-scm==7.1.0 # via gp2040ce-binary-tools (pyproject.toml) smmap==5.0.0 @@ -128,6 +142,8 @@ snowballstemmer==2.2.0 # via pydocstyle stevedore==5.1.0 # via bandit +textual==0.28.1 + # via gp2040ce-binary-tools (pyproject.toml) tomli==2.0.1 # via # build @@ -145,10 +161,15 @@ typing-extensions==4.6.3 # via # mypy # setuptools-scm + # textual +uc-micro-py==1.0.2 + # via linkify-it-py virtualenv==20.23.1 # via tox wheel==0.40.0 # via pip-tools +zipp==3.15.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 672f6d6..b047097 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -4,12 +4,37 @@ # # pip-compile --output-file=requirements/requirements.txt # -grpcio==1.54.2 +grpcio==1.56.0 # via grpcio-tools -grpcio-tools==1.54.2 +grpcio-tools==1.56.0 # via gp2040ce-binary-tools (pyproject.toml) +importlib-metadata==6.7.0 + # via textual +linkify-it-py==2.0.2 + # via markdown-it-py +markdown-it-py[linkify,plugins]==2.2.0 + # via + # mdit-py-plugins + # rich + # textual +mdit-py-plugins==0.4.0 + # via markdown-it-py +mdurl==0.1.2 + # via markdown-it-py protobuf==4.23.3 # via grpcio-tools +pygments==2.15.1 + # via rich +rich==13.4.2 + # via textual +textual==0.28.1 + # via gp2040ce-binary-tools (pyproject.toml) +typing-extensions==4.6.3 + # via textual +uc-micro-py==1.0.2 + # via linkify-it-py +zipp==3.15.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..9ec900d --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,123 @@ +"""Test the Textual GUI.""" +import os +import sys + +import pytest +from textual.widgets import Tree + +from gp2040ce_bintools.gui import ConfigEditor +from gp2040ce_bintools.storage import get_config_from_file + +HERE = os.path.dirname(os.path.abspath(__file__)) +proto_path = os.path.join(HERE, 'test-files', 'pb2-files') +sys.path.append(proto_path) + + +@pytest.mark.asyncio +async def test_simple_tree_building(): + """Test some basics of the config tree being built.""" + app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) + async with app.run_test() as pilot: + check_node = pilot.app.query_one(Tree).root.children[2] + assert "boardVersion = 'v0.7.2'" in check_node.label + parent_config, field_descriptor = check_node.data + assert parent_config == pilot.app.config + assert field_descriptor == pilot.app.config.DESCRIPTOR.fields_by_name['boardVersion'] + + +@pytest.mark.asyncio +async def test_simple_toggle(): + """Test that we can navigate a bit and toggle a bool.""" + app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) + async with app.run_test() as pilot: + tree = pilot.app.query_one(Tree) + display_node = tree.root.children[3] + invert_node = display_node.children[11] + + assert 'False' in invert_node.label + app._modify_node(invert_node) + assert 'True' in invert_node.label + + +@pytest.mark.asyncio +async def test_simple_edit_via_input_field(): + """Test that we can change an int via UI and see it reflected in the config.""" + app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) + async with app.run_test() as pilot: + tree = pilot.app.query_one(Tree) + display_node = tree.root.children[3] + i2cspeed_node = display_node.children[10] + assert pilot.app.config.displayOptions.i2cSpeed == 400000 + + tree.root.expand_all() + await pilot.wait_for_scheduled_animations() + tree.select_node(i2cspeed_node) + tree.action_select_cursor() + await pilot.wait_for_scheduled_animations() + await pilot.click('Input#field-input') + await pilot.wait_for_scheduled_animations() + await pilot.press('backspace', 'backspace', 'backspace', 'backspace', 'backspace', 'backspace', '5') + await pilot.wait_for_scheduled_animations() + await pilot.click('Button#save-button') + assert pilot.app.config.displayOptions.i2cSpeed == 5 + + +@pytest.mark.asyncio +async def test_simple_edit_via_input_field_enum(): + """Test that we can change an enum via the UI and see it reflected in the config.""" + app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) + async with app.run_test() as pilot: + tree = pilot.app.query_one(Tree) + gamepad_node = tree.root.children[5] + dpadmode_node = gamepad_node.children[0] + assert pilot.app.config.gamepadOptions.dpadMode == 0 + + tree.root.expand_all() + await pilot.wait_for_scheduled_animations() + tree.select_node(dpadmode_node) + tree.action_select_cursor() + await pilot.wait_for_scheduled_animations() + await pilot.click('Select#field-input') + await pilot.wait_for_scheduled_animations() + await pilot.press('down', 'down', 'enter') + await pilot.wait_for_scheduled_animations() + await pilot.click('Button#save-button') + assert pilot.app.config.gamepadOptions.dpadMode == 1 + + +@pytest.mark.asyncio +async def test_simple_edit_via_input_field_string(): + """Test that we can change a string via the UI and see it reflected in the config.""" + app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) + async with app.run_test() as pilot: + tree = pilot.app.query_one(Tree) + version_node = tree.root.children[2] + assert pilot.app.config.boardVersion == 'v0.7.2' + + # tree.root.expand_all() + # await pilot.wait_for_scheduled_animations() + tree.select_node(version_node) + tree.action_select_cursor() + await pilot.wait_for_scheduled_animations() + await pilot.click('Input#field-input') + await pilot.wait_for_scheduled_animations() + await pilot.press('backspace', '-', 'h', 'i') + await pilot.wait_for_scheduled_animations() + await pilot.click('Button#save-button') + assert pilot.app.config.boardVersion == 'v0.7.-hi' + + +@pytest.mark.asyncio +async def test_save(config_binary, tmp_path): + """Test that the tree builds and things are kind of where they should be.""" + new_filename = os.path.join(tmp_path, 'config-copy.bin') + with open(new_filename, 'wb') as file: + file.write(config_binary) + + app = ConfigEditor(config_filename=new_filename) + async with app.run_test() as pilot: + pilot.app.config.boardVersion = 'v0.7.2-bss-wuz-here' + await pilot.press('s') + + config = get_config_from_file(new_filename) + assert config.boardVersion == 'v0.7.2-bss-wuz-here'