Fix #5: compact file workflow forms
This commit is contained in:
parent
41a96b8156
commit
2c7840426d
2 changed files with 128 additions and 69 deletions
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue