diff --git a/__pycache__/common_tools.cpython-312.pyc b/__pycache__/common_tools.cpython-312.pyc new file mode 100644 index 0000000..7049ed3 Binary files /dev/null and b/__pycache__/common_tools.cpython-312.pyc differ diff --git a/__pycache__/custom_file_dialog.cpython-312.pyc b/__pycache__/custom_file_dialog.cpython-312.pyc index 2bb42b2..9b3533a 100644 Binary files a/__pycache__/custom_file_dialog.cpython-312.pyc and b/__pycache__/custom_file_dialog.cpython-312.pyc differ diff --git a/common_tools.py b/common_tools.py new file mode 100755 index 0000000..a14e1e3 --- /dev/null +++ b/common_tools.py @@ -0,0 +1,621 @@ + +""" Classes Method and Functions for lx Apps """ + +import logging +import signal +import base64 +from subprocess import CompletedProcess, run +import re +import sys +import shutil +import tkinter as tk +from tkinter import ttk +import os +from typing import Optional, Dict, Any, NoReturn +from pathlib import Path +from tkinter import Toplevel + + +class CryptoUtil: + """ + This class is for the creation of the folders and files + required by Wire-Py, as well as for decryption + the tunnel from the user's home directory + """ + + @staticmethod + def decrypt(user) -> None: + """ + Starts SSL dencrypt + """ + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/ssl_decrypt.py", "--user", user], + capture_output=True, + text=True, + check=False, + ) + + # Output from Openssl Error + if process.stderr: + logging.error(process.stderr, exc_info=True) + + if process.returncode == 0: + logging.info("Files successfully decrypted...", exc_info=True) + else: + + logging.error( + f"Error process decrypt: Code {process.returncode}", exc_info=True + ) + + @staticmethod + def encrypt(user) -> None: + """ + Starts SSL encryption + """ + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/ssl_encrypt.py", "--user", user], + capture_output=True, + text=True, + check=False, + ) + + # Output from Openssl Error + if process.stderr: + logging.error(process.stderr, exc_info=True) + + if process.returncode == 0: + logging.info("Files successfully encrypted...", exc_info=True) + else: + logging.error( + f"Error process encrypt: Code {process.returncode}", exc_info=True + ) + + @staticmethod + def find_key(key: str = "") -> bool: + """ + Checks if the private key already exists in the system using an external script. + Returns True only if the full key is found exactly (no partial match). + """ + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/match_found.py", key], + capture_output=True, + text=True, + check=False, + ) + if "True" in process.stdout: + return True + elif "False" in process.stdout: + return False + logging.error( + f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}", + exc_info=True, + ) + return False + + @staticmethod + def is_valid_base64(key: str) -> bool: + """ + Validates if the input is a valid Base64 string (WireGuard private key format). + Returns True only for non-empty strings that match the expected length. + """ + # Check for empty string + if not key or key.strip() == "": + return False + + # Regex pattern to validate Base64: [A-Za-z0-9+/]+={0,2} + base64_pattern = r"^[A-Za-z0-9+/]+={0,2}$" + if not re.match(base64_pattern, key): + return False + + try: + # Decode and check length (WireGuard private keys are 32 bytes long) + decoded = base64.b64decode(key) + if len(decoded) != 32: # 32 bytes = 256 bits + return False + except Exception as e: + logging.error(f"Error on decode Base64: {e}", exc_info=True) + return False + + return True + + +class LxTools: + """ + Class LinuxTools methods that can also be used for other apps + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + @staticmethod + def center_window_cross_platform(window, width, height): + """ + Centers a window on the primary monitor in a way that works on both X11 and Wayland + + Args: + window: The tkinter window to center + width: Window width + height: Window height + """ + # Calculate the position before showing the window + + # First attempt: Try to use GDK if available (works on both X11 and Wayland) + try: + import gi + + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk + + display = Gdk.Display.get_default() + monitor = display.get_primary_monitor() or display.get_monitor(0) + geometry = monitor.get_geometry() + scale_factor = monitor.get_scale_factor() + + # Calculate center position on the primary monitor + x = geometry.x + (geometry.width - width // scale_factor) // 2 + y = geometry.y + (geometry.height - height // scale_factor) // 2 + + # Set window geometry + window.geometry(f"{width}x{height}+{x}+{y}") + return + except (ImportError, AttributeError): + pass + + # Second attempt: Try xrandr for X11 + try: + import subprocess + + output = subprocess.check_output( + ["xrandr", "--query"], universal_newlines=True + ) + + # Parse the output to find the primary monitor + primary_info = None + for line in output.splitlines(): + if "primary" in line: + parts = line.split() + for part in parts: + if "x" in part and "+" in part: + primary_info = part + break + break + + if primary_info: + # Parse the geometry: WIDTH x HEIGHT+X+Y + geometry = primary_info.split("+") + dimensions = geometry[0].split("x") + primary_width = int(dimensions[0]) + primary_height = int(dimensions[1]) + primary_x = int(geometry[1]) + primary_y = int(geometry[2]) + + # Calculate center position on the primary monitor + x = primary_x + (primary_width - width) // 2 + y = primary_y + (primary_height - height) // 2 + + # Set window geometry + window.geometry(f"{width}x{height}+{x}+{y}") + return + except (ImportError, IndexError, ValueError): + pass + + # Final fallback: Use standard Tkinter method + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + # Try to make an educated guess for multi-monitor setups + # If screen width is much larger than height, assume multiple monitors side by side + if ( + screen_width > screen_height * 1.8 + ): # Heuristic for detecting multiple monitors + # Assume the primary monitor is on the left half + screen_width = screen_width // 2 + + x = (screen_width - width) // 2 + y = (screen_height - height) // 2 + window.geometry(f"{width}x{height}+{x}+{y}") + + @staticmethod + def clean_files(tmp_dir: Path = None, file: Path = None) -> None: + """ + Deletes temporary files and directories for cleanup when exiting the application. + + This method safely removes an optional directory defined by `AppConfig.TEMP_DIR` + and a single file to free up resources at the end of the program's execution. + All operations are performed securely, and errors such as `FileNotFoundError` + are ignored if the target files or directories do not exist. + :param tmp_dir: (Path, optional): Path to the temporary directory that should be deleted. + If `None`, the value of `AppConfig.TEMP_DIR` is used. + :param file: (Path, optional): Path to the file that should be deleted. + If `None`, no additional file will be deleted. + + Returns: + None: The method does not return any value. + """ + + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) + try: + if file is not None: + Path.unlink(file) + + except FileNotFoundError: + pass + + @staticmethod + def sigi(file_path: Optional[Path] = None, file: Optional[Path] = None) -> None: + """ + Function for cleanup after a program interruption + + :param file: Optional - File to be deleted + :param file_path: Optional - Directory to be deleted + """ + + def signal_handler(signum: int, frame: Any) -> NoReturn: + """ + Determines clear text names for signal numbers and handles signals + + Args: + signum: The signal number + frame: The current stack frame + + Returns: + NoReturn since the function either exits the program or continues execution + """ + + signals_to_names_dict: Dict[int, str] = dict( + (getattr(signal, n), n) + for n in dir(signal) + if n.startswith("SIG") and "_" not in n + ) + + signal_name: str = signals_to_names_dict.get( + signum, f"Unnamed signal: {signum}" + ) + + # End program for certain signals, report to others only reception + if signum in (signal.SIGINT, signal.SIGTERM): + exit_code: int = 1 + logging.error( + f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}.", + exc_info=True, + ) + LxTools.clean_files(file_path, file) + logging.info("Breakdown by user...") + sys.exit(exit_code) + else: + logging.info(f"Signal {signum} received and ignored.") + LxTools.clean_files(file_path, file) + logging.error("Process unexpectedly ended...") + + # Register signal handlers for various signals + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGHUP, signal_handler) + + +# ConfigManager with caching +class ConfigManager: + """ + Universal class for managing configuration files with caching support. + + This class provides a general solution to load, save, and manage configuration + files across different projects. It uses a caching system to optimize access efficiency. + The `init()` method initializes the configuration file path, while `load()` and `save()` + synchronize data between the file and internal memory structures. + + Key Features: + - Caching to minimize I/O operations. + - Default values for missing or corrupted configuration files. + - Reusability across different projects and use cases. + + The class is designed for central application configuration management, working closely + with `ThemeManager` to dynamically manage themes or other settings. + """ + + _config = None + _config_file = None + + @classmethod + def init(cls, config_file): + """Initial the Configmanager with the given config file""" + cls._config_file = config_file + cls._config = None # Reset the cache + + @classmethod + def load(cls): + """Load the config file and return the config as dict""" + if not cls._config: + try: + lines = Path(cls._config_file).read_text(encoding="utf-8").splitlines() + cls._config = { + "updates": lines[1].strip(), + "theme": lines[3].strip(), + "tooltips": lines[5].strip() + == "True", # is converted here to boolean!!! + "autostart": lines[7].strip() if len(lines) > 7 else "off", + "logfile": lines[9].strip(), + } + except (IndexError, FileNotFoundError): + # DeDefault values in case of error + cls._config = { + "updates": "on", + "theme": "light", + "tooltips": "True", # Default Value as string! + "autostart": "off", + "logfile": LOG_FILE_PATH, + } + return cls._config + + @classmethod + def save(cls): + """Save the config to the config file""" + if cls._config: + lines = [ + "# Configuration\n", + f"{cls._config['updates']}\n", + "# Theme\n", + f"{cls._config['theme']}\n", + "# Tooltips\n", + f"{str(cls._config['tooltips'])}\n", + "# Autostart\n", + f"{cls._config['autostart']}\n", + "# Logfile\n", + f"{cls._config['logfile']}\n", + ] + Path(cls._config_file).write_text("".join(lines), encoding="utf-8") + + @classmethod + def set(cls, key, value): + """Sets a configuration value and saves the change""" + cls.load() + cls._config[key] = value + cls.save() + + @classmethod + def get(cls, key, default=None): + """Returns a configuration value""" + config = cls.load() + return config.get(key, default) + + +class ThemeManager: + """ + Class for central theme management and UI customization. + + This static class allows dynamic adjustment of the application's appearance. + The method `change_theme()` updates the current theme and saves + the selection in the configuration file via `ConfigManager`. + It ensures a consistent visual design across the entire project. + + Key Features: + - Central control over themes. + - Automatic saving of theme settings to the configuration file. + - Tight integration with `ConfigManager` for persistent storage of preferences. + + The class is designed to apply themes consistently throughout the application, + ensuring that changes are traceable and uniform across all parts of the project. + """ + + @staticmethod + def change_theme(root, theme_in_use, theme_name=None): + """Change application theme centrally""" + root.tk.call("set_theme", theme_in_use) + if theme_in_use == theme_name: + ConfigManager.set("theme", theme_in_use) + + +class Tooltip: + def __init__(self, widget, text, wraplength=250): + self.widget = widget + self.text = text + self.wraplength = wraplength + self.tooltip_window = None + self.id = None + self.widget.bind("", self.enter) + self.widget.bind("", self.leave) + self.widget.bind("", self.leave) + + def enter(self, event=None): self.schedule() + def leave(self, event=None): self.unschedule(); self.hide_tooltip() + + def schedule(self): self.unschedule( + ); self.id = self.widget.after(250, self.show_tooltip) + + def unschedule(self): + id = self.id + self.id = None + if id: + self.widget.after_cancel(id) + + def show_tooltip(self, event=None): + x, y, _, _ = self.widget.bbox("insert") + x += self.widget.winfo_rootx() + 25 + y += self.widget.winfo_rooty() + 20 + self.tooltip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + label = ttk.Label(tw, text=self.text, justify=tk.LEFT, background="#FFFFE0", foreground="black", + relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2)) + label.pack(ipadx=1) + + def hide_tooltip(self): + tw = self.tooltip_window + self.tooltip_window = None + if tw: + tw.destroy() + + +class LogConfig: + @staticmethod + def logger(file_path) -> None: + + file_handler = logging.FileHandler( + filename=f"{file_path}", + mode="a", + encoding="utf-8", + ) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + file_handler.setLevel(logging.DEBUG) + + logger = logging.getLogger() + logger.addHandler(file_handler) + +import os + +class IconManager: + def __init__(self, base_path='/usr/share/icons/lx-icons'): + self.base_path = base_path + self.icons = {} + self._define_icon_paths() + self._load_all() + + def _define_icon_paths(self): + self.icon_paths = { + # 16x16 + 'settings_16': '16/settings.png', + + # 32x32 + 'back': '32/arrow-left.png', + 'forward': '32/arrow-right.png', + 'audio_small': '32/audio.png', + 'icon_view': '32/carrel.png', + 'computer_small': '32/computer.png', + 'device_small': '32/device.png', + 'file_small': '32/document.png', + 'download_error_small': '32/download_error.png', + 'download_small': '32/download.png', + 'error_small': '32/error.png', + 'python_small': '32/file-python.png', + 'documents_small': '32/folder-water-documents.png', + 'downloads_small': '32/folder-water-download.png', + 'music_small': '32/folder-water-music.png', + 'pictures_small': '32/folder-water-pictures.png', + 'folder_small': '32/folder-water.png', + 'video_small': '32/folder-water-video.png', + 'hide': '32/hide.png', + 'home': '32/home.png', + 'info_small': '32/info.png', + 'list_view': '32/list.png', + 'log_small': '32/log.png', + 'lunix_tools_small': '32/Lunix_Tools.png', + 'key_small': '32/lxtools_key.png', + 'iso_small': '32/media-optical.png', + 'new_document_small': '32/new-document.png', + 'new_folder_small': '32/new-folder.png', + 'pdf_small': '32/pdf.png', + 'picture_small': '32/picture.png', + 'question_mark_small': '32/question_mark.png', + 'recursive_small': '32/recursive.png', + 'search_small': '32/search.png', + 'settings_small': '32/settings.png', + 'archive_small': '32/tar.png', + 'unhide': '32/unhide.png', + 'usb_small': '32/usb.png', + 'video_small_file': '32/video.png', + 'warning_small': '32/warning.png', + 'export_small': '32/wg_export.png', + 'import_small': '32/wg_import.png', + 'message_small': '32/wg_msg.png', + 'trash_small': '32/wg_trash.png', + 'vpn_small': '32/wg_vpn.png', + 'vpn_start_small': '32/wg_vpn-start.png', + 'vpn_stop_small': '32/wg_vpn-stop.png', + + # 48x48 + 'back_large': '48/arrow-left.png', + 'forward_large': '48/arrow-right.png', + 'icon_view_large': '48/carrel.png', + 'computer_large': '48/computer.png', + 'device_large': '48/device.png', + 'download_error_large': '48/download_error.png', + 'download_large': '48/download.png', + 'error_large': '48/error.png', + 'documents_large': '48/folder-water-documents.png', + 'downloads_large': '48/folder-water-download.png', + 'music_large': '48/folder-water-music.png', + 'pictures_large': '48/folder-water-pictures.png', + 'folder_large_48': '48/folder-water.png', + 'video_large_folder': '48/folder-water-video.png', + 'hide_large': '48/hide.png', + 'home_large': '48/home.png', + 'info_large': '48/info.png', + 'list_view_large': '48/list.png', + 'log_large': '48/log.png', + 'lunix_tools_large': '48/Lunix_Tools.png', + 'new_document_large': '48/new-document.png', + 'new_folder_large': '48/new-folder.png', + 'question_mark_large': '48/question_mark.png', + 'search_large_48': '48/search.png', + 'settings_large': '48/settings.png', + 'unhide_large': '48/unhide.png', + 'usb_large': '48/usb.png', + 'warning_large_48': '48/warning.png', + 'export_large': '48/wg_export.png', + 'import_large': '48/wg_import.png', + 'message_large': '48/wg_msg.png', + 'trash_large': '48/wg_trash.png', + 'vpn_large': '48/wg_vpn.png', + 'vpn_start_large': '48/wg_vpn-start.png', + 'vpn_stop_large': '48/wg_vpn-stop.png', + + # 64x64 + 'back_extralarge': '64/arrow-left.png', + 'forward_extralarge': '64/arrow-right.png', + 'audio_large': '64/audio.png', + 'icon_view_extralarge': '64/carrel.png', + 'computer_extralarge': '64/computer.png', + 'device_extralarge': '64/device.png', + 'file_large': '64/document.png', + 'download_error_extralarge': '64/download_error.png', + 'download_extralarge': '64/download.png', + 'error_extralarge': '64/error.png', + 'python_large': '64/file-python.png', + 'documents_extralarge': '64/folder-water-documents.png', + 'downloads_extralarge': '64/folder-water-download.png', + 'music_extralarge': '64/folder-water-music.png', + 'pictures_extralarge': '64/folder-water-pictures.png', + 'folder_large': '64/folder-water.png', + 'video_extralarge_folder': '64/folder-water-video.png', + 'hide_extralarge': '64/hide.png', + 'home_extralarge': '64/home.png', + 'info_extralarge': '64/info.png', + 'list_view_extralarge': '64/list.png', + 'log_extralarge': '64/log.png', + 'lunix_tools_extralarge': '64/Lunix_Tools.png', + 'iso_large': '64/media-optical.png', + 'new_document_extralarge': '64/new-document.png', + 'new_folder_extralarge': '64/new-folder.png', + 'pdf_large': '64/pdf.png', + 'picture_large': '64/picture.png', + 'question_mark_extralarge': '64/question_mark.png', + 'recursive_large': '64/recursive.png', + 'search_large': '64/search.png', + 'settings_extralarge': '64/settings.png', + 'archive_large': '64/tar.png', + 'unhide_extralarge': '64/unhide.png', + 'usb_extralarge': '64/usb.png', + 'video_large': '64/video.png', + 'warning_large': '64/warning.png', + 'export_extralarge': '64/wg_export.png', + 'import_extralarge': '64/wg_import.png', + 'message_extralarge': '64/wg_msg.png', + 'trash_extralarge': '64/wg_trash.png', + 'vpn_extralarge': '64/wg_vpn.png', + 'vpn_start_extralarge': '64/wg_vpn-start.png', + 'vpn_stop_extralarge': '64/wg_vpn-stop.png', + } + + def _load_all(self): + for key, rel_path in self.icon_paths.items(): + full_path = os.path.join(self.base_path, rel_path) + try: + self.icons[key] = tk.PhotoImage(file=full_path) + except tk.TclError as e: + print(f"Error loading icon '{key}' from '{full_path}': {e}") + size = 32 # Default size + if '16' in rel_path: size = 16 + elif '48' in rel_path: size = 48 + elif '64' in rel_path: size = 64 + self.icons[key] = tk.PhotoImage(width=size, height=size) diff --git a/custom_file_dialog.py b/custom_file_dialog.py index 4f91573..7f38a00 100644 --- a/custom_file_dialog.py +++ b/custom_file_dialog.py @@ -5,16 +5,14 @@ from tkinter import ttk from datetime import datetime import subprocess import json +from shared_libs.message import MessageDialog +from shared_libs.common_tools import IconManager, Tooltip # Helper to make icon paths robust, so the script can be run from anywhere SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) MAX_ITEMS_TO_DISPLAY = 1000 -def get_icon_path(icon_name): - return os.path.join(SCRIPT_DIR, icon_name) - - def get_xdg_user_dir(dir_key, fallback_name): home = os.path.expanduser("~") fallback_path = os.path.join(home, fallback_name) @@ -38,47 +36,6 @@ def get_xdg_user_dir(dir_key, fallback_name): return fallback_path -class Tooltip: - def __init__(self, widget, text, wraplength=250): - self.widget = widget - self.text = text - self.wraplength = wraplength - self.tooltip_window = None - self.id = None - self.widget.bind("", self.enter) - self.widget.bind("", self.leave) - self.widget.bind("", self.leave) - - def enter(self, event=None): self.schedule() - def leave(self, event=None): self.unschedule(); self.hide_tooltip() - - def schedule(self): self.unschedule( - ); self.id = self.widget.after(250, self.show_tooltip) - - def unschedule(self): - id = self.id - self.id = None - if id: - self.widget.after_cancel(id) - - def show_tooltip(self, event=None): - x, y, _, _ = self.widget.bbox("insert") - x += self.widget.winfo_rootx() + 25 - y += self.widget.winfo_rooty() + 20 - self.tooltip_window = tw = tk.Toplevel(self.widget) - tw.wm_overrideredirect(True) - tw.wm_geometry(f"+{x}+{y}") - label = ttk.Label(tw, text=self.text, justify=tk.LEFT, background="#FFFFE0", foreground="black", - relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2)) - label.pack(ipadx=1) - - def hide_tooltip(self): - tw = self.tooltip_window - self.tooltip_window = None - if tw: - tw.destroy() - - class CustomFileDialog(tk.Toplevel): def __init__(self, parent, initial_dir=None, filetypes=None): super().__init__(parent) @@ -100,56 +57,18 @@ class CustomFileDialog(tk.Toplevel): self.show_hidden_files = tk.BooleanVar(value=False) self.resize_job = None self.last_width = 0 + self.sidebar_buttons = [] + self.device_buttons = [] + self.search_results = [] # Store search results + self.search_mode = False # Track if in search mode + self.original_path_text = "" # Store original path text - self.load_icons() + self.icon_manager = IconManager() + self.icons = self.icon_manager.icons self.create_styles() self.create_widgets() self.navigate_to(self.current_dir) - def load_icons(self): - self.icons = {} - icon_files = { - 'computer_small': '/usr/share/icons/lx-icons/32/computer-32.png', - 'computer_large': '/usr/share/icons/lx-icons/48/computer-48.png', - 'device_small': '/usr/share/icons/lx-icons/32/device-32.png', - 'device_large': '/usr/share/icons/lx-icons/48/device-48.png', - 'usb_small': '/usr/share/icons/lx-icons/32/usb-32.png', - 'usb_large': '/usr/share/icons/lx-icons/48/usb-48.png', - 'downloads_small': '/usr/share/icons/lx-icons/32/folder-water-download-32.png', - 'downloads_large': '/usr/share/icons/lx-icons/48/folder-water-download-48.png', - 'documents_small': '/usr/share/icons/lx-icons/32/folder-water-documents-32.png', - 'documents_large': '/usr/share/icons/lx-icons/48/folder-water-documents-48.png', - 'pictures_small': '/usr/share/icons/lx-icons/32/folder-water-pictures-32.png', - 'pictures_large': '/usr/share/icons/lx-icons/48/folder-water-pictures-48.png', - 'music_small': '/usr/share/icons/lx-icons/32/folder-water-music-32.png', - 'music_large': '/usr/share/icons/lx-icons/48/folder-water-music-48.png', - 'video_small': '/usr/share/icons/lx-icons/32/folder-water-video-32.png', - 'video_large_folder': '/usr/share/icons/lx-icons/48/folder-water-video-48.png', - 'warning_small': '/usr/share/icons/lx-icons/32/warning.png', 'warning_large': '/usr/share/icons/lx-icons/64/warning.png', - 'folder_large': '/usr/share/icons/lx-icons/64/folder-water-64.png', 'file_large': '/usr/share/icons/lx-icons/64/document-64.png', - 'python_large': '/usr/share/icons/lx-icons/64/file-python-64.png', 'pdf_large': '/usr/share/icons/lx-icons/64/pdf-64.png', - 'archive_large': '/usr/share/icons/lx-icons/64/tar-64.png', 'audio_large': '/usr/share/icons/lx-icons/64/audio-64.png', - 'video_large': '/usr/share/icons/lx-icons/64/video-64.png', 'picture_large': '/usr/share/icons/lx-icons/64/picture-64.png', - 'iso_large': '/usr/share/icons/lx-icons/64/media-optical-64.png', 'folder_small': '/usr/share/icons/lx-icons/32/folder-water-32.png', - 'file_small': '/usr/share/icons/lx-icons/32/document-32.png', 'python_small': '/usr/share/icons/lx-icons/32/file-python-32.png', - 'pdf_small': '/usr/share/icons/lx-icons/32/pdf-32.png', 'archive_small': '/usr/share/icons/lx-icons/32/tar-32.png', - 'audio_small': '/usr/share/icons/lx-icons/32/audio-32.png', 'video_small_file': '/usr/share/icons/lx-icons/32/video-32.png', - 'picture_small': '/usr/share/icons/lx-icons/32/picture-32.png', 'iso_small': '/usr/share/icons/lx-icons/32/media-optical-32.png', - 'list_view': '/usr/share/icons/lx-icons/32/list-32.png', - 'icon_view': '/usr/share/icons/lx-icons/32/carrel-32.png', - 'hide': '/usr/share/icons/lx-icons/32/hide-32.png', - 'unhide': '/usr/share/icons/lx-icons/32/unhide-32.png', - 'back': '/usr/share/icons/lx-icons/32/arrow-left-32.png', - 'forward': '/usr/share/icons/lx-icons/32/arrow-right-32.png', - 'home': '/usr/share/icons/lx-icons/32/home-32.png' - } - for key, filename in icon_files.items(): - try: - self.icons[key] = tk.PhotoImage(file=get_icon_path(filename)) - except tk.TclError: - size = 32 if 'small' in key or 'view' in key or 'hide' in key or 'unhide' in key or 'back' in key or 'forward' in key or 'home' in key else 64 - self.icons[key] = tk.PhotoImage(width=size, height=size) - def get_file_icon(self, filename, size='large'): ext = os.path.splitext(filename)[1].lower() if ext == '.svg': @@ -207,6 +126,17 @@ class CustomFileDialog(tk.Toplevel): style.map("Header.TButton.Borderless.Round", background=[ ('active', self.hover_extrastyle)]) + # Style for active/pressed header buttons + style.configure("Header.TButton.Active.Round", + background=self.selection_color) + + # Copy layout from the base style + style.layout("Header.TButton.Active.Round", + style.layout("Header.TButton.Borderless.Round")) + + style.map("Header.TButton.Active.Round", background=[ + ('active', self.selection_color)]) + style.configure("Dark.TButton.Borderless", anchor="w", background=self.sidebar_color, foreground=self.color_foreground, padding=(20, 5, 0, 5)) @@ -278,21 +208,43 @@ class CustomFileDialog(tk.Toplevel): self.path_entry.bind( "", lambda e: self.navigate_to(self.path_entry.get())) - # View switch and hidden files button + # Search, view switch and hidden files button right_top_bar_frame = ttk.Frame(top_bar, style='Accent.TFrame') right_top_bar_frame.grid(row=0, column=2, sticky="e") + # Search button and options container + search_container = ttk.Frame( + right_top_bar_frame, style='Accent.TFrame') + search_container.pack(side="left", padx=(0, 10)) + + self.search_button = ttk.Button(search_container, image=self.icons['search_small'], + command=self.toggle_search_mode, style="Header.TButton.Borderless.Round") + self.search_button.pack(side="left") + Tooltip(self.search_button, "Suchen") + + # Search options frame (initially hidden, next to search button) + self.search_options_frame = ttk.Frame( + search_container, style='Accent.TFrame') + + # Recursive search toggle button + self.recursive_search = tk.BooleanVar(value=True) + self.recursive_button = ttk.Button(self.search_options_frame, image=self.icons['recursive_small'], + command=self.toggle_recursive_search, + style="Header.TButton.Active.Round") + self.recursive_button.pack(side="left", padx=2) + Tooltip(self.recursive_button, "Rekursive Suche ein/ausschalten") + view_switch = ttk.Frame(right_top_bar_frame, padding=(5, 0), style='Accent.TFrame') view_switch.pack(side="left") - self.icon_view_button = ttk.Button(view_switch, image=self.icons['icon_view'], command=lambda: ( - self.view_mode.set("icons"), self.populate_files()), style="Header.TButton.Borderless.Round") + self.icon_view_button = ttk.Button(view_switch, image=self.icons['icon_view'], + command=self.set_icon_view, style="Header.TButton.Active.Round") self.icon_view_button.pack(side="left", padx=(50, 10)) Tooltip(self.icon_view_button, "Kachelansicht") - self.list_view_button = ttk.Button(view_switch, image=self.icons['list_view'], command=lambda: ( - self.view_mode.set("list"), self.populate_files()), style="Header.TButton.Borderless.Round") + self.list_view_button = ttk.Button(view_switch, image=self.icons['list_view'], + command=self.set_list_view, style="Header.TButton.Borderless.Round") self.list_view_button.pack(side="left") Tooltip(self.list_view_button, "Listenansicht") @@ -312,14 +264,14 @@ class CustomFileDialog(tk.Toplevel): # Sidebar sidebar_frame = ttk.Frame( - paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0)) + paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200) # Prevent content from resizing the frame - # sidebar_frame.grid_propagate(False) + sidebar_frame.grid_propagate(False) + sidebar_frame.bind("", self.on_sidebar_resize) # Use weight=0 to give it a fixed size paned_window.add(sidebar_frame, weight=0) - sidebar_frame.grid_rowconfigure(2, weight=1) - + # No weight on any row - let storage stay at bottom sidebar_buttons_frame = ttk.Frame( sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0)) sidebar_buttons_frame.grid( @@ -342,43 +294,121 @@ class CustomFileDialog(tk.Toplevel): btn = ttk.Button(sidebar_buttons_frame, text=f" {config['name']}", image=config['icon'], compound="left", command=lambda p=config['path']: self.navigate_to(p), style="Dark.TButton.Borderless") btn.pack(fill="x", pady=1) + self.sidebar_buttons.append((btn, f" {config['name']}")) # Horizontal separator separator_color = "#a9a9a9" if self.is_dark else "#7c7c7c" tk.Frame(sidebar_frame, height=1, bg=separator_color).grid( row=1, column=0, sticky="ew", padx=20, pady=15) - # Mounted devices + # Mounted devices with scrollable frame mounted_devices_frame = ttk.Frame( sidebar_frame, style="Sidebar.TFrame") mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10) - ttk.Label(mounted_devices_frame, text="Geräte:", background=self.sidebar_color, - foreground=self.color_foreground).pack(fill="x", padx=10, pady=(5, 0)) + # Don't expand devices frame so storage stays in position + mounted_devices_frame.grid_columnconfigure(0, weight=1) + ttk.Label(mounted_devices_frame, text="Geräte:", background=self.sidebar_color, + foreground=self.color_foreground).grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 0)) + + # Create scrollable canvas for devices + self.devices_canvas = tk.Canvas(mounted_devices_frame, highlightthickness=0, + bg=self.sidebar_color, height=150, width=180) + self.devices_scrollbar = ttk.Scrollbar(mounted_devices_frame, orient="vertical", + command=self.devices_canvas.yview) + self.devices_canvas.configure( + yscrollcommand=self.devices_scrollbar.set) + + self.devices_canvas.grid(row=1, column=0, sticky="nsew") + # Scrollbar initially hidden + + # Create scrollable frame inside canvas + self.devices_scrollable_frame = ttk.Frame( + self.devices_canvas, style="Sidebar.TFrame") + self.devices_canvas_window = self.devices_canvas.create_window( + (0, 0), window=self.devices_scrollable_frame, anchor="nw") + + # Bind events for showing/hiding scrollbar on hover + self.devices_canvas.bind("", self._on_devices_enter) + self.devices_canvas.bind("", self._on_devices_leave) + self.devices_scrollable_frame.bind("", self._on_devices_enter) + self.devices_scrollable_frame.bind("", self._on_devices_leave) + + # Bind canvas width to scrollable frame width + def _configure_devices_canvas(event): + self.devices_canvas.configure( + scrollregion=self.devices_canvas.bbox("all")) + canvas_width = event.width + self.devices_canvas.itemconfig( + self.devices_canvas_window, width=canvas_width) + + self.devices_scrollable_frame.bind("", lambda e: self.devices_canvas.configure( + scrollregion=self.devices_canvas.bbox("all"))) + self.devices_canvas.bind("", _configure_devices_canvas) + + # Mouse wheel scrolling for devices area + def _on_devices_mouse_wheel(event): + if event.num == 4: # Scroll up on Linux + delta = -1 + elif event.num == 5: # Scroll down on Linux + delta = 1 + else: # MouseWheel event for Windows/macOS + delta = -1 * int(event.delta / 120) + self.devices_canvas.yview_scroll(delta, "units") + + # Bind mouse wheel to canvas and scrollable frame + for widget in [self.devices_canvas, self.devices_scrollable_frame]: + widget.bind("", _on_devices_mouse_wheel) + widget.bind("", _on_devices_mouse_wheel) + widget.bind("", _on_devices_mouse_wheel) + + # Populate devices for device_name, mount_point, removable in self._get_mounted_devices(): icon = self.icons['usb_small'] if removable else self.icons['device_small'] button_text = f" {device_name}" if len(device_name) > 15: # Static wrapping for long names button_text = f" {device_name[:15]}\n{device_name[15:]}" - btn = ttk.Button(mounted_devices_frame, text=button_text, image=icon, + btn = ttk.Button(self.devices_scrollable_frame, text=button_text, image=icon, compound="left", command=lambda p=mount_point: self.navigate_to(p), style="Dark.TButton.Borderless") btn.pack(fill="x", pady=1) + self.device_buttons.append((btn, button_text)) + + # Bind mouse wheel to device buttons too + btn.bind("", _on_devices_mouse_wheel) + btn.bind("", _on_devices_mouse_wheel) + btn.bind("", _on_devices_mouse_wheel) + + # Bind hover events for scrollbar visibility + btn.bind("", self._on_devices_enter) + btn.bind("", self._on_devices_leave) + try: total, used, _ = shutil.disk_usage(mount_point) progress_bar = ttk.Progressbar( - mounted_devices_frame, orient="horizontal", length=100, mode="determinate", style='Small.Horizontal.TProgressbar') + self.devices_scrollable_frame, orient="horizontal", length=100, mode="determinate", style='Small.Horizontal.TProgressbar') progress_bar.pack(fill="x", pady=(2, 8), padx=25) progress_bar['value'] = (used / total) * 100 + + # Bind mouse wheel to progress bars too + progress_bar.bind("", _on_devices_mouse_wheel) + progress_bar.bind("", _on_devices_mouse_wheel) + progress_bar.bind("", _on_devices_mouse_wheel) + + # Bind hover events for scrollbar visibility + progress_bar.bind("", self._on_devices_enter) + progress_bar.bind("", self._on_devices_leave) except (FileNotFoundError, PermissionError): # In case of errors (e.g., unreadable drive), just skip the progress bar pass + # Separator before storage tk.Frame(sidebar_frame, height=1, bg=separator_color).grid( row=3, column=0, sticky="ew", padx=20, pady=15) + # Storage section at bottom - use pack instead of grid to stay at bottom storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame") - storage_frame.grid(row=4, column=0, sticky="ew", padx=10) + storage_frame.grid(row=4, column=0, sticky="sew", padx=10, pady=10) self.storage_label = ttk.Label( storage_frame, text="Freier Speicher:", background=self.freespace_background) self.storage_label.pack(fill="x", padx=10) @@ -441,6 +471,280 @@ class CustomFileDialog(tk.Toplevel): self.resize_job = self.after(200, self.populate_files) self.last_width = new_width + def on_sidebar_resize(self, event): + current_width = event.width + # Define a threshold for when to hide/show text + threshold_width = 100 # Adjust this value as needed + + if current_width < threshold_width: + # Hide text, show only icons + for btn, original_text in self.sidebar_buttons: + btn.config(text="", compound="top") + for btn, original_text in self.device_buttons: + btn.config(text="", compound="top") + else: + # Show text + for btn, original_text in self.sidebar_buttons: + btn.config(text=original_text, compound="left") + for btn, original_text in self.device_buttons: + btn.config(text=original_text, compound="left") + + def _on_devices_enter(self, event): + """Show scrollbar when mouse enters devices area""" + self.devices_scrollbar.grid(row=1, column=1, sticky="ns") + + def _on_devices_leave(self, event): + """Hide scrollbar when mouse leaves devices area""" + # Check if mouse is really leaving the devices area + x, y = event.x_root, event.y_root + widget_x = self.devices_canvas.winfo_rootx() + widget_y = self.devices_canvas.winfo_rooty() + widget_width = self.devices_canvas.winfo_width() + widget_height = self.devices_canvas.winfo_height() + + # Add small buffer to prevent flickering + buffer = 5 + if not (widget_x - buffer <= x <= widget_x + widget_width + buffer and + widget_y - buffer <= y <= widget_y + widget_height + buffer): + self.devices_scrollbar.grid_remove() + + def toggle_search_mode(self): + """Toggle between search mode and normal mode""" + if not self.search_mode: + # Enter search mode + self.search_mode = True + self.original_path_text = self.path_entry.get() + self.path_entry.delete(0, tk.END) + self.path_entry.insert(0, "Suchbegriff eingeben...") + self.path_entry.bind("", self.execute_search) + self.path_entry.bind("", self.clear_search_placeholder) + + # Show search options + self.search_options_frame.pack(side="left", padx=(5, 0)) + else: + # Exit search mode + self.search_mode = False + self.path_entry.delete(0, tk.END) + self.path_entry.insert(0, self.original_path_text) + self.path_entry.bind( + "", lambda e: self.navigate_to(self.path_entry.get())) + self.path_entry.unbind("") + + # Hide search options + self.search_options_frame.pack_forget() + + # Return to normal file view + self.populate_files() + + def toggle_recursive_search(self): + """Toggle recursive search on/off and update button style""" + self.recursive_search.set(not self.recursive_search.get()) + if self.recursive_search.get(): + self.recursive_button.configure( + style="Header.TButton.Active.Round") + else: + self.recursive_button.configure( + style="Header.TButton.Borderless.Round") + + def set_icon_view(self): + """Set icon view and update button styles""" + self.view_mode.set("icons") + self.icon_view_button.configure(style="Header.TButton.Active.Round") + self.list_view_button.configure( + style="Header.TButton.Borderless.Round") + self.populate_files() + + def set_list_view(self): + """Set list view and update button styles""" + self.view_mode.set("list") + self.list_view_button.configure(style="Header.TButton.Active.Round") + self.icon_view_button.configure( + style="Header.TButton.Borderless.Round") + self.populate_files() + + def clear_search_placeholder(self, event): + """Clear placeholder text when focus enters search field""" + if self.path_entry.get() == "Suchbegriff eingeben...": + self.path_entry.delete(0, tk.END) + + def execute_search(self, event): + """Execute search when Enter is pressed in search mode""" + search_term = self.path_entry.get().strip() + if not search_term or search_term == "Suchbegriff eingeben...": + return + + # Clear previous search results + self.search_results.clear() + + # Determine search directories + search_dirs = [self.current_dir] + + # If searching from home directory, also include XDG directories + home_dir = os.path.expanduser("~") + if os.path.abspath(self.current_dir) == os.path.abspath(home_dir): + xdg_dirs = [ + get_xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads"), + get_xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents"), + get_xdg_user_dir("XDG_PICTURES_DIR", "Pictures"), + get_xdg_user_dir("XDG_MUSIC_DIR", "Music"), + get_xdg_user_dir("XDG_VIDEO_DIR", "Videos") + ] + # Add XDG directories that exist and are not already in home + for xdg_dir in xdg_dirs: + if (os.path.exists(xdg_dir) and + os.path.abspath(xdg_dir) != os.path.abspath(home_dir) and + xdg_dir not in search_dirs): + search_dirs.append(xdg_dir) + + try: + all_files = [] + + # Search in each directory + for search_dir in search_dirs: + if not os.path.exists(search_dir): + continue + + # Change to directory and use relative paths to avoid path issues + original_cwd = os.getcwd() + try: + os.chdir(search_dir) + + # Build find command based on recursive setting (use . for current directory) + if self.recursive_search.get(): + find_cmd = ['find', '.', '-iname', + f'*{search_term}*', '-type', 'f'] + else: + find_cmd = ['find', '.', '-maxdepth', '1', + '-iname', f'*{search_term}*', '-type', 'f'] + + result = subprocess.run( + find_cmd, capture_output=True, text=True, timeout=30) + + if result.returncode == 0: + files = result.stdout.strip().split('\n') + # Convert relative paths back to absolute paths + directory_files = [] + for f in files: + if f and f.startswith('./'): + abs_path = os.path.join( + search_dir, f[2:]) # Remove './' prefix + if os.path.isfile(abs_path): + directory_files.append(abs_path) + all_files.extend(directory_files) + + finally: + os.chdir(original_cwd) + + # Remove duplicates while preserving order + seen = set() + unique_files = [] + for file_path in all_files: + if file_path not in seen: + seen.add(file_path) + unique_files.append(file_path) + + # Filter based on currently selected filter pattern + self.search_results = [] + for file_path in unique_files: + filename = os.path.basename(file_path) + if self._matches_filetype(filename): + self.search_results.append(file_path) + + # Show search results in TreeView + if self.search_results: + self.show_search_results_treeview() + else: + MessageDialog( + message_type="info", + text=f"Keine Dateien mit '{search_term}' gefunden.", + title="Suche", + master=self + ).show() + + except subprocess.TimeoutExpired: + MessageDialog( + message_type="error", + text="Suche dauert zu lange und wurde abgebrochen.", + title="Suche", + master=self + ).show() + except Exception as e: + MessageDialog( + message_type="error", + text=f"Fehler bei der Suche: {e}", + title="Suchfehler", + master=self + ).show() + + def show_search_results_treeview(self): + """Show search results in TreeView format""" + # Clear current file list and replace with search results + for widget in self.file_list_frame.winfo_children(): + widget.destroy() + + # Create TreeView for search results + tree_frame = ttk.Frame(self.file_list_frame) + tree_frame.pack(fill='both', expand=True) + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + columns = ("path", "size", "modified") + search_tree = ttk.Treeview( + tree_frame, columns=columns, show="tree headings") + + # Configure columns + search_tree.heading("#0", text="Dateiname", anchor="w") + search_tree.column("#0", anchor="w", width=200, stretch=True) + search_tree.heading("path", text="Pfad", anchor="w") + search_tree.column("path", anchor="w", width=300, stretch=True) + search_tree.heading("size", text="Größe", anchor="e") + search_tree.column("size", anchor="e", width=100, stretch=False) + search_tree.heading("modified", text="Geändert am", anchor="w") + search_tree.column("modified", anchor="w", width=160, stretch=False) + + # Add scrollbars + v_scrollbar = ttk.Scrollbar( + tree_frame, orient="vertical", command=search_tree.yview) + h_scrollbar = ttk.Scrollbar( + tree_frame, orient="horizontal", command=search_tree.xview) + search_tree.configure(yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set) + + search_tree.grid(row=0, column=0, sticky='nsew') + v_scrollbar.grid(row=0, column=1, sticky='ns') + h_scrollbar.grid(row=1, column=0, sticky='ew') + + # Populate with search results + for file_path in self.search_results: + try: + filename = os.path.basename(file_path) + directory = os.path.dirname(file_path) + stat = os.stat(file_path) + size = self._format_size(stat.st_size) + modified_time = datetime.fromtimestamp( + stat.st_mtime).strftime('%d.%m.%Y %H:%M') + + icon = self.get_file_icon(filename, 'small') + search_tree.insert("", "end", text=f" {filename}", image=icon, + values=(directory, size, modified_time)) + except (FileNotFoundError, PermissionError): + continue + + # Bind double-click to select file + def on_search_double_click(event): + selection = search_tree.selection() + if selection: + item = search_tree.item(selection[0]) + filename = item['text'].strip() + directory = item['values'][0] + full_path = os.path.join(directory, filename) + + # Select the file and close dialog + self.selected_file = full_path + self.destroy() + + search_tree.bind("", on_search_double_click) + def _unbind_mouse_wheel_events(self): # Unbind all mouse wheel events from the root window self.unbind_all("") @@ -811,12 +1115,9 @@ class CustomFileDialog(tk.Toplevel): name = block_device.get('name') mountpoint = block_device.get('mountpoint') label = block_device.get('label') - # size = block_device.get('size') removable = block_device.get('rm', False) display_name = label if label else name - # if size: - # display_name += f" ({size})" devices.append((display_name, mountpoint, removable)) # Process children (partitions) @@ -833,12 +1134,9 @@ class CustomFileDialog(tk.Toplevel): name = child_device.get('name') mountpoint = child_device.get('mountpoint') label = child_device.get('label') - # size = child_device.get('size') removable = child_device.get('rm', False) display_name = label if label else name - # if size: - # display_name += f" ({size})" devices.append( (display_name, mountpoint, removable)) diff --git a/mainwindow.py b/mainwindow.py old mode 100644 new mode 100755 index 0610908..2683470 --- a/mainwindow.py +++ b/mainwindow.py @@ -32,10 +32,7 @@ class GlotzMol(tk.Tk): dialog = CustomFileDialog(self, initial_dir=os.path.expanduser("~"), - filetypes=[("Alle Dateien", "*.*"), - ("Audio-Dateien", "*.mp3 *.wav"), - ("Video-Dateien", "*.mkv *.mp4"), - ("ISO-Images", "*.iso"), + filetypes=[("Wireguard Files (.conf)", "*.conf"), ]) # This is the crucial part: wait for the dialog to be closed @@ -58,7 +55,7 @@ if __name__ == "__main__": style = ttk.Style(root) root.tk.call('source', f"{theme_path}/water.tcl") try: - root.tk.call('set_theme', 'light') + root.tk.call('set_theme', 'dark') except tk.TclError: pass root.mainloop()