commit 36 mit common tools

This commit is contained in:
2025-08-01 09:29:26 +02:00
parent 2404a60b6c
commit e3bb68f7e2
5 changed files with 1032 additions and 116 deletions

Binary file not shown.

621
common_tools.py Executable file
View File

@@ -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("<Enter>", self.enter)
self.widget.bind("<Leave>", self.leave)
self.widget.bind("<ButtonPress>", 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)

View File

@@ -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("<Enter>", self.enter)
self.widget.bind("<Leave>", self.leave)
self.widget.bind("<ButtonPress>", 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(
"<Return>", 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("<Configure>", 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("<Enter>", self._on_devices_enter)
self.devices_canvas.bind("<Leave>", self._on_devices_leave)
self.devices_scrollable_frame.bind("<Enter>", self._on_devices_enter)
self.devices_scrollable_frame.bind("<Leave>", 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("<Configure>", lambda e: self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all")))
self.devices_canvas.bind("<Configure>", _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("<MouseWheel>", _on_devices_mouse_wheel)
widget.bind("<Button-4>", _on_devices_mouse_wheel)
widget.bind("<Button-5>", _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("<MouseWheel>", _on_devices_mouse_wheel)
btn.bind("<Button-4>", _on_devices_mouse_wheel)
btn.bind("<Button-5>", _on_devices_mouse_wheel)
# Bind hover events for scrollbar visibility
btn.bind("<Enter>", self._on_devices_enter)
btn.bind("<Leave>", 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("<MouseWheel>", _on_devices_mouse_wheel)
progress_bar.bind("<Button-4>", _on_devices_mouse_wheel)
progress_bar.bind("<Button-5>", _on_devices_mouse_wheel)
# Bind hover events for scrollbar visibility
progress_bar.bind("<Enter>", self._on_devices_enter)
progress_bar.bind("<Leave>", 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("<Return>", self.execute_search)
self.path_entry.bind("<FocusIn>", 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(
"<Return>", lambda e: self.navigate_to(self.path_entry.get()))
self.path_entry.unbind("<FocusIn>")
# 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("<Double-1>", on_search_double_click)
def _unbind_mouse_wheel_events(self):
# Unbind all mouse wheel events from the root window
self.unbind_all("<MouseWheel>")
@@ -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))

7
mainwindow.py Normal file → Executable file
View File

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