diff --git a/automation_file/ui/tabs/base.py b/automation_file/ui/tabs/base.py index c9da9a3..88095e8 100644 --- a/automation_file/ui/tabs/base.py +++ b/automation_file/ui/tabs/base.py @@ -16,6 +16,7 @@ QHBoxLayout, QLineEdit, QPushButton, + QTabWidget, QVBoxLayout, QWidget, ) @@ -92,20 +93,35 @@ class RemoteBackendTab(BaseTab): """Shared layout template for cloud/SFTP tabs. Subclasses supply ``_init_group`` (credentials / session setup) and - ``_ops_group`` (file transfer actions). The base class stacks both - inside a ``QVBoxLayout`` with a trailing stretch so the groups pin - to the top of the tab. + ``_ops_group`` (file transfer actions). The base class places each + group on its own inner sub-tab ("Credentials" / "Operations") so + the two concerns aren't crammed together on a single vertical page. """ def __init__(self, log: LogPanel, pool: QThreadPool) -> None: super().__init__(log, pool) root = QVBoxLayout(self) - root.addWidget(self._init_group()) - root.addWidget(self._ops_group()) - root.addStretch() + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) + + inner = QTabWidget() + inner.addTab(self._page(self._init_group()), "Credentials") + inner.addTab(self._page(self._ops_group()), "Operations") + root.addWidget(inner) def _init_group(self) -> QGroupBox: raise NotImplementedError def _ops_group(self) -> QGroupBox: raise NotImplementedError + + @staticmethod + def _page(group: QGroupBox) -> QWidget: + """Wrap a group box in a padded page so the sub-tab has breathing room.""" + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(12) + layout.addWidget(group) + layout.addStretch() + return page diff --git a/automation_file/ui/tabs/drive_tab.py b/automation_file/ui/tabs/drive_tab.py index 3ce1a42..f5bbc79 100644 --- a/automation_file/ui/tabs/drive_tab.py +++ b/automation_file/ui/tabs/drive_tab.py @@ -7,7 +7,6 @@ QGroupBox, QLineEdit, QPushButton, - QVBoxLayout, ) from automation_file.remote.google_drive.client import driver_instance @@ -15,19 +14,12 @@ from automation_file.remote.google_drive.download_ops import drive_download_file from automation_file.remote.google_drive.search_ops import drive_search_all_file from automation_file.remote.google_drive.upload_ops import drive_upload_to_drive -from automation_file.ui.tabs.base import BaseTab +from automation_file.ui.tabs.base import RemoteBackendTab -class GoogleDriveTab(BaseTab): +class GoogleDriveTab(RemoteBackendTab): """Initialise Drive credentials and dispatch a subset of FA_drive_* ops.""" - def __init__(self, log, pool) -> None: - super().__init__(log, pool) - root = QVBoxLayout(self) - root.addWidget(self._init_group()) - root.addWidget(self._ops_group()) - root.addStretch() - def _init_group(self) -> QGroupBox: box = QGroupBox("Credentials") form = QFormLayout(box) diff --git a/automation_file/ui/tabs/home_tab.py b/automation_file/ui/tabs/home_tab.py index 7ef332a..870b8fa 100644 --- a/automation_file/ui/tabs/home_tab.py +++ b/automation_file/ui/tabs/home_tab.py @@ -49,8 +49,11 @@ def __init__(self, log: LogPanel, pool: QThreadPool) -> None: self._status_labels: dict[str, QLabel] = {} root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) root.addWidget(self._overview_group()) row = QHBoxLayout() + row.setSpacing(12) row.addWidget(self._status_group(), 1) row.addWidget(self._actions_group(), 1) root.addLayout(row) diff --git a/automation_file/ui/tabs/http_tab.py b/automation_file/ui/tabs/http_tab.py index 233c34d..e4f3153 100644 --- a/automation_file/ui/tabs/http_tab.py +++ b/automation_file/ui/tabs/http_tab.py @@ -14,7 +14,11 @@ class HTTPDownloadTab(BaseTab): def __init__(self, log, pool) -> None: super().__init__(log, pool) root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) form = QFormLayout() + form.setVerticalSpacing(10) + form.setHorizontalSpacing(12) self._url = QLineEdit() self._url.setPlaceholderText("https://example.com/file.bin") self._dest = QLineEdit() diff --git a/automation_file/ui/tabs/json_editor_tab.py b/automation_file/ui/tabs/json_editor_tab.py index 360f184..b2fd88d 100644 --- a/automation_file/ui/tabs/json_editor_tab.py +++ b/automation_file/ui/tabs/json_editor_tab.py @@ -264,6 +264,8 @@ def __init__(self, log, pool) -> None: splitter.setStretchFactor(1, 2) root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) root.addWidget(self._build_toolbar()) root.addWidget(splitter) root.addWidget(self._build_run_bar()) diff --git a/automation_file/ui/tabs/local_tab.py b/automation_file/ui/tabs/local_tab.py index 06ed203..327dba6 100644 --- a/automation_file/ui/tabs/local_tab.py +++ b/automation_file/ui/tabs/local_tab.py @@ -1,14 +1,25 @@ -"""Local filesystem / ZIP operations tab.""" +"""Local filesystem / ZIP operations tab. + +One operation visible at a time — a dropdown at the top selects an +action, and only that action's fields (plus its Run button) are shown +below. Avoids the "every field at once" wall of inputs that the flat +form layout produced. +""" from __future__ import annotations +from collections.abc import Callable +from typing import NamedTuple + from PySide6.QtWidgets import ( + QComboBox, QFormLayout, - QGroupBox, QLineEdit, QPushButton, + QStackedWidget, QTextEdit, QVBoxLayout, + QWidget, ) from automation_file.local.dir_ops import copy_dir, create_dir, remove_dir_tree, rename_dir @@ -21,101 +32,191 @@ from automation_file.local.zip_ops import unzip_all, zip_dir, zip_file from automation_file.ui.tabs.base import BaseTab +_GROUP_SEPARATOR = "——" + + +class _ActionEntry(NamedTuple): + group: str + label: str + build: Callable[[LocalOpsTab], QWidget] + class LocalOpsTab(BaseTab): - """Form-driven local file, directory, and ZIP operations.""" + """Dropdown-driven local file, directory, and ZIP operations.""" def __init__(self, log, pool) -> None: super().__init__(log, pool) + + entries = self._entries() + self._picker = QComboBox() + self._stack = QStackedWidget() + + previous_group: str | None = None + for entry in entries: + if previous_group is not None and entry.group != previous_group: + self._picker.insertSeparator(self._picker.count()) + self._picker.addItem(f"{entry.group} {_GROUP_SEPARATOR} {entry.label}") + self._stack.addWidget(entry.build(self)) + previous_group = entry.group + + self._picker.currentIndexChanged.connect(self._on_picker_changed) + root = QVBoxLayout(self) - root.addWidget(self._file_group()) - root.addWidget(self._dir_group()) - root.addWidget(self._zip_group()) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) + root.addWidget(self._picker) + root.addWidget(self._stack, 1) root.addStretch() - def _file_group(self) -> QGroupBox: - box = QGroupBox("Files") - form = QFormLayout(box) + def _entries(self) -> list[_ActionEntry]: + return [ + _ActionEntry("Files", "Create file", LocalOpsTab._page_create_file), + _ActionEntry("Files", "Copy file", LocalOpsTab._page_copy_file), + _ActionEntry("Files", "Rename file", LocalOpsTab._page_rename_file), + _ActionEntry("Files", "Delete file", LocalOpsTab._page_delete_file), + _ActionEntry("Directories", "Create directory", LocalOpsTab._page_create_dir), + _ActionEntry("Directories", "Copy directory", LocalOpsTab._page_copy_dir), + _ActionEntry("Directories", "Rename directory", LocalOpsTab._page_rename_dir), + _ActionEntry("Directories", "Delete directory tree", LocalOpsTab._page_remove_dir), + _ActionEntry("ZIP", "Zip file", LocalOpsTab._page_zip_file), + _ActionEntry("ZIP", "Zip directory", LocalOpsTab._page_zip_dir), + _ActionEntry("ZIP", "Unzip archive", LocalOpsTab._page_unzip), + ] + def _on_picker_changed(self, index: int) -> None: + # Skip over inserted separators — QComboBox.itemData returns None for them. + if index < 0: + return + stack_index = self._stack_index_for(index) + if stack_index is None: + return + self._stack.setCurrentIndex(stack_index) + + def _stack_index_for(self, picker_index: int) -> int | None: + """Translate a combobox row (which may include separators) to a stack index.""" + seen = 0 + for i in range(picker_index + 1): + if self._picker.itemText(i) == "": # separator rows have empty text + continue + if i == picker_index: + return seen + seen += 1 + return None + + # ----- page builders ----------------------------------------------------- + + @staticmethod + def _form_page() -> tuple[QWidget, QFormLayout]: + page = QWidget() + form = QFormLayout(page) + form.setContentsMargins(4, 4, 4, 4) + form.setVerticalSpacing(10) + form.setHorizontalSpacing(12) + return page, form + + def _page_create_file(self) -> QWidget: + page, form = self._form_page() self._create_path = QLineEdit() self._create_content = QTextEdit() self._create_content.setPlaceholderText("Optional file content") form.addRow("Path", self._create_path) form.addRow("Content", self._create_content) - create_btn = QPushButton("Create file") - create_btn.clicked.connect(self._on_create_file) - form.addRow(create_btn) + form.addRow(self._run_button("Create file", self._on_create_file)) + return page + def _page_copy_file(self) -> QWidget: + page, form = self._form_page() self._copy_src = QLineEdit() self._copy_dst = QLineEdit() - form.addRow("Copy source", self._copy_src) - form.addRow("Copy target", self._copy_dst) - copy_btn = QPushButton("Copy file") - copy_btn.clicked.connect(self._on_copy_file) - form.addRow(copy_btn) + form.addRow("Source", self._copy_src) + form.addRow("Target", self._copy_dst) + form.addRow(self._run_button("Copy file", self._on_copy_file)) + return page + def _page_rename_file(self) -> QWidget: + page, form = self._form_page() self._rename_src = QLineEdit() self._rename_dst = QLineEdit() - form.addRow("Rename source", self._rename_src) - form.addRow("Rename target", self._rename_dst) - rename_btn = QPushButton("Rename file") - rename_btn.clicked.connect(self._on_rename_file) - form.addRow(rename_btn) + form.addRow("Source", self._rename_src) + form.addRow("New name", self._rename_dst) + form.addRow(self._run_button("Rename file", self._on_rename_file)) + return page + def _page_delete_file(self) -> QWidget: + page, form = self._form_page() self._remove_path = QLineEdit() - form.addRow("Remove file", self._remove_path) - remove_btn = QPushButton("Delete file") - remove_btn.clicked.connect(self._on_remove_file) - form.addRow(remove_btn) - return box - - def _dir_group(self) -> QGroupBox: - box = QGroupBox("Directories") - form = QFormLayout(box) + form.addRow("Path", self._remove_path) + form.addRow(self._run_button("Delete file", self._on_remove_file)) + return page + + def _page_create_dir(self) -> QWidget: + page, form = self._form_page() self._dir_create = QLineEdit() - form.addRow("Create dir", self._dir_create) - form.addRow(self._button("Create", self._on_create_dir)) + form.addRow("Path", self._dir_create) + form.addRow(self._run_button("Create directory", self._on_create_dir)) + return page + def _page_copy_dir(self) -> QWidget: + page, form = self._form_page() self._dir_copy_src = QLineEdit() self._dir_copy_dst = QLineEdit() - form.addRow("Copy source", self._dir_copy_src) - form.addRow("Copy target", self._dir_copy_dst) - form.addRow(self._button("Copy dir", self._on_copy_dir)) + form.addRow("Source", self._dir_copy_src) + form.addRow("Target", self._dir_copy_dst) + form.addRow(self._run_button("Copy directory", self._on_copy_dir)) + return page + def _page_rename_dir(self) -> QWidget: + page, form = self._form_page() self._dir_rename_src = QLineEdit() self._dir_rename_dst = QLineEdit() - form.addRow("Rename source", self._dir_rename_src) - form.addRow("Rename target", self._dir_rename_dst) - form.addRow(self._button("Rename dir", self._on_rename_dir)) + form.addRow("Source", self._dir_rename_src) + form.addRow("New name", self._dir_rename_dst) + form.addRow(self._run_button("Rename directory", self._on_rename_dir)) + return page + def _page_remove_dir(self) -> QWidget: + page, form = self._form_page() self._dir_remove = QLineEdit() - form.addRow("Remove tree", self._dir_remove) - form.addRow(self._button("Delete dir tree", self._on_remove_dir)) - return box - - def _zip_group(self) -> QGroupBox: - box = QGroupBox("ZIP") - form = QFormLayout(box) - self._zip_target = QLineEdit() - self._zip_name = QLineEdit() - form.addRow("Path (file or dir)", self._zip_target) - form.addRow("Archive name (no .zip)", self._zip_name) - form.addRow(self._button("Zip file", self._on_zip_file)) - form.addRow(self._button("Zip directory", self._on_zip_dir)) + form.addRow("Path", self._dir_remove) + form.addRow(self._run_button("Delete directory tree", self._on_remove_dir)) + return page + + def _page_zip_file(self) -> QWidget: + page, form = self._form_page() + self._zip_file_path = QLineEdit() + self._zip_file_name = QLineEdit() + form.addRow("File to compress", self._zip_file_path) + form.addRow("Archive name (no .zip)", self._zip_file_name) + form.addRow(self._run_button("Zip file", self._on_zip_file)) + return page + def _page_zip_dir(self) -> QWidget: + page, form = self._form_page() + self._zip_dir_path = QLineEdit() + self._zip_dir_name = QLineEdit() + form.addRow("Directory to compress", self._zip_dir_path) + form.addRow("Archive name (no .zip)", self._zip_dir_name) + form.addRow(self._run_button("Zip directory", self._on_zip_dir)) + return page + + def _page_unzip(self) -> QWidget: + page, form = self._form_page() self._unzip_archive = QLineEdit() self._unzip_target = QLineEdit() + self._unzip_target.setPlaceholderText("leave blank to extract next to the archive") form.addRow("Archive", self._unzip_archive) form.addRow("Extract to", self._unzip_target) - form.addRow(self._button("Unzip all", self._on_unzip_all)) - return box + form.addRow(self._run_button("Unzip archive", self._on_unzip_all)) + return page @staticmethod - def _button(label: str, handler) -> QPushButton: + def _run_button(label: str, handler: Callable[[], None]) -> QPushButton: button = QPushButton(label) button.clicked.connect(handler) return button + # ----- handlers ---------------------------------------------------------- + def _on_create_file(self) -> None: path = self._create_path.text().strip() content = self._create_content.toPlainText() @@ -170,8 +271,8 @@ def _on_remove_dir(self) -> None: self.run_action(remove_dir_tree, f"remove_dir_tree {path}", kwargs={"dir_path": path}) def _on_zip_file(self) -> None: - path = self._zip_target.text().strip() - name = self._zip_name.text().strip() + path = self._zip_file_path.text().strip() + name = self._zip_file_name.text().strip() archive = name if name.endswith(".zip") else f"{name}.zip" self.run_action( zip_file, @@ -180,8 +281,8 @@ def _on_zip_file(self) -> None: ) def _on_zip_dir(self) -> None: - path = self._zip_target.text().strip() - name = self._zip_name.text().strip() + path = self._zip_dir_path.text().strip() + name = self._zip_dir_name.text().strip() self.run_action( zip_dir, f"zip_dir {path} -> {name}.zip", diff --git a/automation_file/ui/tabs/server_tab.py b/automation_file/ui/tabs/server_tab.py index 6f70884..c4c265c 100644 --- a/automation_file/ui/tabs/server_tab.py +++ b/automation_file/ui/tabs/server_tab.py @@ -25,6 +25,8 @@ def __init__(self, log, pool) -> None: self._tcp_server: TCPActionServer | None = None self._http_server: HTTPActionServer | None = None root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(12) root.addWidget(self._tcp_group()) root.addWidget(self._http_group()) root.addStretch() diff --git a/automation_file/ui/tabs/transfer_tab.py b/automation_file/ui/tabs/transfer_tab.py index aa52ac5..92a91d5 100644 --- a/automation_file/ui/tabs/transfer_tab.py +++ b/automation_file/ui/tabs/transfer_tab.py @@ -59,7 +59,8 @@ def __init__(self, log: LogPanel, pool: QThreadPool) -> None: self._sidebar.setCurrentRow(0) root = QHBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(8) root.addWidget(self._sidebar) root.addWidget(self._stack, 1)