From 7e377363c6283c031d80b5f044be4706e8185c82 Mon Sep 17 00:00:00 2001 From: Akamaru Date: Mon, 8 Sep 2025 17:19:22 +0200 Subject: [PATCH] Kompletter Rewrite --- .gitignore | 45 ++ README.md | 119 +++-- config.py | 165 +++++++ data/__init__.py | 0 data/data_manager.py | 153 ++++++ gui/__init__.py | 0 gui/dialogs.py | 351 ++++++++++++++ gui/main_window.py | 396 ++++++++++++++++ gui/modern_widgets.py | 288 ++++++++++++ main.py | 32 ++ models/__init__.py | 0 models/category.py | 31 ++ models/program.py | 39 ++ run.py | 1025 ----------------------------------------- screenshot.png | Bin 32834 -> 31464 bytes start.bat | 2 +- utils/__init__.py | 0 utils/icon_utils.py | 94 ++++ 18 files changed, 1688 insertions(+), 1052 deletions(-) create mode 100644 config.py create mode 100644 data/__init__.py create mode 100644 data/data_manager.py create mode 100644 gui/__init__.py create mode 100644 gui/dialogs.py create mode 100644 gui/main_window.py create mode 100644 gui/modern_widgets.py create mode 100644 main.py create mode 100644 models/__init__.py create mode 100644 models/category.py create mode 100644 models/program.py delete mode 100644 run.py create mode 100644 utils/__init__.py create mode 100644 utils/icon_utils.py diff --git a/.gitignore b/.gitignore index f49cca7..aa90d30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,48 @@ +# User data data.json +program_list.json + +# Archive files Programm-Shortcut.7z + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +*.tmp +*.log + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg diff --git a/README.md b/README.md index 608b250..706766e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ -# Programm-Shortcut +# Programm-Shortcut v2.0 -Ein Programm-Launcher für Windows mit Kategorieverwaltung und Programm-Icons. +Ein moderner Programm-Launcher für Windows mit Kategorieverwaltung, Programm-Icons und verbesserter Benutzeroberfläche. ![Screenshot des Programm-Shortcut](screenshot.png) ## Features -- Organisiere deine Programme in benutzerdefinierten Kategorien -- Programme werden mit ihren Original-Icons angezeigt -- Sortiere Programme nach Name oder Kategorie -- Einfaches Hinzufügen, Bearbeiten und Entfernen von Programmen -- Einstellungen werden automatisch gespeichert -- Kompaktes und benutzerfreundliches Interface +- **Organisiere Programme** in benutzerdefinierten Kategorien +- **Original-Icons** werden automatisch aus den Programmen extrahiert +- **Flexible Sortierung** nach Name oder Kategorie +- **Intuitive Bedienung** mit modernem Interface +- **Automatische Speicherung** aller Einstellungen +- **Administratorrechte-Unterstützung** für Programme, die diese benötigen +- **Konsistente Icons** in allen Fenstern und Dialogen ## Installation @@ -19,7 +20,7 @@ Ein Programm-Launcher für Windows mit Kategorieverwaltung und Programm-Icons. 2. Klone das Repository oder lade es herunter 3. Installiere die erforderlichen Abhängigkeiten: -``` +```bash pip install pywin32 Pillow ``` @@ -27,37 +28,103 @@ pip install pywin32 Pillow Starte die Anwendung mit: +```bash +python main.py ``` -python run.py -``` -oder mit der start.bat + +oder verwende die mitgelieferte `start.bat` Datei. ### Programme hinzufügen: -1. Klicke auf "Hinzufügen" -2. Gebe den Programmnamen ein +1. Klicke im Menü auf "Programm" → "Hinzufügen..." oder drücke `Ctrl+N` +2. Gib den Programmnamen ein 3. Wähle den Programmpfad über "Durchsuchen" 4. Wähle eine Kategorie oder erstelle eine neue -5. Klicke auf "Speichern" +5. Aktiviere "Benötigt Administratorrechte" falls erforderlich +6. Klicke auf "Speichern" ### Programme starten: -1. Wähle eine Kategorie aus der linken Seitenleiste +1. Wähle eine Kategorie aus dem Menü "Kategorie" 2. Wähle ein Programm aus der Liste -3. Klicke auf "Starten" +3. Doppelklicke oder drücke `Enter` zum Starten -## Abhängigkeiten +### Tastaturkürzel: -- Python 3.6+ -- tkinter (in der Standardinstallation von Python enthalten) -- pywin32 (für das Extrahieren von Programm-Icons) -- Pillow (für die Bildverarbeitung) +- `Ctrl+N`: Neues Programm hinzufügen +- `F2`: Ausgewähltes Programm bearbeiten +- `Del`: Ausgewähltes Programm entfernen +- `Enter`: Ausgewähltes Programm starten + +## Architektur v2.0 + +Das Programm wurde komplett in eine modulare, wartbare Architektur überarbeitet: + +``` +├── main.py # Haupteinstiegspunkt +├── config.py # Zentrale Konfiguration und Themes +├── models/ # Datenmodelle +│ ├── program.py # Program-Klasse mit Admin-Unterstützung +│ └── category.py # CategoryManager +├── data/ # Datenverarbeitung +│ └── data_manager.py # JSON-Operationen und Migration +├── utils/ # Hilfsfunktionen +│ └── icon_utils.py # Icon-Extraktion und -Verwaltung +└── gui/ # GUI-Komponenten + ├── main_window.py # Hauptfenster mit moderner UI + ├── dialogs.py # Alle Dialog-Komponenten + └── modern_widgets.py # Moderne UI-Komponenten +``` + +## Was ist neu in v2.0 + +### 🎨 Verbessertes Design +- **Moderne Themes**: Drei verschiedene Farbschemas (Modern Dark, Modern Light, Gradient Blue) +- **Konsistente Icons**: Alle Fenster und Dialoge verwenden das Anwendungsicon +- **Verbesserte Typography**: Professionelle Schriftarten und Größen +- **Responsive Layout**: Bessere Anpassung an verschiedene Fenstergrößen + +### 🔧 Technische Verbesserungen +- **Eigene Dialog-Klassen**: Alle System-Dialoge wurden durch eigene Implementierungen ersetzt +- **Bessere Icon-Behandlung**: Optimierte Icon-Extraktion und -Caching +- **Robuste Pfad-Auflösung**: Verbesserte Kompatibilität mit WSL und verschiedenen Umgebungen +- **Admin-Rechte-Unterstützung**: Programme können als Administrator gestartet werden + +### 🏗️ Architektur-Verbesserungen +- **Separation of Concerns**: Klare Trennung von GUI, Daten und Geschäftslogik +- **Modulare Komponenten**: Wiederverwendbare UI-Elemente +- **Erweiterte Konfiguration**: Zentrale Theme- und Einstellungsverwaltung +- **Bessere Testbarkeit**: Kleinere, fokussierte Module + +### Vorteile der neuen Architektur: +- **Wartbarkeit**: Übersichtliche Codestruktur mit klaren Verantwortlichkeiten +- **Erweiterbarkeit**: Einfache Integration neuer Features +- **Stabilität**: Robuste Fehlerbehandlung und Validierung +- **Performance**: Optimierte Icon-Verwaltung und UI-Updates + +## Migration von v1.x + +Die Migration erfolgt automatisch beim ersten Start von v2.0: +- Bestehende `data.json` Dateien werden automatisch geladen +- Alte `program_list.json` Dateien werden migriert +- Alle Benutzereinstellungen bleiben erhalten +- Keine manuelle Konfiguration erforderlich + +## Systemanforderungen + +- **Python**: 3.6 oder höher +- **Betriebssystem**: Windows (Icon-Unterstützung), Linux/WSL (grundlegende Funktionalität) +- **Abhängigkeiten**: + - `tkinter` (standardmäßig in Python enthalten) + - `pywin32` (für Icon-Extraktion unter Windows) + - `Pillow` (für Bildverarbeitung) ## Hinweise -- Icon-Unterstützung funktioniert nur unter Windows -- Ohne die optionalen Abhängigkeiten (pywin32 und Pillow) funktioniert das Programm weiterhin, jedoch ohne Icon-Anzeige +- **Icon-Unterstützung**: Funktioniert vollständig nur unter Windows +- **WSL-Kompatibilität**: Grundlegende Funktionalität verfügbar +- **Fallback-Modus**: Ohne optionale Abhängigkeiten läuft das Programm ohne Icons -## Info +## Entwicklung -- Erstellt mit Hilfe von Claude \ No newline at end of file +Erstellt mit Hilfe von Claude AI und kontinuierlich weiterentwickelt. \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..a510f57 --- /dev/null +++ b/config.py @@ -0,0 +1,165 @@ +from typing import Dict, Any + + +class Config: + # Application settings + APP_NAME = "Programm-Shortcut" + APP_VERSION = "2.0" + APP_AUTHOR = "© 2025 Akamaru" + APP_DESCRIPTION = "Erstellt mit Hilfe von Claude" + SOURCE_URL = "https://git.ponywave.de/Akamaru/Programm-Shortcut" + + # Window settings + DEFAULT_WINDOW_WIDTH = 700 + DEFAULT_WINDOW_HEIGHT = 550 + MIN_WINDOW_WIDTH = 600 + MIN_WINDOW_HEIGHT = 450 + + # GUI settings + TREEVIEW_ROW_HEIGHT = 24 + CATEGORY_LIST_HEIGHT = 15 + CATEGORY_LIST_WIDTH = 15 + PROGRAM_NAME_PADDING = " " # 3 spaces for icon alignment + + # Icon settings + ICON_SIZE = (16, 16) + ICON_FILE = "icon.ico" + + # File settings + DATA_FILE = "data.json" + LEGACY_FILE = "program_list.json" + + + # Modern color themes + THEMES = { + "modern_dark": { + "bg_primary": "#2b2b2b", + "bg_secondary": "#3c3c3c", + "bg_tertiary": "#4a4a4a", + "accent": "#007acc", + "accent_hover": "#1e90ff", + "success": "#28a745", + "success_hover": "#34ce57", + "danger": "#dc3545", + "text_primary": "#ffffff", + "text_secondary": "#cccccc", + "text_muted": "#888888", + "border": "#555555", + "selection": "#007acc", + "selection_hover": "#1e90ff" + }, + "modern_light": { + "bg_primary": "#ffffff", + "bg_secondary": "#f8f9fa", + "bg_tertiary": "#e9ecef", + "accent": "#007acc", + "accent_hover": "#1e90ff", + "success": "#28a745", + "success_hover": "#34ce57", + "danger": "#dc3545", + "text_primary": "#212529", + "text_secondary": "#495057", + "text_muted": "#6c757d", + "border": "#dee2e6", + "selection": "#007acc", + "selection_hover": "#1e90ff" + }, + "gradient_blue": { + "bg_primary": "#f0f2f5", + "bg_secondary": "#ffffff", + "bg_tertiary": "#e3f2fd", + "accent": "#1976d2", + "accent_hover": "#1565c0", + "success": "#4caf50", + "success_hover": "#43a047", + "danger": "#f44336", + "text_primary": "#263238", + "text_secondary": "#546e7a", + "text_muted": "#90a4ae", + "border": "#cfd8dc", + "selection": "#1976d2", + "selection_hover": "#1565c0" + } + } + + # Current theme (can be changed) + CURRENT_THEME = "gradient_blue" + + @classmethod + def get_theme(cls) -> dict: + return cls.THEMES[cls.CURRENT_THEME] + + # Modern typography + FONTS = { + "heading": ("Segoe UI", 18, "bold"), + "subheading": ("Segoe UI", 14, "bold"), + "body": ("Segoe UI", 11), + "body_small": ("Segoe UI", 10), + "caption": ("Segoe UI", 9), + "button": ("Segoe UI", 10, "bold"), + "link": ("Segoe UI", 10, "underline") + } + + # Compact UI Spacing + SPACING = { + "xs": 2, + "sm": 4, + "md": 6, + "lg": 8, + "xl": 12, + "xxl": 16 + } + + # Compact button styles + BUTTON_STYLES = { + "primary": {"style": "primary", "width": 80, "padding": (6, 4)}, + "secondary": {"style": "secondary", "width": 70, "padding": (4, 3)}, + "small": {"style": "small", "width": 24, "padding": (2, 2)}, + "icon": {"style": "icon", "width": 28, "padding": (3, 3)} + } + + # Border radius for modern look + BORDER_RADIUS = 8 + + # Compact card styling + CARD_PADDING = 8 + CARD_SHADOW = {"x": 0, "y": 1, "blur": 3} + + # Sort options + SORT_OPTIONS = [ + "Keine", + "Programm (A-Z)", + "Programm (Z-A)", + "Kategorie (A-Z)", + "Kategorie (Z-A)" + ] + + # Messages + MESSAGES = { + "no_program_selected": "Bitte wählen Sie ein Programm aus.", + "no_category_selected": "Bitte wählen Sie eine Kategorie zum Entfernen aus.", + "cannot_remove_all": "Die Kategorie 'Alle' kann nicht entfernt werden.", + "fill_all_fields": "Bitte füllen Sie alle Felder aus.", + "path_not_exists": "Der angegebene Pfad existiert nicht.", + "category_exists": "Die Kategorie '{}' existiert bereits.", + "program_not_found": "Programm '{}' konnte nicht gefunden werden.", + "error_starting_program": "Fehler beim Starten des Programms: {}", + "error_loading_data": "Fehler beim Laden der Daten: {}\nEine neue Liste wird erstellt.", + "error_saving_data": "Fehler beim Speichern der Daten: {}", + "error_opening_url": "Fehler beim Öffnen der URL: {}", + "icon_libraries_missing": "Warnung: Die Bibliotheken für Programm-Icons fehlen. Installieren Sie diese mit: pip install pywin32 Pillow", + "confirm_remove_category": "Möchten Sie die Kategorie '{}' wirklich entfernen?\n\nAlle Programme in dieser Kategorie werden ohne Kategorie gespeichert.", + "confirm_remove_program": "Möchten Sie '{}' wirklich entfernen?", + "help_text": "Wählen Sie ein Programm zum Starten oder fügen Sie ein neues hinzu" + } + + @classmethod + def get_default_options(cls) -> Dict[str, Any]: + return { + "default_sort_column": None, + "default_sort_reverse": False, + "remember_last_category": False, + "last_category": "Alle", + "window_width": cls.DEFAULT_WINDOW_WIDTH, + "window_height": cls.DEFAULT_WINDOW_HEIGHT + } \ No newline at end of file diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/data_manager.py b/data/data_manager.py new file mode 100644 index 0000000..1ea2624 --- /dev/null +++ b/data/data_manager.py @@ -0,0 +1,153 @@ +import json +import os +from typing import List, Dict, Any +from models.program import Program +from models.category import CategoryManager + + +class DataManager: + def __init__(self, json_file: str = 'data.json'): + self.json_file = json_file + self.category_manager = CategoryManager() + self.programs: List[Program] = [] + self.options: Dict[str, Any] = self._get_default_options() + + def _get_default_options(self) -> Dict[str, Any]: + return { + "default_sort_column": None, + "default_sort_reverse": False, + "remember_last_category": False, + "last_category": "Alle", + "window_width": 700, + "window_height": 550 + } + + def load_data(self) -> bool: + try: + # Handle legacy file migration + if not os.path.exists(self.json_file) and os.path.exists('program_list.json'): + self._migrate_from_legacy_file() + return True + + if not os.path.exists(self.json_file): + self._create_empty_file() + return True + + with open(self.json_file, encoding='utf-8') as f: + data = json.load(f) + + # Handle old format without options + if "options" not in data: + data["options"] = self._get_default_options() + + self._load_from_data(data) + return True + + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Error loading data: {e}") + self._create_empty_file() + return False + + def _migrate_from_legacy_file(self): + with open('program_list.json', encoding='utf-8') as f: + old_data = json.load(f) + + if isinstance(old_data, list): + # Old format: just a list of programs + # Extract categories from programs + categories = [] + for program_data in old_data: + if "Kategorie" in program_data and program_data["Kategorie"] not in categories: + categories.append(program_data["Kategorie"]) + + data = { + "categories": categories, + "programs": old_data, + "options": self._get_default_options() + } + else: + # Newer format but without options + old_data["options"] = self._get_default_options() + data = old_data + + self._load_from_data(data) + self.save_data() + + def _create_empty_file(self): + data = { + "categories": [], + "programs": [], + "options": self._get_default_options() + } + self._load_from_data(data) + self.save_data() + + def _load_from_data(self, data: Dict[str, Any]): + self.category_manager.load_from_data(data.get("categories", [])) + self.programs = [Program.from_dict(p) for p in data.get("programs", [])] + self.options = data.get("options", self._get_default_options()) + + def save_data(self) -> bool: + try: + data = { + "categories": self.category_manager.to_list(), + "programs": [p.to_dict() for p in self.programs], + "options": self.options + } + + with open(self.json_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + + except Exception as e: + print(f"Error saving data: {e}") + return False + + def add_program(self, program: Program) -> bool: + if program.category and not self.category_manager.has_category(program.category): + self.category_manager.add_category(program.category) + + self.programs.append(program) + return self.save_data() + + def remove_program(self, program_name: str) -> bool: + for i, program in enumerate(self.programs): + if program.name.strip() == program_name.strip(): + del self.programs[i] + return self.save_data() + return False + + def update_program(self, old_name: str, updated_program: Program) -> bool: + for i, program in enumerate(self.programs): + if program.name.strip() == old_name.strip(): + if updated_program.category and not self.category_manager.has_category(updated_program.category): + self.category_manager.add_category(updated_program.category) + + self.programs[i] = updated_program + return self.save_data() + return False + + def get_programs_by_category(self, category: str = None) -> List[Program]: + if not category or category == "Alle": + return self.programs + return [p for p in self.programs if p.category == category] + + def add_category(self, category: str) -> bool: + if self.category_manager.add_category(category): + return self.save_data() + return False + + def remove_category(self, category: str) -> bool: + if not self.category_manager.remove_category(category): + return False + + # Remove category from all programs + for program in self.programs: + if program.category == category: + program.category = None + + return self.save_data() + + def update_options(self, **kwargs): + self.options.update(kwargs) + return self.save_data() \ No newline at end of file diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/dialogs.py b/gui/dialogs.py new file mode 100644 index 0000000..0982700 --- /dev/null +++ b/gui/dialogs.py @@ -0,0 +1,351 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, simpledialog +import os +import webbrowser +from typing import Callable, Optional, Dict, Any, List +from models.program import Program +from config import Config +from utils.icon_utils import IconManager +from gui.modern_widgets import ModernStyle, ModernCard, ModernButton + + +class BaseDialog: + def __init__(self, parent: tk.Widget, title: str, width: int = 500, height: int = 400): + self.parent = parent + self.result = None + self.modern_style = ModernStyle() + self.icon_manager = IconManager() + theme = Config.get_theme() + + self.window = tk.Toplevel(parent) + self.window.title(title) + self.window.geometry(f"{width}x{height}") + self.window.minsize(width, height) # Mindestgröße setzen + self.window.resizable(True, True) # Größe änderbar machen + self.window.grab_set() + self.window.configure(bg=theme["bg_primary"]) + + # Set icon + try: + self.window.iconbitmap(self.icon_manager.resource_path(Config.ICON_FILE)) + except: + pass + + # Configure modern styling + style = ttk.Style() + self.modern_style.configure_styles(style) + + self._center_window() + self._create_widgets() + + def _center_window(self): + self.window.update_idletasks() + width = self.window.winfo_width() + height = self.window.winfo_height() + x = (self.window.winfo_screenwidth() // 2) - (width // 2) + y = (self.window.winfo_screenheight() // 2) - (height // 2) + self.window.geometry(f"{width}x{height}+{x}+{y}") + + def _create_widgets(self): + pass + + def show(self): + self.window.wait_window() + return self.result + + +class ProgramDialog(BaseDialog): + def __init__(self, parent: tk.Widget, title: str, categories: List[str], + program: Optional[Program] = None): + self.categories = categories + self.program = program + self.is_edit = program is not None + super().__init__(parent, title, 450, 280) + + def _create_widgets(self): + # Simple container + container = ttk.Frame(self.window, padding="20") + container.pack(fill=tk.BOTH, expand=True) + + # Form fields using grid + self._create_form_fields(container) + + # Buttons + self._create_buttons(container) + + def _create_form_fields(self, parent): + # Name field + ttk.Label(parent, text="Programmname:").grid(row=0, column=0, sticky="w", pady=5) + self.name_entry = ttk.Entry(parent, width=30) + self.name_entry.grid(row=0, column=1, columnspan=2, sticky="we", pady=5) + + # Path field + ttk.Label(parent, text="Programmpfad:").grid(row=1, column=0, sticky="w", pady=5) + self.path_entry = ttk.Entry(parent, width=30) + self.path_entry.grid(row=1, column=1, sticky="we", pady=5) + + browse_button = ttk.Button(parent, text="Durchsuchen", command=self._browse_file) + browse_button.grid(row=1, column=2, padx=5, pady=5) + + # Category field + ttk.Label(parent, text="Kategorie:").grid(row=2, column=0, sticky="w", pady=5) + self.category_combobox = ttk.Combobox(parent, width=28, values=sorted(self.categories)) + self.category_combobox.grid(row=2, column=1, columnspan=2, sticky="we", pady=5) + + # Admin rights checkbox + self.admin_var = tk.BooleanVar(value=False) + admin_check = ttk.Checkbutton(parent, text="Benötigt Administratorrechte", + variable=self.admin_var) + admin_check.grid(row=3, column=0, columnspan=3, sticky="w", pady=5) + + # Fill fields if editing + if self.program: + self.name_entry.insert(0, self.program.name) + self.path_entry.insert(0, self.program.path) + if self.program.category: + self.category_combobox.set(self.program.category) + elif self.categories: + self.category_combobox.current(0) + self.admin_var.set(self.program.requires_admin) + elif self.categories: + self.category_combobox.current(0) + + self.name_entry.focus_set() + + # Make column 1 expandable + parent.columnconfigure(1, weight=1) + + def _create_buttons(self, parent): + # Button frame + button_frame = ttk.Frame(parent) + button_frame.grid(row=4, column=0, columnspan=3, pady=15) + + ttk.Button(button_frame, text="Speichern", command=self._save).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Abbrechen", command=self.window.destroy).pack(side=tk.LEFT, padx=5) + + def _browse_file(self): + if not self.is_edit: + self.path_entry.delete(0, tk.END) + filename = filedialog.askopenfilename( + filetypes=[ + ("Ausführbare Dateien", "*.exe"), + ("Batch-Dateien", "*.bat;*.cmd"), + ("Alle ausführbaren Dateien", "*.exe;*.bat;*.cmd"), + ("Alle Dateien", "*.*") + ] + ) + if filename: + if not self.is_edit: + self.path_entry.insert(0, filename) + else: + self.path_entry.delete(0, tk.END) + self.path_entry.insert(0, filename) + + def _save(self): + name = self.name_entry.get().strip() + path = self.path_entry.get().strip() + category = self.category_combobox.get().strip() + requires_admin = self.admin_var.get() + + if not name or not path: + messagebox.showwarning("Warnung", "Bitte füllen Sie alle Felder aus.") + return + + if not os.path.exists(path): + messagebox.showwarning("Warnung", "Der angegebene Pfad existiert nicht.") + return + + self.result = Program( + name=name, + path=path, + category=category if category else None, + requires_admin=requires_admin + ) + self.window.destroy() + + +class OptionsDialog(BaseDialog): + def __init__(self, parent: tk.Widget, current_options: Dict[str, Any]): + self.current_options = current_options.copy() + super().__init__(parent, "Programmoptionen", 400, 320) + + def _create_widgets(self): + # Simple container with normal padding + container = ttk.Frame(self.window, padding="20") + container.pack(fill=tk.BOTH, expand=True) + + # Title + title_label = ttk.Label(container, text="Programmoptionen", + font=("Segoe UI", 12, "bold")) + title_label.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 15)) + + # Sort option + ttk.Label(container, text="Standardsortierung:").grid(row=1, column=0, sticky="w", pady=5) + self.sort_var = tk.StringVar(value=self._get_sort_option_text()) + sort_combobox = ttk.Combobox(container, textvariable=self.sort_var, + values=Config.SORT_OPTIONS, + width=20) + sort_combobox.grid(row=1, column=1, sticky="we", pady=5) + sort_combobox.configure(state="readonly") + + # Remember category option + self.remember_category_var = tk.BooleanVar( + value=self.current_options.get("remember_last_category", False) + ) + remember_category_check = ttk.Checkbutton( + container, + text="Letzte ausgewählte Kategorie merken", + variable=self.remember_category_var + ) + remember_category_check.grid(row=2, column=0, columnspan=2, sticky="w", pady=10) + + # Separator + separator = ttk.Separator(container, orient="horizontal") + separator.grid(row=3, column=0, columnspan=2, sticky="ew", pady=10) + + # Info section + info_frame = ttk.Frame(container) + info_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=5) + + ttk.Label(info_frame, text=f"Version: {Config.APP_VERSION}", + font=("Segoe UI", 9)).pack(anchor="w") + ttk.Label(info_frame, text=f"{Config.APP_AUTHOR}", + font=("Segoe UI", 9)).pack(anchor="w") + ttk.Label(info_frame, text=f"{Config.APP_DESCRIPTION}", + font=("Segoe UI", 9)).pack(anchor="w") + + # Link + link_label = ttk.Label(info_frame, text="Quellcode auf git.ponywave.de", + font=("Segoe UI", 9, "underline"), + foreground="blue", cursor="hand2") + link_label.pack(anchor="w", pady=(5, 0)) + link_label.bind("", lambda e: self._open_url(Config.SOURCE_URL)) + + # Buttons + button_frame = ttk.Frame(container) + button_frame.grid(row=5, column=0, columnspan=2, pady=20) + + ttk.Button(button_frame, text="Speichern", command=self._save).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Abbrechen", command=self.window.destroy).pack(side=tk.LEFT, padx=5) + + # Make column 1 expandable + container.columnconfigure(1, weight=1) + + + def _get_sort_option_text(self) -> str: + column = self.current_options.get("default_sort_column") + reverse = self.current_options.get("default_sort_reverse", False) + + if column == "name": + return "Programm (Z-A)" if reverse else "Programm (A-Z)" + elif column == "category": + return "Kategorie (Z-A)" if reverse else "Kategorie (A-Z)" + else: + return "Keine" + + def _save(self): + sort_option = self.sort_var.get() + remember_category = self.remember_category_var.get() + + # Translate sort options + if sort_option == "Programm (A-Z)": + sort_column, sort_reverse = "name", False + elif sort_option == "Programm (Z-A)": + sort_column, sort_reverse = "name", True + elif sort_option == "Kategorie (A-Z)": + sort_column, sort_reverse = "category", False + elif sort_option == "Kategorie (Z-A)": + sort_column, sort_reverse = "category", True + else: # "Keine" + sort_column, sort_reverse = None, False + + self.result = { + "default_sort_column": sort_column, + "default_sort_reverse": sort_reverse, + "remember_last_category": remember_category + } + self.window.destroy() + + def _open_url(self, url: str): + try: + webbrowser.open(url) + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Öffnen der URL: {str(e)}") + + +class CategoryDialog(BaseDialog): + def __init__(self, parent: tk.Widget, dialog_type: str = "add", categories: List[str] = None): + self.dialog_type = dialog_type + self.categories = categories or [] + + if dialog_type == "add": + title = "Neue Kategorie" + width, height = 400, 150 + else: # delete + title = "Kategorie löschen" + width, height = 450, 200 + + super().__init__(parent, title, width, height) + + def _create_widgets(self): + container = ttk.Frame(self.window, padding="20") + container.pack(fill=tk.BOTH, expand=True) + + if self.dialog_type == "add": + # Label + ttk.Label(container, text="Geben Sie den Namen der neuen Kategorie ein:").pack(pady=(0, 10)) + + # Entry + self.name_entry = ttk.Entry(container, width=30) + self.name_entry.pack(pady=(0, 15)) + self.name_entry.focus_set() + + # Bind Enter key + self.name_entry.bind("", lambda e: self._ok()) + else: # delete + # Label + label_text = f"Verfügbare Kategorien: {', '.join(sorted(self.categories))}\n\nWelche Kategorie soll gelöscht werden?" + ttk.Label(container, text=label_text, wraplength=400).pack(pady=(0, 10)) + + # Combobox for category selection + self.category_combobox = ttk.Combobox(container, values=sorted(self.categories), width=30) + self.category_combobox.pack(pady=(0, 15)) + self.category_combobox.focus_set() + if self.categories: + self.category_combobox.current(0) + + # Buttons + button_frame = ttk.Frame(container) + button_frame.pack() + + ttk.Button(button_frame, text="OK", command=self._ok).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Abbrechen", command=self.window.destroy).pack(side=tk.LEFT, padx=5) + + def _ok(self): + if self.dialog_type == "add": + self.result = self.name_entry.get().strip() + else: # delete + self.result = self.category_combobox.get().strip() + self.window.destroy() + + @staticmethod + def add_category(parent: tk.Widget) -> Optional[str]: + dialog = CategoryDialog(parent, "add") + return dialog.show() + + @staticmethod + def delete_category(parent: tk.Widget, categories: List[str]) -> Optional[str]: + dialog = CategoryDialog(parent, "delete", categories) + return dialog.show() + + @staticmethod + def confirm_remove_category(parent: tk.Widget, category: str) -> bool: + return messagebox.askyesno("Bestätigung", + Config.MESSAGES["confirm_remove_category"].format(category), + parent=parent) + + @staticmethod + def confirm_remove_program(parent: tk.Widget, program_name: str) -> bool: + return messagebox.askyesno("Bestätigung", + f"Möchten Sie '{program_name}' wirklich entfernen?", + parent=parent) \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..787c127 --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,396 @@ +import tkinter as tk +from tkinter import ttk, messagebox +import subprocess +import sys +import os +import ctypes +from typing import Optional, List + +from config import Config +from data.data_manager import DataManager +from utils.icon_utils import IconManager +from gui.dialogs import ProgramDialog, OptionsDialog, CategoryDialog +from gui.modern_widgets import (ModernStyle, ModernCard, ModernButton, + ModernListbox, ModernTreeview, IconButton) +from models.program import Program + + +class MainWindow: + def __init__(self, root: tk.Tk): + self.root = root + self.data_manager = DataManager() + self.icon_manager = IconManager() + self.sort_column: Optional[str] = None + self.sort_reverse: bool = False + self.modern_style = ModernStyle() + + self._setup_window() + self._load_data() + self._create_widgets() + self._populate_program_list() + self._apply_options() + self._center_window() + + def _setup_window(self): + theme = Config.get_theme() + self.root.title(Config.APP_NAME) + self.root.geometry(f"{Config.DEFAULT_WINDOW_WIDTH}x{Config.DEFAULT_WINDOW_HEIGHT}") + self.root.minsize(Config.MIN_WINDOW_WIDTH, Config.MIN_WINDOW_HEIGHT) + self.root.configure(bg=theme["bg_primary"]) + + # Load icon if available + try: + self.root.iconbitmap(self.icon_manager.resource_path(Config.ICON_FILE)) + except: + pass + + def _load_data(self): + if not self.data_manager.load_data(): + messagebox.showwarning("Warnung", Config.MESSAGES["error_loading_data"].format("JSON-Fehler")) + + def _create_widgets(self): + self._configure_styles() + self._create_menubar() + self._create_program_list() + + def _configure_styles(self): + style = ttk.Style() + self.modern_style.configure_styles(style) + + def _create_menubar(self): + # Windows-style menu bar + self.menubar = tk.Menu(self.root) + self.root.config(menu=self.menubar) + + # Program menu + program_menu = tk.Menu(self.menubar, tearoff=0) + self.menubar.add_cascade(label="Programm", menu=program_menu) + program_menu.add_command(label="Hinzufügen...", command=self._add_program, accelerator="Ctrl+N") + program_menu.add_command(label="Bearbeiten...", command=self._edit_program, accelerator="F2") + program_menu.add_command(label="Entfernen", command=self._remove_program, accelerator="Del") + program_menu.add_separator() + program_menu.add_command(label="Starten", command=self._start_program, accelerator="Enter") + + # Category menu + self.category_menu = tk.Menu(self.menubar, tearoff=0) + self.menubar.add_cascade(label="Kategorie", menu=self.category_menu) + self._create_category_menu(self.category_menu) + + # Tools menu + tools_menu = tk.Menu(self.menubar, tearoff=0) + self.menubar.add_cascade(label="Extras", menu=tools_menu) + tools_menu.add_command(label="Optionen...", command=self._show_options) + + # Bind keyboard shortcuts + self.root.bind("", lambda e: self._add_program()) + self.root.bind("", lambda e: self._edit_program()) + self.root.bind("", lambda e: self._remove_program()) + self.root.bind("", lambda e: self._start_program()) + + def _create_category_menu(self, menu): + # Add categories to menu with radio buttons + self.category_var = tk.StringVar(value="Alle") + + menu.add_radiobutton(label="Alle", variable=self.category_var, + value="Alle", command=self._on_category_change) + + if self.data_manager.category_manager.categories: + menu.add_separator() + for category in sorted(self.data_manager.category_manager.categories): + menu.add_radiobutton(label=category, variable=self.category_var, + value=category, command=self._on_category_change) + + menu.add_separator() + menu.add_command(label="Neue Kategorie...", command=self._add_category) + menu.add_command(label="Kategorie löschen...", command=self._remove_category) + + def _on_category_change(self): + self._populate_program_list() + + def _update_category_menu(self): + # Clear and recreate category menu + self.category_menu.delete(0, 'end') + self._create_category_menu(self.category_menu) + + def _create_program_list(self): + # Simple program list taking full window + list_frame = ttk.Frame(self.root, style="Modern.TFrame") + list_frame.pack(fill=tk.BOTH, expand=True, padx=Config.SPACING["sm"], + pady=Config.SPACING["sm"]) + + # Scrollbar + scrollbar = ttk.Scrollbar(list_frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Program treeview + columns = ("name", "category") + show_option = "tree headings" if self.icon_manager.icon_support else "headings" + + self.program_tree = ModernTreeview(list_frame, yscrollcommand=scrollbar.set, + columns=columns, show=show_option) + + # Headers without emojis for clean look + self.program_tree.heading("name", text="Programm", + command=lambda: self._sort_treeview("name", False)) + self.program_tree.heading("category", text="Kategorie", + command=lambda: self._sort_treeview("category", False)) + + # Better column sizing + self.program_tree.column("name", width=400, anchor="w", minwidth=200) + self.program_tree.column("category", width=150, anchor="w", minwidth=80) + self.program_tree.column("#0", width=24, stretch=False, anchor="w") + + self.program_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.config(command=self.program_tree.yview) + + # Bindings + self.program_tree.bind("", lambda event: self._start_program()) + self.program_tree.bind("", lambda event: self._start_program()) + + + + + + def _populate_program_list(self): + # Clear existing items + for item in self.program_tree.get_children(): + self.program_tree.delete(item) + + # Get selected category from menu + selected_category = self.category_var.get() + + # Get programs for category + if selected_category == "Alle": + programs = self.data_manager.programs + else: + programs = self.data_manager.get_programs_by_category(selected_category) + + # Sort programs + sorted_programs = self._sort_programs(programs) + + # Add programs to tree + for program in sorted_programs: + self._add_program_to_tree(program) + + def _sort_programs(self, programs: List[Program]) -> List[Program]: + if self.sort_column == "name": + return sorted(programs, key=lambda x: x.name.lower(), reverse=self.sort_reverse) + elif self.sort_column == "category": + return sorted(programs, key=lambda x: (x.category or "").lower(), reverse=self.sort_reverse) + else: + # Default sort: category then name + return sorted(programs, key=lambda x: ((x.category or "").lower(), x.name.lower())) + + def _add_program_to_tree(self, program: Program): + program_name = Config.PROGRAM_NAME_PADDING + program.name + category = program.category or "" + + # Get icon if supported + icon = None + if self.icon_manager.icon_support: + icon = self.icon_manager.get_icon(program.path) + + # Add to tree + if self.icon_manager.icon_support and icon: + self.program_tree.insert("", tk.END, text="", + values=(program_name, category), + tags=(program.path,), image=icon) + else: + self.program_tree.insert("", tk.END, text="", + values=(program_name, category), + tags=(program.path,)) + + + def _sort_treeview(self, column: str, reverse: bool): + if self.sort_column == column: + reverse = not self.sort_reverse + + self.sort_column = column + self.sort_reverse = reverse + self._populate_program_list() + + def _get_selected_program(self) -> Optional[Program]: + selected_items = self.program_tree.selection() + if not selected_items: + return None + + item_id = selected_items[0] + program_values = self.program_tree.item(item_id, "values") + program_name = program_values[0].strip() + + # Find program in data + for program in self.data_manager.programs: + if program.name.strip() == program_name: + return program + return None + + def _start_program(self): + program = self._get_selected_program() + if not program: + messagebox.showinfo("Information", Config.MESSAGES["no_program_selected"]) + return + + try: + # Set working directory to the program's directory + program_dir = os.path.dirname(program.path) + + # Check if it's a batch file + is_batch_file = program.path.lower().endswith(('.bat', '.cmd')) + + if sys.platform == "win32" and program.requires_admin: + try: + # For admin programs, use ShellExecuteW with working directory + result = ctypes.windll.shell32.ShellExecuteW( + None, "runas", program.path, None, program_dir, 1) + if result <= 32: + # Fallback with working directory + if is_batch_file: + subprocess.Popen(['cmd', '/c', program.path], cwd=program_dir) + else: + subprocess.Popen(program.path, cwd=program_dir) + except Exception: + # Fallback with working directory + if is_batch_file: + subprocess.Popen(['cmd', '/c', program.path], cwd=program_dir) + else: + subprocess.Popen(program.path, cwd=program_dir) + else: + # Normal start with working directory + if is_batch_file: + # Batch files need to be run through cmd + subprocess.Popen(['cmd', '/c', program.path], cwd=program_dir) + else: + # Regular executable + subprocess.Popen(program.path, cwd=program_dir) + except Exception as e: + messagebox.showerror("Fehler", + Config.MESSAGES["error_starting_program"].format(str(e))) + + def _add_program(self): + dialog = ProgramDialog(self.root, "Programm hinzufügen", + self.data_manager.category_manager.categories) + program = dialog.show() + + if program: + if self.data_manager.add_program(program): + self._update_category_menu() + self._populate_program_list() + + def _edit_program(self): + program = self._get_selected_program() + if not program: + messagebox.showinfo("Information", + "Bitte wählen Sie ein Programm zum Bearbeiten aus.") + return + + dialog = ProgramDialog(self.root, "Programm bearbeiten", + self.data_manager.category_manager.categories, + program) + updated_program = dialog.show() + + if updated_program: + if self.data_manager.update_program(program.name, updated_program): + self._update_category_menu() + self._populate_program_list() + + def _remove_program(self): + program = self._get_selected_program() + if not program: + messagebox.showinfo("Information", + "Bitte wählen Sie ein Programm zum Entfernen aus.") + return + + if CategoryDialog.confirm_remove_program(self.root, program.name): + if self.data_manager.remove_program(program.name): + self._populate_program_list() + + def _add_category(self): + category_name = CategoryDialog.add_category(self.root) + if not category_name: + return + + if self.data_manager.category_manager.has_category(category_name): + messagebox.showwarning("Warnung", + Config.MESSAGES["category_exists"].format(category_name)) + return + + if self.data_manager.add_category(category_name): + self._update_category_menu() + self._populate_program_list() + + def _remove_category(self): + # Show list of categories to delete + categories = self.data_manager.category_manager.categories + if not categories: + messagebox.showinfo("Information", "Keine Kategorien zum Löschen vorhanden.") + return + + # Use custom dialog for category deletion + category = CategoryDialog.delete_category(self.root, list(categories)) + + if not category or category not in categories: + return + + if CategoryDialog.confirm_remove_category(self.root, category): + if self.data_manager.remove_category(category): + # Reset to "Alle" if deleted category was selected + if self.category_var.get() == category: + self.category_var.set("Alle") + self._update_category_menu() + self._populate_program_list() + + def _show_options(self): + dialog = OptionsDialog(self.root, self.data_manager.options) + result = dialog.show() + + if result: + self.sort_column = result["default_sort_column"] + self.sort_reverse = result["default_sort_reverse"] + + self.data_manager.update_options(**result) + self._apply_options() + self._populate_program_list() + + def _apply_options(self): + # Apply sort settings + if self.data_manager.options.get("default_sort_column"): + self.sort_column = self.data_manager.options["default_sort_column"] + self.sort_reverse = self.data_manager.options["default_sort_reverse"] + + # Apply category selection + if (self.data_manager.options.get("remember_last_category") and + self.data_manager.options.get("last_category")): + last_category = self.data_manager.options["last_category"] + # Check if category still exists + all_categories = ["Alle"] + self.data_manager.category_manager.categories + if last_category in all_categories: + self.category_var.set(last_category) + self._populate_program_list() + + # Apply window size + if (self.data_manager.options.get("window_width") and + self.data_manager.options.get("window_height")): + width = self.data_manager.options["window_width"] + height = self.data_manager.options["window_height"] + self.root.geometry(f"{width}x{height}") + + def _center_window(self): + self.root.update_idletasks() + width = self.root.winfo_width() + height = self.root.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + self.root.geometry(f"{width}x{height}+{x}+{y}") + + def on_closing(self): + # Save current state + self.data_manager.update_options( + window_width=self.root.winfo_width(), + window_height=self.root.winfo_height() + ) + + # Save last category if option is enabled + if self.data_manager.options.get("remember_last_category"): + self.data_manager.update_options(last_category=self.category_var.get()) + + self.root.destroy() \ No newline at end of file diff --git a/gui/modern_widgets.py b/gui/modern_widgets.py new file mode 100644 index 0000000..b4c2bf4 --- /dev/null +++ b/gui/modern_widgets.py @@ -0,0 +1,288 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Any +from config import Config + + +class ModernStyle: + """Manages modern styling for tkinter widgets""" + + def __init__(self): + self.theme = Config.get_theme() + self.fonts = Config.FONTS + self.spacing = Config.SPACING + + def configure_styles(self, style: ttk.Style): + """Configure modern ttk styles""" + theme = self.theme + + # Configure main frame + style.configure("Modern.TFrame", + background=theme["bg_primary"], + relief="flat") + + # Configure card frames + style.configure("Card.TFrame", + background=theme["bg_secondary"], + relief="flat", + borderwidth=1) + + # Configure modern buttons + style.configure("Modern.TButton", + background=theme["accent"], + foreground=theme["text_primary"] if "dark" in Config.CURRENT_THEME else "white", + font=self.fonts["button"], + borderwidth=0, + focuscolor="none", + relief="flat", + padding=(12, 8)) + + style.map("Modern.TButton", + background=[("active", theme["accent_hover"]), + ("pressed", theme["accent"])], + foreground=[("active", "white"), + ("pressed", "white")]) + + # Configure success buttons + style.configure("Success.TButton", + background=theme["success"], + foreground="white", + font=self.fonts["button"], + borderwidth=0, + focuscolor="none", + relief="flat", + padding=(12, 8)) + + style.map("Success.TButton", + background=[("active", theme["success_hover"]), + ("pressed", theme["success"])], + foreground=[("active", "white"), + ("pressed", "white")]) + + # Configure secondary buttons + style.configure("Secondary.TButton", + background=theme["bg_tertiary"], + foreground=theme["text_primary"], + font=self.fonts["body"], + borderwidth=1, + focuscolor="none", + relief="flat", + padding=(8, 6)) + + style.map("Secondary.TButton", + background=[("active", theme["border"]), + ("pressed", theme["bg_tertiary"])], + relief=[("pressed", "flat")]) + + # Configure icon buttons + style.configure("Icon.TButton", + background=theme["bg_tertiary"], + foreground=theme["text_primary"], + font=self.fonts["subheading"], + borderwidth=1, + focuscolor="none", + relief="flat", + padding=(8, 8)) + + style.map("Icon.TButton", + background=[("active", theme["border"]), + ("pressed", theme["bg_tertiary"])], + relief=[("pressed", "flat")]) + + # Configure labels + style.configure("Heading.TLabel", + background=theme["bg_primary"], + foreground=theme["text_primary"], + font=self.fonts["heading"]) + + style.configure("Subheading.TLabel", + background=theme["bg_primary"], + foreground=theme["text_secondary"], + font=self.fonts["subheading"]) + + style.configure("Body.TLabel", + background=theme["bg_primary"], + foreground=theme["text_secondary"], + font=self.fonts["body"]) + + style.configure("Caption.TLabel", + background=theme["bg_primary"], + foreground=theme["text_muted"], + font=self.fonts["caption"]) + + # Configure modern treeview + style.configure("Modern.Treeview", + background=theme["bg_secondary"], + foreground=theme["text_primary"], + fieldbackground=theme["bg_secondary"], + borderwidth=0, + relief="flat", + font=self.fonts["body"]) + + style.configure("Modern.Treeview.Heading", + background=theme["bg_tertiary"], + foreground=theme["text_primary"], + font=self.fonts["subheading"], + relief="flat", + borderwidth=0) + + style.map("Modern.Treeview", + background=[("selected", theme["selection"])], + foreground=[("selected", "white")]) + + style.map("Modern.Treeview.Heading", + background=[("active", theme["border"])]) + + # Configure modern listbox (via tk styling) + # Note: Listbox doesn't support ttk styling, will be handled in widget creation + + # Configure modern entry + style.configure("Modern.TEntry", + fieldbackground=theme["bg_secondary"], + foreground=theme["text_primary"], + borderwidth=1, + relief="flat", + insertcolor=theme["text_primary"], + font=self.fonts["body"]) + + style.map("Modern.TEntry", + focuscolor=[("focus", theme["accent"])], + bordercolor=[("focus", theme["accent"])]) + + # Configure modern combobox + style.configure("Modern.TCombobox", + fieldbackground=theme["bg_secondary"], + foreground=theme["text_primary"], + background=theme["bg_secondary"], + borderwidth=1, + relief="flat", + font=self.fonts["body"]) + + # Configure modern checkbutton + style.configure("Modern.TCheckbutton", + background=theme["bg_secondary"], + foreground=theme["text_primary"], + font=self.fonts["body"], + focuscolor="none") + + style.map("Modern.TCheckbutton", + background=[("active", theme["bg_secondary"]), + ("pressed", theme["bg_secondary"])]) + + +class ModernCard(ttk.Frame): + """A modern card-like container with shadow effect simulation""" + + def __init__(self, parent, padding=None, **kwargs): + super().__init__(parent, style="Card.TFrame", + padding=padding or Config.CARD_PADDING, **kwargs) + + +class ModernButton(ttk.Button): + """Enhanced button with modern styling and hover effects""" + + def __init__(self, parent, text="", style_type="primary", icon=None, + command=None, **kwargs): + + # Determine style based on type + if style_type == "primary": + style = "Modern.TButton" + elif style_type == "success": + style = "Success.TButton" + elif style_type == "secondary": + style = "Secondary.TButton" + else: + style = "Modern.TButton" + + super().__init__(parent, text=text, style=style, command=command, **kwargs) + + # Add hover effects + self.bind("", self._on_enter) + self.bind("", self._on_leave) + + def _on_enter(self, event): + """Handle mouse enter""" + self.configure(cursor="hand2") + + def _on_leave(self, event): + """Handle mouse leave""" + self.configure(cursor="") + + +class ModernListbox(tk.Listbox): + """Modern styled listbox with custom colors""" + + def __init__(self, parent, **kwargs): + theme = Config.get_theme() + + # Configure modern listbox appearance + defaults = { + "background": theme["bg_secondary"], + "foreground": theme["text_primary"], + "selectbackground": theme["selection"], + "selectforeground": "white", + "activestyle": "none", + "relief": "flat", + "borderwidth": 0, + "highlightthickness": 1, + "highlightcolor": theme["accent"], + "highlightbackground": theme["border"], + "font": Config.FONTS["body"] + } + + # Merge with user provided kwargs + defaults.update(kwargs) + + super().__init__(parent, **defaults) + + +class ModernTreeview(ttk.Treeview): + """Enhanced treeview with modern styling""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, style="Modern.Treeview", **kwargs) + + # Configure modern row height + style = ttk.Style() + style.configure("Modern.Treeview", rowheight=32) + + +class ModernScrollbar(ttk.Scrollbar): + """Modern styled scrollbar""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + # Configure modern scrollbar + style = ttk.Style() + theme = Config.get_theme() + + style.configure("Modern.Vertical.TScrollbar", + background=theme["bg_tertiary"], + troughcolor=theme["bg_primary"], + borderwidth=0, + arrowcolor=theme["text_muted"], + darkcolor=theme["bg_tertiary"], + lightcolor=theme["bg_tertiary"]) + + +class IconButton(ttk.Button): + """Button designed specifically for icons""" + + def __init__(self, parent, icon_text="", command=None, **kwargs): + super().__init__(parent, text=icon_text, style="Icon.TButton", + command=command, **kwargs) + + self.configure(width=3) + + # Add hover effects + self.bind("", self._on_enter) + self.bind("", self._on_leave) + + def _on_enter(self, event): + """Handle mouse enter""" + self.configure(cursor="hand2") + + def _on_leave(self, event): + """Handle mouse leave""" + self.configure(cursor="") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a7f78a9 --- /dev/null +++ b/main.py @@ -0,0 +1,32 @@ +import tkinter as tk +import sys +import os + +# Add current directory to Python path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gui.main_window import MainWindow +from utils.icon_utils import IconManager +from config import Config + + +def main(): + root = tk.Tk() + + # Set icon for main window + icon_manager = IconManager() + try: + root.iconbitmap(icon_manager.resource_path(Config.ICON_FILE)) + except: + pass + + app = MainWindow(root) + + # Handle window closing + root.protocol("WM_DELETE_WINDOW", app.on_closing) + + root.mainloop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/category.py b/models/category.py new file mode 100644 index 0000000..8af45bf --- /dev/null +++ b/models/category.py @@ -0,0 +1,31 @@ +from typing import List, Set + + +class CategoryManager: + def __init__(self): + self._categories: Set[str] = set() + + @property + def categories(self) -> List[str]: + return sorted(list(self._categories)) + + def add_category(self, category: str) -> bool: + if not category or category in self._categories: + return False + self._categories.add(category) + return True + + def remove_category(self, category: str) -> bool: + if category not in self._categories: + return False + self._categories.remove(category) + return True + + def has_category(self, category: str) -> bool: + return category in self._categories + + def load_from_data(self, categories: List[str]): + self._categories = set(categories) + + def to_list(self) -> List[str]: + return self.categories \ No newline at end of file diff --git a/models/program.py b/models/program.py new file mode 100644 index 0000000..bf5de7f --- /dev/null +++ b/models/program.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Program: + name: str + path: str + category: Optional[str] = None + requires_admin: bool = False + + def __post_init__(self): + if not self.name: + raise ValueError("Program name cannot be empty") + if not self.path: + raise ValueError("Program path cannot be empty") + + def to_dict(self) -> dict: + result = { + "Name": self.name, + "Pfad": self.path + } + if self.category: + result["Kategorie"] = self.category + if self.requires_admin: + result["Adminrechte"] = True + return result + + @classmethod + def from_dict(cls, data: dict) -> 'Program': + return cls( + name=data["Name"], + path=data["Pfad"], + category=data.get("Kategorie"), + requires_admin=data.get("Adminrechte", False) + ) + + def __str__(self) -> str: + return f"{self.name} ({self.category or 'Ohne Kategorie'})" \ No newline at end of file diff --git a/run.py b/run.py deleted file mode 100644 index 7248e4f..0000000 --- a/run.py +++ /dev/null @@ -1,1025 +0,0 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox, simpledialog -import json -import subprocess -import os -import sys - -# Versuche die Icon-Bibliotheken zu importieren -try: - import win32ui - import win32gui - import win32con - import win32api - from PIL import Image, ImageTk - ICON_SUPPORT = True -except ImportError: - # Wir können messagebox hier nicht verwenden, da es aus tkinter importiert wird - print("Warnung: Die Bibliotheken für Programm-Icons fehlen. Installieren Sie diese mit: pip install pywin32 Pillow") - ICON_SUPPORT = False - -def resource_path(relative_path): - """Get absolute path to resource, works for dev and for PyInstaller""" - try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - base_path = sys._MEIPASS - except Exception: - base_path = os.path.abspath(".") - return os.path.join(base_path, relative_path) - -def extract_icon_from_exe(exe_path): - """Extrahiert das Icon aus einer EXE-Datei""" - if not ICON_SUPPORT: - return None - - try: - # Icon-Handle erhalten - large, small = win32gui.ExtractIconEx(exe_path, 0) - - # Wir verwenden das größere Icon - if large: - # Wir nehmen das erste Icon - ico_x = win32api.GetSystemMetrics(win32con.SM_CXICON) - ico_y = win32api.GetSystemMetrics(win32con.SM_CYICON) - - # Icon in DC (Device Context) zeichnen - hdc = win32ui.CreateDCFromHandle(win32gui.GetDC(0)) - hbmp = win32ui.CreateBitmap() - hbmp.CreateCompatibleBitmap(hdc, ico_x, ico_y) - hdc = hdc.CreateCompatibleDC() - - hdc.SelectObject(hbmp) - hdc.DrawIcon((0, 0), large[0]) - - # Bitmap in ein Python-Image-Objekt umwandeln - bmpinfo = hbmp.GetInfo() - bmpstr = hbmp.GetBitmapBits(True) - img = Image.frombuffer( - 'RGBA', - (bmpinfo['bmWidth'], bmpinfo['bmHeight']), - bmpstr, 'raw', 'BGRA', 0, 1 - ) - - # Aufräumen - win32gui.DestroyIcon(large[0]) - for icon in small: - if icon: - win32gui.DestroyIcon(icon) - - # Bild auf die gewünschte Größe skalieren - img = img.resize((16, 16), Image.LANCZOS) - - # In PhotoImage umwandeln für Tkinter - return ImageTk.PhotoImage(img) - return None - except Exception as e: - print(f"Fehler beim Extrahieren des Icons: {str(e)}") - return None - -class ProgramLauncher: - def __init__(self, root): - self.root = root - self.root.title("Programm-Shortcut") - self.root.geometry("700x550") - self.root.minsize(600, 450) - self.root.configure(bg="#f0f0f0") - - # Lade das Symbol, falls vorhanden - try: - self.root.iconbitmap(resource_path("icon.ico")) - except: - pass - - # Initialisiere Standardwerte - self.programs = [] - self.categories = ["Spiele", "Werkzeuge", "Office", "Multimedia", "Internet"] - self.options = self.get_default_options() - self.sort_column = None - self.sort_reverse = False - - # Icon-Cache initialisieren - self.icon_cache = {} - - # Laden der Daten aus der JSON-Datei - self.json_file = 'data.json' - self.load_data() - - self.create_widgets() - self.populate_program_list() - - # Wende gespeicherte Optionen an - self.apply_options() - - # Zentriere das Fenster - self.center_window() - - def center_window(self): - self.root.update_idletasks() - width = self.root.winfo_width() - height = self.root.winfo_height() - x = (self.root.winfo_screenwidth() // 2) - (width // 2) - y = (self.root.winfo_screenheight() // 2) - (height // 2) - self.root.geometry(f"{width}x{height}+{x}+{y}") - - def load_data(self): - """Lädt Programme und Kategorien aus der JSON-Datei""" - try: - # Prüfen, ob die alte Datei existiert und umbenannt werden muss - if not os.path.exists(self.json_file) and os.path.exists('program_list.json'): - # Wandle altes in neues Format um und speichere es - with open('program_list.json', encoding='utf-8') as f: - old_data = json.load(f) - - # Wenn es sich um das alte Format handelt (nur Liste von Programmen) - if isinstance(old_data, list): - self.data = { - "categories": self.categories, - "programs": old_data, - "options": self.get_default_options() - } - # Kategorien aus Programmen extrahieren und mit Standardkategorien zusammenführen - for program in old_data: - if "Kategorie" in program and program["Kategorie"] not in self.data["categories"]: - self.data["categories"].append(program["Kategorie"]) - else: - # Wenn es bereits das neue Format hat, füge nur Optionen hinzu - old_data["options"] = self.get_default_options() - self.data = old_data - - # Speichere in neuer Datei - with open(self.json_file, 'w', encoding='utf-8') as f: - json.dump(self.data, f, indent=2, ensure_ascii=False) - elif not os.path.exists(self.json_file): - # Erstelle eine neue Datei mit leeren Werten und Standardoptionen - self.data = { - "categories": self.categories, - "programs": [], - "options": self.get_default_options() - } - self.save_data() - else: - # Datei laden - with open(self.json_file, encoding='utf-8') as f: - data = json.load(f) - - # Überprüfe, ob es sich um das alte Format handelt (ohne options) - if "options" not in data: - data["options"] = self.get_default_options() - - # Verwende das neue Format - self.data = data - - # Aktualisiere die Werte für den einfacheren Zugriff - self.programs = self.data["programs"] - self.categories = self.data["categories"] - self.options = self.data.get("options", self.get_default_options()) - - except (FileNotFoundError, json.JSONDecodeError) as e: - messagebox.showwarning("Warnung", f"Fehler beim Laden der Daten: {str(e)}\nEine neue Liste wird erstellt.") - self.data = { - "categories": self.categories, - "programs": [], - "options": self.get_default_options() - } - # Speichere die neue Liste - self.save_data() - - # Stelle sicher, dass die Variablen immer richtig gesetzt sind - self.programs = self.data["programs"] - self.categories = self.data["categories"] - self.options = self.data["options"] - - def get_default_options(self): - """Gibt Standardoptionen zurück""" - return { - "default_sort_column": None, - "default_sort_reverse": False, - "remember_last_category": False, - "last_category": "Alle", - "window_width": 700, - "window_height": 550 - } - - def apply_options(self): - """Wendet gespeicherte Optionen an""" - # Wenn eine Standardsortierung gespeichert ist, anwenden - if self.options.get("default_sort_column"): - self.sort_column = self.options["default_sort_column"] - self.sort_reverse = self.options["default_sort_reverse"] - self.populate_program_list() - - # Wenn die letzte Kategorie gespeichert werden soll, auswählen - if self.options.get("remember_last_category") and self.options.get("last_category"): - # Finde die Kategorie in der Listbox - for i in range(self.category_listbox.size()): - if self.category_listbox.get(i) == self.options["last_category"]: - self.category_listbox.selection_clear(0, tk.END) - self.category_listbox.selection_set(i) - self.on_category_select(None) - break - - # Fenstergröße anwenden, falls gespeichert - if self.options.get("window_width") and self.options.get("window_height"): - self.root.geometry(f"{self.options['window_width']}x{self.options['window_height']}") - - def save_data(self): - """Speichert Programme und Kategorien in der JSON-Datei""" - try: - # Aktuelle Optionen aktualisieren - if hasattr(self, 'sort_column'): - self.options["default_sort_column"] = self.sort_column - self.options["default_sort_reverse"] = self.sort_reverse - - # Fenstergröße speichern - self.options["window_width"] = self.root.winfo_width() - self.options["window_height"] = self.root.winfo_height() - - # Aktuelle Kategorie speichern, wenn Option aktiviert ist - if self.options.get("remember_last_category"): - try: - index = self.category_listbox.curselection()[0] - self.options["last_category"] = self.category_listbox.get(index) - except (IndexError, AttributeError): - pass - - # Stelle sicher, dass die Optionen im Hauptdatenobjekt aktualisiert werden - self.data["options"] = self.options - - # In Datei speichern - with open(self.json_file, 'w', encoding='utf-8') as f: - json.dump(self.data, f, indent=2, ensure_ascii=False) - - # Debug: Dateiinhalt überprüfen - # print(f"Saved options: {self.options}") - except Exception as e: - messagebox.showerror("Fehler", f"Fehler beim Speichern der Daten: {str(e)}") - - def create_widgets(self): - # Hauptframe - main_frame = ttk.Frame(self.root, padding="10") - main_frame.pack(fill=tk.BOTH, expand=True) - - # Stil konfigurieren - style = ttk.Style() - style.configure("TFrame", background="#f0f0f0") - style.configure("TButton", background="#4CAF50", foreground="black", padding=5) - style.configure("TLabel", background="#f0f0f0", foreground="#333333", font=("Segoe UI", 10)) - style.configure("Title.TLabel", background="#f0f0f0", foreground="#333333", font=("Segoe UI", 14, "bold")) - style.configure("Category.TLabel", background="#f0f0f0", foreground="#555555", font=("Segoe UI", 12, "bold")) - - # Titel - title_label = ttk.Label(main_frame, text="Programm-Shortcut", style="Title.TLabel") - title_label.pack(pady=10) - - # Erklärungstext - help_text = ttk.Label(main_frame, text="Wählen Sie ein Programm zum Starten oder fügen Sie ein neues hinzu") - help_text.pack(pady=5) - - # Horizontaler Frame für Kategorieauswahl und Programmliste - h_frame = ttk.Frame(main_frame) - h_frame.pack(fill=tk.BOTH, expand=True, pady=10) - - # Linker Frame für Kategorien (vertikal ausgerichtet) - category_frame = ttk.Frame(h_frame, padding="5") - category_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) - - # Container für Kategorien-Bereich als vertikalen Stack - category_content = ttk.Frame(category_frame) - category_content.pack(fill=tk.BOTH, expand=True) - - # Kategorie-Label - cat_label = ttk.Label(category_content, text="Kategorien", style="Category.TLabel") - cat_label.pack(pady=(0, 5), anchor="w") - - # Container für Listbox und Scrollbar - list_container = ttk.Frame(category_content) - list_container.pack(fill=tk.BOTH, expand=True) - - # Scrollbar für Kategorieliste - cat_scrollbar = ttk.Scrollbar(list_container) - cat_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Listbox für Kategorien - self.category_listbox = tk.Listbox(list_container, height=15, width=15, - yscrollcommand=cat_scrollbar.set, - font=("Segoe UI", 10), - selectbackground="#4CAF50", - activestyle="none", - exportselection=0) - self.category_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - cat_scrollbar.config(command=self.category_listbox.yview) - - # Füllen der Kategorie-Listbox - for category in sorted(self.categories): - self.category_listbox.insert(tk.END, category) - - # Standardmäßig "Alle" auswählen - self.category_listbox.insert(0, "Alle") - self.category_listbox.selection_set(0) - self.category_listbox.bind("<>", self.on_category_select) - - # Kategorie-Buttons-Container erstellen (unterhalb der Listbox) - button_container = ttk.Frame(category_content) - button_container.pack(fill=tk.X, pady=10) - - # Container für die Buttons, damit sie zentriert werden können - buttons_center = ttk.Frame(button_container) - buttons_center.pack(anchor=tk.CENTER) - - # Die Buttons nebeneinander anordnen - self.add_cat_button = ttk.Button(buttons_center, text="+", width=3, command=self.add_category) - self.add_cat_button.pack(side=tk.LEFT, padx=5) - - self.remove_cat_button = ttk.Button(buttons_center, text="-", width=3, command=self.remove_category) - self.remove_cat_button.pack(side=tk.LEFT, padx=5) - - # Rechter Frame für Programme - program_frame = ttk.Frame(h_frame) - program_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # Frame für Programmliste und Scrollen - list_frame = ttk.Frame(program_frame) - list_frame.pack(fill=tk.BOTH, expand=True) - - # Scrollbar für die Treeview - scrollbar = ttk.Scrollbar(list_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Anzeige von Icons aktivieren und Platz dafür schaffen - style.configure("Treeview", rowheight=24) # Mehr Platz für Icons - - # Statt ein Zellenpadding zu verwenden, verwenden wir einen zusätzlichen Abstand im Text - style.configure("Treeview.Cell", padding=(0, 0)) - - # Treeview für Programme - # Wenn Icon-Unterstützung aktiviert ist, verwenden wir ein anderes Format - if ICON_SUPPORT: - self.program_tree = ttk.Treeview(list_frame, yscrollcommand=scrollbar.set, - columns=("name", "category"), - show="tree headings") - else: - self.program_tree = ttk.Treeview(list_frame, yscrollcommand=scrollbar.set, - columns=("name", "category"), - show="headings") - - # Setze Spaltenüberschriften mit Sortierfunktion - self.program_tree.heading("name", text="Programm", command=lambda: self.sort_treeview("name", False)) - self.program_tree.heading("category", text="Kategorie", command=lambda: self.sort_treeview("category", False)) - - # Konfiguriere Spaltenbreiten und Ausrichtung - self.program_tree.column("name", width=220, anchor="w", minwidth=150) - self.program_tree.column("category", width=100, anchor="w") - self.program_tree.column("#0", width=30, stretch=False, anchor="w") # Icon-Spalte - - self.program_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - scrollbar.config(command=self.program_tree.yview) - - # Frame für Buttons - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X, pady=10) - - # Buttons - self.start_button = ttk.Button(button_frame, text="Starten", command=self.start_program) - self.start_button.pack(side=tk.LEFT, padx=5) - - self.add_button = ttk.Button(button_frame, text="Hinzufügen", command=self.add_program) - self.add_button.pack(side=tk.LEFT, padx=5) - - self.remove_button = ttk.Button(button_frame, text="Entfernen", command=self.remove_program) - self.remove_button.pack(side=tk.LEFT, padx=5) - - self.edit_button = ttk.Button(button_frame, text="Bearbeiten", command=self.edit_program) - self.edit_button.pack(side=tk.LEFT, padx=5) - - self.options_button = ttk.Button(button_frame, text="Optionen", command=self.show_options_dialog) - self.options_button.pack(side=tk.RIGHT, padx=5) - - # Doppelklick auf einen Eintrag - self.program_tree.bind("", lambda event: self.start_program()) - - # Sortierungszustand - self.sort_column = self.options.get("default_sort_column") - self.sort_reverse = self.options.get("default_sort_reverse", False) - - def show_options_dialog(self): - """Zeigt einen Dialog mit Programmoptionen an""" - options_window = tk.Toplevel(self.root) - options_window.title("Programmoptionen") - options_window.geometry("450x350") - options_window.resizable(False, False) - options_window.grab_set() # Modal machen - - # Zentriere das Dialogfenster - options_window.update_idletasks() - width = options_window.winfo_width() - height = options_window.winfo_height() - x = (options_window.winfo_screenwidth() // 2) - (width // 2) - y = (options_window.winfo_screenheight() // 2) - (height // 2) - options_window.geometry(f"{width}x{height}+{x}+{y}") - - # Frame - frame = ttk.Frame(options_window, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - # Überschrift - ttk.Label(frame, text="Programmoptionen", font=("Segoe UI", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 15)) - - # Option für Standardsortierung - ttk.Label(frame, text="Standardsortierung:").grid(row=1, column=0, sticky="w", pady=5) - sort_var = tk.StringVar(value=self.get_sort_option_text()) - sort_combobox = ttk.Combobox(frame, textvariable=sort_var, values=[ - "Keine", - "Programm (A-Z)", - "Programm (Z-A)", - "Kategorie (A-Z)", - "Kategorie (Z-A)" - ]) - sort_combobox.grid(row=1, column=1, sticky="we", pady=5) - sort_combobox.configure(state="readonly") - - # Option zum Speichern der letzten Kategorie - remember_category_var = tk.BooleanVar(value=self.options.get("remember_last_category", False)) - remember_category_check = ttk.Checkbutton( - frame, - text="Letzte ausgewählte Kategorie merken", - variable=remember_category_var - ) - remember_category_check.grid(row=2, column=0, columnspan=2, sticky="w", pady=5) - - # Trennlinie - separator = ttk.Separator(frame, orient="horizontal") - separator.grid(row=3, column=0, columnspan=2, sticky="ew", pady=10) - - # Info-Bereich - info_frame = ttk.Frame(frame) - info_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=5) - - # Version - version_label = ttk.Label(info_frame, text="Version: 1.1", font=("Segoe UI", 9)) - version_label.pack(anchor="w") - - # Copyright & Autor Info - author_label = ttk.Label(info_frame, text="© 2025 Akamaru", font=("Segoe UI", 9)) - author_label.pack(anchor="w") - - created_label = ttk.Label(info_frame, text="Erstellt mit Hilfe von Claude", font=("Segoe UI", 9)) - created_label.pack(anchor="w") - - # Link zum Quellcode mit Unterstreichung - link_style = ttk.Style() - link_style.configure("Link.TLabel", foreground="blue", font=("Segoe UI", 9, "underline")) - - link_label = ttk.Label(info_frame, text="Quellcode auf git.ponywave.de verfügbar", - style="Link.TLabel", cursor="hand2") - link_label.pack(anchor="w", pady=(5, 0)) - link_label.bind("", lambda e: self.open_url("https://git.ponywave.de/Akamaru/Programm-Shortcut")) - - # Buttons - button_frame = ttk.Frame(frame) - button_frame.grid(row=5, column=0, columnspan=2, pady=20) - - save_button = ttk.Button( - button_frame, - text="Speichern", - command=lambda: self.save_options( - sort_combobox.get(), - remember_category_var.get(), - options_window - ) - ) - save_button.pack(side=tk.LEFT, padx=5) - - cancel_button = ttk.Button(button_frame, text="Abbrechen", command=options_window.destroy) - cancel_button.pack(side=tk.LEFT, padx=5) - - def get_sort_option_text(self): - """Gibt den Text für die aktuelle Sortierungsoption zurück""" - if self.sort_column == "name": - if self.sort_reverse: - return "Programm (Z-A)" - else: - return "Programm (A-Z)" - elif self.sort_column == "category": - if self.sort_reverse: - return "Kategorie (Z-A)" - else: - return "Kategorie (A-Z)" - else: - return "Keine" - - def save_options(self, sort_option, remember_category, window): - """Speichert die Programmoptionen""" - # Sortieroptionen übersetzen - if sort_option == "Programm (A-Z)": - self.options["default_sort_column"] = "name" - self.options["default_sort_reverse"] = False - self.sort_column = "name" - self.sort_reverse = False - elif sort_option == "Programm (Z-A)": - self.options["default_sort_column"] = "name" - self.options["default_sort_reverse"] = True - self.sort_column = "name" - self.sort_reverse = True - elif sort_option == "Kategorie (A-Z)": - self.options["default_sort_column"] = "category" - self.options["default_sort_reverse"] = False - self.sort_column = "category" - self.sort_reverse = False - elif sort_option == "Kategorie (Z-A)": - self.options["default_sort_column"] = "category" - self.options["default_sort_reverse"] = True - self.sort_column = "category" - self.sort_reverse = True - else: # "Keine" - self.options["default_sort_column"] = None - self.options["default_sort_reverse"] = False - self.sort_column = None - self.sort_reverse = False - - # Kategorie-Speicher-Option setzen - self.options["remember_last_category"] = remember_category - - # Aktuelle Kategorie speichern, wenn Option aktiviert ist - if remember_category: - try: - index = self.category_listbox.curselection()[0] - self.options["last_category"] = self.category_listbox.get(index) - except (IndexError, AttributeError): - pass - - # Stelle sicher, dass die Optionen im Hauptdatenobjekt aktualisiert werden - self.data["options"] = self.options - - # Änderungen speichern - self.save_data() - - # Liste neu sortieren - self.populate_program_list() - - # Fenster schließen - window.destroy() - - def open_url(self, url): - """Öffnet eine URL im Standard-Browser""" - try: - import webbrowser - webbrowser.open(url) - except Exception as e: - messagebox.showerror("Fehler", f"Fehler beim Öffnen der URL: {str(e)}") - - def on_category_select(self, event): - # Aktualisiere die Programmliste basierend auf der ausgewählten Kategorie - self.populate_program_list() - - def add_category(self): - """Fügt eine neue Kategorie hinzu""" - # Dialog für neue Kategorie - category_name = simpledialog.askstring("Neue Kategorie", "Geben Sie den Namen der neuen Kategorie ein:") - if not category_name: - return - - # Prüfe, ob die Kategorie bereits existiert - if category_name in self.categories: - messagebox.showwarning("Warnung", f"Die Kategorie '{category_name}' existiert bereits.") - return - - # Füge die Kategorie zur Liste hinzu - self.categories.append(category_name) - - # Aktualisiere die Kategorie-Listbox - self.category_listbox.delete(1, tk.END) # Lösche alle außer "Alle" - for category in sorted(self.categories): - self.category_listbox.insert(tk.END, category) - - # Speichere die aktualisierte Kategorienliste - self.save_data() - - def remove_category(self): - """Entfernt eine Kategorie""" - # Hole die ausgewählte Kategorie - try: - index = self.category_listbox.curselection()[0] - category = self.category_listbox.get(index) - except IndexError: - messagebox.showinfo("Information", "Bitte wählen Sie eine Kategorie zum Entfernen aus.") - return - - # "Alle" kann nicht entfernt werden - if category == "Alle": - messagebox.showinfo("Information", "Die Kategorie 'Alle' kann nicht entfernt werden.") - return - - # Bestätigung einholen - confirm = messagebox.askyesno("Bestätigung", - f"Möchten Sie die Kategorie '{category}' wirklich entfernen?\n\n" - "Alle Programme in dieser Kategorie werden ohne Kategorie gespeichert.") - if not confirm: - return - - # Programme ohne Kategorie speichern - for program in self.programs: - if program.get("Kategorie") == category: - if "Kategorie" in program: - del program["Kategorie"] - - # Entferne die Kategorie aus der Liste - self.categories.remove(category) - - # Aktualisiere die Kategorie-Listbox - self.category_listbox.delete(1, tk.END) # Lösche alle außer "Alle" - for category in sorted(self.categories): - self.category_listbox.insert(tk.END, category) - - # Wähle "Alle" aus - self.category_listbox.selection_set(0) - - # Speichere die Änderungen - self.save_data() - self.populate_program_list() - - def populate_program_list(self): - # Lösche alle bestehenden Einträge - for item in self.program_tree.get_children(): - self.program_tree.delete(item) - - # Hole die ausgewählte Kategorie - try: - index = self.category_listbox.curselection()[0] - selected_category = self.category_listbox.get(index) - except IndexError: - selected_category = "Alle" - - # Sortiere Programme nach Kategorie und Name - def get_category(program): - return program.get("Kategorie", "").lower() if "Kategorie" in program else "" - - # Nach dem aktuellen Sortierzustand sortieren - if self.sort_column == "name": - # Nach Programmname sortieren (Groß-/Kleinschreibung ignorieren) - sorted_programs = sorted(self.programs, key=lambda x: x["Name"].lower(), reverse=self.sort_reverse) - elif self.sort_column == "category": - # Nach Kategorie sortieren (Groß-/Kleinschreibung ignorieren) - sorted_programs = sorted(self.programs, key=lambda x: get_category(x), reverse=self.sort_reverse) - else: - # Standardsortierung: Kategorie dann Name (Groß-/Kleinschreibung ignorieren) - sorted_programs = sorted(self.programs, key=lambda x: (get_category(x), x["Name"].lower())) - - # Füge Programme hinzu - for program in sorted_programs: - category = program.get("Kategorie", "") if "Kategorie" in program else "" - - # Filtere nach Kategorie, wenn nicht "Alle" ausgewählt ist - if selected_category != "Alle" and category != selected_category: - continue - - program_path = program["Pfad"] - program_name = " " + program["Name"] # Fester Abstand mit 3 Leerzeichen - - # Hole das Icon nur, wenn Icon-Unterstützung aktiviert ist - icon = None - if ICON_SUPPORT: - # Hole das Icon, wenn es noch nicht im Cache ist - if program_path in self.icon_cache: - icon = self.icon_cache[program_path] - else: - # Versuche, das Icon zu extrahieren - icon = extract_icon_from_exe(program_path) - # Speichere im Cache für zukünftige Verwendung - self.icon_cache[program_path] = icon - - # Füge Programm hinzu - if ICON_SUPPORT and icon: - # Mit Icon in der #0 Spalte - item_id = self.program_tree.insert("", tk.END, text="", values=(program_name, category), - tags=(program_path,), image=icon) - else: - # Ohne Icon, normaler Eintrag - item_id = self.program_tree.insert("", tk.END, text="", values=(program_name, category), - tags=(program_path,)) - - def start_program(self): - selected_items = self.program_tree.selection() - if not selected_items: - messagebox.showinfo("Information", "Bitte wählen Sie ein Programm aus.") - return - - item_id = selected_items[0] - program_values = self.program_tree.item(item_id, "values") - program_name = program_values[0].strip() # Leerzeichen am Anfang entfernen - program_path = self.program_tree.item(item_id, "tags")[0] - - # Prüfen, ob das Programm Adminrechte benötigt - requires_admin = False - for program in self.programs: - if program["Name"].strip() == program_name.strip(): - requires_admin = program.get("Adminrechte", False) - break - - try: - if sys.platform == "win32" and requires_admin: - try: - # Direkter Aufruf über ShellExecute API mit erhöhten Rechten - import ctypes - # Der Parameter "runas" fordert erhöhte Rechte an - result = ctypes.windll.shell32.ShellExecuteW(None, "runas", program_path, None, None, 1) - # Wenn der Rückgabewert <= 32 ist, gab es einen Fehler - if result <= 32: - # Fallback: Starte normal ohne erhöhte Rechte - subprocess.Popen(program_path) - except Exception: - # Wenn es fehlschlägt, starte normal - subprocess.Popen(program_path) - else: - # Normaler Start ohne Adminrechte - subprocess.Popen(program_path) - except Exception as e: - messagebox.showerror("Fehler", f"Fehler beim Starten des Programms: {str(e)}") - - def add_program(self): - add_window = tk.Toplevel(self.root) - add_window.title("Programm hinzufügen") - add_window.geometry("450x280") # Etwas mehr Platz für die Checkbox - add_window.resizable(False, False) - add_window.grab_set() # Modal machen - - # Zentriere das Dialogfenster - add_window.update_idletasks() - width = add_window.winfo_width() - height = add_window.winfo_height() - x = (add_window.winfo_screenwidth() // 2) - (width // 2) - y = (add_window.winfo_screenheight() // 2) - (height // 2) - add_window.geometry(f"{width}x{height}+{x}+{y}") - - # Frame - frame = ttk.Frame(add_window, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - # Eingabefelder - ttk.Label(frame, text="Programmname:").grid(row=0, column=0, sticky="w", pady=5) - name_entry = ttk.Entry(frame, width=30) - name_entry.grid(row=0, column=1, columnspan=2, sticky="we", pady=5) - - ttk.Label(frame, text="Programmpfad:").grid(row=1, column=0, sticky="w", pady=5) - path_entry = ttk.Entry(frame, width=30) - path_entry.grid(row=1, column=1, sticky="we", pady=5) - - browse_button = ttk.Button(frame, text="Durchsuchen", - command=lambda: self.browse_file_and_clear(path_entry)) - browse_button.grid(row=1, column=2, padx=5, pady=5) - - ttk.Label(frame, text="Kategorie:").grid(row=2, column=0, sticky="w", pady=5) - - # Combobox für Kategorien - category_combobox = ttk.Combobox(frame, width=28, values=sorted(self.categories)) - category_combobox.grid(row=2, column=1, columnspan=2, sticky="we", pady=5) - if self.categories: - category_combobox.current(0) # Erste Kategorie auswählen, falls vorhanden - - # Checkbox für Adminrechte - admin_var = tk.BooleanVar(value=False) - admin_check = ttk.Checkbutton(frame, text="Benötigt Administratorrechte", variable=admin_var) - admin_check.grid(row=3, column=0, columnspan=3, sticky="w", pady=5) - - # Buttons - button_frame = ttk.Frame(frame) - button_frame.grid(row=4, column=0, columnspan=3, pady=15) - - save_button = ttk.Button(button_frame, text="Speichern", - command=lambda: self.save_new_program( - name_entry.get(), - path_entry.get(), - category_combobox.get(), - admin_var.get(), - add_window)) - save_button.pack(side=tk.LEFT, padx=5) - - cancel_button = ttk.Button(button_frame, text="Abbrechen", command=add_window.destroy) - cancel_button.pack(side=tk.LEFT, padx=5) - - # Focus auf erstes Eingabefeld - name_entry.focus_set() - - def browse_file_and_clear(self, entry_widget): - """Durchsucht nach einer Datei und löscht vorher den Inhalt des Eingabefelds""" - entry_widget.delete(0, tk.END) - filename = filedialog.askopenfilename(filetypes=[("Ausführbare Dateien", "*.exe"), ("Alle Dateien", "*.*")]) - if filename: - entry_widget.insert(0, filename) - - def save_new_program(self, name, path, category, requires_admin, window): - if not name or not path: - messagebox.showwarning("Warnung", "Bitte füllen Sie alle Felder aus.") - return - - if not os.path.exists(path): - messagebox.showwarning("Warnung", "Der angegebene Pfad existiert nicht.") - return - - # Neues Programm hinzufügen (nur mit Kategorie, wenn eine ausgewählt wurde) - new_program = {"Name": name, "Pfad": path} - if category: - new_program["Kategorie"] = category - - # Füge Kategorie hinzu, falls sie noch nicht existiert - if category not in self.categories: - self.categories.append(category) - - # Aktualisiere die Kategorie-Listbox - self.category_listbox.delete(1, tk.END) # Lösche alle außer "Alle" - for cat in sorted(self.categories): - self.category_listbox.insert(tk.END, cat) - - # Adminrechte-Flag hinzufügen - if requires_admin: - new_program["Adminrechte"] = True - - self.programs.append(new_program) - self.save_data() - self.populate_program_list() - window.destroy() - - def remove_program(self): - selected_items = self.program_tree.selection() - if not selected_items: - messagebox.showinfo("Information", "Bitte wählen Sie ein Programm zum Entfernen aus.") - return - - item_id = selected_items[0] - program_values = self.program_tree.item(item_id, "values") - program_name = program_values[0].strip() # Leerzeichen am Anfang entfernen - - # Bestätigung - confirm = messagebox.askyesno("Bestätigung", f"Möchten Sie '{program_name}' wirklich entfernen?") - if not confirm: - return - - # Finde den Index des zu entfernenden Programms - found = False - for i, program in enumerate(self.programs): - if program["Name"] == program_name: - del self.programs[i] - found = True - break - - # Wenn kein exakter Treffer gefunden wurde, versuche es ohne Leerzeichen - if not found: - for i, program in enumerate(self.programs): - if program["Name"].strip() == program_name: - del self.programs[i] - break - - self.save_data() - self.populate_program_list() - - def edit_program(self): - selected_items = self.program_tree.selection() - if not selected_items: - messagebox.showinfo("Information", "Bitte wählen Sie ein Programm zum Bearbeiten aus.") - return - - item_id = selected_items[0] - program_values = self.program_tree.item(item_id, "values") - program_name = program_values[0].strip() # Leerzeichen am Anfang entfernen - program_category = program_values[1] if len(program_values) > 1 else "" - program_path = self.program_tree.item(item_id, "tags")[0] - - # Finde den Index des zu bearbeitenden Programms - program_index = None - for i, program in enumerate(self.programs): - if program["Name"] == program_name.strip(): # Vergleich ohne führende Leerzeichen - program_index = i - break - - if program_index is None: - # Wenn kein exakter Treffer gefunden wird, versuche ohne Leerzeichen zu vergleichen - for i, program in enumerate(self.programs): - if program["Name"].strip() == program_name.strip(): # Beide Namen ohne Leerzeichen vergleichen - program_index = i - break - - if program_index is None: - messagebox.showinfo("Information", f"Programm '{program_name}' konnte nicht gefunden werden.") - return - - # Bearbeitungsfenster - edit_window = tk.Toplevel(self.root) - edit_window.title("Programm bearbeiten") - edit_window.geometry("450x280") # Etwas mehr Platz für die Checkbox - edit_window.resizable(False, False) - edit_window.grab_set() # Modal machen - - # Zentriere das Dialogfenster - edit_window.update_idletasks() - width = edit_window.winfo_width() - height = edit_window.winfo_height() - x = (edit_window.winfo_screenwidth() // 2) - (width // 2) - y = (edit_window.winfo_screenheight() // 2) - (height // 2) - edit_window.geometry(f"{width}x{height}+{x}+{y}") - - # Frame - frame = ttk.Frame(edit_window, padding="20") - frame.pack(fill=tk.BOTH, expand=True) - - # Eingabefelder - ttk.Label(frame, text="Programmname:").grid(row=0, column=0, sticky="w", pady=5) - name_entry = ttk.Entry(frame, width=30) - name_entry.insert(0, program_name.strip()) # Original-Programmnamen ohne Leerzeichen einfügen - name_entry.grid(row=0, column=1, columnspan=2, sticky="we", pady=5) - - ttk.Label(frame, text="Programmpfad:").grid(row=1, column=0, sticky="w", pady=5) - path_entry = ttk.Entry(frame, width=30) - path_entry.insert(0, program_path) - path_entry.grid(row=1, column=1, sticky="we", pady=5) - - browse_button = ttk.Button(frame, text="Durchsuchen", - command=lambda: self.browse_file(path_entry)) - browse_button.grid(row=1, column=2, padx=5, pady=5) - - ttk.Label(frame, text="Kategorie:").grid(row=2, column=0, sticky="w", pady=5) - - # Combobox für Kategorien - category_combobox = ttk.Combobox(frame, width=28, values=sorted(self.categories)) - category_combobox.grid(row=2, column=1, columnspan=2, sticky="we", pady=5) - if program_category: - category_combobox.set(program_category) - elif self.categories: - category_combobox.current(0) # Erste Kategorie auswählen, falls vorhanden - - # Checkbox für Adminrechte - admin_var = tk.BooleanVar(value=self.programs[program_index].get("Adminrechte", False)) - admin_check = ttk.Checkbutton(frame, text="Benötigt Administratorrechte", variable=admin_var) - admin_check.grid(row=3, column=0, columnspan=3, sticky="w", pady=5) - - # Buttons - button_frame = ttk.Frame(frame) - button_frame.grid(row=4, column=0, columnspan=3, pady=15) - - save_button = ttk.Button(button_frame, text="Speichern", - command=lambda: self.update_program( - program_index, - name_entry.get(), - path_entry.get(), - category_combobox.get(), - admin_var.get(), - edit_window)) - save_button.pack(side=tk.LEFT, padx=5) - - cancel_button = ttk.Button(button_frame, text="Abbrechen", command=edit_window.destroy) - cancel_button.pack(side=tk.LEFT, padx=5) - - def browse_file(self, entry_widget): - filename = filedialog.askopenfilename(filetypes=[("Ausführbare Dateien", "*.exe"), ("Alle Dateien", "*.*")]) - if filename: - entry_widget.delete(0, tk.END) - entry_widget.insert(0, filename) - - def update_program(self, index, name, path, category, requires_admin, window): - if not name or not path: - messagebox.showwarning("Warnung", "Bitte füllen Sie alle Felder aus.") - return - - if not os.path.exists(path): - messagebox.showwarning("Warnung", "Der angegebene Pfad existiert nicht.") - return - - # Programm aktualisieren (nur mit Kategorie, wenn eine ausgewählt wurde) - updated_program = {"Name": name, "Pfad": path} - if category: - updated_program["Kategorie"] = category - - # Füge Kategorie hinzu, falls sie noch nicht existiert - if category not in self.categories: - self.categories.append(category) - - # Aktualisiere die Kategorie-Listbox - self.category_listbox.delete(1, tk.END) # Lösche alle außer "Alle" - for cat in sorted(self.categories): - self.category_listbox.insert(tk.END, cat) - - # Adminrechte-Flag hinzufügen - if requires_admin: - updated_program["Adminrechte"] = True - - self.programs[index] = updated_program - self.save_data() - self.populate_program_list() - window.destroy() - - def sort_treeview(self, column, reverse): - """Sortiert die Treeview nach der angegebenen Spalte""" - if self.sort_column == column: - # Wenn dieselbe Spalte erneut geklickt wird, umkehren - reverse = not self.sort_reverse - - # Speichern des aktuellen Sortierzustands - self.sort_column = column - self.sort_reverse = reverse - - # Aktualisiere die Programmliste mit dem neuen Sortierzustand - self.populate_program_list() - -if __name__ == "__main__": - root = tk.Tk() - app = ProgramLauncher(root) -root.mainloop() \ No newline at end of file diff --git a/screenshot.png b/screenshot.png index d47f2eaad2135c38b65b3359fd030538ea8554de..cbbfc811428f0589e00917553039e5421469666b 100644 GIT binary patch literal 31464 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 F`5zUs=qUgI diff --git a/start.bat b/start.bat index 9daf64a..9c2c326 100644 --- a/start.bat +++ b/start.bat @@ -1,2 +1,2 @@ @echo off -start "" /b pythonw.exe run.py \ No newline at end of file +start "" /b pythonw.exe main.py \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/icon_utils.py b/utils/icon_utils.py new file mode 100644 index 0000000..5ea25b3 --- /dev/null +++ b/utils/icon_utils.py @@ -0,0 +1,94 @@ +import sys +import os +from typing import Optional, Dict, Any + +try: + import win32ui + import win32gui + import win32con + import win32api + from PIL import Image, ImageTk + ICON_SUPPORT = True +except ImportError: + print("Warnung: Die Bibliotheken für Programm-Icons fehlen. Installieren Sie diese mit: pip install pywin32 Pillow") + ICON_SUPPORT = False + + +class IconManager: + def __init__(self): + self.icon_cache: Dict[str, Any] = {} + self.icon_support = ICON_SUPPORT + + def resource_path(self, relative_path: str) -> str: + """Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + def extract_icon_from_exe(self, exe_path: str) -> Optional[Any]: + """Extrahiert das Icon aus einer EXE-Datei""" + if not self.icon_support: + return None + + try: + # Icon-Handle erhalten + large, small = win32gui.ExtractIconEx(exe_path, 0) + + # Wir verwenden das größere Icon + if large: + # Wir nehmen das erste Icon + ico_x = win32api.GetSystemMetrics(win32con.SM_CXICON) + ico_y = win32api.GetSystemMetrics(win32con.SM_CYICON) + + # Icon in DC (Device Context) zeichnen + hdc = win32ui.CreateDCFromHandle(win32gui.GetDC(0)) + hbmp = win32ui.CreateBitmap() + hbmp.CreateCompatibleBitmap(hdc, ico_x, ico_y) + hdc = hdc.CreateCompatibleDC() + + hdc.SelectObject(hbmp) + hdc.DrawIcon((0, 0), large[0]) + + # Bitmap in ein Python-Image-Objekt umwandeln + bmpinfo = hbmp.GetInfo() + bmpstr = hbmp.GetBitmapBits(True) + img = Image.frombuffer( + 'RGBA', + (bmpinfo['bmWidth'], bmpinfo['bmHeight']), + bmpstr, 'raw', 'BGRA', 0, 1 + ) + + # Aufräumen + win32gui.DestroyIcon(large[0]) + for icon in small: + if icon: + win32gui.DestroyIcon(icon) + + # Bild auf die gewünschte Größe skalieren + img = img.resize((16, 16), Image.LANCZOS) + + # In PhotoImage umwandeln für Tkinter + return ImageTk.PhotoImage(img) + return None + except Exception as e: + print(f"Fehler beim Extrahieren des Icons: {str(e)}") + return None + + def get_icon(self, program_path: str) -> Optional[Any]: + """Holt ein Icon aus dem Cache oder extrahiert es neu""" + if not self.icon_support: + return None + + if program_path in self.icon_cache: + return self.icon_cache[program_path] + + icon = self.extract_icon_from_exe(program_path) + self.icon_cache[program_path] = icon + return icon + + def clear_cache(self): + """Leert den Icon-Cache""" + self.icon_cache.clear() \ No newline at end of file