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.containers import Grid, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import (
Button,
DirectoryTree,
Input,
Label,
RadioButton,
RadioSet,
Static,
)
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
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-copy { margin-top: 1; color: $text-muted; }
.form-section { margin-top: 1; }
#path-row { grid-size: 2; grid-columns: 1fr 16; height: 3; }
#path-input { width: 1fr; height: 3; margin: 0; }
#workflow-error { color: $error; height: auto; margin-top: 1; }
#workflow-warning { color: $warning; height: auto; margin-top: 1; }
.browse-button { width: 16; height: 3; margin: 0; }
.format-section { margin-top: 1; padding: 0 1; border: round $primary; }
.path-section {
height: 3; margin-top: 1; padding: 0 1; border: round $primary;
}
#path-row { grid-size: 2; grid-columns: 1fr 14; height: 1; }
#path-input { width: 1fr; height: 1; margin: 0; padding: 0; border: none; }
#workflow-error { color: $error; height: auto; }
#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; }
"""
@ -68,11 +66,16 @@ class _FormatModal(ModalScreen):
def _path_or_error(self) -> Path | None:
value = self.query_one("#path-input", Input).value.strip()
if value:
self.query_one("#workflow-error", Static).update("")
self._show_message("#workflow-error", "")
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
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:
self.dismiss(None)
@ -93,21 +96,31 @@ class ExportModal(_FormatModal):
"Choose a destination and format. Existing files require confirmation.",
classes="format-copy",
)
with Vertical(classes="form-section"):
yield Label("Export file path")
with Vertical(classes="path-section") as location:
location.border_title = "Export file path"
with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input")
yield Button("Browse…", id="browse-button", classes="browse-button")
yield Static("", id="workflow-error")
yield Static("", id="workflow-warning")
yield Input(
placeholder="/path/to/entries.hosts",
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")
yield Static("", id="workflow-warning", classes="workflow-message hidden")
with Vertical(classes="format-section") as formats:
formats.border_title = "File format"
with RadioSet(id="format-select"):
with RadioSet(id="format-select", compact=True):
for index, format in enumerate(self._formats):
yield RadioButton(
_format_label(format),
id=f"format-{format.value}",
value=index == 0,
compact=True,
)
with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button")
@ -135,13 +148,13 @@ class ExportModal(_FormatModal):
request = ExportRequest(path, self._selected_format(ExportFormat))
warnings = self._validate_path(path, request.format)
self._request = request
warning = self.query_one("#workflow-warning", Static)
warning.update("\n".join(warnings))
self._show_message("#workflow-warning", "\n".join(warnings))
blocking_warnings = [item for item in warnings if "already exists" not in item]
if blocking_warnings:
self.query_one("#workflow-error", Static).update(
self._show_message(
"#workflow-error",
"Correct the destination before exporting: "
+ "; ".join(blocking_warnings)
+ "; ".join(blocking_warnings),
)
return
if warnings:
@ -181,20 +194,30 @@ class ImportModal(_FormatModal):
"Import replaces the current Host Entries and saves the Hosts File.",
classes="format-copy",
)
with Vertical(classes="form-section"):
yield Label("Import file path")
with Vertical(classes="path-section") as location:
location.border_title = "Import file path"
with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input")
yield Button("Browse…", id="browse-button", classes="browse-button")
yield Static("", id="workflow-error")
yield Input(
placeholder="/path/to/entries.hosts",
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:
formats.border_title = "File format"
with RadioSet(id="format-select"):
with RadioSet(id="format-select", compact=True):
for index, format in enumerate(self._formats):
yield RadioButton(
_format_label(format),
id=f"format-{format.value}",
value=index == 0,
compact=True,
)
with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button")
@ -212,9 +235,7 @@ class ImportModal(_FormatModal):
format = self._selected_format(ImportFormat)
result = self._import_file(format, path)
if not result.success:
self.query_one("#workflow-error", Static).update(
"; ".join(result.errors)
)
self._show_message("#workflow-error", "; ".join(result.errors))
return
self.dismiss(ImportRequest(path, format, result))
elif event.button.id == "browse-button":
@ -234,13 +255,28 @@ class ImportModal(_FormatModal):
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]):
"""A keyboard-accessible directory tree for file and folder selection."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("u", "go_up", "Up directory"),
]
BINDINGS = [Binding("escape", "cancel", "Cancel")]
CSS = """
FileBrowserModal { align: center middle; }
#file-browser-container {
@ -269,13 +305,12 @@ class FileBrowserModal(ModalScreen[Path | None]):
classes="browser-title",
)
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",
)
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"):
yield Button("Up", id="up-button")
yield Button("Cancel", id="cancel-button")
if self._choose_directory:
yield Button(
@ -300,33 +335,24 @@ class FileBrowserModal(ModalScreen[Path | None]):
def on_directory_tree_directory_selected(
self, event: DirectoryTree.DirectorySelected
) -> 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.query_one("#file-browser-status", Static).update(str(event.path))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "up-button":
self.action_go_up()
elif event.button.id == "cancel-button":
if event.button.id == "cancel-button":
self.action_cancel()
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:
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:
"""Give the core-supported formats concise, file-oriented labels."""

View file

@ -3,7 +3,8 @@
from unittest.mock import Mock
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 (
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
@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
async def test_export_browser_lets_the_user_choose_a_destination_directory():
"""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)
container = app.screen.query_one("#format-container")
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.y == path_input.region.y
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")
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("enter")
@ -98,8 +125,8 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory():
@pytest.mark.asyncio
async def test_file_browser_can_move_to_its_parent_directory(tmp_path):
"""The chooser exposes an explicit route back up the directory tree."""
async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path):
"""The chooser exposes its parent as the first conventional tree entry."""
child = tmp_path / "child"
child.mkdir()
app = HostsManagerApp()
@ -110,11 +137,17 @@ async def test_file_browser_can_move_to_its_parent_directory(tmp_path):
await pilot.pause()
browser = app.screen
assert isinstance(browser, FileBrowserModal)
browser.action_go_up()
tree = browser.query_one("#file-browser-tree", DirectoryTree)
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