Initial commit: PyQt6 FileBrowser mit Preview

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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-02 13:25:15 +01:00
commit eb61e746c1
15 changed files with 2695 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -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/

29
main.py Normal file
View file

@ -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()

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
PyQt6>=6.6.0
PyQt6-Pdf>=6.6.0
PyQt6-PdfWidgets>=6.6.0

1
src/__init__.py Normal file
View file

@ -0,0 +1 @@
# FileBrowser - PyQt6 File Manager with Preview

230
src/dialogs.py Normal file
View file

@ -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)

603
src/main_window.py Normal file
View file

@ -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)

160
src/preview_window.py Normal file
View file

@ -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)

2
src/utils/__init__.py Normal file
View file

@ -0,0 +1,2 @@
from .file_utils import get_file_icon, format_file_size, natural_sort_key
from .themes import ThemeManager

100
src/utils/file_utils.py Normal file
View file

@ -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)

315
src/utils/themes.py Normal file
View file

@ -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

4
src/widgets/__init__.py Normal file
View file

@ -0,0 +1,4 @@
from .folder_tree import FolderTreeWidget
from .file_list import FileListWidget
from .preview_panel import PreviewPanel
from .breadcrumb import BreadcrumbWidget

191
src/widgets/breadcrumb.py Normal file
View file

@ -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

363
src/widgets/file_list.py Normal file
View file

@ -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)

162
src/widgets/folder_tree.py Normal file
View file

@ -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)

View file

@ -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