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:
parent
91db1b169c
commit
221f45557e
@ -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
|
||||||
|
@ -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')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user