Fix #5: compact file workflow forms

This commit is contained in:
Philip Henning 2026-09-08 17:34:25 +02:00
parent 41a96b8156
commit 2c7840426d
2 changed files with 128 additions and 69 deletions

View file

@ -7,15 +7,7 @@ from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Grid, Horizontal, Vertical from textual.containers import Grid, Horizontal, Vertical
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import ( from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
Button,
DirectoryTree,
Input,
Label,
RadioButton,
RadioSet,
Static,
)
from ..core.import_export import ExportFormat, ImportFormat, ImportResult from ..core.import_export import ExportFormat, ImportFormat, ImportResult
@ -49,13 +41,19 @@ class _FormatModal(ModalScreen):
} }
.format-title { text-align: center; text-style: bold; color: $primary; } .format-title { text-align: center; text-style: bold; color: $primary; }
.format-copy { margin-top: 1; color: $text-muted; } .format-copy { margin-top: 1; color: $text-muted; }
.form-section { margin-top: 1; } .path-section {
#path-row { grid-size: 2; grid-columns: 1fr 16; height: 3; } height: 3; margin-top: 1; padding: 0 1; border: round $primary;
#path-input { width: 1fr; height: 3; margin: 0; } }
#workflow-error { color: $error; height: auto; margin-top: 1; } #path-row { grid-size: 2; grid-columns: 1fr 14; height: 1; }
#workflow-warning { color: $warning; height: auto; margin-top: 1; } #path-input { width: 1fr; height: 1; margin: 0; padding: 0; border: none; }
.browse-button { width: 16; height: 3; margin: 0; } #workflow-error { color: $error; height: auto; }
.format-section { margin-top: 1; padding: 0 1; border: round $primary; } #workflow-warning { color: $warning; height: auto; }
.workflow-message.hidden { display: none; }
.browse-button { width: 14; height: 1; margin: 0; border: none; }
.format-section {
height: 5; margin-top: 1; padding: 0 1; border: round $primary;
}
#format-select { height: 3; margin: 0; padding: 0; border: none; }
.button-row { margin-top: 1; height: 3; align: center middle; } .button-row { margin-top: 1; height: 3; align: center middle; }
""" """
@ -68,11 +66,16 @@ class _FormatModal(ModalScreen):
def _path_or_error(self) -> Path | None: def _path_or_error(self) -> Path | None:
value = self.query_one("#path-input", Input).value.strip() value = self.query_one("#path-input", Input).value.strip()
if value: if value:
self.query_one("#workflow-error", Static).update("") self._show_message("#workflow-error", "")
return Path(value).expanduser() return Path(value).expanduser()
self.query_one("#workflow-error", Static).update("Choose a file path first.") self._show_message("#workflow-error", "Choose a file path first.")
return None return None
def _show_message(self, selector: str, message: str) -> None:
widget = self.query_one(selector, Static)
widget.update(message)
widget.set_class(not message, "hidden")
def action_cancel(self) -> None: def action_cancel(self) -> None:
self.dismiss(None) self.dismiss(None)
@ -93,21 +96,31 @@ class ExportModal(_FormatModal):
"Choose a destination and format. Existing files require confirmation.", "Choose a destination and format. Existing files require confirmation.",
classes="format-copy", classes="format-copy",
) )
with Vertical(classes="form-section"): with Vertical(classes="path-section") as location:
yield Label("Export file path") location.border_title = "Export file path"
with Grid(id="path-row"): with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input") yield Input(
yield Button("Browse…", id="browse-button", classes="browse-button") placeholder="/path/to/entries.hosts",
yield Static("", id="workflow-error") id="path-input",
yield Static("", id="workflow-warning") compact=True,
)
yield Button(
"Browse…",
id="browse-button",
classes="browse-button",
compact=True,
)
yield Static("", id="workflow-error", classes="workflow-message hidden")
yield Static("", id="workflow-warning", classes="workflow-message hidden")
with Vertical(classes="format-section") as formats: with Vertical(classes="format-section") as formats:
formats.border_title = "File format" formats.border_title = "File format"
with RadioSet(id="format-select"): with RadioSet(id="format-select", compact=True):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
_format_label(format), _format_label(format),
id=f"format-{format.value}", id=f"format-{format.value}",
value=index == 0, value=index == 0,
compact=True,
) )
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
@ -135,13 +148,13 @@ class ExportModal(_FormatModal):
request = ExportRequest(path, self._selected_format(ExportFormat)) request = ExportRequest(path, self._selected_format(ExportFormat))
warnings = self._validate_path(path, request.format) warnings = self._validate_path(path, request.format)
self._request = request self._request = request
warning = self.query_one("#workflow-warning", Static) self._show_message("#workflow-warning", "\n".join(warnings))
warning.update("\n".join(warnings))
blocking_warnings = [item for item in warnings if "already exists" not in item] blocking_warnings = [item for item in warnings if "already exists" not in item]
if blocking_warnings: if blocking_warnings:
self.query_one("#workflow-error", Static).update( self._show_message(
"#workflow-error",
"Correct the destination before exporting: " "Correct the destination before exporting: "
+ "; ".join(blocking_warnings) + "; ".join(blocking_warnings),
) )
return return
if warnings: if warnings:
@ -181,20 +194,30 @@ class ImportModal(_FormatModal):
"Import replaces the current Host Entries and saves the Hosts File.", "Import replaces the current Host Entries and saves the Hosts File.",
classes="format-copy", classes="format-copy",
) )
with Vertical(classes="form-section"): with Vertical(classes="path-section") as location:
yield Label("Import file path") location.border_title = "Import file path"
with Grid(id="path-row"): with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input") yield Input(
yield Button("Browse…", id="browse-button", classes="browse-button") placeholder="/path/to/entries.hosts",
yield Static("", id="workflow-error") id="path-input",
compact=True,
)
yield Button(
"Browse…",
id="browse-button",
classes="browse-button",
compact=True,
)
yield Static("", id="workflow-error", classes="workflow-message hidden")
with Vertical(classes="format-section") as formats: with Vertical(classes="format-section") as formats:
formats.border_title = "File format" formats.border_title = "File format"
with RadioSet(id="format-select"): with RadioSet(id="format-select", compact=True):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
_format_label(format), _format_label(format),
id=f"format-{format.value}", id=f"format-{format.value}",
value=index == 0, value=index == 0,
compact=True,
) )
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
@ -212,9 +235,7 @@ class ImportModal(_FormatModal):
format = self._selected_format(ImportFormat) format = self._selected_format(ImportFormat)
result = self._import_file(format, path) result = self._import_file(format, path)
if not result.success: if not result.success:
self.query_one("#workflow-error", Static).update( self._show_message("#workflow-error", "; ".join(result.errors))
"; ".join(result.errors)
)
return return
self.dismiss(ImportRequest(path, format, result)) self.dismiss(ImportRequest(path, format, result))
elif event.button.id == "browse-button": elif event.button.id == "browse-button":
@ -234,13 +255,28 @@ class ImportModal(_FormatModal):
self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected)
class ParentDirectoryTree(DirectoryTree):
"""Directory tree with a conventional parent entry above its root."""
def _populate_node(self, node, content) -> None:
super()._populate_node(node, content)
if node is not self.root or node.data is None:
return
current = node.data.path.expanduser().resolve()
parent = current.parent
if parent != current:
node.add(
"..",
data=type(node.data)(parent),
allow_expand=False,
before=0,
)
class FileBrowserModal(ModalScreen[Path | None]): class FileBrowserModal(ModalScreen[Path | None]):
"""A keyboard-accessible directory tree for file and folder selection.""" """A keyboard-accessible directory tree for file and folder selection."""
BINDINGS = [ BINDINGS = [Binding("escape", "cancel", "Cancel")]
Binding("escape", "cancel", "Cancel"),
Binding("u", "go_up", "Up directory"),
]
CSS = """ CSS = """
FileBrowserModal { align: center middle; } FileBrowserModal { align: center middle; }
#file-browser-container { #file-browser-container {
@ -269,13 +305,12 @@ class FileBrowserModal(ModalScreen[Path | None]):
classes="browser-title", classes="browser-title",
) )
yield Static( yield Static(
"Use arrow keys to browse. Enter selects a file; expand folders with Right.", "Use arrow keys to browse. Select .. to go up; expand folders with Right.",
classes="browser-copy", classes="browser-copy",
) )
yield Static(str(self._start_path), id="file-browser-status") yield Static(str(self._start_path), id="file-browser-status")
yield DirectoryTree(self._start_path, id="file-browser-tree") yield ParentDirectoryTree(self._start_path, id="file-browser-tree")
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Up", id="up-button")
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
if self._choose_directory: if self._choose_directory:
yield Button( yield Button(
@ -300,33 +335,24 @@ class FileBrowserModal(ModalScreen[Path | None]):
def on_directory_tree_directory_selected( def on_directory_tree_directory_selected(
self, event: DirectoryTree.DirectorySelected self, event: DirectoryTree.DirectorySelected
) -> None: ) -> None:
if str(event.node.label) == "..":
self._current_path = event.path
self._selected_directory = None
self.query_one("#file-browser-tree", DirectoryTree).path = event.path
self.query_one("#file-browser-status", Static).update(str(event.path))
return
self._selected_directory = event.path self._selected_directory = event.path
self.query_one("#file-browser-status", Static).update(str(event.path)) self.query_one("#file-browser-status", Static).update(str(event.path))
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "up-button": if event.button.id == "cancel-button":
self.action_go_up()
elif event.button.id == "cancel-button":
self.action_cancel() self.action_cancel()
elif event.button.id == "choose-directory-button": elif event.button.id == "choose-directory-button":
self.dismiss(self._selected_directory or self._start_path) self.dismiss(self._selected_directory or self._current_path)
def action_cancel(self) -> None: def action_cancel(self) -> None:
self.dismiss(None) self.dismiss(None)
def action_go_up(self) -> None:
"""Re-root the tree at the parent of the current directory."""
parent = self._current_path.parent
if parent == self._current_path:
self.query_one("#file-browser-status", Static).update(
"Already at the filesystem root."
)
return
self._current_path = parent
self._selected_directory = None
self.query_one("#file-browser-tree", DirectoryTree).path = parent
self.query_one("#file-browser-status", Static).update(str(parent))
def _format_label(format: ExportFormat | ImportFormat) -> str: def _format_label(format: ExportFormat | ImportFormat) -> str:
"""Give the core-supported formats concise, file-oriented labels.""" """Give the core-supported formats concise, file-oriented labels."""

View file

@ -3,7 +3,8 @@
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
from textual.widgets import Button, DirectoryTree, Input, RadioButton, Static from textual.containers import Vertical
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
from hosts.core.import_export import ( from hosts.core.import_export import (
ExportFormat, ExportFormat,
@ -58,6 +59,31 @@ async def test_export_modal_is_centered_in_the_terminal_viewport():
assert container.region.y == (app.size.height - container.region.height) // 2 assert container.region.y == (app.size.height - container.region.height) // 2
@pytest.mark.asyncio
async def test_format_modal_uses_compact_consistent_form_sections():
"""Location and format fields use matching borders without excess rows."""
app = HostsManagerApp()
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
location = app.screen.query_one(".path-section", Vertical)
formats = app.screen.query_one(".format-section", Vertical)
path_input = app.screen.query_one("#path-input", Input)
browse = app.screen.query_one("#browse-button", Button)
format_select = app.screen.query_one("#format-select", RadioSet)
container = app.screen.query_one("#format-container")
assert location.border_title == "Export file path"
assert path_input.region.height == 1
assert browse.region.height == 1
assert format_select.region.height == 3
assert formats.region.height == 5
assert container.region.height <= 22
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_export_browser_lets_the_user_choose_a_destination_directory(): async def test_export_browser_lets_the_user_choose_a_destination_directory():
"""Browse opens a centered directory tree rather than requiring a typed path.""" """Browse opens a centered directory tree rather than requiring a typed path."""
@ -71,6 +97,8 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
path_input = app.screen.query_one("#path-input", Input) path_input = app.screen.query_one("#path-input", Input)
container = app.screen.query_one("#format-container") container = app.screen.query_one("#format-container")
assert browse.region.width > 0 assert browse.region.width > 0
assert path_input.region.height == 1
assert browse.region.height == 1
assert browse.region.x >= path_input.region.right assert browse.region.x >= path_input.region.right
assert browse.region.y == path_input.region.y assert browse.region.y == path_input.region.y
assert browse.region.right <= container.region.right assert browse.region.right <= container.region.right
@ -85,7 +113,6 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
container = app.screen.query_one("#file-browser-container") container = app.screen.query_one("#file-browser-container")
assert container.region.x == (app.size.width - container.region.width) // 2 assert container.region.x == (app.size.width - container.region.width) // 2
await pilot.press("tab")
await pilot.press("tab") await pilot.press("tab")
await pilot.press("tab") await pilot.press("tab")
await pilot.press("enter") await pilot.press("enter")
@ -98,8 +125,8 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_browser_can_move_to_its_parent_directory(tmp_path): async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path):
"""The chooser exposes an explicit route back up the directory tree.""" """The chooser exposes its parent as the first conventional tree entry."""
child = tmp_path / "child" child = tmp_path / "child"
child.mkdir() child.mkdir()
app = HostsManagerApp() app = HostsManagerApp()
@ -110,11 +137,17 @@ async def test_file_browser_can_move_to_its_parent_directory(tmp_path):
await pilot.pause() await pilot.pause()
browser = app.screen browser = app.screen
assert isinstance(browser, FileBrowserModal) assert isinstance(browser, FileBrowserModal)
tree = browser.query_one("#file-browser-tree", DirectoryTree)
browser.action_go_up()
await pilot.pause() await pilot.pause()
assert browser.query_one("#file-browser-tree", DirectoryTree).path == tmp_path assert str(tree.root.children[0].label) == ".."
assert len(browser.query("#up-button")) == 0
tree.focus()
tree.select_node(tree.root.children[0])
await pilot.pause()
assert tree.path == tmp_path
@pytest.mark.asyncio @pytest.mark.asyncio