From 58a31ea90166ef582f084815b780cd779e149737 Mon Sep 17 00:00:00 2001 From: data Date: Mon, 2 Feb 2026 13:25:15 +0100 Subject: [PATCH] Initial commit: PyQt6 FileBrowser mit Preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - 3-Panel Layout (Ordnerbaum, Dateiliste, Vorschau) - PDF-Vorschau mit Zoom/Seiten-Modus Einstellungen - Bild- und Text-Vorschau - Dateioperationen (Umbenennen, Verschieben, Löschen) - Drag & Drop Support - Kontextmenü - Tastenkürzel (F2, Delete, F5, Backspace) - Breadcrumb-Navigation mit Copy/Paste Textzeile - Themes (Dark, Breeze Dark/Light) - Separates Vorschaufenster - Einstellungen werden gespeichert Co-Authored-By: Claude Opus 4.5 --- .gitignore | 45 +++ main.py | 29 ++ requirements.txt | 3 + src/__init__.py | 1 + src/dialogs.py | 230 +++++++++++++ src/main_window.py | 603 +++++++++++++++++++++++++++++++++++ src/preview_window.py | 160 ++++++++++ src/utils/__init__.py | 2 + src/utils/file_utils.py | 100 ++++++ src/utils/themes.py | 315 ++++++++++++++++++ src/widgets/__init__.py | 4 + src/widgets/breadcrumb.py | 191 +++++++++++ src/widgets/file_list.py | 363 +++++++++++++++++++++ src/widgets/folder_tree.py | 162 ++++++++++ src/widgets/preview_panel.py | 487 ++++++++++++++++++++++++++++ 15 files changed, 2695 insertions(+) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/dialogs.py create mode 100644 src/main_window.py create mode 100644 src/preview_window.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/file_utils.py create mode 100644 src/utils/themes.py create mode 100644 src/widgets/__init__.py create mode 100644 src/widgets/breadcrumb.py create mode 100644 src/widgets/file_list.py create mode 100644 src/widgets/folder_tree.py create mode 100644 src/widgets/preview_panel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bec757 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Qt +*.qmlc +*.jsc + +# OS +.DS_Store +Thumbs.db + +# Config (optional - kann aktiviert werden wenn gewünscht) +# ~/.config/FileBrowser/ diff --git a/main.py b/main.py new file mode 100644 index 0000000..f830b6d --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""FileBrowser - Ein Dateimanager mit Vorschau-Funktion in PyQt6.""" + +import sys +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +from src.main_window import MainWindow + + +def main(): + # High DPI Support + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + app = QApplication(sys.argv) + app.setApplicationName("FileBrowser") + app.setOrganizationName("FileBrowser") + + # Hauptfenster erstellen und anzeigen + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d999ee6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyQt6>=6.6.0 +PyQt6-Pdf>=6.6.0 +PyQt6-PdfWidgets>=6.6.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..70dbcc8 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# FileBrowser - PyQt6 File Manager with Preview diff --git a/src/dialogs.py b/src/dialogs.py new file mode 100644 index 0000000..13decf9 --- /dev/null +++ b/src/dialogs.py @@ -0,0 +1,230 @@ +"""Dialoge für Dateioperationen.""" + +import os +from pathlib import Path +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QTreeView, QDialogButtonBox, QMessageBox, + QFrame +) +from PyQt6.QtCore import Qt, QDir +from PyQt6.QtGui import QFileSystemModel + + +class RenameDialog(QDialog): + """Dialog zum Umbenennen von Dateien.""" + + def __init__(self, path: str, parent=None): + super().__init__(parent) + self.path = path + self.old_name = os.path.basename(path) + self.new_name = "" + + self.setWindowTitle("Umbenennen") + self.setModal(True) + self.setMinimumWidth(400) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Alte Name anzeigen + old_label = QLabel(f"Alter Name: {self.old_name}") + layout.addWidget(old_label) + + # Eingabefeld für neuen Namen + new_label = QLabel("Neuer Name:") + layout.addWidget(new_label) + + self.name_input = QLineEdit() + self.name_input.setText(self.old_name) + self.name_input.returnPressed.connect(self.accept) + layout.addWidget(self.name_input) + + # Name ohne Erweiterung selektieren + name = Path(self.old_name) + if name.suffix: + self.name_input.setSelection(0, len(name.stem)) + else: + self.name_input.selectAll() + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def accept(self): + """Validiert und akzeptiert die Eingabe.""" + self.new_name = self.name_input.text().strip() + + if not self.new_name: + QMessageBox.warning(self, "Fehler", "Name darf nicht leer sein.") + return + + if '/' in self.new_name or '\\' in self.new_name: + QMessageBox.warning(self, "Fehler", "Name darf keine Pfadtrennzeichen enthalten.") + return + + if self.new_name == self.old_name: + self.reject() + return + + new_path = os.path.join(os.path.dirname(self.path), self.new_name) + if os.path.exists(new_path): + QMessageBox.warning(self, "Fehler", "Eine Datei mit diesem Namen existiert bereits.") + return + + super().accept() + + def get_new_name(self) -> str: + """Gibt den neuen Namen zurück.""" + return self.new_name + + +class MoveDialog(QDialog): + """Dialog zum Verschieben von Dateien.""" + + def __init__(self, source_path: str, parent=None): + super().__init__(parent) + self.source_path = source_path + self.target_folder = "" + + self.setWindowTitle("Verschieben") + self.setModal(True) + self.setMinimumSize(500, 400) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Quelle anzeigen + source_label = QLabel(f"Verschiebe: {os.path.basename(self.source_path)}") + source_label.setWordWrap(True) + layout.addWidget(source_label) + + layout.addWidget(QLabel("Zielordner:")) + + # Ordnerbaum + self.model = QFileSystemModel() + self.model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + self.model.setRootPath('') + + self.tree = QTreeView() + self.tree.setModel(self.model) + self.tree.setHeaderHidden(True) + + # Nur Name-Spalte anzeigen + for i in range(1, self.model.columnCount()): + self.tree.hideColumn(i) + + # Aktuellen Ordner expandieren + current_dir = os.path.dirname(self.source_path) + index = self.model.index(current_dir) + if index.isValid(): + self.tree.setCurrentIndex(index) + self.tree.scrollTo(index) + + layout.addWidget(self.tree) + + # Ausgewählter Pfad + self.path_label = QLabel() + self.path_label.setWordWrap(True) + layout.addWidget(self.path_label) + + self.tree.clicked.connect(self._on_selection_changed) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_selection_changed(self, index): + """Aktualisiert den ausgewählten Pfad.""" + path = self.model.filePath(index) + self.path_label.setText(f"Ziel: {path}") + self.target_folder = path + + def accept(self): + """Validiert und akzeptiert die Auswahl.""" + if not self.target_folder: + QMessageBox.warning(self, "Fehler", "Bitte wähle einen Zielordner aus.") + return + + if not os.path.isdir(self.target_folder): + QMessageBox.warning(self, "Fehler", "Der ausgewählte Pfad ist kein Ordner.") + return + + # Prüfen ob Ziel im Quellpfad liegt (bei Ordnern) + if os.path.isdir(self.source_path): + if self.target_folder.startswith(self.source_path): + QMessageBox.warning( + self, "Fehler", + "Ein Ordner kann nicht in sich selbst verschoben werden." + ) + return + + super().accept() + + def get_target_folder(self) -> str: + """Gibt den Zielordner zurück.""" + return self.target_folder + + +class DeleteDialog(QDialog): + """Bestätigungsdialog zum Löschen.""" + + def __init__(self, paths: list, parent=None): + super().__init__(parent) + self.paths = paths + + self.setWindowTitle("Löschen bestätigen") + self.setModal(True) + self.setMinimumWidth(400) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Warnung + if len(self.paths) == 1: + name = os.path.basename(self.paths[0]) + is_dir = os.path.isdir(self.paths[0]) + type_str = "Ordner" if is_dir else "Datei" + message = f"Möchtest du diese {type_str} wirklich löschen?\n\n{name}" + else: + message = f"Möchtest du diese {len(self.paths)} Elemente wirklich löschen?" + + label = QLabel(message) + label.setWordWrap(True) + layout.addWidget(label) + + # Warnung bei Ordnern + has_dirs = any(os.path.isdir(p) for p in self.paths) + if has_dirs: + warning = QLabel("⚠️ Ordner werden mit allen Inhalten gelöscht!") + warning.setStyleSheet("color: #ef4444;") + layout.addWidget(warning) + + # Buttons + button_box = QDialogButtonBox() + + delete_btn = QPushButton("🗑 Löschen") + delete_btn.clicked.connect(self.accept) + button_box.addButton(delete_btn, QDialogButtonBox.ButtonRole.AcceptRole) + + cancel_btn = QPushButton("Abbrechen") + cancel_btn.clicked.connect(self.reject) + button_box.addButton(cancel_btn, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) diff --git a/src/main_window.py b/src/main_window.py new file mode 100644 index 0000000..d319923 --- /dev/null +++ b/src/main_window.py @@ -0,0 +1,603 @@ +"""Hauptfenster des FileBrowsers.""" + +import os +import shutil +from pathlib import Path +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QMenuBar, QMenu, QToolBar, QStatusBar, QLabel, QComboBox, + QMessageBox, QFrame, QApplication +) +from PyQt6.QtCore import Qt, QSettings, QTimer +from PyQt6.QtGui import QAction, QKeySequence, QShortcut + +from .widgets.folder_tree import FolderTreeWidget +from .widgets.file_list import FileListWidget +from .widgets.preview_panel import PreviewPanel +from .widgets.breadcrumb import BreadcrumbWidget +from .dialogs import RenameDialog, MoveDialog, DeleteDialog +from .preview_window import PreviewWindow +from .utils.themes import ThemeManager + + +class MainWindow(QMainWindow): + """Hauptfenster des FileBrowsers mit 3-Panel Layout.""" + + def __init__(self): + super().__init__() + self.settings = QSettings('FileBrowser', 'FileBrowser') + self.theme_manager = ThemeManager() + self.preview_window = None + self._current_path = "" + + self.setWindowTitle("FileBrowser") + self.setMinimumSize(800, 600) + + self._setup_ui() + self._setup_menubar() + self._setup_toolbar() + self._setup_statusbar() + self._setup_shortcuts() + self._connect_signals() + self._load_settings() + self._apply_theme() + + def _setup_ui(self): + """Erstellt das UI.""" + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Breadcrumb-Navigation + self.breadcrumb = BreadcrumbWidget() + breadcrumb_frame = QFrame() + breadcrumb_layout = QHBoxLayout(breadcrumb_frame) + breadcrumb_layout.setContentsMargins(8, 4, 8, 4) + breadcrumb_layout.addWidget(self.breadcrumb) + layout.addWidget(breadcrumb_frame) + + # Hauptsplitter (3 Panels) + self.main_splitter = QSplitter(Qt.Orientation.Horizontal) + self.main_splitter.setChildrenCollapsible(False) + + # Panel 1: Ordnerbaum + self.folder_tree = FolderTreeWidget() + self.folder_tree.setMinimumWidth(150) + self.main_splitter.addWidget(self.folder_tree) + + # Panel 2: Dateiliste + self.file_list = FileListWidget() + self.file_list.setMinimumWidth(200) + self.main_splitter.addWidget(self.file_list) + + # Panel 3: Vorschau + self.preview_panel = PreviewPanel() + self.preview_panel.setMinimumWidth(200) + self.main_splitter.addWidget(self.preview_panel) + + # Standardgrößen + self.main_splitter.setSizes([250, 500, 350]) + self.main_splitter.setStretchFactor(0, 0) + self.main_splitter.setStretchFactor(1, 1) + self.main_splitter.setStretchFactor(2, 0) + + # Splitter soll den ganzen verfügbaren Platz füllen + layout.addWidget(self.main_splitter, 1) + + def _setup_menubar(self): + """Erstellt die Menüleiste.""" + menubar = self.menuBar() + + # Datei-Menü + file_menu = menubar.addMenu("&Datei") + + new_folder_action = QAction("Neuer Ordner", self) + new_folder_action.setShortcut(QKeySequence("Ctrl+Shift+N")) + new_folder_action.triggered.connect(self._create_new_folder) + file_menu.addAction(new_folder_action) + + file_menu.addSeparator() + + quit_action = QAction("Beenden", self) + quit_action.setShortcut(QKeySequence.StandardKey.Quit) + quit_action.triggered.connect(self.close) + file_menu.addAction(quit_action) + + # Bearbeiten-Menü + edit_menu = menubar.addMenu("&Bearbeiten") + + rename_action = QAction("Umbenennen", self) + rename_action.setShortcut(QKeySequence("F2")) + rename_action.triggered.connect(self._rename_selected) + edit_menu.addAction(rename_action) + + move_action = QAction("Verschieben", self) + move_action.triggered.connect(self._move_selected) + edit_menu.addAction(move_action) + + delete_action = QAction("Löschen", self) + delete_action.setShortcut(QKeySequence.StandardKey.Delete) + delete_action.triggered.connect(self._delete_selected) + edit_menu.addAction(delete_action) + + edit_menu.addSeparator() + + refresh_action = QAction("Aktualisieren", self) + refresh_action.setShortcut(QKeySequence("F5")) + refresh_action.triggered.connect(self._refresh) + edit_menu.addAction(refresh_action) + + # Ansicht-Menü + view_menu = menubar.addMenu("&Ansicht") + + toggle_tree_action = QAction("Ordnerbaum", self) + toggle_tree_action.setCheckable(True) + toggle_tree_action.setChecked(True) + toggle_tree_action.triggered.connect( + lambda checked: self.folder_tree.setVisible(checked) + ) + view_menu.addAction(toggle_tree_action) + + toggle_preview_action = QAction("Vorschau", self) + toggle_preview_action.setCheckable(True) + toggle_preview_action.setChecked(True) + toggle_preview_action.triggered.connect( + lambda checked: self.preview_panel.setVisible(checked) + ) + view_menu.addAction(toggle_preview_action) + + view_menu.addSeparator() + + detach_preview_action = QAction("Vorschau abtrennen", self) + detach_preview_action.triggered.connect(self._detach_preview) + view_menu.addAction(detach_preview_action) + + view_menu.addSeparator() + + # Theme-Untermenü + theme_menu = view_menu.addMenu("Theme") + self.theme_actions = [] + + for theme_id, theme_name in self.theme_manager.get_available_themes(): + action = QAction(theme_name, self) + action.setCheckable(True) + action.setData(theme_id) + action.triggered.connect(lambda checked, tid=theme_id: self._change_theme(tid)) + theme_menu.addAction(action) + self.theme_actions.append(action) + + view_menu.addSeparator() + + # PDF-Einstellungen Untermenü + pdf_menu = view_menu.addMenu("PDF-Vorschau") + + # Zoom-Modi + pdf_zoom_menu = pdf_menu.addMenu("Zoom-Modus") + self.pdf_zoom_actions = [] + zoom_modes = [ + ('fit_width', 'Seitenbreite'), + ('fit_page', 'Ganze Seite'), + ('custom', 'Benutzerdefiniert'), + ] + for mode_id, mode_name in zoom_modes: + action = QAction(mode_name, self) + action.setCheckable(True) + action.setData(mode_id) + action.triggered.connect(lambda checked, mid=mode_id: self._set_pdf_zoom_mode(mid)) + pdf_zoom_menu.addAction(action) + self.pdf_zoom_actions.append(action) + + # Seiten-Modi + pdf_page_menu = pdf_menu.addMenu("Seiten-Modus") + self.pdf_page_actions = [] + page_modes = [ + ('single', 'Einzelseite'), + ('multi', 'Mehrere Seiten'), + ] + for mode_id, mode_name in page_modes: + action = QAction(mode_name, self) + action.setCheckable(True) + action.setData(mode_id) + action.triggered.connect(lambda checked, mid=mode_id: self._set_pdf_page_mode(mid)) + pdf_page_menu.addAction(action) + self.pdf_page_actions.append(action) + + # Hilfe-Menü + help_menu = menubar.addMenu("&Hilfe") + + about_action = QAction("Über", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _setup_toolbar(self): + """Erstellt die Werkzeugleiste.""" + toolbar = QToolBar() + toolbar.setMovable(False) + self.addToolBar(toolbar) + + # Zurück-Button + back_action = QAction("⬅", self) + back_action.setToolTip("Zurück (Backspace)") + back_action.triggered.connect(self._go_back) + toolbar.addAction(back_action) + + # Aktualisieren-Button + refresh_action = QAction("🔄", self) + refresh_action.setToolTip("Aktualisieren (F5)") + refresh_action.triggered.connect(self._refresh) + toolbar.addAction(refresh_action) + + toolbar.addSeparator() + + # Home-Button + home_action = QAction("🏠", self) + home_action.setToolTip("Home-Verzeichnis") + home_action.triggered.connect(self._go_home) + toolbar.addAction(home_action) + + toolbar.addSeparator() + + # Theme-Auswahl + toolbar.addWidget(QLabel("Theme: ")) + self.theme_combo = QComboBox() + for theme_id, theme_name in self.theme_manager.get_available_themes(): + self.theme_combo.addItem(theme_name, theme_id) + self.theme_combo.currentIndexChanged.connect(self._on_theme_combo_changed) + toolbar.addWidget(self.theme_combo) + + def _setup_statusbar(self): + """Erstellt die Statusleiste.""" + self.statusbar = QStatusBar() + self.setStatusBar(self.statusbar) + + self.path_label = QLabel() + self.statusbar.addWidget(self.path_label, 1) + + self.count_label = QLabel() + self.statusbar.addPermanentWidget(self.count_label) + + def _setup_shortcuts(self): + """Richtet Tastenkürzel ein.""" + # Backspace für zurück + QShortcut(QKeySequence("Backspace"), self, self._go_back) + + def _connect_signals(self): + """Verbindet alle Signale.""" + # Ordnerbaum + self.folder_tree.folder_selected.connect(self._navigate_to) + self.folder_tree.folder_double_clicked.connect(self._navigate_to) + + # Dateiliste + self.file_list.file_selected.connect(self._on_file_selected) + self.file_list.file_double_clicked.connect(self._open_external) + self.file_list.folder_entered.connect(self._navigate_to) + self.file_list.file_rename_requested.connect(self._rename_file) + self.file_list.file_delete_requested.connect(self._delete_file) + self.file_list.file_move_requested.connect(self._move_file) + self.file_list.files_dropped.connect(self._handle_files_dropped) + + # Breadcrumb + self.breadcrumb.path_clicked.connect(self._navigate_to) + self.breadcrumb.path_entered.connect(self._navigate_to) + + # Preview-Panel + self.preview_panel.rename_requested.connect(self._rename_file) + self.preview_panel.move_requested.connect(self._move_file) + self.preview_panel.delete_requested.connect(self._delete_file) + self.preview_panel.open_external_requested.connect(self._open_external) + self.preview_panel.detach_requested.connect(self._detach_preview) + + def _load_settings(self): + """Lädt gespeicherte Einstellungen.""" + # Fenstergeometrie + geometry = self.settings.value('window_geometry') + if geometry: + self.restoreGeometry(geometry) + else: + self.resize(1200, 800) + + # Splitter-Größen + splitter_sizes = self.settings.value('splitter_sizes') + if splitter_sizes: + self.main_splitter.setSizes([int(s) for s in splitter_sizes]) + + # Letzter Pfad + last_path = self.settings.value('last_path', os.path.expanduser('~')) + if os.path.exists(last_path): + self._navigate_to(last_path) + else: + self._navigate_to(os.path.expanduser('~')) + + # Theme + theme = self.settings.value('theme', 'dark') + index = self.theme_combo.findData(theme) + if index >= 0: + self.theme_combo.setCurrentIndex(index) + + def _save_settings(self): + """Speichert Einstellungen.""" + self.settings.setValue('window_geometry', self.saveGeometry()) + self.settings.setValue('splitter_sizes', self.main_splitter.sizes()) + self.settings.setValue('last_path', self._current_path) + self.settings.setValue('theme', self.theme_manager.get_current_theme()) + + def _apply_theme(self): + """Wendet das aktuelle Theme an.""" + theme = self.theme_manager.get_current_theme() + stylesheet = self.theme_manager.apply_theme(theme) + self.setStyleSheet(stylesheet) + + # Theme-Menü-Aktionen aktualisieren + for action in self.theme_actions: + action.setChecked(action.data() == theme) + + # PDF-Menü-Aktionen aktualisieren + self._update_pdf_menu_checks() + + def _change_theme(self, theme_id: str): + """Wechselt das Theme.""" + stylesheet = self.theme_manager.apply_theme(theme_id) + self.setStyleSheet(stylesheet) + + # Combo-Box aktualisieren + index = self.theme_combo.findData(theme_id) + if index >= 0: + self.theme_combo.blockSignals(True) + self.theme_combo.setCurrentIndex(index) + self.theme_combo.blockSignals(False) + + # Menü-Aktionen aktualisieren + for action in self.theme_actions: + action.setChecked(action.data() == theme_id) + + # Preview-Fenster aktualisieren + if self.preview_window: + self.preview_window.setStyleSheet(stylesheet) + + def _on_theme_combo_changed(self, index): + """Behandelt Theme-Auswahl in der Combo-Box.""" + theme_id = self.theme_combo.itemData(index) + if theme_id: + self._change_theme(theme_id) + + def _navigate_to(self, path: str): + """Navigiert zu einem Ordner.""" + if not os.path.isdir(path): + return + + self._current_path = path + self.file_list.set_path(path) + self.breadcrumb.set_path(path) + self.folder_tree.navigate_to(path) + self.preview_panel.clear() + + # Status aktualisieren + self.path_label.setText(path) + self._update_count() + + def _update_count(self): + """Aktualisiert die Elementanzahl.""" + count = len(self.file_list.list_model.items) + self.count_label.setText(f"{count} Elemente") + + def _on_file_selected(self, path: str, name: str): + """Behandelt Dateiauswahl.""" + self.preview_panel.load_file(path, name) + + # Preview-Fenster aktualisieren + if self.preview_window and self.preview_window.isVisible(): + self.preview_window.load_file(path, name) + + def _open_external(self, path: str): + """Öffnet eine Datei extern.""" + import subprocess + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Konnte Datei nicht öffnen: {e}") + + def _go_back(self): + """Navigiert zum übergeordneten Ordner.""" + if self._current_path: + parent = os.path.dirname(self._current_path) + if parent and parent != self._current_path: + self._navigate_to(parent) + + def _go_home(self): + """Navigiert zum Home-Verzeichnis.""" + self._navigate_to(os.path.expanduser('~')) + + def _refresh(self): + """Aktualisiert die Ansicht.""" + self.file_list.refresh() + self.folder_tree.refresh() + self._update_count() + self.statusbar.showMessage("Aktualisiert", 2000) + + def _rename_selected(self): + """Benennt das ausgewählte Element um.""" + path = self.file_list.get_selected_path() + if path: + self._rename_file(path) + + def _rename_file(self, path: str): + """Öffnet den Umbenennen-Dialog.""" + dialog = RenameDialog(path, self) + if dialog.exec(): + new_name = dialog.get_new_name() + new_path = os.path.join(os.path.dirname(path), new_name) + try: + os.rename(path, new_path) + self._refresh() + self.statusbar.showMessage(f"Umbenannt zu: {new_name}", 3000) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Umbenennen fehlgeschlagen: {e}") + + def _move_selected(self): + """Verschiebt das ausgewählte Element.""" + path = self.file_list.get_selected_path() + if path: + self._move_file(path) + + def _move_file(self, path: str): + """Öffnet den Verschieben-Dialog.""" + dialog = MoveDialog(path, self) + if dialog.exec(): + target = dialog.get_target_folder() + try: + name = os.path.basename(path) + new_path = os.path.join(target, name) + shutil.move(path, new_path) + self._refresh() + self.statusbar.showMessage(f"Verschoben nach: {target}", 3000) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Verschieben fehlgeschlagen: {e}") + + def _delete_selected(self): + """Löscht die ausgewählten Elemente.""" + items = self.file_list.get_selected_items() + if items: + paths = [item.path for item in items] + self._delete_files(paths) + + def _delete_file(self, path: str): + """Löscht eine einzelne Datei.""" + self._delete_files([path]) + + def _delete_files(self, paths: list): + """Löscht mehrere Dateien/Ordner.""" + dialog = DeleteDialog(paths, self) + if dialog.exec(): + errors = [] + for path in paths: + try: + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + except Exception as e: + errors.append(f"{os.path.basename(path)}: {e}") + + self._refresh() + + if errors: + QMessageBox.warning( + self, "Fehler", + "Einige Elemente konnten nicht gelöscht werden:\n\n" + + "\n".join(errors) + ) + else: + self.statusbar.showMessage( + f"{len(paths)} Element{'e' if len(paths) > 1 else ''} gelöscht", + 3000 + ) + + def _handle_files_dropped(self, source_paths: list, target_folder: str): + """Behandelt per Drag & Drop verschobene Dateien.""" + errors = [] + moved = 0 + + for path in source_paths: + try: + name = os.path.basename(path) + new_path = os.path.join(target_folder, name) + shutil.move(path, new_path) + moved += 1 + except Exception as e: + errors.append(f"{os.path.basename(path)}: {e}") + + self._refresh() + + if errors: + QMessageBox.warning( + self, "Fehler", + "Einige Elemente konnten nicht verschoben werden:\n\n" + + "\n".join(errors) + ) + elif moved > 0: + self.statusbar.showMessage( + f"{moved} Element{'e' if moved > 1 else ''} verschoben", + 3000 + ) + + def _create_new_folder(self): + """Erstellt einen neuen Ordner.""" + from PyQt6.QtWidgets import QInputDialog + + name, ok = QInputDialog.getText( + self, "Neuer Ordner", "Ordnername:" + ) + if ok and name: + new_path = os.path.join(self._current_path, name) + try: + os.makedirs(new_path, exist_ok=True) + self._refresh() + self.statusbar.showMessage(f"Ordner erstellt: {name}", 3000) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen: {e}") + + def _detach_preview(self): + """Öffnet die Vorschau in einem separaten Fenster.""" + if not self.preview_window: + self.preview_window = PreviewWindow(self) + self.preview_window.closed.connect(self._on_preview_window_closed) + + # Theme übertragen + stylesheet = self.theme_manager.get_stylesheet() + self.preview_window.setStyleSheet(stylesheet) + + # Aktuelle Datei übertragen + path, name = self.preview_panel.get_current_file() + if path and name: + self.preview_window.load_file(path, name) + + self.preview_window.show() + self.preview_window.raise_() + + def _on_preview_window_closed(self): + """Behandelt das Schließen des Preview-Fensters.""" + pass # Preview-Fenster bleibt im Speicher für schnelles Wiedereröffnen + + def _set_pdf_zoom_mode(self, mode_id: str): + """Setzt den PDF-Zoom-Modus.""" + settings = self.preview_panel.pdf_preview.get_settings() + settings['zoom_mode'] = mode_id + self.preview_panel.pdf_preview.set_settings(settings) + self._update_pdf_menu_checks() + + def _set_pdf_page_mode(self, mode_id: str): + """Setzt den PDF-Seiten-Modus.""" + settings = self.preview_panel.pdf_preview.get_settings() + settings['page_mode'] = mode_id + self.preview_panel.pdf_preview.set_settings(settings) + self._update_pdf_menu_checks() + + def _update_pdf_menu_checks(self): + """Aktualisiert die Häkchen im PDF-Menü.""" + settings = self.preview_panel.pdf_preview.get_settings() + for action in self.pdf_zoom_actions: + action.setChecked(action.data() == settings['zoom_mode']) + for action in self.pdf_page_actions: + action.setChecked(action.data() == settings['page_mode']) + + def _show_about(self): + """Zeigt den Über-Dialog.""" + QMessageBox.about( + self, + "Über FileBrowser", + "FileBrowser\n\n" + "Ein einfacher Dateimanager mit Vorschau-Funktion.\n\n" + "Erstellt mit PyQt6" + ) + + def closeEvent(self, event): + """Behandelt das Schließen des Fensters.""" + self._save_settings() + + # Preview-Fenster schließen + if self.preview_window: + self.preview_window.close() + + super().closeEvent(event) diff --git a/src/preview_window.py b/src/preview_window.py new file mode 100644 index 0000000..88ba71f --- /dev/null +++ b/src/preview_window.py @@ -0,0 +1,160 @@ +"""Separates Vorschaufenster.""" + +import os +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QLabel, QFrame, + QSizePolicy +) +from PyQt6.QtCore import Qt, pyqtSignal, QSettings +from PyQt6.QtGui import QFont + +from .widgets.preview_panel import ImagePreview, TextPreview, PdfPreview, NoPreview +from .utils.file_utils import format_file_size, get_file_type + + +class PreviewWindow(QMainWindow): + """Separates Fenster für die Dateivorschau.""" + + closed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path = "" + self._current_name = "" + + self.setWindowTitle("Vorschau") + self.setMinimumSize(400, 300) + + self._setup_ui() + self._load_settings() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + + # Header + header = QFrame() + header_layout = QVBoxLayout(header) + header_layout.setContentsMargins(0, 0, 0, 8) + header_layout.setSpacing(4) + + self.name_label = QLabel() + self.name_label.setWordWrap(True) + font = QFont() + font.setBold(True) + font.setPointSize(12) + self.name_label.setFont(font) + header_layout.addWidget(self.name_label) + + self.size_label = QLabel() + header_layout.addWidget(self.size_label) + + layout.addWidget(header) + + # Preview-Container + self.preview_container = QWidget() + self.preview_layout = QVBoxLayout(self.preview_container) + self.preview_layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.preview_container, 1) + + # Leerer Zustand + self.empty_label = QLabel("Wähle eine Datei im Hauptfenster aus") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_layout.addWidget(self.empty_label) + + # Preview-Widgets (werden bei Bedarf erstellt) + self.image_preview = None + self.text_preview = None + self.pdf_preview = None + self.no_preview = None + self._current_preview = None + + def _load_settings(self): + """Lädt gespeicherte Fenstereinstellungen.""" + settings = QSettings('FileBrowser', 'FileBrowser') + geometry = settings.value('preview_window_geometry') + if geometry: + self.restoreGeometry(geometry) + else: + self.resize(600, 500) + + def _save_settings(self): + """Speichert Fenstereinstellungen.""" + settings = QSettings('FileBrowser', 'FileBrowser') + settings.setValue('preview_window_geometry', self.saveGeometry()) + + def _clear_preview(self): + """Entfernt das aktuelle Preview-Widget.""" + if self._current_preview: + self.preview_layout.removeWidget(self._current_preview) + self._current_preview.hide() + self._current_preview = None + self.empty_label.show() + + def load_file(self, path: str, name: str): + """Lädt eine Datei für die Vorschau.""" + self._current_path = path + self._current_name = name + + # Header aktualisieren + self.name_label.setText(name) + self.setWindowTitle(f"Vorschau - {name}") + + try: + size = os.path.getsize(path) + self.size_label.setText(format_file_size(size)) + except OSError: + self.size_label.setText("") + + # Aktuelles Preview-Widget entfernen + self._clear_preview() + self.empty_label.hide() + + # Passende Vorschau laden + file_type = get_file_type(name) + + if file_type == 'image': + if not self.image_preview: + self.image_preview = ImagePreview() + self.image_preview.load_image(path) + self._current_preview = self.image_preview + + elif file_type == 'text': + if not self.text_preview: + self.text_preview = TextPreview() + self.text_preview.load_text(path) + self._current_preview = self.text_preview + + elif file_type == 'pdf': + if not self.pdf_preview: + self.pdf_preview = PdfPreview() + self.pdf_preview.load_pdf(path) + self._current_preview = self.pdf_preview + + else: + if not self.no_preview: + self.no_preview = NoPreview() + self.no_preview.set_file(path, name) + self._current_preview = self.no_preview + + if self._current_preview: + self.preview_layout.addWidget(self._current_preview) + self._current_preview.show() + + def clear(self): + """Leert die Vorschau.""" + self._current_path = "" + self._current_name = "" + self.name_label.setText("") + self.size_label.setText("") + self.setWindowTitle("Vorschau") + self._clear_preview() + + def closeEvent(self, event): + """Behandelt das Schließen des Fensters.""" + self._save_settings() + self.closed.emit() + super().closeEvent(event) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..23395fa --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,2 @@ +from .file_utils import get_file_icon, format_file_size, natural_sort_key +from .themes import ThemeManager diff --git a/src/utils/file_utils.py b/src/utils/file_utils.py new file mode 100644 index 0000000..63dc8c4 --- /dev/null +++ b/src/utils/file_utils.py @@ -0,0 +1,100 @@ +"""Datei-Hilfsfunktionen für den FileBrowser.""" + +import os +import re +from pathlib import Path + + +def get_file_icon(filename: str, is_dir: bool = False) -> str: + """Gibt das passende Emoji-Icon für einen Dateityp zurück.""" + if is_dir: + return "📁" + + ext = Path(filename).suffix.lower() + + icons = { + # PDF + '.pdf': '📄', + # Bilder + '.jpg': '🖼️', '.jpeg': '🖼️', '.png': '🖼️', '.gif': '🖼️', + '.bmp': '🖼️', '.webp': '🖼️', '.tiff': '🖼️', '.svg': '🖼️', + # Dokumente + '.doc': '📝', '.docx': '📝', '.odt': '📝', '.rtf': '📝', + # Tabellen + '.xls': '📊', '.xlsx': '📊', '.ods': '📊', '.csv': '📊', + # Archive + '.zip': '📦', '.rar': '📦', '.7z': '📦', '.tar': '📦', + '.gz': '📦', '.bz2': '📦', '.xz': '📦', + # Text + '.txt': '📃', '.md': '📃', '.log': '📃', + # Code + '.py': '🐍', '.js': '📜', '.ts': '📜', '.html': '🌐', + '.css': '🎨', '.json': '📋', '.xml': '📋', '.yaml': '📋', + '.yml': '📋', '.sh': '⚙️', '.bash': '⚙️', + # Audio + '.mp3': '🎵', '.wav': '🎵', '.flac': '🎵', '.ogg': '🎵', + '.m4a': '🎵', '.aac': '🎵', + # Video + '.mp4': '🎬', '.mkv': '🎬', '.avi': '🎬', '.mov': '🎬', + '.wmv': '🎬', '.webm': '🎬', + } + + return icons.get(ext, '📎') + + +def format_file_size(size_bytes: int) -> str: + """Formatiert eine Dateigröße in lesbarer Form.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + +def natural_sort_key(s: str) -> list: + """ + Erzeugt einen Sortierungsschlüssel für natürliche Sortierung. + Sortiert: Sonderzeichen → Zahlen → Buchstaben + """ + def convert(text): + if text.isdigit(): + return (1, int(text), text.lower()) + elif text[0].isalpha() if text else False: + return (2, 0, text.lower()) + else: + return (0, 0, text.lower()) + + return [convert(c) for c in re.split(r'(\d+)', s)] + + +def get_file_type(filename: str) -> str: + """Bestimmt den Dateityp für die Preview.""" + ext = Path(filename).suffix.lower() + + image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg'} + text_exts = {'.txt', '.md', '.log', '.py', '.js', '.ts', '.html', '.css', + '.json', '.xml', '.yaml', '.yml', '.sh', '.bash', '.csv', '.ini', + '.conf', '.cfg'} + pdf_exts = {'.pdf'} + + if ext in image_exts: + return 'image' + elif ext in text_exts: + return 'text' + elif ext in pdf_exts: + return 'pdf' + else: + return 'unknown' + + +def is_readable(path: str) -> bool: + """Prüft, ob ein Pfad lesbar ist.""" + return os.access(path, os.R_OK) + + +def is_writable(path: str) -> bool: + """Prüft, ob ein Pfad beschreibbar ist.""" + return os.access(path, os.W_OK) diff --git a/src/utils/themes.py b/src/utils/themes.py new file mode 100644 index 0000000..a03c9e0 --- /dev/null +++ b/src/utils/themes.py @@ -0,0 +1,315 @@ +"""Theme-Management für den FileBrowser.""" + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QPalette, QColor +from PyQt6.QtCore import QSettings + + +class ThemeManager: + """Verwaltet die Themes der Anwendung.""" + + THEMES = { + 'dark': { + 'name': 'Dark', + 'window': '#0f172a', + 'window_text': '#f1f5f9', + 'base': '#1e293b', + 'alternate_base': '#334155', + 'text': '#f1f5f9', + 'button': '#334155', + 'button_text': '#f1f5f9', + 'highlight': '#3b82f6', + 'highlight_text': '#ffffff', + 'link': '#60a5fa', + 'border': '#475569', + }, + 'breeze_dark': { + 'name': 'Breeze Dark', + 'window': '#31363b', + 'window_text': '#eff0f1', + 'base': '#232629', + 'alternate_base': '#31363b', + 'text': '#eff0f1', + 'button': '#31363b', + 'button_text': '#eff0f1', + 'highlight': '#3daee9', + 'highlight_text': '#eff0f1', + 'link': '#2980b9', + 'border': '#76797c', + }, + 'breeze_light': { + 'name': 'Breeze Light', + 'window': '#eff0f1', + 'window_text': '#31363b', + 'base': '#fcfcfc', + 'alternate_base': '#eff0f1', + 'text': '#31363b', + 'button': '#eff0f1', + 'button_text': '#31363b', + 'highlight': '#3daee9', + 'highlight_text': '#ffffff', + 'link': '#2980b9', + 'border': '#bdc3c7', + }, + } + + def __init__(self): + self.settings = QSettings('FileBrowser', 'FileBrowser') + self.current_theme = self.settings.value('theme', 'dark') + + def get_available_themes(self) -> list: + """Gibt eine Liste der verfügbaren Themes zurück.""" + return [(key, theme['name']) for key, theme in self.THEMES.items()] + + def apply_theme(self, theme_name: str): + """Wendet ein Theme auf die Anwendung an.""" + if theme_name not in self.THEMES: + theme_name = 'dark' + + self.current_theme = theme_name + self.settings.setValue('theme', theme_name) + + theme = self.THEMES[theme_name] + app = QApplication.instance() + + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(theme['window'])) + palette.setColor(QPalette.ColorRole.WindowText, QColor(theme['window_text'])) + palette.setColor(QPalette.ColorRole.Base, QColor(theme['base'])) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(theme['alternate_base'])) + palette.setColor(QPalette.ColorRole.Text, QColor(theme['text'])) + palette.setColor(QPalette.ColorRole.Button, QColor(theme['button'])) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(theme['button_text'])) + palette.setColor(QPalette.ColorRole.Highlight, QColor(theme['highlight'])) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(theme['highlight_text'])) + palette.setColor(QPalette.ColorRole.Link, QColor(theme['link'])) + + # Disabled colors + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, + QColor(theme['window_text']).darker(150)) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, + QColor(theme['text']).darker(150)) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, + QColor(theme['button_text']).darker(150)) + + app.setPalette(palette) + + return self.get_stylesheet(theme_name) + + def get_stylesheet(self, theme_name: str = None) -> str: + """Gibt das Stylesheet für ein Theme zurück.""" + if theme_name is None: + theme_name = self.current_theme + + theme = self.THEMES.get(theme_name, self.THEMES['dark']) + + return f""" + QMainWindow, QWidget {{ + background-color: {theme['window']}; + color: {theme['window_text']}; + }} + + QTreeView, QListView, QTableView {{ + background-color: {theme['base']}; + color: {theme['text']}; + border: 1px solid {theme['border']}; + border-radius: 4px; + padding: 4px; + }} + + QTreeView::item, QListView::item, QTableView::item {{ + padding: 4px; + border-radius: 2px; + }} + + QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {{ + background-color: {theme['highlight']}; + color: {theme['highlight_text']}; + }} + + QTreeView::item:hover, QListView::item:hover, QTableView::item:hover {{ + background-color: {theme['alternate_base']}; + }} + + QHeaderView::section {{ + background-color: {theme['button']}; + color: {theme['button_text']}; + padding: 6px; + border: none; + border-right: 1px solid {theme['border']}; + border-bottom: 1px solid {theme['border']}; + }} + + QSplitter::handle {{ + background-color: {theme['border']}; + }} + + QSplitter::handle:horizontal {{ + width: 4px; + }} + + QSplitter::handle:vertical {{ + height: 4px; + }} + + QSplitter::handle:hover {{ + background-color: {theme['highlight']}; + }} + + QPushButton {{ + background-color: {theme['button']}; + color: {theme['button_text']}; + border: 1px solid {theme['border']}; + border-radius: 4px; + padding: 6px 12px; + min-width: 60px; + }} + + QPushButton:hover {{ + background-color: {theme['highlight']}; + color: {theme['highlight_text']}; + }} + + QPushButton:pressed {{ + background-color: {theme['highlight']}; + }} + + QLineEdit, QTextEdit, QPlainTextEdit {{ + background-color: {theme['base']}; + color: {theme['text']}; + border: 1px solid {theme['border']}; + border-radius: 4px; + padding: 6px; + }} + + QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{ + border-color: {theme['highlight']}; + }} + + QMenuBar {{ + background-color: {theme['window']}; + color: {theme['window_text']}; + }} + + QMenuBar::item:selected {{ + background-color: {theme['highlight']}; + }} + + QMenu {{ + background-color: {theme['base']}; + color: {theme['text']}; + border: 1px solid {theme['border']}; + }} + + QMenu::item:selected {{ + background-color: {theme['highlight']}; + color: {theme['highlight_text']}; + }} + + QToolBar {{ + background-color: {theme['window']}; + border: none; + spacing: 4px; + }} + + QStatusBar {{ + background-color: {theme['window']}; + color: {theme['window_text']}; + }} + + QScrollBar:vertical {{ + background-color: {theme['base']}; + width: 12px; + border-radius: 6px; + }} + + QScrollBar::handle:vertical {{ + background-color: {theme['border']}; + border-radius: 5px; + min-height: 20px; + margin: 2px; + }} + + QScrollBar::handle:vertical:hover {{ + background-color: {theme['highlight']}; + }} + + QScrollBar:horizontal {{ + background-color: {theme['base']}; + height: 12px; + border-radius: 6px; + }} + + QScrollBar::handle:horizontal {{ + background-color: {theme['border']}; + border-radius: 5px; + min-width: 20px; + margin: 2px; + }} + + QScrollBar::handle:horizontal:hover {{ + background-color: {theme['highlight']}; + }} + + QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0px; + height: 0px; + }} + + QLabel {{ + color: {theme['window_text']}; + }} + + QComboBox {{ + background-color: {theme['button']}; + color: {theme['button_text']}; + border: 1px solid {theme['border']}; + border-radius: 4px; + padding: 4px 8px; + }} + + QComboBox:hover {{ + border-color: {theme['highlight']}; + }} + + QComboBox::drop-down {{ + border: none; + width: 20px; + }} + + QComboBox QAbstractItemView {{ + background-color: {theme['base']}; + color: {theme['text']}; + selection-background-color: {theme['highlight']}; + }} + + QTabWidget::pane {{ + border: 1px solid {theme['border']}; + border-radius: 4px; + }} + + QTabBar::tab {{ + background-color: {theme['button']}; + color: {theme['button_text']}; + padding: 8px 16px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + }} + + QTabBar::tab:selected {{ + background-color: {theme['highlight']}; + color: {theme['highlight_text']}; + }} + + QMessageBox {{ + background-color: {theme['window']}; + }} + + QDialog {{ + background-color: {theme['window']}; + }} + """ + + def get_current_theme(self) -> str: + """Gibt den Namen des aktuellen Themes zurück.""" + return self.current_theme diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py new file mode 100644 index 0000000..c852f9d --- /dev/null +++ b/src/widgets/__init__.py @@ -0,0 +1,4 @@ +from .folder_tree import FolderTreeWidget +from .file_list import FileListWidget +from .preview_panel import PreviewPanel +from .breadcrumb import BreadcrumbWidget diff --git a/src/widgets/breadcrumb.py b/src/widgets/breadcrumb.py new file mode 100644 index 0000000..34dd267 --- /dev/null +++ b/src/widgets/breadcrumb.py @@ -0,0 +1,191 @@ +"""Breadcrumb-Navigation für den FileBrowser.""" + +import os +from pathlib import Path +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QScrollArea, + QSizePolicy, QFrame, QLineEdit, QStackedWidget, QApplication +) +from PyQt6.QtCore import Qt, pyqtSignal, QSettings +from PyQt6.QtGui import QFont + + +class BreadcrumbWidget(QWidget): + """Breadcrumb-Navigation mit klickbaren Pfadsegmenten und Textzeile.""" + + path_clicked = pyqtSignal(str) # Ausgewählter Pfad + path_entered = pyqtSignal(str) # Manuell eingegebener Pfad + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path = "" + self.settings = QSettings('FileBrowser', 'FileBrowser') + self._setup_ui() + self._load_settings() + + def _setup_ui(self): + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(4) + + # Stacked Widget für Breadcrumb/Textzeile + self.stack = QStackedWidget() + self.stack.setMaximumHeight(36) + + # Breadcrumb-Ansicht (Index 0) + breadcrumb_widget = QWidget() + breadcrumb_layout = QHBoxLayout(breadcrumb_widget) + breadcrumb_layout.setContentsMargins(0, 0, 0, 0) + breadcrumb_layout.setSpacing(0) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setMaximumHeight(36) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(4, 4, 4, 4) + self.button_layout.setSpacing(2) + self.button_layout.addStretch() + + self.scroll_area.setWidget(self.button_container) + breadcrumb_layout.addWidget(self.scroll_area) + + self.stack.addWidget(breadcrumb_widget) + + # Text-Eingabe (Index 1) + self.path_edit = QLineEdit() + self.path_edit.setPlaceholderText("Pfad eingeben...") + font = QFont("Monospace") + self.path_edit.setFont(font) + self.path_edit.returnPressed.connect(self._on_path_entered) + self.path_edit.editingFinished.connect(self._on_editing_finished) + self.stack.addWidget(self.path_edit) + + main_layout.addWidget(self.stack, 1) + + # Toggle-Button für Ansicht + self.toggle_btn = QPushButton("📝") + self.toggle_btn.setToolTip("Zwischen Breadcrumb und Texteingabe wechseln") + self.toggle_btn.setFixedSize(32, 28) + self.toggle_btn.setCheckable(True) + self.toggle_btn.clicked.connect(self._toggle_view) + main_layout.addWidget(self.toggle_btn) + + # Kopieren-Button + self.copy_btn = QPushButton("📋") + self.copy_btn.setToolTip("Pfad kopieren") + self.copy_btn.setFixedSize(32, 28) + self.copy_btn.clicked.connect(self._copy_path) + main_layout.addWidget(self.copy_btn) + + def _load_settings(self): + """Lädt gespeicherte Einstellungen.""" + show_text = self.settings.value('breadcrumb_text_mode', False, type=bool) + if show_text: + self.toggle_btn.setChecked(True) + self.stack.setCurrentIndex(1) + self.toggle_btn.setText("🗂️") + + def _save_settings(self): + """Speichert Einstellungen.""" + self.settings.setValue('breadcrumb_text_mode', self.stack.currentIndex() == 1) + + def _toggle_view(self, checked): + """Wechselt zwischen Breadcrumb und Texteingabe.""" + if checked: + self.stack.setCurrentIndex(1) + self.path_edit.setText(self._current_path) + self.path_edit.setFocus() + self.path_edit.selectAll() + self.toggle_btn.setText("🗂️") + self.toggle_btn.setToolTip("Zur Breadcrumb-Ansicht wechseln") + else: + self.stack.setCurrentIndex(0) + self.toggle_btn.setText("📝") + self.toggle_btn.setToolTip("Zur Texteingabe wechseln") + self._save_settings() + + def _on_path_entered(self): + """Behandelt Enter in der Pfadeingabe.""" + path = self.path_edit.text().strip() + if path and os.path.isdir(path): + self._current_path = path + self.path_entered.emit(path) + # Zurück zur Breadcrumb-Ansicht + self.toggle_btn.setChecked(False) + self._toggle_view(False) + self.set_path(path) + + def _on_editing_finished(self): + """Behandelt Ende der Bearbeitung.""" + # Bei Escape zurück zur Breadcrumb-Ansicht + if self.stack.currentIndex() == 1: + self.path_edit.setText(self._current_path) + + def _copy_path(self): + """Kopiert den aktuellen Pfad in die Zwischenablage.""" + if self._current_path: + clipboard = QApplication.clipboard() + clipboard.setText(self._current_path) + + def set_path(self, path: str): + """Setzt den aktuellen Pfad und aktualisiert die Breadcrumbs.""" + self._current_path = path + self.path_edit.setText(path) + + # Alte Buttons entfernen + while self.button_layout.count() > 1: + item = self.button_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + if not path: + return + + # Pfad in Segmente aufteilen + parts = Path(path).parts + + for i, part in enumerate(parts): + segment_path = str(Path(*parts[:i + 1])) + + btn = QPushButton(part if part != '/' else '/') + btn.setFlat(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setProperty('segment_path', segment_path) + btn.clicked.connect(self._on_segment_clicked) + + font = QFont() + font.setFamily("Monospace") + btn.setFont(font) + + if i == len(parts) - 1: + btn.setEnabled(False) + btn.setStyleSheet("QPushButton { font-weight: bold; }") + + self.button_layout.insertWidget(self.button_layout.count() - 1, btn) + + if i < len(parts) - 1: + separator = QLabel("/") + separator.setStyleSheet("color: gray;") + self.button_layout.insertWidget(self.button_layout.count() - 1, separator) + + # Scroll ans Ende + self.scroll_area.horizontalScrollBar().setValue( + self.scroll_area.horizontalScrollBar().maximum() + ) + + def _on_segment_clicked(self): + """Behandelt Klicks auf Breadcrumb-Segmente.""" + btn = self.sender() + if btn: + path = btn.property('segment_path') + if path: + self.path_clicked.emit(path) + + def get_current_path(self) -> str: + """Gibt den aktuellen Pfad zurück.""" + return self._current_path diff --git a/src/widgets/file_list.py b/src/widgets/file_list.py new file mode 100644 index 0000000..029883a --- /dev/null +++ b/src/widgets/file_list.py @@ -0,0 +1,363 @@ +"""Dateiliste-Widget mit natürlicher Sortierung und Drag & Drop.""" + +import os +from pathlib import Path +from datetime import datetime +from PyQt6.QtWidgets import ( + QTableView, QAbstractItemView, QMenu, QHeaderView, + QStyledItemDelegate, QStyle +) +from PyQt6.QtCore import ( + Qt, QModelIndex, pyqtSignal, QAbstractTableModel, QMimeData, + QUrl, QVariant, QSize +) +from PyQt6.QtGui import QAction, QDrag, QIcon + +from ..utils.file_utils import get_file_icon, format_file_size, natural_sort_key + + +class FileItem: + """Repräsentiert einen Dateieintrag.""" + + def __init__(self, name: str, path: str, is_dir: bool = False, + size: int = 0, modified: datetime = None): + self.name = name + self.path = path + self.is_dir = is_dir + self.size = size + self.modified = modified or datetime.now() + self.icon = get_file_icon(name, is_dir) + + +class FileListModel(QAbstractTableModel): + """Model für die Dateiliste.""" + + HEADERS = ['', 'Name', 'Größe', 'Geändert'] + + def __init__(self, parent=None): + super().__init__(parent) + self.items: list[FileItem] = [] + self.current_path = "" + + def rowCount(self, parent=None): + return len(self.items) + + def columnCount(self, parent=None): + return len(self.HEADERS) + + def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid() or index.row() >= len(self.items): + return None + + item = self.items[index.row()] + col = index.column() + + if role == Qt.ItemDataRole.DisplayRole: + if col == 0: + return item.icon + elif col == 1: + return item.name + elif col == 2: + return "" if item.is_dir else format_file_size(item.size) + elif col == 3: + return item.modified.strftime("%d.%m.%Y %H:%M") + elif role == Qt.ItemDataRole.UserRole: + return item + elif role == Qt.ItemDataRole.TextAlignmentRole: + if col == 0: + return Qt.AlignmentFlag.AlignCenter + elif col == 2: + return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + elif role == Qt.ItemDataRole.ToolTipRole: + return item.path + + return None + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self.HEADERS[section] + return None + + def set_path(self, path: str): + """Lädt den Inhalt eines Ordners.""" + self.beginResetModel() + self.items.clear() + self.current_path = path + + if not os.path.exists(path): + self.endResetModel() + return + + try: + entries = os.scandir(path) + folders = [] + files = [] + + for entry in entries: + try: + stat = entry.stat() + item = FileItem( + name=entry.name, + path=entry.path, + is_dir=entry.is_dir(), + size=stat.st_size if entry.is_file() else 0, + modified=datetime.fromtimestamp(stat.st_mtime) + ) + if entry.is_dir(): + folders.append(item) + else: + files.append(item) + except (PermissionError, OSError): + continue + + # Natürliche Sortierung + folders.sort(key=lambda x: natural_sort_key(x.name)) + files.sort(key=lambda x: natural_sort_key(x.name)) + + self.items = folders + files + + except PermissionError: + pass + + self.endResetModel() + + def get_item(self, index: QModelIndex) -> FileItem: + """Gibt das FileItem für einen Index zurück.""" + if index.isValid() and index.row() < len(self.items): + return self.items[index.row()] + return None + + def refresh(self): + """Aktualisiert die Dateiliste.""" + if self.current_path: + self.set_path(self.current_path) + + +class FileListWidget(QTableView): + """Dateiliste mit Kontextmenü und Drag & Drop.""" + + file_selected = pyqtSignal(str, str) # path, name + file_double_clicked = pyqtSignal(str) # path + folder_entered = pyqtSignal(str) # path + file_rename_requested = pyqtSignal(str) # path + file_delete_requested = pyqtSignal(str) # path + file_move_requested = pyqtSignal(str) # path + files_dropped = pyqtSignal(list, str) # source_paths, target_folder + + def __init__(self, parent=None): + super().__init__(parent) + + self.list_model = FileListModel(self) + self.setModel(self.list_model) + + # Einstellungen + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setShowGrid(False) + self.setAlternatingRowColors(True) + self.verticalHeader().setVisible(False) + self.setWordWrap(False) + + # Spaltenbreiten + header = self.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) + self.setColumnWidth(0, 30) + self.setColumnWidth(2, 80) + self.setColumnWidth(3, 130) + + # Drag & Drop + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) + + # Signale + self.clicked.connect(self._on_clicked) + self.doubleClicked.connect(self._on_double_clicked) + + # Kontextmenü + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + + def set_path(self, path: str): + """Lädt einen Ordner.""" + self.list_model.set_path(path) + + def _on_clicked(self, index: QModelIndex): + """Behandelt Klicks auf Dateien.""" + item = self.list_model.get_item(index) + if item and not item.is_dir: + self.file_selected.emit(item.path, item.name) + + def _on_double_clicked(self, index: QModelIndex): + """Behandelt Doppelklicks.""" + item = self.list_model.get_item(index) + if item: + if item.is_dir: + self.folder_entered.emit(item.path) + else: + self.file_double_clicked.emit(item.path) + + def _show_context_menu(self, position): + """Zeigt das Kontextmenü.""" + indexes = self.selectedIndexes() + if not indexes: + return + + # Eindeutige Zeilen ermitteln + rows = set(idx.row() for idx in indexes) + items = [self.list_model.items[row] for row in rows if row < len(self.list_model.items)] + + if not items: + return + + menu = QMenu(self) + + if len(items) == 1: + item = items[0] + + # Öffnen + if item.is_dir: + open_action = QAction("📂 Öffnen", self) + open_action.triggered.connect(lambda: self.folder_entered.emit(item.path)) + else: + open_action = QAction("🔗 Öffnen", self) + open_action.triggered.connect(lambda: self._open_external(item.path)) + menu.addAction(open_action) + + menu.addSeparator() + + # Umbenennen + rename_action = QAction("✏️ Umbenennen (F2)", self) + rename_action.triggered.connect(lambda: self.file_rename_requested.emit(item.path)) + menu.addAction(rename_action) + + # Verschieben + move_action = QAction("📦 Verschieben", self) + move_action.triggered.connect(lambda: self.file_move_requested.emit(item.path)) + menu.addAction(move_action) + + menu.addSeparator() + + # Löschen (auch für mehrere) + delete_action = QAction(f"🗑 Löschen ({len(items)} Element{'e' if len(items) > 1 else ''})", self) + delete_action.triggered.connect(lambda: self._delete_items(items)) + menu.addAction(delete_action) + + menu.exec(self.viewport().mapToGlobal(position)) + + def _open_external(self, path: str): + """Öffnet eine Datei mit der Standard-Anwendung.""" + import subprocess + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + print(f"Fehler beim Öffnen: {e}") + + def _delete_items(self, items: list): + """Löscht mehrere Elemente.""" + for item in items: + self.file_delete_requested.emit(item.path) + + def get_selected_items(self) -> list[FileItem]: + """Gibt die ausgewählten Elemente zurück.""" + indexes = self.selectedIndexes() + rows = set(idx.row() for idx in indexes) + return [self.list_model.items[row] for row in rows if row < len(self.list_model.items)] + + def get_selected_path(self) -> str: + """Gibt den Pfad des ersten ausgewählten Elements zurück.""" + items = self.get_selected_items() + return items[0].path if items else "" + + def refresh(self): + """Aktualisiert die Dateiliste.""" + self.list_model.refresh() + + # Drag & Drop + def startDrag(self, supportedActions): + """Startet einen Drag-Vorgang.""" + items = self.get_selected_items() + if not items: + return + + drag = QDrag(self) + mime_data = QMimeData() + + urls = [QUrl.fromLocalFile(item.path) for item in items] + mime_data.setUrls(urls) + + drag.setMimeData(mime_data) + drag.exec(Qt.DropAction.MoveAction) + + def dragEnterEvent(self, event): + """Akzeptiert Drag-Events mit URLs.""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Markiert Zielordner beim Drag.""" + if event.mimeData().hasUrls(): + index = self.indexAt(event.position().toPoint()) + if index.isValid(): + item = self.list_model.get_item(index) + if item and item.is_dir: + event.acceptProposedAction() + return + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + """Verarbeitet Drop-Events.""" + if not event.mimeData().hasUrls(): + event.ignore() + return + + urls = event.mimeData().urls() + source_paths = [url.toLocalFile() for url in urls] + + # Zielordner ermitteln + index = self.indexAt(event.position().toPoint()) + if index.isValid(): + item = self.list_model.get_item(index) + if item and item.is_dir: + target_folder = item.path + else: + target_folder = self.list_model.current_path + else: + target_folder = self.list_model.current_path + + if source_paths and target_folder: + self.files_dropped.emit(source_paths, target_folder) + + event.acceptProposedAction() + + def keyPressEvent(self, event): + """Behandelt Tastatureingaben.""" + if event.key() == Qt.Key.Key_F2: + items = self.get_selected_items() + if len(items) == 1: + self.file_rename_requested.emit(items[0].path) + elif event.key() == Qt.Key.Key_Delete: + items = self.get_selected_items() + if items: + self._delete_items(items) + elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + items = self.get_selected_items() + if len(items) == 1: + if items[0].is_dir: + self.folder_entered.emit(items[0].path) + else: + self._open_external(items[0].path) + elif event.key() == Qt.Key.Key_Backspace: + parent = os.path.dirname(self.list_model.current_path) + if parent and parent != self.list_model.current_path: + self.folder_entered.emit(parent) + else: + super().keyPressEvent(event) diff --git a/src/widgets/folder_tree.py b/src/widgets/folder_tree.py new file mode 100644 index 0000000..17def8f --- /dev/null +++ b/src/widgets/folder_tree.py @@ -0,0 +1,162 @@ +"""Ordnerbaum-Widget mit Lazy Loading.""" + +import os +from pathlib import Path +from PyQt6.QtWidgets import ( + QTreeView, QAbstractItemView, QMenu +) +from PyQt6.QtCore import ( + Qt, QModelIndex, pyqtSignal, QDir, QFileInfo +) +from PyQt6.QtGui import ( + QFileSystemModel, QAction +) + + +class FolderTreeWidget(QTreeView): + """Ordnerbaum mit Lazy Loading und Kontextmenü.""" + + folder_selected = pyqtSignal(str) # Pfad des ausgewählten Ordners + folder_double_clicked = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + + self.model = QFileSystemModel() + self.model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + self.model.setRootPath('') + + self.setModel(self.model) + + # Nur Name-Spalte anzeigen + for i in range(1, self.model.columnCount()): + self.hideColumn(i) + + # Einstellungen + self.setHeaderHidden(True) + self.setAnimated(True) + self.setIndentation(20) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setExpandsOnDoubleClick(True) + + # Drag & Drop für Ordner + self.setDragEnabled(False) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + + # Signale verbinden + self.clicked.connect(self._on_clicked) + self.doubleClicked.connect(self._on_double_clicked) + + # Kontextmenü + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + + # Standard-Startpfade expandieren + self._expand_default_paths() + + def _expand_default_paths(self): + """Expandiert die Standard-Startpfade.""" + default_paths = ['/', '/home', '/mnt', '/srv'] + for path in default_paths: + if os.path.exists(path): + index = self.model.index(path) + if index.isValid(): + self.expand(index) + + def _on_clicked(self, index: QModelIndex): + """Behandelt Klicks auf Ordner.""" + path = self.model.filePath(index) + if path and os.path.isdir(path): + self.folder_selected.emit(path) + + def _on_double_clicked(self, index: QModelIndex): + """Behandelt Doppelklicks auf Ordner.""" + path = self.model.filePath(index) + if path and os.path.isdir(path): + self.folder_double_clicked.emit(path) + + def _show_context_menu(self, position): + """Zeigt das Kontextmenü für Ordner.""" + index = self.indexAt(position) + if not index.isValid(): + return + + path = self.model.filePath(index) + if not path: + return + + menu = QMenu(self) + + # Öffnen + open_action = QAction("📂 Öffnen", self) + open_action.triggered.connect(lambda: self.folder_selected.emit(path)) + menu.addAction(open_action) + + # Im Terminal öffnen + terminal_action = QAction("⚙️ Im Terminal öffnen", self) + terminal_action.triggered.connect(lambda: self._open_in_terminal(path)) + menu.addAction(terminal_action) + + menu.addSeparator() + + # Neuer Ordner + new_folder_action = QAction("➕ Neuer Ordner", self) + new_folder_action.triggered.connect(lambda: self._create_new_folder(path)) + menu.addAction(new_folder_action) + + menu.exec(self.viewport().mapToGlobal(position)) + + def _open_in_terminal(self, path: str): + """Öffnet den Ordner im Terminal.""" + import subprocess + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + print(f"Fehler beim Öffnen im Terminal: {e}") + + def _create_new_folder(self, parent_path: str): + """Erstellt einen neuen Ordner.""" + from PyQt6.QtWidgets import QInputDialog + + name, ok = QInputDialog.getText( + self, "Neuer Ordner", "Ordnername:", + ) + if ok and name: + new_path = os.path.join(parent_path, name) + try: + os.makedirs(new_path, exist_ok=True) + self.model.setRootPath('') # Refresh + except Exception as e: + from PyQt6.QtWidgets import QMessageBox + QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen: {e}") + + def navigate_to(self, path: str): + """Navigiert zu einem bestimmten Pfad und expandiert ihn.""" + if not os.path.exists(path): + return + + index = self.model.index(path) + if index.isValid(): + self.setCurrentIndex(index) + self.scrollTo(index) + + # Alle Elternordner expandieren + parent = index.parent() + while parent.isValid(): + self.expand(parent) + parent = parent.parent() + + def get_selected_path(self) -> str: + """Gibt den aktuell ausgewählten Pfad zurück.""" + indexes = self.selectedIndexes() + if indexes: + return self.model.filePath(indexes[0]) + return "" + + def refresh(self): + """Aktualisiert den Baum.""" + current_path = self.get_selected_path() + self.model.setRootPath('') + if current_path: + self.navigate_to(current_path) diff --git a/src/widgets/preview_panel.py b/src/widgets/preview_panel.py new file mode 100644 index 0000000..e79afc2 --- /dev/null +++ b/src/widgets/preview_panel.py @@ -0,0 +1,487 @@ +"""Preview-Panel für Bilder, Text und PDF.""" + +import os +import subprocess +from pathlib import Path +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QPlainTextEdit, QStackedWidget, QSizePolicy, + QFrame, QApplication, QComboBox, QSpinBox +) +from PyQt6.QtCore import Qt, pyqtSignal, QUrl, QSize, QTimer, QSettings +from PyQt6.QtGui import QPixmap, QImage, QFont, QPainter +from PyQt6.QtPdf import QPdfDocument +from PyQt6.QtPdfWidgets import QPdfView + +from ..utils.file_utils import get_file_icon, format_file_size, get_file_type + + +class ImagePreview(QScrollArea): + """Bild-Vorschau mit Scroll-Unterstützung.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setMinimumSize(100, 100) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding + ) + self.image_label.setMinimumSize(50, 50) + self.setWidget(self.image_label) + + self._pixmap = None + + def load_image(self, path: str): + """Lädt ein Bild.""" + self._pixmap = QPixmap(path) + if self._pixmap.isNull(): + self.image_label.setText("Bild konnte nicht geladen werden") + return False + + self._update_scaled_image() + return True + + def _update_scaled_image(self): + """Skaliert das Bild passend zum verfügbaren Platz.""" + if self._pixmap is None or self._pixmap.isNull(): + return + + available_size = self.size() - QSize(20, 20) + scaled = self._pixmap.scaled( + available_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.image_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_scaled_image() + + def clear(self): + """Leert die Vorschau.""" + self._pixmap = None + self.image_label.clear() + + +class TextPreview(QPlainTextEdit): + """Text-Vorschau.""" + + MAX_SIZE = 1024 * 1024 # 1 MB + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.setMinimumSize(100, 100) + + font = QFont("Monospace", 10) + font.setStyleHint(QFont.StyleHint.Monospace) + self.setFont(font) + + def load_text(self, path: str) -> bool: + """Lädt eine Textdatei.""" + try: + size = os.path.getsize(path) + if size > self.MAX_SIZE: + self.setPlainText(f"Datei zu groß für Vorschau ({format_file_size(size)})") + return False + + with open(path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + self.setPlainText(content) + return True + + except Exception as e: + self.setPlainText(f"Fehler beim Laden: {e}") + return False + + +class PdfPreview(QWidget): + """PDF-Vorschau mit nativem Qt PDF-Renderer und Einstellungen.""" + + # Zoom-Modi + ZOOM_MODES = [ + ('fit_width', 'Seitenbreite', QPdfView.ZoomMode.FitToWidth), + ('fit_page', 'Ganze Seite', QPdfView.ZoomMode.FitInView), + ('custom', 'Benutzerdefiniert', QPdfView.ZoomMode.Custom), + ] + + # Seiten-Modi + PAGE_MODES = [ + ('single', 'Einzelseite', QPdfView.PageMode.SinglePage), + ('multi', 'Mehrere Seiten', QPdfView.PageMode.MultiPage), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(100, 100) + self.settings = QSettings('FileBrowser', 'FileBrowser') + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Toolbar für PDF-Einstellungen + toolbar = QWidget() + toolbar.setFixedHeight(32) + toolbar_layout = QHBoxLayout(toolbar) + toolbar_layout.setContentsMargins(4, 0, 4, 0) + toolbar_layout.setSpacing(8) + + # Zoom-Modus + toolbar_layout.addWidget(QLabel("Zoom:")) + self.zoom_combo = QComboBox() + self.zoom_combo.setFixedWidth(120) + for key, label, _ in self.ZOOM_MODES: + self.zoom_combo.addItem(label, key) + self.zoom_combo.currentIndexChanged.connect(self._on_zoom_mode_changed) + toolbar_layout.addWidget(self.zoom_combo) + + # Zoom-Prozent (nur bei Custom) + self.zoom_spin = QSpinBox() + self.zoom_spin.setRange(10, 500) + self.zoom_spin.setValue(100) + self.zoom_spin.setSuffix('%') + self.zoom_spin.setFixedWidth(80) + self.zoom_spin.valueChanged.connect(self._on_zoom_value_changed) + self.zoom_spin.setVisible(False) + toolbar_layout.addWidget(self.zoom_spin) + + toolbar_layout.addSpacing(16) + + # Seiten-Modus + toolbar_layout.addWidget(QLabel("Ansicht:")) + self.page_combo = QComboBox() + self.page_combo.setFixedWidth(120) + for key, label, _ in self.PAGE_MODES: + self.page_combo.addItem(label, key) + self.page_combo.currentIndexChanged.connect(self._on_page_mode_changed) + toolbar_layout.addWidget(self.page_combo) + + toolbar_layout.addStretch() + + layout.addWidget(toolbar) + + # PDF Document und View + self.pdf_document = QPdfDocument(self) + self.pdf_view = QPdfView(self) + self.pdf_view.setDocument(self.pdf_document) + + layout.addWidget(self.pdf_view, 1) + + # Gespeicherte Einstellungen laden + self._load_settings() + + def _load_settings(self): + """Lädt gespeicherte PDF-Einstellungen.""" + # Zoom-Modus + zoom_mode = self.settings.value('pdf_zoom_mode', 'fit_width') + for i, (key, _, _) in enumerate(self.ZOOM_MODES): + if key == zoom_mode: + self.zoom_combo.setCurrentIndex(i) + break + + # Zoom-Wert + zoom_value = self.settings.value('pdf_zoom_value', 100, type=int) + self.zoom_spin.setValue(zoom_value) + + # Seiten-Modus + page_mode = self.settings.value('pdf_page_mode', 'multi') + for i, (key, _, _) in enumerate(self.PAGE_MODES): + if key == page_mode: + self.page_combo.setCurrentIndex(i) + break + + # Einstellungen anwenden + self._apply_zoom_mode() + self._apply_page_mode() + + def _save_settings(self): + """Speichert PDF-Einstellungen.""" + zoom_key = self.zoom_combo.currentData() + self.settings.setValue('pdf_zoom_mode', zoom_key) + self.settings.setValue('pdf_zoom_value', self.zoom_spin.value()) + + page_key = self.page_combo.currentData() + self.settings.setValue('pdf_page_mode', page_key) + + def _on_zoom_mode_changed(self, index): + """Behandelt Änderung des Zoom-Modus.""" + self._apply_zoom_mode() + self._save_settings() + + def _on_zoom_value_changed(self, value): + """Behandelt Änderung des Zoom-Werts.""" + if self.zoom_combo.currentData() == 'custom': + self.pdf_view.setZoomFactor(value / 100.0) + self._save_settings() + + def _on_page_mode_changed(self, index): + """Behandelt Änderung des Seiten-Modus.""" + self._apply_page_mode() + self._save_settings() + + def _apply_zoom_mode(self): + """Wendet den aktuellen Zoom-Modus an.""" + key = self.zoom_combo.currentData() + for k, _, mode in self.ZOOM_MODES: + if k == key: + self.pdf_view.setZoomMode(mode) + break + + # Zoom-Spin nur bei Custom anzeigen + self.zoom_spin.setVisible(key == 'custom') + if key == 'custom': + self.pdf_view.setZoomFactor(self.zoom_spin.value() / 100.0) + + def _apply_page_mode(self): + """Wendet den aktuellen Seiten-Modus an.""" + key = self.page_combo.currentData() + for k, _, mode in self.PAGE_MODES: + if k == key: + self.pdf_view.setPageMode(mode) + break + + def load_pdf(self, path: str) -> bool: + """Lädt eine PDF-Datei.""" + error = self.pdf_document.load(path) + if error != QPdfDocument.Error.None_: + return False + return True + + def clear(self): + """Leert die Vorschau.""" + self.pdf_document.close() + + def get_settings(self) -> dict: + """Gibt die aktuellen Einstellungen zurück.""" + return { + 'zoom_mode': self.zoom_combo.currentData(), + 'zoom_value': self.zoom_spin.value(), + 'page_mode': self.page_combo.currentData(), + } + + def set_settings(self, settings: dict): + """Setzt die Einstellungen.""" + if 'zoom_mode' in settings: + for i, (key, _, _) in enumerate(self.ZOOM_MODES): + if key == settings['zoom_mode']: + self.zoom_combo.setCurrentIndex(i) + break + if 'zoom_value' in settings: + self.zoom_spin.setValue(settings['zoom_value']) + if 'page_mode' in settings: + for i, (key, _, _) in enumerate(self.PAGE_MODES): + if key == settings['page_mode']: + self.page_combo.setCurrentIndex(i) + break + + +class NoPreview(QWidget): + """Platzhalter wenn keine Vorschau verfügbar.""" + + open_external = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(100, 100) + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.icon_label = QLabel() + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = QFont() + font.setPointSize(48) + self.icon_label.setFont(font) + layout.addWidget(self.icon_label) + + self.message_label = QLabel("Keine Vorschau verfügbar") + self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.message_label) + + self.open_button = QPushButton("Extern öffnen") + self.open_button.setMaximumWidth(150) + self.open_button.clicked.connect(self._on_open_clicked) + layout.addWidget(self.open_button, alignment=Qt.AlignmentFlag.AlignCenter) + + self._current_path = "" + + def set_file(self, path: str, name: str): + """Setzt die Datei für die keine Vorschau verfügbar ist.""" + self._current_path = path + icon = get_file_icon(name, False) + self.icon_label.setText(icon) + + def _on_open_clicked(self): + if self._current_path: + self.open_external.emit(self._current_path) + + +class PreviewPanel(QWidget): + """Preview-Panel mit Dateiinfo und Aktionsbuttons.""" + + rename_requested = pyqtSignal(str) + move_requested = pyqtSignal(str) + delete_requested = pyqtSignal(str) + open_external_requested = pyqtSignal(str) + detach_requested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path = "" + self._current_name = "" + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + + # Header mit Dateiinfo + header = QFrame() + header.setFixedHeight(50) + header_layout = QVBoxLayout(header) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(4) + + # Dateiname + self.name_label = QLabel() + self.name_label.setWordWrap(True) + font = QFont() + font.setBold(True) + self.name_label.setFont(font) + header_layout.addWidget(self.name_label) + + # Dateigröße + self.size_label = QLabel() + header_layout.addWidget(self.size_label) + + layout.addWidget(header) + + # Aktionsbuttons + button_widget = QWidget() + button_widget.setFixedHeight(40) + button_layout = QHBoxLayout(button_widget) + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + + self.rename_btn = QPushButton("✏️") + self.rename_btn.setToolTip("Umbenennen (F2)") + self.rename_btn.setFixedSize(36, 32) + self.rename_btn.clicked.connect(lambda: self.rename_requested.emit(self._current_path)) + button_layout.addWidget(self.rename_btn) + + self.move_btn = QPushButton("📦") + self.move_btn.setToolTip("Verschieben") + self.move_btn.setFixedSize(36, 32) + self.move_btn.clicked.connect(lambda: self.move_requested.emit(self._current_path)) + button_layout.addWidget(self.move_btn) + + self.delete_btn = QPushButton("🗑") + self.delete_btn.setToolTip("Löschen") + self.delete_btn.setFixedSize(36, 32) + self.delete_btn.clicked.connect(lambda: self.delete_requested.emit(self._current_path)) + button_layout.addWidget(self.delete_btn) + + button_layout.addStretch() + + self.detach_btn = QPushButton("⧉") + self.detach_btn.setToolTip("In separatem Fenster öffnen") + self.detach_btn.setFixedSize(36, 32) + self.detach_btn.clicked.connect(self.detach_requested.emit) + button_layout.addWidget(self.detach_btn) + + layout.addWidget(button_widget) + + # Stacked Widget für verschiedene Preview-Typen + self.preview_stack = QStackedWidget() + self.preview_stack.setMinimumSize(100, 100) + + # Leere Vorschau (Index 0) + self.empty_widget = QLabel("Wähle eine Datei aus") + self.empty_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_stack.addWidget(self.empty_widget) + + # Bild-Vorschau (Index 1) + self.image_preview = ImagePreview() + self.preview_stack.addWidget(self.image_preview) + + # Text-Vorschau (Index 2) + self.text_preview = TextPreview() + self.preview_stack.addWidget(self.text_preview) + + # PDF-Vorschau (Index 3) + self.pdf_preview = PdfPreview() + self.preview_stack.addWidget(self.pdf_preview) + + # Keine Vorschau (Index 4) + self.no_preview = NoPreview() + self.no_preview.open_external.connect(self.open_external_requested.emit) + self.preview_stack.addWidget(self.no_preview) + + layout.addWidget(self.preview_stack, 1) + + self._set_buttons_enabled(False) + + def _set_buttons_enabled(self, enabled: bool): + """Aktiviert/Deaktiviert die Aktionsbuttons.""" + self.rename_btn.setEnabled(enabled) + self.move_btn.setEnabled(enabled) + self.delete_btn.setEnabled(enabled) + self.detach_btn.setEnabled(enabled) + + def load_file(self, path: str, name: str): + """Lädt eine Datei für die Vorschau.""" + self._current_path = path + self._current_name = name + + # Header aktualisieren + self.name_label.setText(name) + try: + size = os.path.getsize(path) + self.size_label.setText(format_file_size(size)) + except OSError: + self.size_label.setText("") + + self._set_buttons_enabled(True) + + # Passende Vorschau laden + file_type = get_file_type(name) + + if file_type == 'image': + self.image_preview.load_image(path) + self.preview_stack.setCurrentIndex(1) + elif file_type == 'text': + self.text_preview.load_text(path) + self.preview_stack.setCurrentIndex(2) + elif file_type == 'pdf': + self.pdf_preview.load_pdf(path) + self.preview_stack.setCurrentIndex(3) + else: + self.no_preview.set_file(path, name) + self.preview_stack.setCurrentIndex(4) + + def clear(self): + """Leert die Vorschau.""" + self._current_path = "" + self._current_name = "" + self.name_label.setText("") + self.size_label.setText("") + self._set_buttons_enabled(False) + + self.image_preview.clear() + self.text_preview.clear() + self.pdf_preview.clear() + self.preview_stack.setCurrentIndex(0) + + def get_current_file(self) -> tuple: + """Gibt den aktuellen Pfad und Namen zurück.""" + return self._current_path, self._current_name