implement Save As... in the TUI

this allows for loading an existing GP2040-CE dump or board in BOOTSEL
over USB and saving the parsed configuration to a new .bin/.uf2 file.
might be useful for making quick backups or variants of configs

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
This commit is contained in:
Brian S. Stephan 2024-03-26 13:04:27 -05:00
parent f6d3ad02e0
commit 3a55cad86f
Signed by: bss
GPG Key ID: 3DE06D3180895FCB
3 changed files with 84 additions and 5 deletions

View File

@ -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%;

View File

@ -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()

View File

@ -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