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:
parent
f6d3ad02e0
commit
3a55cad86f
@ -10,7 +10,7 @@ Button {
|
|||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
EditScreen, MessageScreen {
|
EditScreen, MessageScreen, SaveAsScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ EditScreen Label {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-dialog {
|
#edit-dialog, #save-as-dialog {
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
grid-rows: 1fr 1fr 1fr 1fr;
|
grid-rows: 1fr 1fr 1fr 1fr;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
@ -16,7 +16,7 @@ from textual.app import App, ComposeResult
|
|||||||
from textual.containers import Container, Grid, Horizontal
|
from textual.containers import Container, Grid, Horizontal
|
||||||
from textual.logging import TextualHandler
|
from textual.logging import TextualHandler
|
||||||
from textual.screen import ModalScreen
|
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 import Button, Footer, Header, Input, Label, Pretty, Select, TextArea, Tree
|
||||||
from textual.widgets.tree import TreeNode
|
from textual.widgets.tree import TreeNode
|
||||||
|
|
||||||
@ -125,11 +125,61 @@ class MessageScreen(ModalScreen):
|
|||||||
self.app.pop_screen()
|
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):
|
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"),
|
('a', 'save_as', "Save As..."),
|
||||||
|
('n', 'add_node', "Add Node"),
|
||||||
('s', 'save', "Save Config"),
|
('s', 'save', "Save Config"),
|
||||||
('q', 'quit', "Quit"),
|
('q', 'quit', "Quit"),
|
||||||
('?', 'about', "About"),
|
('?', 'about', "About"),
|
||||||
@ -238,6 +288,10 @@ class ConfigEditor(App):
|
|||||||
self.notify(f"Saved to {self.config_filename}.",
|
self.notify(f"Saved to {self.config_filename}.",
|
||||||
title="Configuration Saved")
|
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:
|
def action_quit(self) -> None:
|
||||||
"""Quit the application."""
|
"""Quit the application."""
|
||||||
self.exit()
|
self.exit()
|
||||||
|
@ -183,7 +183,7 @@ async def test_add_node_to_repeated():
|
|||||||
tree.root.expand_all()
|
tree.root.expand_all()
|
||||||
await pilot.wait_for_scheduled_animations()
|
await pilot.wait_for_scheduled_animations()
|
||||||
tree.select_node(altpinmappings_node)
|
tree.select_node(altpinmappings_node)
|
||||||
await pilot.press('a')
|
await pilot.press('n')
|
||||||
newpinmappings_node = altpinmappings_node.children[0]
|
newpinmappings_node = altpinmappings_node.children[0]
|
||||||
newpinmappings_node.expand()
|
newpinmappings_node.expand()
|
||||||
await pilot.wait_for_scheduled_animations()
|
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)
|
config = get_config_from_file(new_filename)
|
||||||
assert config.boardVersion == 'v0.7.5-bss-wuz-here'
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user