support the adding of new repeated elements

take for instance:
repeated AlternativePinMappings alternativePinMappings = 1 [(nanopb).max_count = 3];

this, in C, creates a three-struct-sized array alternativePinMappings[].
in python, this is the same idea, where profileOptions' field is a
special container to which AlternativePinMappings can be added. this
allows adding elements via the UI. it does *NOT* implement limits (yet?)
so you can add more (and I think the board will just ignore them and
drop them on write)
This commit is contained in:
Brian S. Stephan 2023-07-01 13:56:52 -05:00
parent 91db1b169c
commit 221f45557e
Signed by: bss
GPG Key ID: 3DE06D3180895FCB
2 changed files with 131 additions and 13 deletions

View File

@ -121,6 +121,7 @@ class ConfigEditor(App):
"""Display the GP2040-CE configuration as a tree.""" """Display the GP2040-CE configuration as a tree."""
BINDINGS = [ BINDINGS = [
('a', 'add_node', "Add Node"),
('s', 'save', "Save Config"), ('s', 'save', "Save Config"),
('q', 'quit', "Quit"), ('q', 'quit', "Quit"),
] ]
@ -153,14 +154,50 @@ class ConfigEditor(App):
tree.root.data = (None, self.config.DESCRIPTOR, self.config) tree.root.data = (None, self.config.DESCRIPTOR, self.config)
tree.root.set_label(self.config_filename) tree.root.set_label(self.config_filename)
missing_fields = [f for f in self.config.DESCRIPTOR.fields
if f not in [fp for fp, vp in self.config.ListFields()]]
for field_descriptor, field_value in sorted(self.config.ListFields(), key=lambda f: f[0].name): for field_descriptor, field_value in sorted(self.config.ListFields(), key=lambda f: f[0].name):
ConfigEditor._add_node(tree.root, self.config, field_descriptor, field_value) child_is_message = ConfigEditor._descriptor_is_message(field_descriptor)
ConfigEditor._add_node(tree.root, self.config, field_descriptor, field_value,
value_is_config=child_is_message)
for child_field_descriptor in sorted(missing_fields, key=lambda f: f.name):
child_is_message = ConfigEditor._descriptor_is_message(field_descriptor)
ConfigEditor._add_node(tree.root, self.config, child_field_descriptor,
getattr(self.config, child_field_descriptor.name),
value_is_config=child_is_message)
tree.root.expand() tree.root.expand()
def on_tree_node_selected(self, node_event: Tree.NodeSelected) -> None: def on_tree_node_selected(self, node_event: Tree.NodeSelected) -> None:
"""Take the appropriate action for this type of node.""" """Take the appropriate action for this type of node."""
self._modify_node(node_event.node) self._modify_node(node_event.node)
def action_add_node(self) -> None:
"""Add a node to the tree item, if allowed by the tree and config section."""
tree = self.query_one(Tree)
current_node = tree.cursor_node
if not current_node or not current_node.allow_expand:
logger.debug("no node selected, or it does not allow expansion")
return
parent_config, field_descriptor, field_value = current_node.data
if not parent_config:
logger.debug("adding to the root is unsupported!")
return
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE:
config = field_value
else:
config = getattr(parent_config, field_descriptor.name)
logger.debug("config: %s", config)
if hasattr(config, 'add'):
field_value = config.add()
actual_field_descriptor = parent_config.DESCRIPTOR.fields_by_name[field_descriptor.name]
logger.debug("adding new node %s", field_value.DESCRIPTOR.name)
ConfigEditor._add_node(current_node, config, actual_field_descriptor, field_value,
value_is_config=True)
current_node.expand()
def action_save(self) -> None: def action_save(self) -> None:
"""Save the configuration.""" """Save the configuration."""
write_new_config_to_filename(self.config, self.config_filename, inject=self.whole_board) write_new_config_to_filename(self.config, self.config_filename, inject=self.whole_board)
@ -172,7 +209,8 @@ class ConfigEditor(App):
@staticmethod @staticmethod
def _add_node(parent_node: TreeNode, parent_config: Message, def _add_node(parent_node: TreeNode, parent_config: Message,
field_descriptor: descriptor.FieldDescriptor, field_value: object) -> None: field_descriptor: descriptor.FieldDescriptor, field_value: object,
value_is_config: bool = False, uninitialized: bool = False) -> None:
"""Add a node to the overall tree, recursively. """Add a node to the overall tree, recursively.
Args: Args:
@ -180,32 +218,62 @@ class ConfigEditor(App):
parent_config: the Config object parent. parent_config + field_descriptor.name = this node parent_config: the Config object parent. parent_config + field_descriptor.name = this node
field_descriptor: descriptor for the protobuf field field_descriptor: descriptor for the protobuf field
field_value: data to add to the parent node as new node(s) field_value: data to add to the parent node as new node(s)
value_is_config: get the config from the value rather than deriving it (important for repeated)
uninitialized: this node's data is from the spec and not the actual config, handle with care
""" """
# all nodes relate to their parent and retain info about themselves # all nodes relate to their parent and retain info about themselves
this_node = parent_node.add("") this_node = parent_node.add("")
if uninitialized and 'google._upb._message.RepeatedCompositeContainer' in str(type(field_value)):
# python segfaults if I refer to/retain its actual, presumably uninitialized in C, value
logger.warning("PROBLEM: %s %s", type(field_value), field_value)
# WORKAROUND BEGINS HERE
if not field_value:
x = field_value.add()
field_value.remove(x)
# WORKAROUND ENDS HERE
this_node.data = (parent_config, field_descriptor, field_value) this_node.data = (parent_config, field_descriptor, field_value)
this_node.set_label(pb_field_to_node_label(field_descriptor, field_value))
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE: if uninitialized:
this_config = getattr(parent_config, field_descriptor.name) this_node.set_label(Text.from_markup("[red][b]NEW:[/b][/red] ") +
pb_field_to_node_label(field_descriptor, field_value))
else:
this_node.set_label(pb_field_to_node_label(field_descriptor, field_value))
if ConfigEditor._descriptor_is_message(field_descriptor):
if value_is_config:
this_config = field_value
else:
this_config = getattr(parent_config, field_descriptor.name)
if hasattr(field_value, 'add'): if hasattr(field_value, 'add'):
# support repeated # support repeated
for child in field_value: for child in field_value:
ConfigEditor._add_node(this_node, this_config, child.DESCRIPTOR, child) child_is_message = ConfigEditor._descriptor_is_message(child.DESCRIPTOR)
ConfigEditor._add_node(this_node, this_config, child.DESCRIPTOR, child,
value_is_config=child_is_message)
else: else:
# a message has stuff under it, recurse into it # a message has stuff under it, recurse into it
missing_fields = [f for f in field_value.DESCRIPTOR.fields missing_fields = [f for f in field_value.DESCRIPTOR.fields
if f not in [fp for fp, vp in field_value.ListFields()]] if f not in [fp for fp, vp in field_value.ListFields()]]
for child_field_descriptor, child_field_value in sorted(field_value.ListFields(), for child_field_descriptor, child_field_value in sorted(field_value.ListFields(),
key=lambda f: f[0].name): key=lambda f: f[0].name):
ConfigEditor._add_node(this_node, this_config, child_field_descriptor, child_field_value) child_is_message = ConfigEditor._descriptor_is_message(child_field_descriptor)
ConfigEditor._add_node(this_node, this_config, child_field_descriptor, child_field_value,
value_is_config=child_is_message)
for child_field_descriptor in sorted(missing_fields, key=lambda f: f.name): for child_field_descriptor in sorted(missing_fields, key=lambda f: f.name):
child_is_message = ConfigEditor._descriptor_is_message(child_field_descriptor)
ConfigEditor._add_node(this_node, this_config, child_field_descriptor, ConfigEditor._add_node(this_node, this_config, child_field_descriptor,
getattr(this_config, child_field_descriptor.name)) getattr(this_config, child_field_descriptor.name), uninitialized=True,
value_is_config=child_is_message)
else: else:
# leaf node, stop here # leaf node, stop here
this_node.allow_expand = False this_node.allow_expand = False
@staticmethod
def _descriptor_is_message(desc: descriptor.Descriptor) -> bool:
return (getattr(desc, 'type', None) == descriptor.FieldDescriptor.TYPE_MESSAGE or
hasattr(desc, 'fields'))
def _modify_node(self, node: TreeNode) -> None: def _modify_node(self, node: TreeNode) -> None:
"""Modify the selected node by context of what type of config item it is.""" """Modify the selected node by context of what type of config item it is."""
parent_config, field_descriptor, _ = node.data parent_config, field_descriptor, _ = node.data
@ -240,7 +308,10 @@ def pb_field_to_node_label(field_descriptor, field_value):
prettified text representation of the field prettified text representation of the field
""" """
highlighter = ReprHighlighter() highlighter = ReprHighlighter()
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE: if hasattr(field_value, 'add'):
label = Text.from_markup(f"[b]{field_descriptor.name}[][/b]")
elif (getattr(field_descriptor, 'type', None) == descriptor.FieldDescriptor.TYPE_MESSAGE or
hasattr(field_descriptor, 'fields')):
label = Text.from_markup(f"[b]{field_descriptor.name}[/b]") label = Text.from_markup(f"[b]{field_descriptor.name}[/b]")
elif field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM: elif field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM:
enum_selection = field_descriptor.enum_type.values_by_number[field_value].name enum_selection = field_descriptor.enum_type.values_by_number[field_value].name

View File

@ -3,17 +3,29 @@ import os
import sys import sys
import pytest import pytest
from decorator import decorator
from textual.widgets import Tree from textual.widgets import Tree
from gp2040ce_bintools.gui import ConfigEditor from gp2040ce_bintools.gui import ConfigEditor
from gp2040ce_bintools.storage import get_config_from_file from gp2040ce_bintools.storage import get_config_from_file
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
proto_path = os.path.join(HERE, 'test-files', 'pb2-files')
sys.path.append(proto_path)
@decorator
async def with_pb2s(test, *args, **kwargs):
"""Wrap a test with precompiled pb2 files on the path."""
proto_path = os.path.join(HERE, 'test-files', 'pb2-files')
sys.path.append(proto_path)
await test(*args, **kwargs)
sys.path.pop()
del sys.modules['config_pb2']
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_simple_tree_building(): async def test_simple_tree_building():
"""Test some basics of the config tree being built.""" """Test some basics of the config tree being built."""
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
@ -24,9 +36,11 @@ async def test_simple_tree_building():
assert parent_config == pilot.app.config assert parent_config == pilot.app.config
assert field_descriptor == pilot.app.config.DESCRIPTOR.fields_by_name['boardVersion'] assert field_descriptor == pilot.app.config.DESCRIPTOR.fields_by_name['boardVersion']
assert field_value == 'v0.7.2' assert field_value == 'v0.7.2'
app.exit()
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_simple_toggle(): async def test_simple_toggle():
"""Test that we can navigate a bit and toggle a bool.""" """Test that we can navigate a bit and toggle a bool."""
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin')) app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
@ -41,6 +55,7 @@ async def test_simple_toggle():
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_simple_edit_via_input_field(): async def test_simple_edit_via_input_field():
"""Test that we can change an int via UI and see it reflected in the config.""" """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')) app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
@ -64,6 +79,7 @@ async def test_simple_edit_via_input_field():
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_simple_edit_via_input_field_enum(): 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.""" """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')) app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
@ -87,6 +103,7 @@ async def test_simple_edit_via_input_field_enum():
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_simple_edit_via_input_field_string(): 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.""" """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')) app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
@ -95,8 +112,6 @@ async def test_simple_edit_via_input_field_string():
version_node = tree.root.children[2] version_node = tree.root.children[2]
assert pilot.app.config.boardVersion == 'v0.7.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.select_node(version_node)
tree.action_select_cursor() tree.action_select_cursor()
await pilot.wait_for_scheduled_animations() await pilot.wait_for_scheduled_animations()
@ -109,6 +124,38 @@ async def test_simple_edit_via_input_field_string():
@pytest.mark.asyncio @pytest.mark.asyncio
@with_pb2s
async def test_add_node_to_repeated():
"""Test that we can navigate to an empty repeated and add a node."""
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)
profile_node = tree.root.children[10]
altpinmappings_node = profile_node.children[0]
tree.root.expand_all()
await pilot.wait_for_scheduled_animations()
tree.select_node(altpinmappings_node)
await pilot.press('a')
newpinmappings_node = altpinmappings_node.children[0]
newpinmappings_node.expand()
await pilot.wait_for_scheduled_animations()
tree.select_node(newpinmappings_node)
b4_node = newpinmappings_node.children[3]
tree.select_node(b4_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.profileOptions.alternativePinMappings[0].pinButtonB4 == 5
@pytest.mark.asyncio
@with_pb2s
async def test_save(config_binary, tmp_path): async def test_save(config_binary, tmp_path):
"""Test that the tree builds and things are kind of where they should be.""" """Test that the tree builds and things are kind of where they should be."""
new_filename = os.path.join(tmp_path, 'config-copy.bin') new_filename = os.path.join(tmp_path, 'config-copy.bin')