diff --git a/gp2040ce_bintools/config_tree.css b/gp2040ce_bintools/config_tree.css index f6b6b2f..3188519 100644 --- a/gp2040ce_bintools/config_tree.css +++ b/gp2040ce_bintools/config_tree.css @@ -10,7 +10,7 @@ Button { margin: 0 1; } -EditScreen, MessageScreen { +EditScreen, MessageScreen, SaveAsScreen { align: center middle; } @@ -30,7 +30,7 @@ EditScreen Label { visibility: hidden; } -#edit-dialog { +#edit-dialog, #save-as-dialog { padding: 0 1; grid-rows: 1fr 1fr 1fr 1fr; width: 50%; diff --git a/gp2040ce_bintools/gui.py b/gp2040ce_bintools/gui.py index 1b87bec..4c95ff5 100644 --- a/gp2040ce_bintools/gui.py +++ b/gp2040ce_bintools/gui.py @@ -16,7 +16,7 @@ from textual.app import App, ComposeResult from textual.containers import Container, Grid, Horizontal from textual.logging import TextualHandler from textual.screen import ModalScreen -from textual.validation import Number +from textual.validation import Length, Number from textual.widgets import Button, Footer, Header, Input, Label, Pretty, Select, TextArea, Tree from textual.widgets.tree import TreeNode @@ -125,11 +125,61 @@ class MessageScreen(ModalScreen): self.app.pop_screen() +class SaveAsScreen(ModalScreen): + """Present the option of saving the configuration as a new file.""" + + def __init__(self, config, *args, **kwargs): + """Initialize a filename argument to be populated.""" + self.config = config + super().__init__(*args, **kwargs) + + def compose(self) -> ComposeResult: + """Build the pop-up window prompting for the new filename to save the configuration as.""" + self.filename_field = Input(value=None, id='field-input', validators=[Length(minimum=1)]) + yield Grid( + Container(Label("Filename (.uf2 or .bin) to write to:", id='field-name'), id='field-name-container'), + Container(self.filename_field, id='input-field-container'), + Container(Pretty('', id='input-errors', classes='hidden'), id='error-container'), + Horizontal(Container(Button("Cancel", id='cancel-button'), id='cancel-button-container'), + Container(Button("Confirm", id='confirm-button'), id='confirm-button-container'), + id='button-container'), + id='save-as-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('#confirm-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 == 'confirm-button': + logger.debug("calling _save") + self._save() + self.app.pop_screen() + + def _save(self): + """Save the configuration to the specified file.""" + write_new_config_to_filename(self.config, self.filename_field.value, inject=False) + self.notify(f"Saved to {self.filename_field.value}.", title="Configuration Saved") + + class ConfigEditor(App): """Display the GP2040-CE configuration as a tree.""" BINDINGS = [ - ('a', 'add_node', "Add Node"), + ('a', 'save_as', "Save As..."), + ('n', 'add_node', "Add Node"), ('s', 'save', "Save Config"), ('q', 'quit', "Quit"), ('?', 'about', "About"), @@ -238,6 +288,10 @@ class ConfigEditor(App): self.notify(f"Saved to {self.config_filename}.", title="Configuration Saved") + def action_save_as(self) -> None: + """Present a new dialog to save the configuration as a new standalone file.""" + self.push_screen(SaveAsScreen(self.config)) + def action_quit(self) -> None: """Quit the application.""" self.exit() diff --git a/tests/test_gui.py b/tests/test_gui.py index 1c750a5..90b0710 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -183,7 +183,7 @@ async def test_add_node_to_repeated(): tree.root.expand_all() await pilot.wait_for_scheduled_animations() tree.select_node(altpinmappings_node) - await pilot.press('a') + await pilot.press('n') newpinmappings_node = altpinmappings_node.children[0] newpinmappings_node.expand() await pilot.wait_for_scheduled_animations() @@ -216,3 +216,28 @@ async def test_save(config_binary, tmp_path): config = get_config_from_file(new_filename) assert config.boardVersion == 'v0.7.5-bss-wuz-here' + + +@pytest.mark.asyncio +@with_pb2s +async def test_save_as(config_binary, tmp_path): + """Test that we can save to a new file.""" + filename = os.path.join(tmp_path, 'config-original.bin') + with open(filename, 'wb') as file: + file.write(config_binary) + original_config = get_config(config_binary) + + app = ConfigEditor(config_filename=filename) + async with app.run_test() as pilot: + await pilot.press('a') + await pilot.wait_for_scheduled_animations() + await pilot.click('Input#field-input') + await pilot.wait_for_scheduled_animations() + await pilot.press('/', 't', 'm', 'p', '/', 'g', 'p', 't', 'e', 's', 't') + await pilot.wait_for_scheduled_animations() + await pilot.click('Button#confirm-button') + + with open('/tmp/gptest', 'rb') as new_file: + test_config_binary = new_file.read() + test_config = get_config(test_config_binary) + assert original_config == test_config