diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..733fad7 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,5 @@ +# Gemini Project Configuration + +## Language +Please respond in German. + diff --git a/__pycache__/animated_icon.cpython-312.pyc b/__pycache__/animated_icon.cpython-312.pyc new file mode 100644 index 0000000..c7f4c19 Binary files /dev/null and b/__pycache__/animated_icon.cpython-312.pyc differ diff --git a/__pycache__/cfd_animated_icon.cpython-312.pyc b/__pycache__/cfd_animated_icon.cpython-312.pyc new file mode 100644 index 0000000..8e1af96 Binary files /dev/null and b/__pycache__/cfd_animated_icon.cpython-312.pyc differ diff --git a/__pycache__/cfd_app_config.cpython-312.pyc b/__pycache__/cfd_app_config.cpython-312.pyc new file mode 100644 index 0000000..8342136 Binary files /dev/null and b/__pycache__/cfd_app_config.cpython-312.pyc differ diff --git a/__pycache__/cfd_file_operations.cpython-312.pyc b/__pycache__/cfd_file_operations.cpython-312.pyc new file mode 100644 index 0000000..0591a9c Binary files /dev/null and b/__pycache__/cfd_file_operations.cpython-312.pyc differ diff --git a/__pycache__/cfd_navigation_manager.cpython-312.pyc b/__pycache__/cfd_navigation_manager.cpython-312.pyc new file mode 100644 index 0000000..98f828b Binary files /dev/null and b/__pycache__/cfd_navigation_manager.cpython-312.pyc differ diff --git a/__pycache__/cfd_search_manager.cpython-312.pyc b/__pycache__/cfd_search_manager.cpython-312.pyc new file mode 100644 index 0000000..ac66a51 Binary files /dev/null and b/__pycache__/cfd_search_manager.cpython-312.pyc differ diff --git a/__pycache__/cfd_settings_dialog.cpython-312.pyc b/__pycache__/cfd_settings_dialog.cpython-312.pyc new file mode 100644 index 0000000..2aae24b Binary files /dev/null and b/__pycache__/cfd_settings_dialog.cpython-312.pyc differ diff --git a/__pycache__/cfd_ui_setup.cpython-312.pyc b/__pycache__/cfd_ui_setup.cpython-312.pyc new file mode 100644 index 0000000..8cca357 Binary files /dev/null and b/__pycache__/cfd_ui_setup.cpython-312.pyc differ diff --git a/__pycache__/cfd_view_manager.cpython-312.pyc b/__pycache__/cfd_view_manager.cpython-312.pyc new file mode 100644 index 0000000..de015e9 Binary files /dev/null and b/__pycache__/cfd_view_manager.cpython-312.pyc differ diff --git a/__pycache__/custom_file_dialog.cpython-312.pyc b/__pycache__/custom_file_dialog.cpython-312.pyc new file mode 100644 index 0000000..01f610a Binary files /dev/null and b/__pycache__/custom_file_dialog.cpython-312.pyc differ diff --git a/animated_icon.py b/animated_icon.py new file mode 100644 index 0000000..1f506ea --- /dev/null +++ b/animated_icon.py @@ -0,0 +1,829 @@ +""" +A Tkinter widget for displaying animated icons. + +This module provides the AnimatedIcon class, a custom Tkinter Canvas widget +that can display various types of animations. It supports both native Tkinter +drawing and Pillow (PIL) for anti-aliased graphics if available. +""" +import tkinter as tk +from math import sin, cos, pi +from typing import Tuple, Optional + + +try: + from PIL import Image, ImageDraw, ImageTk + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + +def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: + """Converts a hex color string to an RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + +class AnimatedIcon(tk.Canvas): + """A custom Tkinter Canvas widget for displaying animations.""" + def __init__(self, master: tk.Misc, width: int = 20, height: int = 20, animation_type: str = "counter_arc", color: str = "#2a6fde", highlight_color: str = "#5195ff", use_pillow: bool = False, bg: Optional[str] = None) -> None: + """ + Initializes the AnimatedIcon widget. + + Args: + master: The parent widget. + width (int): The width of the icon. + height (int): The height of the icon. + animation_type (str): The type of animation to display. + Options: "counter_arc", "double_arc", "line", "blink". + color (str): The primary color of the icon. + highlight_color (str): The highlight color of the icon. + use_pillow (bool): Whether to use Pillow for drawing if available. + bg (str): The background color of the canvas. + """ + if bg is None: + try: + bg = master.cget("background") + except tk.TclError: + bg = "#f0f0f0" # Fallback color + super().__init__(master, width=width, height=height, bg=bg, highlightthickness=0) + + self.width = width + self.height = height + self.animation_type = animation_type + self.color = color + self.highlight_color = highlight_color + self.use_pillow = use_pillow and PIL_AVAILABLE + self.running = False + self.angle = 0 + self.pulse_animation = False + + self.color_rgb = _hex_to_rgb(self.color) + self.highlight_color_rgb = _hex_to_rgb(self.highlight_color) + + if self.use_pillow: + self.image = Image.new("RGBA", (width * 4, height * 4), (0, 0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + self.photo_image = None + + def _draw_frame(self) -> None: + """Draws a single frame of the animation.""" + if self.use_pillow: + self._draw_pillow_frame() + else: + self._draw_canvas_frame() + + def _draw_canvas_frame(self) -> None: + """Draws a frame using native Tkinter canvas methods.""" + self.delete("all") + if self.pulse_animation: + self._draw_canvas_pulse() + elif self.animation_type == "line": + self._draw_canvas_line() + elif self.animation_type == "double_arc": + self._draw_canvas_double_arc() + elif self.animation_type == "counter_arc": + self._draw_canvas_counter_arc() + elif self.animation_type == "blink": + self._draw_canvas_blink() + + def _draw_canvas_pulse(self) -> None: + """Draws the pulse animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + pulse_color = f"#{r:02x}{g:02x}{b:02x}" + + if self.animation_type == "line": + for i in range(8): + angle = i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + self.create_line(start_x, start_y, end_x, end_y, fill=pulse_color, width=2) + elif self.animation_type == "double_arc": + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC, outline=pulse_color, width=2) + elif self.animation_type == "counter_arc": + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.create_arc(bbox_outer, start=0, extent=359.9, style=tk.ARC, outline=pulse_color, width=2) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.create_arc(bbox_inner, start=0, extent=359.9, style=tk.ARC, outline=self.color, width=2) + + + def _draw_canvas_line(self) -> None: + """Draws the line animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + for i in range(8): + angle = self.angle + i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + alpha = (cos(self.angle * 2 + i * (pi / 4)) + 1) / 2 + + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + color = f"#{r:02x}{g:02x}{b:02x}" + + self.create_line(start_x, start_y, end_x, end_y, fill=color, width=2) + + def _draw_canvas_double_arc(self) -> None: + """Draws the double arc animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + + start_angle1 = -self.angle * 180 / pi + extent1 = 120 + 60 * sin(-self.angle) + self.create_arc(bbox, start=start_angle1, extent=extent1, style=tk.ARC, outline=self.highlight_color, width=2) + + start_angle2 = (-self.angle + pi) * 180 / pi + extent2 = 120 + 60 * sin(-self.angle + pi / 2) + self.create_arc(bbox, start=start_angle2, extent=extent2, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_counter_arc(self) -> None: + """Draws the counter arc animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + start_angle1 = -self.angle * 180 / pi + self.create_arc(bbox_outer, start=start_angle1, extent=150, style=tk.ARC, outline=self.highlight_color, width=2) + + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + start_angle2 = self.angle * 180 / pi + 60 + self.create_arc(bbox_inner, start=start_angle2, extent=150, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_blink(self) -> None: + """Draws the blink animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + blink_color = f"#{r:02x}{g:02x}{b:02x}" + self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y + radius, start=0, extent=359.9, style=tk.ARC, outline=blink_color, width=4) + + def _draw_pillow_frame(self) -> None: + """Draws a frame using Pillow for anti-aliased graphics.""" + self.draw.rectangle([0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0)) + if self.pulse_animation: + self._draw_pillow_pulse() + elif self.animation_type == "line": + self._draw_pillow_line() + elif self.animation_type == "double_arc": + self._draw_pillow_double_arc() + elif self.animation_type == "counter_arc": + self._draw_pillow_counter_arc() + elif self.animation_type == "blink": + self._draw_pillow_blink() + + resized_image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS) + self.photo_image = ImageTk.PhotoImage(resized_image) + self.delete("all") + self.create_image(0, 0, anchor="nw", image=self.photo_image) + + def _draw_pillow_pulse(self) -> None: + """Draws the pulse animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + pulse_color = (r, g, b) + + if self.animation_type == "line": + for i in range(12): + angle = i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=pulse_color, width=6, joint="curve") + elif self.animation_type == "double_arc": + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.draw.arc(bbox, start=0, end=360, fill=pulse_color, width=5) + elif self.animation_type == "counter_arc": + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.draw.arc(bbox_outer, start=0, end=360, fill=pulse_color, width=7) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.draw.arc(bbox_inner, start=0, end=360, fill=self.color_rgb, width=7) + + def _draw_pillow_line(self) -> None: + """Draws the line animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + for i in range(12): + angle = self.angle + i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + alpha = (cos(self.angle * 2.5 + i * (pi / 6)) + 1) / 2 + + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + color = (r, g, b) + + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=6, joint="curve") + + def _draw_pillow_double_arc(self) -> None: + """Draws the double arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + + start_angle1 = self.angle * 180 / pi + extent1 = 120 + 60 * sin(self.angle) + self.draw.arc(bbox, start=start_angle1, end=start_angle1 + extent1, fill=self.highlight_color_rgb, width=5) + + start_angle2 = (self.angle + pi) * 180 / pi + extent2 = 120 + 60 * sin(self.angle + pi / 2) + self.draw.arc(bbox, start=start_angle2, end=start_angle2 + extent2, fill=self.color_rgb, width=5) + + def _draw_pillow_counter_arc(self) -> None: + """Draws the counter arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + start_angle1 = self.angle * 180 / pi + self.draw.arc(bbox_outer, start=start_angle1, end=start_angle1 + 150, fill=self.highlight_color_rgb, width=7) + + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + start_angle2 = -self.angle * 180 / pi + 60 + self.draw.arc(bbox_inner, start=start_angle2, end=start_angle2 + 150, fill=self.color_rgb, width=7) + + def _draw_pillow_blink(self) -> None: + """Draws the blink animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + blink_color = (r, g, b) + self.draw.arc((center_x - radius, center_y - radius, center_x + radius, center_y + radius), start=0, end=360, fill=blink_color, width=10) + + def _draw_stopped_frame(self) -> None: + """Draws the icon in its stopped (static) state.""" + self.delete("all") + if self.use_pillow: + self._draw_pillow_stopped_frame() + else: + self._draw_canvas_stopped_frame() + + def _draw_canvas_stopped_frame(self) -> None: + """Draws the stopped state using canvas methods.""" + if self.animation_type == "line": + self._draw_canvas_line_stopped() + elif self.animation_type == "double_arc": + self._draw_canvas_double_arc_stopped() + elif self.animation_type == "counter_arc": + self._draw_canvas_counter_arc_stopped() + elif self.animation_type == "blink": + self._draw_canvas_blink_stopped() + + def _draw_canvas_line_stopped(self) -> None: + """Draws the stopped state for the line animation.""" + center_x, center_y = self.width / 2, self.height / 2 + for i in range(8): + angle = i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + self.create_line(start_x, start_y, end_x, end_y, fill=self.highlight_color, width=2) + + def _draw_canvas_double_arc_stopped(self) -> None: + """Draws the stopped state for the double arc animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=2) + + def _draw_canvas_counter_arc_stopped(self) -> None: + """Draws the stopped state for the counter arc animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.create_arc(bbox_outer, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=2) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.create_arc(bbox_inner, start=0, extent=359.9, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_blink_stopped(self) -> None: + """Draws the stopped state for the blink animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y + radius, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=4) + + def _draw_pillow_stopped_frame(self) -> None: + """Draws the stopped state using Pillow.""" + self.draw.rectangle([0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0)) + if self.animation_type == "line": + self._draw_pillow_line_stopped() + elif self.animation_type == "double_arc": + self._draw_pillow_double_arc_stopped() + elif self.animation_type == "counter_arc": + self._draw_pillow_counter_arc_stopped() + elif self.animation_type == "blink": + self._draw_pillow_blink_stopped() + + resized_image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS) + self.photo_image = ImageTk.PhotoImage(resized_image) + self.create_image(0, 0, anchor="nw", image=self.photo_image) + + def _draw_pillow_line_stopped(self) -> None: + """Draws the stopped state for the line animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + for i in range(12): + angle = i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=self.highlight_color_rgb, width=6, joint="curve") + + def _draw_pillow_double_arc_stopped(self) -> None: + """Draws the stopped state for the double arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.draw.arc(bbox, start=0, end=360, fill=self.highlight_color_rgb, width=5) + + def _draw_pillow_counter_arc_stopped(self) -> None: + """Draws the stopped state for the counter arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.draw.arc(bbox_outer, start=0, end=360, fill=self.highlight_color_rgb, width=7) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.draw.arc(bbox_inner, start=0, end=360, fill=self.color_rgb, width=7) + + def _draw_pillow_blink_stopped(self) -> None: + """Draws the stopped state for the blink animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + self.draw.arc((center_x - radius, center_y - radius, center_x + radius, center_y + radius), start=0, end=360, fill=self.highlight_color_rgb, width=10) + + def _animate(self) -> None: + """The main animation loop.""" + if self.running: + self.angle += 0.1 + if self.angle > 2 * pi: + self.angle -= 2 * pi + self._draw_frame() + self.after(30, self._animate) + + def start(self, pulse: bool = False) -> None: + """ + Starts the animation. + + Args: + pulse (bool): If True, plays a pulsing animation instead of the main one. + """ + if not self.running: + self.pulse_animation = pulse + self.running = True + self._animate() + + def stop(self) -> None: + """Stops the animation and shows the static 'stopped' frame.""" + self.running = False + self.pulse_animation = False + self._draw_stopped_frame() + + def hide(self) -> None: + """Stops the animation and clears the canvas.""" + self.running = False + self.pulse_animation = False + self.delete("all") + + def show_full_circle(self) -> None: + """Shows the static 'stopped' frame without starting the animation.""" + if not self.running: + self._draw_stopped_frame() + +import tkinter as tk +from math import sin, cos, pi + + +try: + from PIL import Image, ImageDraw, ImageTk + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + +def _hex_to_rgb(hex_color): + """Converts a hex color string to an RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + +class AnimatedIcon(tk.Canvas): + """A custom Tkinter Canvas widget for displaying animations.""" + def __init__(self, master, width=20, height=20, animation_type="counter_arc", color="#2a6fde", highlight_color="#5195ff", use_pillow=False, bg=None): + """ + Initializes the AnimatedIcon widget. + + Args: + master: The parent widget. + width (int): The width of the icon. + height (int): The height of the icon. + animation_type (str): The type of animation to display. + Options: "counter_arc", "double_arc", "line", "blink". + color (str): The primary color of the icon. + highlight_color (str): The highlight color of the icon. + use_pillow (bool): Whether to use Pillow for drawing if available. + bg (str): The background color of the canvas. + """ + if bg is None: + try: + bg = master.cget("background") + except tk.TclError: + bg = "#f0f0f0" # Fallback color + super().__init__(master, width=width, height=height, bg=bg, highlightthickness=0) + + self.width = width + self.height = height + self.animation_type = animation_type + self.color = color + self.highlight_color = highlight_color + self.use_pillow = use_pillow and PIL_AVAILABLE + self.running = False + self.angle = 0 + self.pulse_animation = False + + self.color_rgb = _hex_to_rgb(self.color) + self.highlight_color_rgb = _hex_to_rgb(self.highlight_color) + + if self.use_pillow: + self.image = Image.new("RGBA", (width * 4, height * 4), (0, 0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + self.photo_image = None + + def _draw_frame(self): + """Draws a single frame of the animation.""" + if self.use_pillow: + self._draw_pillow_frame() + else: + self._draw_canvas_frame() + + def _draw_canvas_frame(self): + """Draws a frame using native Tkinter canvas methods.""" + self.delete("all") + if self.pulse_animation: + self._draw_canvas_pulse() + elif self.animation_type == "line": + self._draw_canvas_line() + elif self.animation_type == "double_arc": + self._draw_canvas_double_arc() + elif self.animation_type == "counter_arc": + self._draw_canvas_counter_arc() + elif self.animation_type == "blink": + self._draw_canvas_blink() + + def _draw_canvas_pulse(self): + """Draws the pulse animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + pulse_color = f"#{r:02x}{g:02x}{b:02x}" + + if self.animation_type == "line": + for i in range(8): + angle = i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + self.create_line(start_x, start_y, end_x, end_y, fill=pulse_color, width=2) + elif self.animation_type == "double_arc": + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC, outline=pulse_color, width=2) + elif self.animation_type == "counter_arc": + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.create_arc(bbox_outer, start=0, extent=359.9, style=tk.ARC, outline=pulse_color, width=2) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.create_arc(bbox_inner, start=0, extent=359.9, style=tk.ARC, outline=self.color, width=2) + + + def _draw_canvas_line(self): + """Draws the line animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + for i in range(8): + angle = self.angle + i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + alpha = (cos(self.angle * 2 + i * (pi / 4)) + 1) / 2 + + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + color = f"#{r:02x}{g:02x}{b:02x}" + + self.create_line(start_x, start_y, end_x, end_y, fill=color, width=2) + + def _draw_canvas_double_arc(self): + """Draws the double arc animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + + start_angle1 = -self.angle * 180 / pi + extent1 = 120 + 60 * sin(-self.angle) + self.create_arc(bbox, start=start_angle1, extent=extent1, style=tk.ARC, outline=self.highlight_color, width=2) + + start_angle2 = (-self.angle + pi) * 180 / pi + extent2 = 120 + 60 * sin(-self.angle + pi / 2) + self.create_arc(bbox, start=start_angle2, extent=extent2, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_counter_arc(self): + """Draws the counter arc animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + start_angle1 = -self.angle * 180 / pi + self.create_arc(bbox_outer, start=start_angle1, extent=150, style=tk.ARC, outline=self.highlight_color, width=2) + + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + start_angle2 = self.angle * 180 / pi + 60 + self.create_arc(bbox_inner, start=start_angle2, extent=150, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_blink(self): + """Draws the blink animation using canvas methods.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + blink_color = f"#{r:02x}{g:02x}{b:02x}" + self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y + radius, start=0, extent=359.9, style=tk.ARC, outline=blink_color, width=4) + + def _draw_pillow_frame(self): + """Draws a frame using Pillow for anti-aliased graphics.""" + self.draw.rectangle([0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0)) + if self.pulse_animation: + self._draw_pillow_pulse() + elif self.animation_type == "line": + self._draw_pillow_line() + elif self.animation_type == "double_arc": + self._draw_pillow_double_arc() + elif self.animation_type == "counter_arc": + self._draw_pillow_counter_arc() + elif self.animation_type == "blink": + self._draw_pillow_blink() + + resized_image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS) + self.photo_image = ImageTk.PhotoImage(resized_image) + self.delete("all") + self.create_image(0, 0, anchor="nw", image=self.photo_image) + + def _draw_pillow_pulse(self): + """Draws the pulse animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + pulse_color = (r, g, b) + + if self.animation_type == "line": + for i in range(12): + angle = i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=pulse_color, width=6, joint="curve") + elif self.animation_type == "double_arc": + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.draw.arc(bbox, start=0, end=360, fill=pulse_color, width=5) + elif self.animation_type == "counter_arc": + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.draw.arc(bbox_outer, start=0, end=360, fill=pulse_color, width=7) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.draw.arc(bbox_inner, start=0, end=360, fill=self.color_rgb, width=7) + + def _draw_pillow_line(self): + """Draws the line animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + for i in range(12): + angle = self.angle + i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + alpha = (cos(self.angle * 2.5 + i * (pi / 6)) + 1) / 2 + + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + color = (r, g, b) + + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=6, joint="curve") + + def _draw_pillow_double_arc(self): + """Draws the double arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + + start_angle1 = self.angle * 180 / pi + extent1 = 120 + 60 * sin(self.angle) + self.draw.arc(bbox, start=start_angle1, end=start_angle1 + extent1, fill=self.highlight_color_rgb, width=5) + + start_angle2 = (self.angle + pi) * 180 / pi + extent2 = 120 + 60 * sin(self.angle + pi / 2) + self.draw.arc(bbox, start=start_angle2, end=start_angle2 + extent2, fill=self.color_rgb, width=5) + + def _draw_pillow_counter_arc(self): + """Draws the counter arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + start_angle1 = self.angle * 180 / pi + self.draw.arc(bbox_outer, start=start_angle1, end=start_angle1 + 150, fill=self.highlight_color_rgb, width=7) + + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + start_angle2 = -self.angle * 180 / pi + 60 + self.draw.arc(bbox_inner, start=start_angle2, end=start_angle2 + 150, fill=self.color_rgb, width=7) + + def _draw_pillow_blink(self): + """Draws the blink animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed + r = int(alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0]) + g = int(alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1]) + b = int(alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2]) + blink_color = (r, g, b) + self.draw.arc((center_x - radius, center_y - radius, center_x + radius, center_y + radius), start=0, end=360, fill=blink_color, width=10) + + def _draw_stopped_frame(self): + """Draws the icon in its stopped (static) state.""" + self.delete("all") + if self.use_pillow: + self._draw_pillow_stopped_frame() + else: + self._draw_canvas_stopped_frame() + + def _draw_canvas_stopped_frame(self): + """Draws the stopped state using canvas methods.""" + if self.animation_type == "line": + self._draw_canvas_line_stopped() + elif self.animation_type == "double_arc": + self._draw_canvas_double_arc_stopped() + elif self.animation_type == "counter_arc": + self._draw_canvas_counter_arc_stopped() + elif self.animation_type == "blink": + self._draw_canvas_blink_stopped() + + def _draw_canvas_line_stopped(self): + """Draws the stopped state for the line animation.""" + center_x, center_y = self.width / 2, self.height / 2 + for i in range(8): + angle = i * (pi / 4) + start_x = center_x + cos(angle) * (self.width * 0.2) + start_y = center_y + sin(angle) * (self.height * 0.2) + end_x = center_x + cos(angle) * (self.width * 0.4) + end_y = center_y + sin(angle) * (self.height * 0.4) + self.create_line(start_x, start_y, end_x, end_y, fill=self.highlight_color, width=2) + + def _draw_canvas_double_arc_stopped(self): + """Draws the stopped state for the double arc animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=2) + + def _draw_canvas_counter_arc_stopped(self): + """Draws the stopped state for the counter arc animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.create_arc(bbox_outer, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=2) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.create_arc(bbox_inner, start=0, extent=359.9, style=tk.ARC, outline=self.color, width=2) + + def _draw_canvas_blink_stopped(self): + """Draws the stopped state for the blink animation.""" + center_x, center_y = self.width / 2, self.height / 2 + radius = min(center_x, center_y) * 0.8 + self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y + radius, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=4) + + def _draw_pillow_stopped_frame(self): + """Draws the stopped state using Pillow.""" + self.draw.rectangle([0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0)) + if self.animation_type == "line": + self._draw_pillow_line_stopped() + elif self.animation_type == "double_arc": + self._draw_pillow_double_arc_stopped() + elif self.animation_type == "counter_arc": + self._draw_pillow_counter_arc_stopped() + elif self.animation_type == "blink": + self._draw_pillow_blink_stopped() + + resized_image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS) + self.photo_image = ImageTk.PhotoImage(resized_image) + self.create_image(0, 0, anchor="nw", image=self.photo_image) + + def _draw_pillow_line_stopped(self): + """Draws the stopped state for the line animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + for i in range(12): + angle = i * (pi / 6) + start_x = center_x + cos(angle) * (self.width * 0.8) + start_y = center_y + sin(angle) * (self.height * 0.8) + end_x = center_x + cos(angle) * (self.width * 1.6) + end_y = center_y + sin(angle) * (self.height * 1.6) + self.draw.line([(start_x, start_y), (end_x, end_y)], fill=self.highlight_color_rgb, width=6, joint="curve") + + def _draw_pillow_double_arc_stopped(self): + """Draws the stopped state for the double arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + bbox = (center_x - radius, center_y - radius, center_x + radius, center_y + radius) + self.draw.arc(bbox, start=0, end=360, fill=self.highlight_color_rgb, width=5) + + def _draw_pillow_counter_arc_stopped(self): + """Draws the stopped state for the counter arc animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius_outer = min(center_x, center_y) * 0.8 + bbox_outer = (center_x - radius_outer, center_y - radius_outer, center_x + radius_outer, center_y + radius_outer) + self.draw.arc(bbox_outer, start=0, end=360, fill=self.highlight_color_rgb, width=7) + radius_inner = min(center_x, center_y) * 0.6 + bbox_inner = (center_x - radius_inner, center_y - radius_inner, center_x + radius_inner, center_y + radius_inner) + self.draw.arc(bbox_inner, start=0, end=360, fill=self.color_rgb, width=7) + + def _draw_pillow_blink_stopped(self): + """Draws the stopped state for the blink animation using Pillow.""" + center_x, center_y = self.width * 2, self.height * 2 + radius = min(center_x, center_y) * 0.8 + self.draw.arc((center_x - radius, center_y - radius, center_x + radius, center_y + radius), start=0, end=360, fill=self.highlight_color_rgb, width=10) + + def _animate(self): + """The main animation loop.""" + if self.running: + self.angle += 0.1 + if self.angle > 2 * pi: + self.angle -= 2 * pi + self._draw_frame() + self.after(30, self._animate) + + def start(self, pulse=False): + """ + Starts the animation. + + Args: + pulse (bool): If True, plays a pulsing animation instead of the main one. + """ + if not self.running: + self.pulse_animation = pulse + self.running = True + self._animate() + + def stop(self): + """Stops the animation and shows the static 'stopped' frame.""" + self.running = False + self.pulse_animation = False + self._draw_stopped_frame() + + def hide(self): + """Stops the animation and clears the canvas.""" + self.running = False + self.pulse_animation = False + self.delete("all") + + def show_full_circle(self): + """Shows the static 'stopped' frame without starting the animation.""" + if not self.running: + self._draw_stopped_frame() \ No newline at end of file diff --git a/cfd_app_config.py b/cfd_app_config.py new file mode 100755 index 0000000..0b92638 --- /dev/null +++ b/cfd_app_config.py @@ -0,0 +1,504 @@ +#!/usr/bin/python3 + +"""App configuration for Custom File Dialog""" +import json +from pathlib import Path +import os +from typing import Dict, Any +from shared_libs.common_tools import Translate + + +class AppConfig: + """ + Holds static configuration values for the application. + + Attributes: + SCRIPT_DIR (str): The absolute path to the directory where the script is running. + MAX_ITEMS_TO_DISPLAY (int): The maximum number of items to show in the file list to prevent performance issues. + BASE_DIR (Path): The user's home directory. + CONFIG_DIR (Path): The directory for storing configuration files. + UI_CONFIG (Dict[str, Any]): A dictionary containing UI-related settings. + """ + # Helper to make icon paths robust, so the script can be run from anywhere + #!/usr/bin/python3 + +"""App configuration for Custom File Dialog""" +import json +from pathlib import Path +import os +from typing import Dict, Any, Optional, Type +from shared_libs.common_tools import Translate + + +class AppConfig: + """ + Holds static configuration values for the application. + + Attributes: + SCRIPT_DIR (str): The absolute path to the directory where the script is running. + MAX_ITEMS_TO_DISPLAY (int): The maximum number of items to show in the file list to prevent performance issues. + BASE_DIR (Path): The user's home directory. + CONFIG_DIR (Path): The directory for storing configuration files. + UI_CONFIG (Dict[str, Any]): A dictionary containing UI-related settings. + """ + # Helper to make icon paths robust, so the script can be run from anywhere + SCRIPT_DIR: str = os.path.dirname(os.path.abspath(__file__)) + MAX_ITEMS_TO_DISPLAY: int = 1000 + + # Base paths + BASE_DIR: Path = Path.home() + CONFIG_DIR: Path = BASE_DIR / ".config/cfiledialog" + + # UI configuration + UI_CONFIG: Dict[str, Any] = { + "window_size": (1050, 850), + "window_min_size": (650, 550), + "font_family": "Ubuntu", + "font_size": 11, + "resizable_window": (True, True), + } + + +# here is initializing the class for translation strings +_ = Translate.setup_translations("custom_file_dialog") + + +class CfdConfigManager: + """ + Manages CFD-specific settings using a JSON file for flexibility. + """ + _config: Optional[Dict[str, Any]] = None + _config_file: Path = AppConfig.CONFIG_DIR / "cfd_settings.json" + _default_settings: Dict[str, Any] = { + "search_icon_pos": "left", # 'left' or 'right' + "button_box_pos": "left", # 'left' or 'right' + "window_size_preset": "1050x850", # e.g., "1050x850" + "default_view_mode": "icons", # 'icons' or 'list' + "search_hidden_files": False, # True or False + "use_trash": False, # True or False + "confirm_delete": False, # True or False + "recursive_search": True, + "use_pillow_animation": True + } + + @classmethod + def _ensure_config_file(cls: Type['CfdConfigManager']) -> None: + """Ensures the configuration file exists, creating it with default settings if necessary.""" + if not cls._config_file.exists(): + try: + cls._config_file.parent.mkdir(parents=True, exist_ok=True) + with open(cls._config_file, 'w', encoding='utf-8') as f: + json.dump(cls._default_settings, f, indent=4) + except IOError as e: + print(f"Error creating default settings file: {e}") + + @classmethod + def load(cls: Type['CfdConfigManager']) -> Dict[str, Any]: + """Loads settings from the JSON file. If the file doesn't exist or is invalid, it loads default settings.""" + cls._ensure_config_file() + if cls._config is None: + try: + with open(cls._config_file, 'r', encoding='utf-8') as f: + loaded_config = json.load(f) + # Merge with defaults to ensure all keys are present + cls._config = cls._default_settings.copy() + cls._config.update(loaded_config) + except (IOError, json.JSONDecodeError): + cls._config = cls._default_settings.copy() + return cls._config + + @classmethod + def save(cls: Type['CfdConfigManager'], settings: Dict[str, Any]) -> None: + """Saves the given settings dictionary to the JSON file.""" + try: + with open(cls._config_file, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=4) + cls._config = settings # Update cached config + except IOError as e: + print(f"Error saving settings: {e}") + + +class LocaleStrings: + """ + Contains all translatable strings for the application, organized by module. + + This class centralizes all user-facing strings to make translation and management easier. + The strings are grouped into nested dictionaries corresponding to the part of the application + where they are used (e.g., CFD for the main dialog, VIEW for view-related strings). + """ + # Strings from custom_file_dialog.py + CFD: Dict[str, str] = { + "title": _("Custom File Dialog"), + "select_file": _("Select a file"), + "open": _("Open"), + "cancel": _("Cancel"), + "file_label": _("File:"), + "no_file_selected": _("No file selected"), + "error_title": _("Error"), + "select_file_error": _("Please select a file."), + "all_files": _("All Files"), + "free_space": _("Free Space"), + "entries": _("entries"), + "directory_not_found": _("Directory not found"), + "unknown": _("Unknown"), + "showing": _("Showing"), + "of": _("of"), + "access_denied": _("Access denied."), + "path_not_found": _("Path not found"), + "directory": _("Directory"), + "not_found": _("not found."), + "access_to": _("Access to"), + "denied": _("denied."), + } + + # Strings from cfd_view_manager.py + VIEW: Dict[str, str] = { + "name": _("Name"), + "date_modified": _("Date Modified"), + "type": _("Type"), + "size": _("Size"), + "view_mode": _("View Mode"), + "icon_view": _("Icon View"), + "list_view": _("List View"), + "filename": _("Filename"), + "path": _("Path"), + } + + # Strings from cfd_ui_setup.py + UI: Dict[str, str] = { + "search": _("Search"), + "go": _("Go"), + "up": _("Up"), + "back": _("Back"), + "forward": _("Forward"), + "home": _("Home"), + "new_folder": _("New Folder"), + "delete": _("Delete"), + "settings": _("Settings"), + "show_hidden_files": _("Show Hidden Files"), + "places": _("Places"), + "devices": _("Devices"), + "bookmarks": _("Bookmarks"), + "new_document": _("New Document"), + "hide_hidden_files": _("Hide Hidden Files"), + "start_search": _("Start Search"), + "cancel_search": _("Cancel Search"), + "delete_move": _("Delete/Move selected item"), + "copy_filename_to_clipboard": _("Copy Filename to Clipboard"), + "copy_path_to_clipboard": _("Copy Path to Clipboard"), + "open_file_location": _("Open File Location"), + "searching_for": _("Searching for"), + "search_cancelled_by_user": _("Search cancelled by user"), + "folders_and": _("folders and"), + "files_found": _("files found."), + "no_results_for": _("No results for"), + "error_during_search": _("Error during search"), + "search_error": _("Search Error"), + } + + # Strings from cfd_settings_dialog.py + SET: Dict[str, str] = { + "title": _("Settings"), + "search_icon_pos_label": _("Search Icon Position"), + "left_radio": _("Left"), + "right_radio": _("Right"), + "button_box_pos_label": _("Button Box Position"), + "window_size_label": _("Window Size"), + "default_view_mode_label": _("Default View Mode"), + "icons_radio": _("Icons"), + "list_radio": _("List"), + "search_hidden_check": _("Search hidden files"), + "use_trash_check": _("Use trash for deletion"), + "confirm_delete_check": _("Confirm file deletion"), + "recursive_search_check": _("Recursive search"), + "use_pillow_check": _("Use Pillow animation"), + "save_button": _("Save"), + "cancel_button": _("Cancel"), + "search_settings": _("Search Settings"), + "deletion_settings": _("Deletion Settings"), + "recommended": _("recommended"), + "send2trash_not_found": _("send2trash library not found"), + "animation_settings": _("Animation Settings"), + "pillow": _("Pillow"), + "pillow_not_found": _("Pillow library not found"), + "animation_type": _("Animation Type"), + "counter_arc": _("Counter Arc"), + "double_arc": _("Double Arc"), + "line": _("Line"), + "blink": _("Blink"), + "deletion_options_info": _("Deletion options are only available in save mode"), + "reset_to_default": _("Reset to Default"), + } + + # Strings from cfd_file_operations.py + FILE: Dict[str, str] = { + "new_folder_title": _("New Folder"), + "enter_folder_name_label": _("Enter folder name:"), + "untitled_folder": _("Untitled Folder"), + "error_title": _("Error"), + "folder_exists_error": _("Folder already exists."), + "create_folder_error": _("Could not create folder."), + "confirm_delete_title": _("Confirm Deletion"), + "confirm_delete_file_message": _("Are you sure you want to permanently delete this file?"), + "confirm_delete_files_message": _("Are you sure you want to permanently delete these files?"), + "delete_button": _("Delete"), + "cancel_button": _("Cancel"), + "file_not_found_error": _("File not found."), + "trash_error": _("Could not move file to trash."), + "delete_error": _("Could not delete file."), + "folder": _("Folder"), + "file": _("File"), + "move_to_trash": _("move to trash"), + "delete_permanently": _("delete permanently"), + "are_you_sure": _("Are you sure you want to"), + "was_successfully_removed": _("was successfully removed."), + "error_removing": _("Error removing"), + "new_document_txt": _("New Document.txt"), + "error_creating": _("Error creating"), + "copied_to_clipboard": _("copied to clipboard."), + "error_renaming": _("Error renaming"), + "not_accessible": _("not accessible"), + } + + # Strings from cfd_navigation_manager.py + NAV: Dict[str, str] = { + "home": _("Home"), + "trash": _("Trash"), + "desktop": _("Desktop"), + "documents": _("Documents"), + "downloads": _("Downloads"), + "music": _("Music"), + "pictures": _("Pictures"), + "videos": _("Videos"), + "computer": _("Computer"), + } + + MAX_ITEMS_TO_DISPLAY = 1000 + + # Base paths + BASE_DIR: Path = Path.home() + CONFIG_DIR: Path = BASE_DIR / ".config/cfiledialog" + + # UI configuration + UI_CONFIG: Dict[str, Any] = { + "window_size": (1050, 850), + "window_min_size": (650, 550), + "font_family": "Ubuntu", + "font_size": 11, + "resizable_window": (True, True), + } + + +# here is initializing the class for translation strings +_ = Translate.setup_translations("custom_file_dialog") + + +class CfdConfigManager: + """ + Manages CFD-specific settings using a JSON file for flexibility. + """ + _config = None + _config_file = AppConfig.CONFIG_DIR / "cfd_settings.json" + _default_settings = { + "search_icon_pos": "left", # 'left' or 'right' + "button_box_pos": "left", # 'left' or 'right' + "window_size_preset": "1050x850", # e.g., "1050x850" + "default_view_mode": "icons", # 'icons' or 'list' + "search_hidden_files": False, # True or False + "use_trash": False, # True or False + "confirm_delete": False, # True or False + "recursive_search": True, + "use_pillow_animation": True + } + + @classmethod + def _ensure_config_file(cls): + """Ensures the configuration file exists, creating it with default settings if necessary.""" + if not cls._config_file.exists(): + try: + cls._config_file.parent.mkdir(parents=True, exist_ok=True) + with open(cls._config_file, 'w', encoding='utf-8') as f: + json.dump(cls._default_settings, f, indent=4) + except IOError as e: + print(f"Error creating default settings file: {e}") + + @classmethod + def load(cls): + """Loads settings from the JSON file. If the file doesn't exist or is invalid, it loads default settings.""" + cls._ensure_config_file() + if cls._config is None: + try: + with open(cls._config_file, 'r', encoding='utf-8') as f: + loaded_config = json.load(f) + # Merge with defaults to ensure all keys are present + cls._config = cls._default_settings.copy() + cls._config.update(loaded_config) + except (IOError, json.JSONDecodeError): + cls._config = cls._default_settings.copy() + return cls._config + + @classmethod + def save(cls, settings): + """Saves the given settings dictionary to the JSON file.""" + try: + with open(cls._config_file, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=4) + cls._config = settings # Update cached config + except IOError as e: + print(f"Error saving settings: {e}") + + +class LocaleStrings: + """ + Contains all translatable strings for the application, organized by module. + + This class centralizes all user-facing strings to make translation and management easier. + The strings are grouped into nested dictionaries corresponding to the part of the application + where they are used (e.g., CFD for the main dialog, VIEW for view-related strings). + """ + # Strings from custom_file_dialog.py + CFD = { + "title": _("Custom File Dialog"), + "select_file": _("Select a file"), + "open": _("Open"), + "cancel": _("Cancel"), + "file_label": _("File:"), + "no_file_selected": _("No file selected"), + "error_title": _("Error"), + "select_file_error": _("Please select a file."), + "all_files": _("All Files"), + "free_space": _("Free Space"), + "entries": _("entries"), + "directory_not_found": _("Directory not found"), + "unknown": _("Unknown"), + "showing": _("Showing"), + "of": _("of"), + "access_denied": _("Access denied."), + "path_not_found": _("Path not found"), + "directory": _("Directory"), + "not_found": _("not found."), + "access_to": _("Access to"), + "denied": _("denied."), + } + + # Strings from cfd_view_manager.py + VIEW = { + "name": _("Name"), + "date_modified": _("Date Modified"), + "type": _("Type"), + "size": _("Size"), + "view_mode": _("View Mode"), + "icon_view": _("Icon View"), + "list_view": _("List View"), + "filename": _("Filename"), + "path": _("Path"), + } + + # Strings from cfd_ui_setup.py + UI = { + "search": _("Search"), + "go": _("Go"), + "up": _("Up"), + "back": _("Back"), + "forward": _("Forward"), + "home": _("Home"), + "new_folder": _("New Folder"), + "delete": _("Delete"), + "settings": _("Settings"), + "show_hidden_files": _("Show Hidden Files"), + "places": _("Places"), + "devices": _("Devices"), + "bookmarks": _("Bookmarks"), + "new_document": _("New Document"), + "hide_hidden_files": _("Hide Hidden Files"), + "start_search": _("Start Search"), + "cancel_search": _("Cancel Search"), + "delete_move": _("Delete/Move selected item"), + "copy_filename_to_clipboard": _("Copy Filename to Clipboard"), + "copy_path_to_clipboard": _("Copy Path to Clipboard"), + "open_file_location": _("Open File Location"), + "searching_for": _("Searching for"), + "search_cancelled_by_user": _("Search cancelled by user"), + "folders_and": _("folders and"), + "files_found": _("files found."), + "no_results_for": _("No results for"), + "error_during_search": _("Error during search"), + "search_error": _("Search Error"), + } + + # Strings from cfd_settings_dialog.py + SET = { + "title": _("Settings"), + "search_icon_pos_label": _("Search Icon Position"), + "left_radio": _("Left"), + "right_radio": _("Right"), + "button_box_pos_label": _("Button Box Position"), + "window_size_label": _("Window Size"), + "default_view_mode_label": _("Default View Mode"), + "icons_radio": _("Icons"), + "list_radio": _("List"), + "search_hidden_check": _("Search hidden files"), + "use_trash_check": _("Use trash for deletion"), + "confirm_delete_check": _("Confirm file deletion"), + "recursive_search_check": _("Recursive search"), + "use_pillow_check": _("Use Pillow animation"), + "save_button": _("Save"), + "cancel_button": _("Cancel"), + "search_settings": _("Search Settings"), + "deletion_settings": _("Deletion Settings"), + "recommended": _("recommended"), + "send2trash_not_found": _("send2trash library not found"), + "animation_settings": _("Animation Settings"), + "pillow": _("Pillow"), + "pillow_not_found": _("Pillow library not found"), + "animation_type": _("Animation Type"), + "counter_arc": _("Counter Arc"), + "double_arc": _("Double Arc"), + "line": _("Line"), + "blink": _("Blink"), + "deletion_options_info": _("Deletion options are only available in save mode"), + "reset_to_default": _("Reset to Default"), + } + + # Strings from cfd_file_operations.py + FILE = { + "new_folder_title": _("New Folder"), + "enter_folder_name_label": _("Enter folder name:"), + "untitled_folder": _("Untitled Folder"), + "error_title": _("Error"), + "folder_exists_error": _("Folder already exists."), + "create_folder_error": _("Could not create folder."), + "confirm_delete_title": _("Confirm Deletion"), + "confirm_delete_file_message": _("Are you sure you want to permanently delete this file?"), + "confirm_delete_files_message": _("Are you sure you want to permanently delete these files?"), + "delete_button": _("Delete"), + "cancel_button": _("Cancel"), + "file_not_found_error": _("File not found."), + "trash_error": _("Could not move file to trash."), + "delete_error": _("Could not delete file."), + "folder": _("Folder"), + "file": _("File"), + "move_to_trash": _("move to trash"), + "delete_permanently": _("delete permanently"), + "are_you_sure": _("Are you sure you want to"), + "was_successfully_removed": _("was successfully removed."), + "error_removing": _("Error removing"), + "new_document_txt": _("New Document.txt"), + "error_creating": _("Error creating"), + "copied_to_clipboard": _("copied to clipboard."), + "error_renaming": _("Error renaming"), + "not_accessible": _("not accessible"), + } + + # Strings from cfd_navigation_manager.py + NAV = { + "home": _("Home"), + "trash": _("Trash"), + "desktop": _("Desktop"), + "documents": _("Documents"), + "downloads": _("Downloads"), + "music": _("Music"), + "pictures": _("Pictures"), + "videos": _("Videos"), + "computer": _("Computer"), + } \ No newline at end of file diff --git a/cfd_file_operations.py b/cfd_file_operations.py new file mode 100644 index 0000000..a45da8c --- /dev/null +++ b/cfd_file_operations.py @@ -0,0 +1,339 @@ +import os +import shutil +import tkinter as tk +from tkinter import ttk +from typing import Optional, Any, TYPE_CHECKING + +try: + import send2trash + SEND2TRASH_AVAILABLE = True +except ImportError: + SEND2TRASH_AVAILABLE = False + +from shared_libs.message import MessageDialog +from cfd_app_config import LocaleStrings, _ + +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + + +class FileOperationsManager: + """Manages file operations like delete, create, and rename.""" + + def __init__(self, dialog: 'CustomFileDialog') -> None: + """ + Initializes the FileOperationsManager. + + Args: + dialog: The main CustomFileDialog instance. + """ + self.dialog = dialog + + def delete_selected_item(self, event: Optional[tk.Event] = None) -> None: + """ + Deletes the selected item or moves it to the trash. + + This method checks user settings to determine whether to move the item + to the system's trash (if available) or delete it permanently. + It also handles the confirmation dialog based on user preferences. + + Args: + event: The event that triggered the deletion (optional). + """ + if not self.dialog.selected_file or not os.path.exists(self.dialog.selected_file): + return + + use_trash = self.dialog.settings.get( + "use_trash", False) and SEND2TRASH_AVAILABLE + confirm = self.dialog.settings.get("confirm_delete", False) + + action_text = LocaleStrings.FILE["move_to_trash"] if use_trash else LocaleStrings.FILE["delete_permanently"] + item_name = os.path.basename(self.dialog.selected_file) + + if not confirm: + dialog = MessageDialog( + master=self.dialog, + title=LocaleStrings.FILE["confirm_delete_title"], + text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {action_text}?", + message_type="question" + ) + if not dialog.show(): + return + + try: + if use_trash: + send2trash.send2trash(self.dialog.selected_file) + else: + if os.path.isdir(self.dialog.selected_file): + shutil.rmtree(self.dialog.selected_file) + else: + os.remove(self.dialog.selected_file) + + self.dialog.view_manager.populate_files() + self.dialog.widget_manager.search_status_label.config( + text=f"'{item_name}' {LocaleStrings.FILE['was_successfully_removed']}") + + except Exception as e: + MessageDialog( + master=self.dialog, + title=LocaleStrings.FILE["error_title"], + text=f"{LocaleStrings.FILE['error_removing']} '{item_name}':\n{e}", + message_type="error" + ).show() + + def create_new_folder(self) -> None: + """Creates a new folder in the current directory.""" + self._create_new_item(is_folder=True) + + def create_new_file(self) -> None: + """Creates a new empty file in the current directory.""" + self._create_new_item(is_folder=False) + + def _create_new_item(self, is_folder: bool) -> None: + """ + Internal helper to create a new file or folder. + + It generates a unique name and creates the item, then refreshes the view. + + Args: + is_folder (bool): True to create a folder, False to create a file. + """ + base_name = LocaleStrings.FILE["new_folder_title"] if is_folder else LocaleStrings.FILE["new_document_txt"] + new_name = self._get_unique_name(base_name) + new_path = os.path.join(self.dialog.current_dir, new_name) + + try: + if is_folder: + os.mkdir(new_path) + else: + open(new_path, 'a').close() + self.dialog.view_manager.populate_files(item_to_rename=new_name) + except Exception as e: + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.FILE['error_creating']}: {e}") + + def _get_unique_name(self, base_name: str) -> str: + """ + Generates a unique name for a file or folder. + + If a file or folder with `base_name` already exists, it appends + a counter (e.g., "New Folder 2") until a unique name is found. + + Args: + base_name (str): The initial name for the item. + + Returns: + str: A unique name for the item in the current directory. + """ + name, ext = os.path.splitext(base_name) + counter = 1 + new_name = base_name + while os.path.exists(os.path.join(self.dialog.current_dir, new_name)): + counter += 1 + new_name = f"{name} {counter}{ext}" + return new_name + + def _copy_to_clipboard(self, data: str) -> None: + """ + Copies the given data to the system clipboard. + + Args: + data (str): The text to be copied. + """ + self.dialog.clipboard_clear() + self.dialog.clipboard_append(data) + self.dialog.widget_manager.search_status_label.config( + text=f"'{self.dialog.shorten_text(data, 50)}' {LocaleStrings.FILE['copied_to_clipboard']}") + + def _show_context_menu(self, event: tk.Event, item_path: str) -> str: + """ + Displays a context menu for the selected item. + + Args: + event: The mouse event that triggered the menu. + item_path (str): The full path to the item. + + Returns: + str: "break" to prevent further event propagation. + """ + if not item_path: + return "break" + + if hasattr(self.dialog, 'context_menu') and self.dialog.context_menu.winfo_exists(): + self.dialog.context_menu.destroy() + + self.dialog.context_menu = tk.Menu(self.dialog, tearoff=0, background=self.dialog.style_manager.header, foreground=self.dialog.style_manager.color_foreground, + activebackground=self.dialog.style_manager.selection_color, activeforeground=self.dialog.style_manager.color_foreground, relief='flat', borderwidth=0) + + self.dialog.context_menu.add_command(label=LocaleStrings.UI["copy_filename_to_clipboard"], + command=lambda: self._copy_to_clipboard(os.path.basename(item_path))) + self.dialog.context_menu.add_command( + label=LocaleStrings.UI["copy_path_to_clipboard"], command=lambda: self._copy_to_clipboard(item_path)) + + self.dialog.context_menu.add_separator() + self.dialog.context_menu.add_command( + label=LocaleStrings.UI["open_file_location"], command=lambda: self._open_file_location_from_context(item_path)) + + self.dialog.context_menu.tk_popup(event.x_root, event.y_root) + return "break" + + def _open_file_location_from_context(self, file_path: str) -> None: + """ + Navigates to the location of the given file path. + + This is used by the context menu to jump to a file's directory, + which is especially useful when in search mode. + + Args: + file_path (str): The full path to the file. + """ + directory = os.path.dirname(file_path) + filename = os.path.basename(file_path) + + if self.dialog.search_mode: + self.dialog.search_manager.hide_search_bar() + + self.dialog.navigation_manager.navigate_to(directory) + self.dialog.after(100, lambda: self.dialog.view_manager._select_file_in_view(filename)) + + def on_rename_request(self, event: tk.Event, item_path: Optional[str] = None, item_frame: Optional[tk.Widget] = None) -> None: + """ + Handles the initial request to rename an item. + + This method is triggered by an event (e.g., F2 key press) and + initiates the renaming process based on the current view mode. + + Args: + event: The event that triggered the rename. + item_path (str, optional): The path of the item in icon view. + item_frame (tk.Widget, optional): The frame of the item in icon view. + """ + if self.dialog.view_mode.get() == "list": + if not self.dialog.tree.selection(): + return + item_id = self.dialog.tree.selection()[0] + item_path = os.path.join( + self.dialog.current_dir, self.dialog.tree.item(item_id, "text").strip()) + self.start_rename(item_id, item_path) + else: # icon view + if item_path and item_frame: + self.start_rename(item_frame, item_path) + + def start_rename(self, item_widget: Any, item_path: str) -> None: + """ + Starts the renaming UI for an item. + + Dispatches to the appropriate method based on the current view mode. + + Args: + item_widget: The widget representing the item (item_id for list view, + item_frame for icon view). + item_path (str): The full path to the item being renamed. + """ + if self.dialog.view_mode.get() == "icons": + self._start_rename_icon_view(item_widget, item_path) + else: # list view + self._start_rename_list_view(item_widget) # item_widget is item_id + + def _start_rename_icon_view(self, item_frame: ttk.Frame, item_path: str) -> None: + """ + Initiates the in-place rename UI for an item in icon view. + + It replaces the item's label with an Entry widget. + + Args: + item_frame (tk.Widget): The frame containing the item's icon and label. + item_path (str): The full path to the item. + """ + for child in item_frame.winfo_children(): + child.destroy() + + entry = ttk.Entry(item_frame) + entry.pack(fill="both", expand=True, padx=2, pady=20) + entry.insert(0, os.path.basename(item_path)) + entry.select_range(0, tk.END) + entry.focus_set() + + def finish_rename(event: tk.Event) -> None: + new_name = entry.get() + new_path = os.path.join(self.dialog.current_dir, new_name) + if new_name and new_path != item_path: + if os.path.exists(new_path): + self.dialog.widget_manager.search_status_label.config( + text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}") + self.dialog.view_manager.populate_files( + item_to_select=os.path.basename(item_path)) + return + try: + os.rename(item_path, new_path) + self.dialog.view_manager.populate_files(item_to_select=new_name) + except Exception as e: + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.FILE['error_renaming']}: {e}") + self.dialog.view_manager.populate_files() + else: + self.dialog.view_manager.populate_files(item_to_select=os.path.basename(item_path)) + + def cancel_rename(event: tk.Event) -> None: + self.dialog.view_manager.populate_files() + + entry.bind("", finish_rename) + entry.bind("", finish_rename) + entry.bind("", cancel_rename) + + def _start_rename_list_view(self, item_id: str) -> None: + """ + Initiates the in-place rename UI for an item in list view. + + It places an Entry widget over the Treeview item's cell. + + Args: + item_id: The ID of the treeview item to be renamed. + """ + self.dialog.tree.see(item_id) + self.dialog.tree.update_idletasks() + + bbox = self.dialog.tree.bbox(item_id, column="#0") + + if not bbox: + return + + x, y, width, height = bbox + entry = ttk.Entry(self.dialog.tree) + entry_width = self.dialog.tree.column("#0", "width") + entry.place(x=x, y=y, width=entry_width, height=height) + + item_text = self.dialog.tree.item(item_id, "text").strip() + entry.insert(0, item_text) + entry.select_range(0, tk.END) + entry.focus_set() + + def finish_rename(event: tk.Event) -> None: + new_name = entry.get() + old_path = os.path.join(self.dialog.current_dir, item_text) + new_path = os.path.join(self.dialog.current_dir, new_name) + + if new_name and new_path != old_path: + if os.path.exists(new_path): + self.dialog.widget_manager.search_status_label.config( + text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}") + self.dialog.view_manager.populate_files(item_to_select=item_text) + else: + try: + os.rename(old_path, new_path) + self.dialog.view_manager.populate_files(item_to_select=new_name) + except Exception as e: + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.FILE['error_renaming']}: {e}") + self.dialog.view_manager.populate_files() + else: + self.dialog.view_manager.populate_files(item_to_select=item_text) + entry.destroy() + + def cancel_rename(event: tk.Event) -> None: + entry.destroy() + + entry.bind("", finish_rename) + entry.bind("", finish_rename) + entry.bind("", cancel_rename) + diff --git a/cfd_navigation_manager.py b/cfd_navigation_manager.py new file mode 100644 index 0000000..9baff4b --- /dev/null +++ b/cfd_navigation_manager.py @@ -0,0 +1,117 @@ +import os +import tkinter as tk +from typing import Optional, TYPE_CHECKING + +from cfd_app_config import LocaleStrings, _ + +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + + +class NavigationManager: + """Manages directory navigation, history, and path handling.""" + + def __init__(self, dialog: 'CustomFileDialog') -> None: + """ + Initializes the NavigationManager. + + Args: + dialog: The main CustomFileDialog instance. + """ + self.dialog = dialog + + def handle_path_entry_return(self, event: tk.Event) -> None: + """ + Handles the Return key press in the path entry field. + + It attempts to navigate to the entered path. If the path is a file, + it navigates to the containing directory and selects the file. + + Args: + event: The tkinter event that triggered this handler. + """ + path_text = self.dialog.widget_manager.path_entry.get().strip() + potential_path = os.path.realpath(os.path.expanduser(path_text)) + + if os.path.isdir(potential_path): + self.navigate_to(potential_path) + elif os.path.isfile(potential_path): + directory = os.path.dirname(potential_path) + filename = os.path.basename(potential_path) + self.navigate_to(directory, file_to_select=filename) + else: + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.CFD['path_not_found']}: {self.dialog.shorten_text(path_text, 50)}") + + def navigate_to(self, path: str, file_to_select: Optional[str] = None) -> None: + """ + Navigates to a specified directory path. + + This is the core navigation method. It validates the path, checks for + read permissions, updates the dialog's current directory, manages the + navigation history, and refreshes the file view. + + Args: + path (str): The absolute path to navigate to. + file_to_select (str, optional): If provided, this filename will be + selected after navigation. Defaults to None. + """ + try: + real_path = os.path.realpath( + os.path.abspath(os.path.expanduser(path))) + if not os.path.isdir(real_path): + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.CFD['error_title']}: {LocaleStrings.CFD['directory']} '{os.path.basename(path)}' {LocaleStrings.CFD['not_found']}") + return + if not os.access(real_path, os.R_OK): + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.CFD['access_to']} '{os.path.basename(path)}' {LocaleStrings.CFD['denied']}") + return + self.dialog.current_dir = real_path + if self.dialog.history_pos < len(self.dialog.history) - 1: + self.dialog.history = self.dialog.history[:self.dialog.history_pos + 1] + if not self.dialog.history or self.dialog.history[-1] != self.dialog.current_dir: + self.dialog.history.append(self.dialog.current_dir) + self.dialog.history_pos = len(self.dialog.history) - 1 + + self.dialog.widget_manager.search_animation.stop() + + self.dialog.view_manager.populate_files(item_to_select=file_to_select) + self.update_nav_buttons() + self.dialog.update_status_bar() + self.dialog.update_action_buttons_state() + except Exception as e: + self.dialog.widget_manager.search_status_label.config(text=f"{LocaleStrings.CFD['error_title']}: {e}") + + def go_back(self) -> None: + """Navigates to the previous directory in the history.""" + if self.dialog.history_pos > 0: + self.dialog.history_pos -= 1 + self.dialog.current_dir = self.dialog.history[self.dialog.history_pos] + self.dialog.view_manager.populate_files() + self.update_nav_buttons() + self.dialog.update_status_bar() + self.dialog.update_action_buttons_state() + + def go_forward(self) -> None: + """Navigates to the next directory in the history.""" + if self.dialog.history_pos < len(self.dialog.history) - 1: + self.dialog.history_pos += 1 + self.dialog.current_dir = self.dialog.history[self.dialog.history_pos] + self.dialog.view_manager.populate_files() + self.update_nav_buttons() + self.dialog.update_status_bar() + self.dialog.update_action_buttons_state() + + def go_up_level(self) -> None: + """Navigates to the parent directory of the current directory.""" + new_path = os.path.dirname(self.dialog.current_dir) + if new_path != self.dialog.current_dir: + self.navigate_to(new_path) + + def update_nav_buttons(self) -> None: + """Updates the state of the back and forward navigation buttons.""" + self.dialog.widget_manager.back_button.config( + state=tk.NORMAL if self.dialog.history_pos > 0 else tk.DISABLED) + self.dialog.widget_manager.forward_button.config(state=tk.NORMAL if self.dialog.history_pos < len( + self.dialog.history) - 1 else tk.DISABLED) diff --git a/cfd_search_manager.py b/cfd_search_manager.py new file mode 100644 index 0000000..73ad065 --- /dev/null +++ b/cfd_search_manager.py @@ -0,0 +1,307 @@ +import os +import threading +import subprocess +from datetime import datetime +import tkinter as tk +from tkinter import ttk +from typing import Optional, TYPE_CHECKING + +from shared_libs.message import MessageDialog +from cfd_ui_setup import get_xdg_user_dir +from cfd_app_config import LocaleStrings, _ + +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + + +class SearchManager: + """Manages the file search functionality, including UI and threading.""" + + def __init__(self, dialog: 'CustomFileDialog') -> None: + """ + Initializes the SearchManager. + + Args: + dialog: The main CustomFileDialog instance. + """ + self.dialog = dialog + + def show_search_ready(self, event: Optional[tk.Event] = None) -> None: + """Shows the static 'full circle' to indicate search is ready.""" + if not self.dialog.search_mode: + self.dialog.widget_manager.search_animation.show_full_circle() + + def activate_search(self, event: Optional[tk.Event] = None) -> None: + """ + Activates the search entry or cancels an ongoing search. + + If a search is running, it cancels it. Otherwise, it executes a new search. + """ + if self.dialog.widget_manager.search_animation.running: + if self.dialog.search_thread and self.dialog.search_thread.is_alive(): + self.dialog.search_thread.cancelled = True + self.dialog.widget_manager.search_animation.stop() + self.dialog.widget_manager.search_status_label.config(text=LocaleStrings.UI["cancel_search"]) + else: + self.execute_search() + + def show_search_bar(self, event: tk.Event) -> None: + """ + Activates search mode and displays the search bar upon user typing. + + Args: + event: The key press event that triggered the search. + """ + if isinstance(event.widget, (ttk.Entry, tk.Entry)) or not event.char.strip(): + return + self.dialog.search_mode = True + self.dialog.widget_manager.filename_entry.focus_set() + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert(0, event.char) + self.dialog.widget_manager.search_animation.show_full_circle() + + def hide_search_bar(self, event: Optional[tk.Event] = None) -> None: + """ + Deactivates search mode, clears the search bar, and restores the file view. + """ + self.dialog.search_mode = False + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.search_status_label.config(text="") + self.dialog.widget_manager.filename_entry.unbind("") + self.dialog.view_manager.populate_files() + self.dialog.widget_manager.search_animation.hide() + + def execute_search(self, event: Optional[tk.Event] = None) -> None: + """ + Initiates a file search in a background thread. + + Prevents starting a new search if one is already running. + """ + if self.dialog.search_thread and self.dialog.search_thread.is_alive(): + return + search_term = self.dialog.widget_manager.filename_entry.get().strip() + if not search_term: + self.hide_search_bar() + return + self.dialog.widget_manager.search_status_label.config(text=f"{LocaleStrings.UI['searching_for']} '{search_term}'...") + self.dialog.widget_manager.search_animation.start(pulse=False) + self.dialog.update_idletasks() + self.dialog.search_thread = threading.Thread(target=self._perform_search_in_thread, args=(search_term,)) + self.dialog.search_thread.start() + + def _perform_search_in_thread(self, search_term: str) -> None: + """ + Performs the actual file search in a background thread. + + Searches the current directory and relevant XDG user directories. + Handles recursive/non-recursive and hidden/non-hidden file searches. + Updates the UI with results upon completion. + + Args: + search_term (str): The term to search for. + """ + self.dialog.search_results.clear() + search_dirs = [self.dialog.current_dir] + home_dir = os.path.expanduser("~") + if os.path.abspath(self.dialog.current_dir) == os.path.abspath(home_dir): + xdg_dirs = [get_xdg_user_dir(d, f) for d, f in [("XDG_DOWNLOAD_DIR", "Downloads"), ("XDG_DOCUMENTS_DIR", "Documents"), ("XDG_PICTURES_DIR", "Pictures"), ("XDG_MUSIC_DIR", "Music"), ("XDG_VIDEO_DIR", "Videos")]] + search_dirs.extend([d for d in xdg_dirs if os.path.exists(d) and os.path.abspath(d) != home_dir and d not in search_dirs]) + + try: + all_files = [] + is_recursive = self.dialog.settings.get("recursive_search", True) + search_hidden = self.dialog.settings.get("search_hidden_files", False) + search_term_lower = search_term.lower() + + for search_dir in search_dirs: + if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): + break + if not os.path.exists(search_dir): + continue + + is_home_search = os.path.abspath(search_dir) == home_dir + follow_links = is_recursive and is_home_search + + if is_recursive: + for root, dirs, files in os.walk(search_dir, followlinks=follow_links): + if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): + raise InterruptedError(LocaleStrings.UI["search_cancelled_by_user"]) + + if not search_hidden: + dirs[:] = [d for d in dirs if not d.startswith('.')] + files = [f for f in files if not f.startswith('.')] + + for name in files: + if search_term_lower in name.lower() and self.dialog._matches_filetype(name): + all_files.append(os.path.join(root, name)) + for name in dirs: + if search_term_lower in name.lower(): + all_files.append(os.path.join(root, name)) + else: + for name in os.listdir(search_dir): + if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): + raise InterruptedError(LocaleStrings.UI["search_cancelled_by_user"]) + + if not search_hidden and name.startswith('.'): + continue + + path = os.path.join(search_dir, name) + is_dir = os.path.isdir(path) + + if search_term_lower in name.lower(): + if is_dir: + all_files.append(path) + elif self.dialog._matches_filetype(name): + all_files.append(path) + + if is_recursive: + break + + if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()): + raise InterruptedError(LocaleStrings.UI["search_cancelled_by_user"]) + + seen = set() + self.dialog.search_results = [x for x in all_files if not (x in seen or seen.add(x))] + + def update_ui() -> None: + if self.dialog.search_results: + self.show_search_results_treeview() + folder_count = sum(1 for p in self.dialog.search_results if os.path.isdir(p)) + file_count = len(self.dialog.search_results) - folder_count + self.dialog.widget_manager.search_status_label.config( + text=f"{folder_count} {LocaleStrings.UI['folders_and']} {file_count} {LocaleStrings.UI['files_found']}") + else: + self.dialog.widget_manager.search_status_label.config( + text=f"{LocaleStrings.UI['no_results_for']} '{search_term}'.") + self.dialog.after(0, update_ui) + + except (Exception, InterruptedError) as e: + if isinstance(e, (InterruptedError, subprocess.SubprocessError)): + self.dialog.after(0, lambda: self.dialog.widget_manager.search_status_label.config(text=LocaleStrings.UI["cancel_search"])) + else: + self.dialog.after(0, lambda: MessageDialog( + message_type="error", text=f"{LocaleStrings.UI['error_during_search']}: {e}", title=LocaleStrings.UI["search_error"], master=self.dialog).show()) + finally: + self.dialog.after(0, self.dialog.widget_manager.search_animation.stop) + self.dialog.search_process = None + + def show_search_results_treeview(self) -> None: + """Displays the search results in a dedicated Treeview.""" + for widget in self.dialog.widget_manager.file_list_frame.winfo_children(): + widget.destroy() + + tree_frame = ttk.Frame(self.dialog.widget_manager.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") + + search_tree.heading("#0", text=LocaleStrings.VIEW["filename"], anchor="w") + search_tree.column("#0", anchor="w", width=200, stretch=True) + search_tree.heading("path", text=LocaleStrings.VIEW["path"], anchor="w") + search_tree.column("path", anchor="w", width=300, stretch=True) + search_tree.heading("size", text=LocaleStrings.VIEW["size"], anchor="e") + search_tree.column("size", anchor="e", width=100, stretch=False) + search_tree.heading("modified", text=LocaleStrings.VIEW["date_modified"], anchor="w") + search_tree.column("modified", anchor="w", width=160, stretch=False) + + 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') + + for file_path in self.dialog.search_results: + try: + filename = os.path.basename(file_path) + directory = os.path.dirname(file_path) + stat = os.stat(file_path) + size = self.dialog._format_size(stat.st_size) + modified_time = datetime.fromtimestamp( + stat.st_mtime).strftime('%d.%m.%Y %H:%M') + + if os.path.isdir(file_path): + icon = self.dialog.icon_manager.get_icon('folder_small') + else: + icon = self.dialog.get_file_icon(filename, 'small') + + search_tree.insert("", "end", text=f" {filename}", image=icon, + values=(directory, size, modified_time)) + except (FileNotFoundError, PermissionError): + continue + + def on_search_select(event: tk.Event) -> None: + 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) + + try: + stat = os.stat(full_path) + size_str = self.dialog._format_size(stat.st_size) + self.dialog.widget_manager.search_status_label.config( + text=f"'{filename}' {LocaleStrings.VIEW['size']}: {size_str}") + except (FileNotFoundError, PermissionError): + self.dialog.widget_manager.search_status_label.config( + text=f"'{filename}' {LocaleStrings.FILE['not_accessible']}") + + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert(0, filename) + + search_tree.bind("<>", on_search_select) + + def on_search_double_click(event: tk.Event) -> None: + selection = search_tree.selection() + if selection: + item = search_tree.item(selection[0]) + filename = item['text'].strip() + directory = item['values'][0] + + self.hide_search_bar() + self.dialog.navigation_manager.navigate_to(directory, file_to_select=filename) + + search_tree.bind("", on_search_double_click) + + def show_context_menu(event: tk.Event) -> str: + iid = search_tree.identify_row(event.y) + if not iid: + return "break" + search_tree.selection_set(iid) + item = search_tree.item(iid) + filename = item['text'].strip() + directory = item['values'][0] + full_path = os.path.join(directory, filename) + self.dialog.file_op_manager._show_context_menu(event, full_path) + return "break" + + search_tree.bind("", show_context_menu) + + def _open_file_location(self, search_tree: ttk.Treeview) -> None: + """ + Navigates to the directory of the selected item in the search results. + + Args: + search_tree: The Treeview widget containing the search results. + """ + selection = search_tree.selection() + if not selection: + return + + item = search_tree.item(selection[0]) + filename = item['text'].strip() + directory = item['values'][0] + + self.hide_search_bar() + self.dialog.navigation_manager.navigate_to(directory) + + self.dialog.after(100, lambda: self.dialog.view_manager._select_file_in_view(filename)) diff --git a/cfd_settings_dialog.py b/cfd_settings_dialog.py new file mode 100644 index 0000000..951f5d0 --- /dev/null +++ b/cfd_settings_dialog.py @@ -0,0 +1,194 @@ +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +from cfd_app_config import CfdConfigManager, LocaleStrings, _ +from animated_icon import PIL_AVAILABLE + +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + +try: + import send2trash + SEND2TRASH_AVAILABLE = True +except ImportError: + SEND2TRASH_AVAILABLE = False + + +class SettingsDialog(tk.Toplevel): + """A dialog window for configuring the application settings.""" + + def __init__(self, parent: 'CustomFileDialog', dialog_mode: str = "save") -> None: + """ + Initializes the SettingsDialog. + + Args: + parent: The parent widget. + dialog_mode (str, optional): The mode of the main dialog ("open" or "save"), + which affects available settings. Defaults to "save". + """ + super().__init__(parent) + self.transient(parent) + self.grab_set() + self.title(LocaleStrings.SET["title"]) + + self.settings = CfdConfigManager.load() + self.dialog_mode = dialog_mode + + # Variables + self.search_icon_pos = tk.StringVar( + value=self.settings.get("search_icon_pos", "right")) + self.button_box_pos = tk.StringVar( + value=self.settings.get("button_box_pos", "left")) + self.window_size_preset = tk.StringVar( + value=self.settings.get("window_size_preset", "1050x850")) + self.default_view_mode = tk.StringVar( + value=self.settings.get("default_view_mode", "icons")) + self.search_hidden_files = tk.BooleanVar( + value=self.settings.get("search_hidden_files", False)) + self.recursive_search = tk.BooleanVar( + value=self.settings.get("recursive_search", True)) + self.use_trash = tk.BooleanVar( + value=self.settings.get("use_trash", False)) + self.confirm_delete = tk.BooleanVar( + value=self.settings.get("confirm_delete", False)) + self.use_pillow_animation = tk.BooleanVar( + value=self.settings.get("use_pillow_animation", False)) + self.animation_type = tk.StringVar( + value=self.settings.get("animation_type", "double_arc")) + + # --- UI Elements --- + main_frame = ttk.Frame(self, padding=10) + main_frame.pack(fill="both", expand=True) + + # Button Box Position + button_box_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["button_box_pos_label"], padding=10) + button_box_frame.pack(fill="x", pady=5) + ttk.Radiobutton(button_box_frame, text=LocaleStrings.SET["left_radio"], + variable=self.button_box_pos, value="left").pack(side="left", padx=5) + ttk.Radiobutton(button_box_frame, text=LocaleStrings.SET["right_radio"], + variable=self.button_box_pos, value="right").pack(side="left", padx=5) + + # Window Size + size_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["window_size_label"], padding=10) + size_frame.pack(fill="x", pady=5) + sizes = ["1050x850", "850x650", "650x450"] + size_combo = ttk.Combobox( + size_frame, textvariable=self.window_size_preset, values=sizes, state="readonly") + size_combo.pack(fill="x") + + # Default View Mode + view_mode_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["default_view_mode_label"], padding=10) + view_mode_frame.pack(fill="x", pady=5) + ttk.Radiobutton(view_mode_frame, text=LocaleStrings.SET["icons_radio"], + variable=self.default_view_mode, value="icons").pack(side="left", padx=5) + ttk.Radiobutton(view_mode_frame, text=LocaleStrings.SET["list_radio"], + variable=self.default_view_mode, value="list").pack(side="left", padx=5) + + # Search Hidden Files + search_hidden_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["search_settings"], padding=10) + search_hidden_frame.pack(fill="x", pady=5) + ttk.Checkbutton(search_hidden_frame, text=LocaleStrings.SET["search_hidden_check"], + variable=self.search_hidden_files).pack(anchor="w") + ttk.Checkbutton(search_hidden_frame, text=LocaleStrings.SET["recursive_search_check"], + variable=self.recursive_search).pack(anchor="w") + + # Deletion Settings + delete_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["deletion_settings"], padding=10) + delete_frame.pack(fill="x", pady=5) + + self.use_trash_checkbutton = ttk.Checkbutton(delete_frame, text=f"{LocaleStrings.SET['use_trash_check']} ({LocaleStrings.SET['recommended']})", + variable=self.use_trash) + self.use_trash_checkbutton.pack(anchor="w") + + if not SEND2TRASH_AVAILABLE: + self.use_trash_checkbutton.config(state=tk.DISABLED) + ttk.Label(delete_frame, text=f"({LocaleStrings.SET['send2trash_not_found']})", + font=("TkDefaultFont", 9, "italic")).pack(anchor="w", padx=(20, 0)) + + self.confirm_delete_checkbutton = ttk.Checkbutton(delete_frame, text=LocaleStrings.SET["confirm_delete_check"], + variable=self.confirm_delete) + self.confirm_delete_checkbutton.pack(anchor="w") + + # Pillow Animation + pillow_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["animation_settings"], padding=10) + pillow_frame.pack(fill="x", pady=5) + self.use_pillow_animation_checkbutton = ttk.Checkbutton(pillow_frame, text=f"{LocaleStrings.SET['use_pillow_check']} ({LocaleStrings.SET['pillow']})", + variable=self.use_pillow_animation) + self.use_pillow_animation_checkbutton.pack(anchor="w") + if not PIL_AVAILABLE: + self.use_pillow_animation_checkbutton.config(state=tk.DISABLED) + ttk.Label(pillow_frame, text=f"({LocaleStrings.SET['pillow_not_found']})", + font=("TkDefaultFont", 9, "italic")).pack(anchor="w", padx=(20, 0)) + + # Animation Type + anim_type_frame = ttk.LabelFrame( + main_frame, text=LocaleStrings.SET["animation_type"], padding=10) + anim_type_frame.pack(fill="x", pady=5) + ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["counter_arc"], variable=self.animation_type, + value="counter_arc").pack(side="left", padx=5) + ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["double_arc"], variable=self.animation_type, + value="double_arc").pack(side="left", padx=5) + ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["line"], variable=self.animation_type, + value="line").pack(side="left", padx=5) + ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["blink"], variable=self.animation_type, + value="blink").pack(side="left", padx=5) + + # Disable deletion options in "open" mode + if not self.dialog_mode == "save": + self.use_trash_checkbutton.config(state=tk.DISABLED) + self.confirm_delete_checkbutton.config(state=tk.DISABLED) + info_label = ttk.Label(delete_frame, text=f"({LocaleStrings.SET['deletion_options_info']})", + font=("TkDefaultFont", 9, "italic")) + info_label.pack(anchor="w", padx=(20, 0)) + + # --- Action Buttons --- + button_frame = ttk.Frame(main_frame) + button_frame.pack(fill="x", pady=(10, 0)) + + ttk.Button(button_frame, text=LocaleStrings.SET["reset_to_default"], + command=self.reset_to_defaults).pack(side="left", padx=5) + ttk.Button(button_frame, text=LocaleStrings.SET["save_button"], + command=self.save_settings).pack(side="right", padx=5) + ttk.Button(button_frame, text=LocaleStrings.SET["cancel_button"], + command=self.destroy).pack(side="right") + + def save_settings(self) -> None: + """ + Saves the current settings to the configuration file and closes the dialog. + + Triggers a UI rebuild in the parent dialog to apply the changes. + """ + new_settings = { + "button_box_pos": self.button_box_pos.get(), + "window_size_preset": self.window_size_preset.get(), + "default_view_mode": self.default_view_mode.get(), + "search_hidden_files": self.search_hidden_files.get(), + "recursive_search": self.recursive_search.get(), + "use_trash": self.use_trash.get(), + "confirm_delete": self.confirm_delete.get(), + "use_pillow_animation": self.use_pillow_animation.get(), + "animation_type": self.animation_type.get() + } + CfdConfigManager.save(new_settings) + self.master.reload_config_and_rebuild_ui() + self.destroy() + + def reset_to_defaults(self) -> None: + """Resets all settings in the dialog to their default values.""" + defaults = CfdConfigManager._default_settings + self.button_box_pos.set(defaults["button_box_pos"]) + self.window_size_preset.set(defaults["window_size_preset"]) + self.default_view_mode.set(defaults["default_view_mode"]) + self.search_hidden_files.set(defaults["search_hidden_files"]) + self.recursive_search.set(defaults["recursive_search"]) + self.use_trash.set(defaults["use_trash"]) + self.confirm_delete.set(defaults["confirm_delete"]) + self.use_pillow_animation.set(defaults.get("use_pillow_animation", True) and PIL_AVAILABLE) + self.animation_type.set(defaults.get("animation_type", "counter_arc")) diff --git a/cfd_ui_setup.py b/cfd_ui_setup.py new file mode 100644 index 0000000..d4e1f80 --- /dev/null +++ b/cfd_ui_setup.py @@ -0,0 +1,506 @@ +import os +import shutil +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any + +# To avoid circular import with custom_file_dialog.py +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + +from shared_libs.common_tools import Tooltip +from animated_icon import AnimatedIcon +from cfd_app_config import LocaleStrings, _ + + +def get_xdg_user_dir(dir_key: str, fallback_name: str) -> str: + """ + Retrieves a user directory path from the XDG user-dirs.dirs config file. + + Args: + dir_key (str): The key for the directory (e.g., "XDG_DOWNLOAD_DIR"). + fallback_name (str): The name of the directory to use as a fallback + if the key is not found (e.g., "Downloads"). + + Returns: + str: The absolute path to the user directory. + """ + home = os.path.expanduser("~") + fallback_path = os.path.join(home, fallback_name) + config_path = os.path.join(home, ".config", "user-dirs.dirs") + + if not os.path.exists(config_path): + return fallback_path + + try: + with open(config_path, 'r') as f: + for line in f: + line = line.strip() + if line.startswith(f"{dir_key}="): + path = line.split('=', 1)[1].strip().strip('"') + path = path.replace('$HOME', home) + if not os.path.isabs(path): + path = os.path.join(home, path) + return path + except Exception: + pass + return fallback_path + + +class StyleManager: + """Manages the visual styling of the application using ttk styles.""" + def __init__(self, dialog: 'CustomFileDialog') -> None: + """ + Initializes the StyleManager. + + Args: + dialog: The main CustomFileDialog instance. + """ + self.dialog = dialog + self.setup_styles() + + def setup_styles(self) -> None: + """ + Configures all the ttk styles for the dialog based on a light or dark theme. + """ + style = ttk.Style(self.dialog) + base_bg = self.dialog.cget('background') + self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768 + + if self.is_dark: + self.selection_color = "#4a6984" + self.icon_bg_color = "#3c3c3c" + self.accent_color = "#2a2a2a" + self.header = "#2b2b2b" + self.hover_extrastyle = "#4a4a4a" + self.hover_extrastyle2 = "#494949" + self.sidebar_color = "#333333" + self.bottom_color = self.accent_color + self.color_foreground = "#ffffff" + self.freespace_background = self.sidebar_color + else: + self.selection_color = "#cce5ff" + self.icon_bg_color = base_bg + self.accent_color = "#e0e0e0" + self.header = "#d9d9d9" + self.hover_extrastyle = "#f5f5f5" + self.hover_extrastyle2 = "#494949" + self.sidebar_color = "#e7e7e7" + self.bottom_color = "#cecece" + self.freespace_background = self.sidebar_color + self.color_foreground = "#000000" + + style.configure("Header.TButton.Borderless.Round", + background=self.header) + style.map("Header.TButton.Borderless.Round", background=[ + ('active', self.hover_extrastyle)]) + style.configure("Header.TButton.Active.Round", + background=self.selection_color) + 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)) + style.map("Dark.TButton.Borderless", background=[ + ('active', self.hover_extrastyle2)]) + style.configure("Accent.TFrame", background=self.header) + style.configure("Accent.TLabel", background=self.header) + style.configure("AccentBottom.TFrame", background=self.bottom_color) + style.configure("AccentBottom.TLabel", background=self.bottom_color) + style.configure("Sidebar.TFrame", background=self.sidebar_color) + style.configure("Content.TFrame", background=self.icon_bg_color) + style.configure("Item.TFrame", background=self.icon_bg_color) + style.map('Item.TFrame', background=[ + ('selected', self.selection_color)]) + style.configure("Item.TLabel", background=self.icon_bg_color) + style.map('Item.TLabel', background=[('selected', self.selection_color)], foreground=[ + ('selected', "black" if not self.is_dark else "white")]) + style.configure("Icon.TLabel", background=self.icon_bg_color) + style.map('Icon.TLabel', background=[ + ('selected', self.selection_color)]) + style.configure("Treeview.Heading", relief="flat", + borderwidth=0, font=('TkDefaultFont', 10, 'bold')) + style.configure("Treeview", rowheight=32, pady=2, background=self.icon_bg_color, + fieldbackground=self.icon_bg_color, borderwidth=0) + style.map("Treeview", background=[('selected', self.selection_color)], foreground=[ + ('selected', "black" if not self.is_dark else "white")]) + style.configure("TButton.Borderless.Round", anchor="w") + style.configure("Small.Horizontal.TProgressbar", thickness=8) + + style.configure("Bottom.TButton.Borderless.Round", + background=self.bottom_color) + style.map("Bottom.TButton.Borderless.Round", + background=[('active', self.hover_extrastyle)]) + style.layout("Bottom.TButton.Borderless.Round", + style.layout("Header.TButton.Borderless.Round") + ) + + +class WidgetManager: + """Manages the creation, layout, and management of all widgets in the dialog.""" + def __init__(self, dialog: 'CustomFileDialog', settings: Dict[str, Any]) -> None: + """ + Initializes the WidgetManager. + + Args: + dialog: The main CustomFileDialog instance. + settings (dict): The application settings. + """ + self.dialog = dialog + self.style_manager = dialog.style_manager + self.settings = settings + self.setup_widgets() + + def _setup_top_bar(self, parent_frame: ttk.Frame) -> None: + """Sets up the top bar with navigation and control buttons.""" + top_bar = ttk.Frame(parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5)) + top_bar.grid(row=0, column=0, columnspan=2, sticky="ew") + top_bar.grid_columnconfigure(1, weight=1) + + # Left navigation + left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame') + left_nav_container.grid(row=0, column=0, sticky="w") + left_nav_container.grid_propagate(False) + + self.back_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon( + 'back'), command=self.dialog.navigation_manager.go_back, state=tk.DISABLED, style="Header.TButton.Borderless.Round") + self.back_button.pack(side="left", padx=(10, 5)) + Tooltip(self.back_button, LocaleStrings.UI["back"]) + + self.forward_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon( + 'forward'), command=self.dialog.navigation_manager.go_forward, state=tk.DISABLED, style="Header.TButton.Borderless.Round") + self.forward_button.pack(side="left", padx=5) + Tooltip(self.forward_button, LocaleStrings.UI["forward"]) + + self.up_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon( + 'up'), command=self.dialog.navigation_manager.go_up_level, style="Header.TButton.Borderless.Round") + self.up_button.pack(side="left", padx=5) + Tooltip(self.up_button, LocaleStrings.UI["up"]) + + self.home_button = ttk.Button(left_nav_container, image=self.dialog.icon_manager.get_icon( + 'home'), command=lambda: self.dialog.navigation_manager.navigate_to(os.path.expanduser("~")), style="Header.TButton.Borderless.Round") + self.home_button.pack(side="left", padx=(5, 10)) + Tooltip(self.home_button, LocaleStrings.UI["home"]) + + # Path and search + path_search_container = ttk.Frame(top_bar, style='Accent.TFrame') + path_search_container.grid(row=0, column=1, sticky="ew") + self.path_entry = ttk.Entry(path_search_container) + self.path_entry.bind("", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get())) + + search_icon_pos = self.settings.get("search_icon_pos", "left") + if search_icon_pos == 'left': + path_search_container.grid_columnconfigure(1, weight=1) + self.path_entry.grid(row=0, column=1, sticky="ew") + else: # right + path_search_container.grid_columnconfigure(0, weight=1) + self.path_entry.grid(row=0, column=0, sticky="ew") + + # Right controls + right_controls_container = ttk.Frame(top_bar, style='Accent.TFrame') + right_controls_container.grid(row=0, column=2, sticky="e") + self.responsive_buttons_container = ttk.Frame(right_controls_container, style='Accent.TFrame') + self.responsive_buttons_container.pack(side="left") + + self.new_folder_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon( + 'new_folder_small'), command=self.dialog.file_op_manager.create_new_folder, style="Header.TButton.Borderless.Round") + self.new_folder_button.pack(side="left", padx=5) + Tooltip(self.new_folder_button, LocaleStrings.UI["new_folder"]) + + self.new_file_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon( + 'new_document_small'), command=self.dialog.file_op_manager.create_new_file, style="Header.TButton.Borderless.Round") + self.new_file_button.pack(side="left", padx=5) + Tooltip(self.new_file_button, LocaleStrings.UI["new_document"]) + + if self.dialog.dialog_mode == "open": + self.new_folder_button.config(state=tk.DISABLED) + self.new_file_button.config(state=tk.DISABLED) + + self.view_switch = ttk.Frame(self.responsive_buttons_container, padding=(5, 0), style='Accent.TFrame') + self.view_switch.pack(side="left") + self.icon_view_button = ttk.Button(self.view_switch, image=self.dialog.icon_manager.get_icon( + 'icon_view'), command=self.dialog.view_manager.set_icon_view, style="Header.TButton.Active.Round") + self.icon_view_button.pack(side="left", padx=5) + Tooltip(self.icon_view_button, LocaleStrings.VIEW["icon_view"]) + + self.list_view_button = ttk.Button(self.view_switch, image=self.dialog.icon_manager.get_icon( + 'list_view'), command=self.dialog.view_manager.set_list_view, style="Header.TButton.Borderless.Round") + self.list_view_button.pack(side="left") + Tooltip(self.list_view_button, LocaleStrings.VIEW["list_view"]) + + self.hidden_files_button = ttk.Button(self.responsive_buttons_container, image=self.dialog.icon_manager.get_icon( + 'hide'), command=self.dialog.view_manager.toggle_hidden_files, style="Header.TButton.Borderless.Round") + self.hidden_files_button.pack(side="left", padx=10) + Tooltip(self.hidden_files_button, LocaleStrings.UI["show_hidden_files"]) + + self.more_button = ttk.Button(right_controls_container, text="...", + command=self.dialog.show_more_menu, style="Header.TButton.Borderless.Round", width=3) + + def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None: + """Sets up the sidebar with bookmarks and devices.""" + sidebar_frame = ttk.Frame(parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200) + sidebar_frame.grid_propagate(False) + sidebar_frame.bind("", self.dialog.on_sidebar_resize) + parent_paned_window.add(sidebar_frame, weight=0) + sidebar_frame.grid_rowconfigure(2, weight=1) + + self._setup_sidebar_bookmarks(sidebar_frame) + + separator_color = "#a9a9a9" if self.style_manager.is_dark else "#7c7c7c" + tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(row=1, column=0, sticky="ew", padx=20, pady=15) + + self._setup_sidebar_devices(sidebar_frame) + + tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(row=3, column=0, sticky="ew", padx=20, pady=15) + + self._setup_sidebar_storage(sidebar_frame) + + def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None: + """Sets up the bookmark buttons in the sidebar.""" + sidebar_buttons_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0)) + sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew") + sidebar_buttons_config = [ + {'name': LocaleStrings.NAV["computer"], 'icon': self.dialog.icon_manager.get_icon('computer_small'), 'path': '/'}, + {'name': LocaleStrings.NAV["downloads"], 'icon': self.dialog.icon_manager.get_icon('downloads_small'), 'path': get_xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads")}, + {'name': LocaleStrings.NAV["documents"], 'icon': self.dialog.icon_manager.get_icon('documents_small'), 'path': get_xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents")}, + {'name': LocaleStrings.NAV["pictures"], 'icon': self.dialog.icon_manager.get_icon('pictures_small'), 'path': get_xdg_user_dir("XDG_PICTURES_DIR", "Pictures")}, + {'name': LocaleStrings.NAV["music"], 'icon': self.dialog.icon_manager.get_icon('music_small'), 'path': get_xdg_user_dir("XDG_MUSIC_DIR", "Music")}, + {'name': LocaleStrings.NAV["videos"], 'icon': self.dialog.icon_manager.get_icon('video_small'), 'path': get_xdg_user_dir("XDG_VIDEO_DIR", "Videos")}, + ] + self.sidebar_buttons = [] + for config in sidebar_buttons_config: + btn = ttk.Button(sidebar_buttons_frame, text=f" {config['name']}", image=config['icon'], compound="left", + command=lambda p=config['path']: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless") + btn.pack(fill="x", pady=1) + self.sidebar_buttons.append((btn, f" {config['name']}")) + + def _setup_sidebar_devices(self, sidebar_frame: ttk.Frame) -> None: + """Sets up the mounted devices section in the sidebar.""" + mounted_devices_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame") + mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10) + mounted_devices_frame.grid_columnconfigure(0, weight=1) + + ttk.Label(mounted_devices_frame, text=LocaleStrings.UI["devices"], background=self.style_manager.sidebar_color, + foreground=self.style_manager.color_foreground).grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 0)) + + self.devices_canvas = tk.Canvas( + mounted_devices_frame, highlightthickness=0, bg=self.style_manager.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") + + 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") + + self.devices_canvas.bind("", self.dialog._on_devices_enter) + self.devices_canvas.bind("", self.dialog._on_devices_leave) + self.devices_scrollable_frame.bind( + "", self.dialog._on_devices_enter) + self.devices_scrollable_frame.bind( + "", self.dialog._on_devices_leave) + + def _configure_devices_canvas(event: tk.Event) -> None: + 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) + + def _on_devices_mouse_wheel(event: tk.Event) -> None: + if event.num == 4: + delta = -1 + elif event.num == 5: + delta = 1 + else: + delta = -1 * int(event.delta / 120) + self.devices_canvas.yview_scroll(delta, "units") + + 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) + + self.device_buttons = [] + for device_name, mount_point, removable in self.dialog._get_mounted_devices(): + icon = self.dialog.icon_manager.get_icon( + 'usb_small') if removable else self.dialog.icon_manager.get_icon('device_small') + button_text = f" {device_name}" + if len(device_name) > 15: + button_text = f" {device_name[:15]}\n{device_name[15:]}" + + btn = ttk.Button(self.devices_scrollable_frame, text=button_text, image=icon, compound="left", + command=lambda p=mount_point: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless") + btn.pack(fill="x", pady=1) + self.device_buttons.append((btn, button_text)) + + for w in [btn, self.devices_canvas, self.devices_scrollable_frame]: + w.bind("", _on_devices_mouse_wheel) + w.bind("", _on_devices_mouse_wheel) + w.bind("", _on_devices_mouse_wheel) + w.bind("", self.dialog._on_devices_enter) + w.bind("", self.dialog._on_devices_leave) + + try: + total, used, _ = shutil.disk_usage(mount_point) + progress_bar = ttk.Progressbar(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 + for w in [progress_bar]: + w.bind("", _on_devices_mouse_wheel) + w.bind("", _on_devices_mouse_wheel) + w.bind("", _on_devices_mouse_wheel) + w.bind("", self.dialog._on_devices_enter) + w.bind("", self.dialog._on_devices_leave) + except (FileNotFoundError, PermissionError): + pass + + def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None: + """Sets up the storage indicator in the sidebar.""" + storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame") + storage_frame.grid(row=5, column=0, sticky="sew", padx=10, pady=10) + self.storage_label = ttk.Label(storage_frame, text=f"{LocaleStrings.CFD["free_space"]}:", background=self.style_manager.freespace_background) + self.storage_label.pack(fill="x", padx=10) + self.storage_bar = ttk.Progressbar(storage_frame, orient="horizontal", length=100, mode="determinate") + self.storage_bar.pack(fill="x", pady=(2, 5), padx=15) + + def _setup_bottom_bar(self) -> None: + """Sets up the bottom bar including filename entry, action buttons, and status info.""" + self.action_status_frame = ttk.Frame(self.content_frame, style="AccentBottom.TFrame") + self.action_status_frame.grid(row=1, column=0, sticky="ew", pady=(5, 5), padx=10) + self.status_container = ttk.Frame(self.action_status_frame, style="AccentBottom.TFrame") + self.left_container = ttk.Frame(self.action_status_frame, style="AccentBottom.TFrame") + self.center_container = ttk.Frame(self.action_status_frame, style="AccentBottom.TFrame") + self.right_container = ttk.Frame(self.action_status_frame, style="AccentBottom.TFrame") + + self.action_status_frame.grid_columnconfigure(1, weight=1) + self.action_status_frame.grid_rowconfigure(0, weight=1) + + self.left_container.grid(row=0, column=0, sticky='nsw', pady=(5, 0)) + self.center_container.grid(row=0, column=1, sticky='nsew', padx=5, pady=(5, 0)) + self.right_container.grid(row=0, column=2, sticky='nse', pady=(5, 0)) + self.separator_color = "#a9a9a9" if self.style_manager.is_dark else "#7c7c7c" + self.separator = tk.Frame(self.action_status_frame, height=1, bg=self.separator_color) + self.separator.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(4, 0)) + self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew') + + # --- Define Widgets --- + self.search_status_label = ttk.Label(self.status_container, text="", style="AccentBottom.TLabel") + self.filename_entry = ttk.Entry(self.center_container) + self.settings_button = ttk.Button(self.action_status_frame, image=self.dialog.icon_manager.get_icon('settings-2_small'), + command=self.dialog.open_settings_dialog, style="Bottom.TButton.Borderless.Round") + + self.search_animation = AnimatedIcon(self.status_container, + width=23, height=23, bg=self.style_manager.bottom_color, + animation_type=self.settings.get('animation_type', 'counter_arc')) + self.search_animation.grid(row=0, column=0, sticky='w', padx=(0, 5), pady=(4,0)) + self.search_animation.bind("", lambda e: self.dialog.search_manager.activate_search()) + self.search_status_label.grid(row=0, column=1, sticky="w") + + + button_box_pos = self.settings.get("button_box_pos", "left") + + if self.dialog.dialog_mode == "save": + self.trash_button = ttk.Button(self.action_status_frame, image=self.dialog.icon_manager.get_icon('trash_small2'), + command=self.dialog.file_op_manager.delete_selected_item, style="Bottom.TButton.Borderless.Round") + Tooltip(self.trash_button, LocaleStrings.UI["delete_move"]) + self.save_button = ttk.Button(self.action_status_frame, text=LocaleStrings.SET["save_button"], command=self.dialog.on_save) + self.cancel_button = ttk.Button(self.action_status_frame, text=LocaleStrings.SET["cancel_button"], command=self.dialog.on_cancel) + self.filter_combobox = ttk.Combobox(self.center_container, values=[ft[0] for ft in self.dialog.filetypes], state="readonly") + self.filter_combobox.bind("<>", self.dialog.view_manager.on_filter_change) + self.filter_combobox.set(self.dialog.filetypes[0][0]) + + self.center_container.grid_rowconfigure(0, weight=1) + self.filename_entry.grid(row=0, column=0, columnspan=2, sticky="ew") + + self._layout_bottom_buttons(button_box_pos) + else: # Open mode + self.open_button = ttk.Button(self.action_status_frame, text=LocaleStrings.CFD["open"], command=self.dialog.on_open) + self.cancel_button = ttk.Button(self.action_status_frame, text=LocaleStrings.CFD["cancel"], command=self.dialog.on_cancel) + self.filter_combobox = ttk.Combobox(self.center_container, values=[ft[0] for ft in self.dialog.filetypes], state="readonly") + self.filter_combobox.bind("<>", self.dialog.view_manager.on_filter_change) + self.filter_combobox.set(self.dialog.filetypes[0][0]) + + self.center_container.grid_rowconfigure(0, weight=1) + self.filename_entry.grid(row=0, column=0, columnspan=2, sticky="ew") + + self._layout_bottom_buttons(button_box_pos) + + def _layout_bottom_buttons(self, button_box_pos: str) -> None: + """Lays out the bottom action buttons based on user settings.""" + # Configure container weights + self.left_container.grid_rowconfigure(0, weight=1) + self.right_container.grid_rowconfigure(0, weight=1) + + # Determine action button and its container + action_button = self.save_button if self.dialog.dialog_mode == "save" else self.open_button + action_container = self.left_container if button_box_pos == 'left' else self.right_container + other_container = self.right_container if button_box_pos == 'left' else self.left_container + + # Place main action buttons + action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5)) + self.cancel_button.grid(in_=action_container, row=1, column=0) + + # Place settings and trash buttons + if button_box_pos == 'left': + self.settings_button.grid(in_=other_container, row=0, column=0, sticky="ne") + if self.dialog.dialog_mode == "save": + self.trash_button.grid(in_=other_container, row=1, column=0, sticky="se", padx=(5, 0)) + else: # right + self.settings_button.grid(in_=action_container, row=0, column=1, sticky="ne", padx=(5, 0)) + if self.dialog.dialog_mode == "save": + self.trash_button.grid(in_=other_container, row=0, column=0, sticky="sw") + + # Layout for the center container (filename, filter, status) + if button_box_pos == 'left': + self.center_container.grid_columnconfigure(0, weight=1) + self.filter_combobox.grid(in_=self.center_container, row=1, column=0, sticky="w", pady=(5, 0)) + else: # right + self.center_container.grid_columnconfigure(1, weight=1) + self.filter_combobox.grid(in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0)) + + + def setup_widgets(self) -> None: + """Creates and arranges all widgets in the main dialog window.""" + # Main container + main_frame = ttk.Frame(self.dialog, style='Accent.TFrame') + main_frame.pack(fill="both", expand=True) + main_frame.grid_rowconfigure(2, weight=1) + main_frame.grid_columnconfigure(0, weight=1) + + self._setup_top_bar(main_frame) + + # Horizontal separator + separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c" + tk.Frame(main_frame, height=1, bg=separator_color).grid( + row=1, column=0, columnspan=2, sticky="ew") + + # PanedWindow for resizable sidebar and content + paned_window = ttk.PanedWindow( + main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame") + paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew") + + self._setup_sidebar(paned_window) + + self.content_frame = ttk.Frame(paned_window, padding=(0, 0, 0, 0), style="AccentBottom.TFrame") + paned_window.add(self.content_frame, weight=1) + self.content_frame.grid_rowconfigure(0, weight=1) + self.content_frame.grid_columnconfigure(0, weight=1) + + self.file_list_frame = ttk.Frame(self.content_frame, style="Content.TFrame") + self.file_list_frame.grid(row=0, column=0, sticky="nsew") + self.dialog.bind("", self.dialog.on_window_resize) + + # --- Bottom Bar --- + self._setup_bottom_bar() + diff --git a/cfd_view_manager.py b/cfd_view_manager.py new file mode 100644 index 0000000..0536b6b --- /dev/null +++ b/cfd_view_manager.py @@ -0,0 +1,603 @@ +import os +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from typing import Optional, List, Tuple, Callable, Any + +# To avoid circular import with custom_file_dialog.py +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from custom_file_dialog import CustomFileDialog + +from shared_libs.common_tools import Tooltip +from cfd_app_config import AppConfig, LocaleStrings, _ + +class ViewManager: + """Manages the display of files and folders in list and icon views.""" + def __init__(self, dialog: 'CustomFileDialog'): + """ + Initializes the ViewManager. + + Args: + dialog: The main CustomFileDialog instance. + """ + self.dialog = dialog + + def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: + """ + Populates the main file display area. + + This method clears the current view and then calls the appropriate + method to populate either the list or icon view. + + Args: + item_to_rename (str, optional): The name of an item to immediately + put into rename mode. Defaults to None. + item_to_select (str, optional): The name of an item to select + after populating. Defaults to None. + """ + self._unbind_mouse_wheel_events() + + for widget in self.dialog.widget_manager.file_list_frame.winfo_children(): + widget.destroy() + self.dialog.widget_manager.path_entry.delete(0, tk.END) + self.dialog.widget_manager.path_entry.insert(0, self.dialog.current_dir) + self.dialog.selected_file = None + self.dialog.update_status_bar() + if self.dialog.view_mode.get() == "list": + self.populate_list_view(item_to_rename, item_to_select) + else: + self.populate_icon_view(item_to_rename, item_to_select) + + def _get_sorted_items(self) -> Tuple[List[str], Optional[str], Optional[str]]: + """ + Gets a sorted list of items from the current directory. + + Returns: + tuple: A tuple containing (list of items, error message, warning message). + """ + try: + items = os.listdir(self.dialog.current_dir) + num_items = len(items) + warning_message = None + if num_items > AppConfig.MAX_ITEMS_TO_DISPLAY: + warning_message = f"{LocaleStrings.CFD['showing']} {AppConfig.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}." + items = items[:AppConfig.MAX_ITEMS_TO_DISPLAY] + dirs = sorted([d for d in items if os.path.isdir( + os.path.join(self.dialog.current_dir, d))], key=str.lower) + files = sorted([f for f in items if not os.path.isdir( + os.path.join(self.dialog.current_dir, f))], key=str.lower) + return (dirs + files, None, warning_message) + except PermissionError: + return ([], LocaleStrings.CFD["access_denied"], None) + except FileNotFoundError: + return ([], LocaleStrings.CFD["directory_not_found"], None) + + def _get_folder_content_count(self, folder_path: str) -> Optional[int]: + """ + Counts the number of items in a given folder. + + Args: + folder_path (str): The path to the folder. + + Returns: + int or None: The number of items, or None if an error occurs. + """ + try: + if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK): + return None + + items = os.listdir(folder_path) + + if not self.dialog.show_hidden_files.get(): + items = [item for item in items if not item.startswith('.')] + + return len(items) + except (PermissionError, FileNotFoundError): + return None + + def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]: + """ + Traverses up the widget hierarchy to find the item_path attribute. + + Args: + widget: The widget to start from. + + Returns: + str or None: The associated file path, or None if not found. + """ + while widget and not hasattr(widget, 'item_path'): + widget = widget.master + return getattr(widget, 'item_path', None) + + def _handle_icon_click(self, event: tk.Event) -> None: + """Handles a single click on an icon view item.""" + item_path = self._get_item_path_from_widget(event.widget) + if item_path: + item_frame = event.widget + while not hasattr(item_frame, 'item_path'): + item_frame = item_frame.master + self.on_item_select(item_path, item_frame) + + def _handle_icon_double_click(self, event: tk.Event) -> None: + """Handles a double click on an icon view item.""" + item_path = self._get_item_path_from_widget(event.widget) + if item_path: + self.on_item_double_click(item_path) + + def _handle_icon_context_menu(self, event: tk.Event) -> None: + """Handles a context menu request on an icon view item.""" + item_path = self._get_item_path_from_widget(event.widget) + if item_path: + self.dialog.file_op_manager._show_context_menu(event, item_path) + + def _handle_icon_rename_request(self, event: tk.Event) -> None: + """Handles a rename request on an icon view item.""" + item_path = self._get_item_path_from_widget(event.widget) + if item_path: + item_frame = event.widget + while not hasattr(item_frame, 'item_path'): + item_frame = item_frame.master + self.dialog.file_op_manager.on_rename_request(event, item_path, item_frame) + + def populate_icon_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: + """ + Populates the file display with items in an icon grid layout. + + Args: + item_to_rename (str, optional): Item to enter rename mode. + item_to_select (str, optional): Item to select. + """ + self.dialog.all_items, error, warning = self._get_sorted_items() + self.dialog.currently_loaded_count = 0 + + self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame, + highlightthickness=0, bg=self.dialog.style_manager.icon_bg_color) + v_scrollbar = ttk.Scrollbar( + self.dialog.widget_manager.file_list_frame, orient="vertical", command=self.dialog.icon_canvas.yview) + self.dialog.icon_canvas.pack(side="left", fill="both", expand=True) + self.dialog.icon_canvas.focus_set() + v_scrollbar.pack(side="right", fill="y") + container_frame = ttk.Frame(self.dialog.icon_canvas, style="Content.TFrame") + self.dialog.icon_canvas.create_window( + (0, 0), window=container_frame, anchor="nw") + container_frame.bind("", lambda e: self.dialog.icon_canvas.configure( + scrollregion=self.dialog.icon_canvas.bbox("all"))) + + def _on_mouse_wheel(event: tk.Event) -> None: + if event.num == 4: + delta = -1 + elif event.num == 5: + delta = 1 + else: + delta = -1 * int(event.delta / 120) + self.dialog.icon_canvas.yview_scroll(delta, "units") + if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.icon_canvas.yview()[1] > 0.9: + self._load_more_items_icon_view(container_frame, _on_mouse_wheel) + + for widget in [self.dialog.icon_canvas, container_frame]: + widget.bind("", _on_mouse_wheel) + widget.bind("", _on_mouse_wheel) + widget.bind("", _on_mouse_wheel) + + if warning: + self.dialog.widget_manager.search_status_label.config(text=warning) + if error: + ttk.Label(container_frame, text=error).pack(pady=20) + return + + widget_to_focus = None + while self.dialog.currently_loaded_count < len(self.dialog.all_items): + widget_to_focus = self._load_more_items_icon_view( + container_frame, _on_mouse_wheel, item_to_rename, item_to_select) + + if widget_to_focus: + break + + if not (item_to_rename or item_to_select): + break + + if widget_to_focus: + def scroll_to_widget() -> None: + self.dialog.update_idletasks() + if not widget_to_focus.winfo_exists(): + return + y = widget_to_focus.winfo_y() + canvas_height = self.dialog.icon_canvas.winfo_height() + scroll_region = self.dialog.icon_canvas.bbox("all") + if not scroll_region: + return + scroll_height = scroll_region[3] + if scroll_height > canvas_height: + fraction = y / scroll_height + self.dialog.icon_canvas.yview_moveto(fraction) + + self.dialog.after(100, scroll_to_widget) + + def _load_more_items_icon_view(self, container: ttk.Frame, scroll_handler: Callable, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> Optional[ttk.Frame]: + """ + Loads a batch of items into the icon view. + + Args: + container: The parent widget for the items. + scroll_handler: The function to handle mouse wheel events. + item_to_rename (str, optional): Item to enter rename mode. + item_to_select (str, optional): Item to select. + + Returns: + The widget that was focused (renamed or selected), or None. + """ + start_index = self.dialog.currently_loaded_count + end_index = min(len(self.dialog.all_items), start_index + + self.dialog.items_to_load_per_batch) + + if start_index >= end_index: + return None + + item_width, item_height = 125, 100 + frame_width = self.dialog.widget_manager.file_list_frame.winfo_width() + col_count = max(1, frame_width // item_width - 1) + + row = start_index // col_count if col_count > 0 else 0 + col = start_index % col_count if col_count > 0 else 0 + + widget_to_focus = None + + for i in range(start_index, end_index): + name = self.dialog.all_items[i] + if not self.dialog.show_hidden_files.get() and name.startswith('.'): + continue + path = os.path.join(self.dialog.current_dir, name) + is_dir = os.path.isdir(path) + if not is_dir and not self.dialog._matches_filetype(name): + continue + + item_frame = ttk.Frame( + container, width=item_width, height=item_height, style="Item.TFrame") + item_frame.grid(row=row, column=col, padx=5, ipadx=25, pady=5) + item_frame.grid_propagate(False) + item_frame.item_path = path + + if name == item_to_rename: + self.dialog.file_op_manager.start_rename(item_frame, path) + widget_to_focus = item_frame + else: + icon = self.dialog.icon_manager.get_icon( + 'folder_large') if is_dir else self.dialog.get_file_icon(name, 'large') + icon_label = ttk.Label( + item_frame, image=icon, style="Icon.TLabel") + icon_label.pack(pady=(10, 5)) + name_label = ttk.Label(item_frame, text=self.dialog.shorten_text( + name, 14), anchor="center", style="Item.TLabel") + name_label.pack(fill="x", expand=True) + + Tooltip(item_frame, name) + + for widget in [item_frame, icon_label, name_label]: + widget.bind("", lambda e, + p=path: self.on_item_double_click(p)) + widget.bind("", lambda e, p=path, + f=item_frame: self.on_item_select(p, f)) + widget.bind("", lambda e, + p=path: self.dialog.file_op_manager._show_context_menu(e, p)) + widget.bind("", lambda e, p=path, + f=item_frame: self.dialog.file_op_manager.on_rename_request(e, p, f)) + widget.bind("", scroll_handler) + widget.bind("", scroll_handler) + widget.bind("", scroll_handler) + + if name == item_to_select: + self.on_item_select(path, item_frame) + widget_to_focus = item_frame + + if col_count > 0: + col = (col + 1) % col_count + if col == 0: + row += 1 + else: + row += 1 + + self.dialog.currently_loaded_count = end_index + return widget_to_focus + + def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None: + """ + Populates the file display with items in a list (Treeview) layout. + + Args: + item_to_rename (str, optional): Item to enter rename mode. + item_to_select (str, optional): Item to select. + """ + self.dialog.all_items, error, warning = self._get_sorted_items() + self.dialog.currently_loaded_count = 0 + + tree_frame = ttk.Frame(self.dialog.widget_manager.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 = ("size", "type", "modified") + self.dialog.tree = ttk.Treeview( + tree_frame, columns=columns, show="tree headings") + + self.dialog.tree.heading("#0", text=LocaleStrings.VIEW["name"], anchor="w") + self.dialog.tree.column("#0", anchor="w", width=250, stretch=True) + self.dialog.tree.heading("size", text=LocaleStrings.VIEW["size"], anchor="e") + self.dialog.tree.column("size", anchor="e", width=120, stretch=False) + self.dialog.tree.heading("type", text=LocaleStrings.VIEW["type"], anchor="w") + self.dialog.tree.column("type", anchor="w", width=120, stretch=False) + self.dialog.tree.heading("modified", text=LocaleStrings.VIEW["date_modified"], anchor="w") + self.dialog.tree.column("modified", anchor="w", width=160, stretch=False) + + v_scrollbar = ttk.Scrollbar( + tree_frame, orient="vertical", command=self.dialog.tree.yview) + h_scrollbar = ttk.Scrollbar( + tree_frame, orient="horizontal", command=self.dialog.tree.xview) + self.dialog.tree.configure(yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set) + + self.dialog.tree.grid(row=0, column=0, sticky='nsew') + self.dialog.tree.focus_set() + v_scrollbar.grid(row=0, column=1, sticky='ns') + h_scrollbar.grid(row=1, column=0, sticky='ew') + + def _on_scroll(*args: Any) -> None: + if self.dialog.currently_loaded_count < len(self.dialog.all_items) and self.dialog.tree.yview()[1] > 0.9: + self._load_more_items_list_view() + v_scrollbar.set(*args) + self.dialog.tree.configure(yscrollcommand=_on_scroll) + + self.dialog.tree.bind("", self.on_list_double_click) + self.dialog.tree.bind("<>", self.on_list_select) + self.dialog.tree.bind("", self.dialog.file_op_manager.on_rename_request) + self.dialog.tree.bind("", self.on_list_context_menu) + + if warning: + self.dialog.widget_manager.search_status_label.config(text=warning) + if error: + self.dialog.tree.insert("", "end", text=error, values=()) + return + + while self.dialog.currently_loaded_count < len(self.dialog.all_items): + item_found = self._load_more_items_list_view(item_to_rename, item_to_select) + if item_found: + break + if not (item_to_rename or item_to_select): + break + + def _load_more_items_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> bool: + """ + Loads a batch of items into the list view. + + Args: + item_to_rename (str, optional): Item to enter rename mode. + item_to_select (str, optional): Item to select. + + Returns: + bool: True if the item to rename/select was found and processed. + """ + start_index = self.dialog.currently_loaded_count + end_index = min(len(self.dialog.all_items), start_index + + self.dialog.items_to_load_per_batch) + + if start_index >= end_index: + return False + + item_found = False + for i in range(start_index, end_index): + name = self.dialog.all_items[i] + if not self.dialog.show_hidden_files.get() and name.startswith('.'): + continue + path = os.path.join(self.dialog.current_dir, name) + is_dir = os.path.isdir(path) + if not is_dir and not self.dialog._matches_filetype(name): + continue + try: + stat = os.stat(path) + modified_time = datetime.fromtimestamp( + stat.st_mtime).strftime('%d.%m.%Y %H:%M') + if is_dir: + icon, file_type, size = self.dialog.icon_manager.get_icon( + 'folder_small'), LocaleStrings.FILE["folder"], "" + else: + icon, file_type, size = self.dialog.get_file_icon( + name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(stat.st_size) + item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=( + size, file_type, modified_time)) + if name == item_to_rename: + self.dialog.tree.selection_set(item_id) + self.dialog.tree.focus(item_id) + self.dialog.tree.see(item_id) + self.dialog.file_op_manager.start_rename(item_id, path) + item_found = True + elif name == item_to_select: + self.dialog.tree.selection_set(item_id) + self.dialog.tree.focus(item_id) + self.dialog.tree.see(item_id) + item_found = True + except (FileNotFoundError, PermissionError): + continue + + self.dialog.currently_loaded_count = end_index + return item_found + + def on_item_select(self, path: str, item_frame: ttk.Frame) -> None: + """ + Handles the selection of an item in the icon view. + + Args: + path (str): The path of the selected item. + item_frame: The widget frame of the selected item. + """ + if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists(): + self.dialog.selected_item_frame.state(['!selected']) + for child in self.dialog.selected_item_frame.winfo_children(): + if isinstance(child, ttk.Label): + child.state(['!selected']) + item_frame.state(['selected']) + for child in item_frame.winfo_children(): + if isinstance(child, ttk.Label): + child.state(['selected']) + self.dialog.selected_item_frame = item_frame + self.dialog.selected_file = path + self.dialog.update_status_bar(path) + self.dialog.search_manager.show_search_ready() + if not os.path.isdir(path): + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert(0, os.path.basename(path)) + + def on_list_select(self, event: tk.Event) -> None: + """Handles the selection of an item in the list view.""" + if not self.dialog.tree.selection(): + return + item_id = self.dialog.tree.selection()[0] + item_text = self.dialog.tree.item(item_id, 'text').strip() + path = os.path.join(self.dialog.current_dir, item_text) + self.dialog.selected_file = path + self.dialog.update_status_bar(path) + self.dialog.search_manager.show_search_ready() + if not os.path.isdir(self.dialog.selected_file): + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert(0, item_text) + + def on_list_context_menu(self, event: tk.Event) -> str: + """Shows the context menu for a list view item.""" + iid = self.dialog.tree.identify_row(event.y) + if not iid: + return "break" + self.dialog.tree.selection_set(iid) + item_text = self.dialog.tree.item(iid, "text").strip() + item_path = os.path.join(self.dialog.current_dir, item_text) + self.dialog.file_op_manager._show_context_menu(event, item_path) + return "break" + + def on_item_double_click(self, path: str) -> None: + """ + Handles a double-click on an icon view item. + + Args: + path (str): The path of the double-clicked item. + """ + if os.path.isdir(path): + self.dialog.navigation_manager.navigate_to(path) + elif self.dialog.dialog_mode == "open": + self.dialog.selected_file = path + self.dialog.destroy() + elif self.dialog.dialog_mode == "save": + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert( + 0, os.path.basename(path)) + self.dialog.on_save() + + def on_list_double_click(self, event: tk.Event) -> None: + """Handles a double-click on a list view item.""" + if not self.dialog.tree.selection(): + return + item_id = self.dialog.tree.selection()[0] + item_text = self.dialog.tree.item(item_id, 'text').strip() + path = os.path.join(self.dialog.current_dir, item_text) + if os.path.isdir(path): + self.dialog.navigation_manager.navigate_to(path) + elif self.dialog.dialog_mode == "open": + self.dialog.selected_file = path + self.dialog.destroy() + elif self.dialog.dialog_mode == "save": + self.dialog.widget_manager.filename_entry.delete(0, tk.END) + self.dialog.widget_manager.filename_entry.insert(0, item_text) + self.dialog.on_save() + + def _select_file_in_view(self, filename: str) -> None: + """ + Programmatically selects a file in the current view. + + Args: + filename (str): The name of the file to select. + """ + if self.dialog.view_mode.get() == "list": + for item_id in self.dialog.tree.get_children(): + if self.dialog.tree.item(item_id, "text").strip() == filename: + self.dialog.tree.selection_set(item_id) + self.dialog.tree.focus(item_id) + self.dialog.tree.see(item_id) + break + elif self.dialog.view_mode.get() == "icons": + if not hasattr(self.dialog, 'icon_canvas') or not self.dialog.icon_canvas.winfo_exists(): + return + + container_frame = self.dialog.icon_canvas.winfo_children()[0] + target_path = os.path.join(self.dialog.current_dir, filename) + + for widget in container_frame.winfo_children(): + if hasattr(widget, 'item_path') and widget.item_path == target_path: + self.on_item_select(widget.item_path, widget) + + def scroll_to_widget() -> None: + self.dialog.update_idletasks() + if not widget.winfo_exists(): return + y = widget.winfo_y() + canvas_height = self.dialog.icon_canvas.winfo_height() + scroll_region = self.dialog.icon_canvas.bbox("all") + if not scroll_region: return + + scroll_height = scroll_region[3] + if scroll_height > canvas_height: + fraction = y / scroll_height + self.dialog.icon_canvas.yview_moveto(fraction) + + self.dialog.after(100, scroll_to_widget) + break + + def _update_view_mode_buttons(self) -> None: + """Updates the visual state of the view mode toggle buttons.""" + if self.dialog.view_mode.get() == "icons": + self.dialog.widget_manager.icon_view_button.configure( + style="Header.TButton.Active.Round") + self.dialog.widget_manager.list_view_button.configure( + style="Header.TButton.Borderless.Round") + else: + self.dialog.widget_manager.list_view_button.configure( + style="Header.TButton.Active.Round") + self.dialog.widget_manager.icon_view_button.configure( + style="Header.TButton.Borderless.Round") + + def set_icon_view(self) -> None: + """Switches to icon view and repopulates the files.""" + self.dialog.view_mode.set("icons") + self._update_view_mode_buttons() + self.populate_files() + + def set_list_view(self) -> None: + """Switches to list view and repopulates the files.""" + self.dialog.view_mode.set("list") + self._update_view_mode_buttons() + self.populate_files() + + def toggle_hidden_files(self) -> None: + """Toggles the visibility of hidden files and refreshes the view.""" + self.dialog.show_hidden_files.set(not self.dialog.show_hidden_files.get()) + if self.dialog.show_hidden_files.get(): + self.dialog.widget_manager.hidden_files_button.config( + image=self.dialog.icon_manager.get_icon('unhide')) + Tooltip(self.dialog.widget_manager.hidden_files_button, + LocaleStrings.UI["hide_hidden_files"]) + else: + self.dialog.widget_manager.hidden_files_button.config( + image=self.dialog.icon_manager.get_icon('hide')) + Tooltip(self.dialog.widget_manager.hidden_files_button, + LocaleStrings.UI["show_hidden_files"]) + self.populate_files() + + def on_filter_change(self, event: tk.Event) -> None: + """Handles a change in the file type filter combobox.""" + selected_desc = self.dialog.widget_manager.filter_combobox.get() + for desc, pattern in self.dialog.filetypes: + if desc == selected_desc: + self.dialog.current_filter_pattern = pattern + break + self.populate_files() + + def _unbind_mouse_wheel_events(self) -> None: + """Unbinds all mouse wheel events from the dialog.""" + self.dialog.unbind_all("") + self.dialog.unbind_all("") + self.dialog.unbind_all("") diff --git a/custom_file_dialog.py b/custom_file_dialog.py new file mode 100644 index 0000000..c49305e --- /dev/null +++ b/custom_file_dialog.py @@ -0,0 +1,588 @@ +import os +import shutil +import tkinter as tk +from tkinter import ttk +from datetime import datetime +import subprocess +import json +import threading +from typing import Optional, List, Tuple, Any, Dict + +from shared_libs.message import MessageDialog +from shared_libs.common_tools import IconManager, Tooltip, ConfigManager, LxTools +from cfd_app_config import AppConfig, CfdConfigManager, LocaleStrings, _ +from cfd_ui_setup import StyleManager, WidgetManager, get_xdg_user_dir +from animated_icon import AnimatedIcon, PIL_AVAILABLE +from cfd_settings_dialog import SettingsDialog +from cfd_file_operations import FileOperationsManager +from cfd_search_manager import SearchManager +from cfd_navigation_manager import NavigationManager +from cfd_view_manager import ViewManager + +class CustomFileDialog(tk.Toplevel): + """ + A custom file dialog window that provides functionalities for file selection, + directory navigation, search, and file operations. + """ + def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None, + filetypes: Optional[List[Tuple[str, str]]] = None, + dialog_mode: str = "open", title: str = LocaleStrings.CFD["title"]): + """ + Initializes the CustomFileDialog. + + Args: + parent: The parent widget. + initial_dir: The initial directory to display. + filetypes: A list of filetype tuples, e.g., [('Text files', '*.txt')]. + dialog_mode: The dialog mode, either "open" or "save". + title: The title of the dialog window. + """ + super().__init__(parent) + + self.my_tool_tip: Optional[Tooltip] = None + self.dialog_mode: str = dialog_mode + + self.load_settings() + + self.geometry(self.settings["window_size_preset"]) + min_width, min_height = self.get_min_size_from_preset( + self.settings["window_size_preset"]) + self.minsize(min_width, min_height) + + self.title(title) + self.image: IconManager = IconManager() + width, height = map( + int, self.settings["window_size_preset"].split('x')) + LxTools.center_window_cross_platform(self, width, height) + self.parent: tk.Widget = parent + self.transient(parent) + self.grab_set() + + self.selected_file: Optional[str] = None + self.current_dir: str = os.path.abspath( + initial_dir) if initial_dir else os.path.expanduser("~") + self.filetypes: List[Tuple[str, str]] = filetypes if filetypes else [(LocaleStrings.CFD["all_files"], "*.* ")] + self.current_filter_pattern: str = self.filetypes[0][1] + self.history: List[str] = [] + self.history_pos: int = -1 + self.view_mode: tk.StringVar = tk.StringVar( + value=self.settings.get("default_view_mode", "icons")) + self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False) + self.resize_job: Optional[str] = None + self.last_width: int = 0 + self.search_results: List[str] = [] + self.search_mode: bool = False + self.original_path_text: str = "" + self.items_to_load_per_batch: int = 250 + self.item_path_map: Dict[int, str] = {} + self.responsive_buttons_hidden: Optional[bool] = None + self.search_job: Optional[str] = None + self.search_thread: Optional[threading.Thread] = None + self.search_process: Optional[subprocess.Popen] = None + + self.icon_manager: IconManager = IconManager() + self.style_manager: StyleManager = StyleManager(self) + + self.file_op_manager: FileOperationsManager = FileOperationsManager(self) + self.search_manager: SearchManager = SearchManager(self) + self.navigation_manager: NavigationManager = NavigationManager(self) + self.view_manager: ViewManager = ViewManager(self) + + self.widget_manager: WidgetManager = WidgetManager(self, self.settings) + + self.widget_manager.filename_entry.bind("", self.search_manager.execute_search) + + self.update_animation_settings() + + self.view_manager._update_view_mode_buttons() + + def initial_load() -> None: + """Performs the initial loading and UI setup.""" + self.update_idletasks() + self.last_width = self.widget_manager.file_list_frame.winfo_width() + self._handle_responsive_buttons(self.winfo_width()) + self.navigation_manager.navigate_to(self.current_dir) + + self.after(10, initial_load) + + self.widget_manager.path_entry.bind( + "", self.navigation_manager.handle_path_entry_return) + + self.bind("", self.search_manager.show_search_bar) + + if self.dialog_mode == "save": + self.bind("", self.file_op_manager.delete_selected_item) + + def load_settings(self) -> None: + """Loads settings from the configuration file.""" + self.settings = CfdConfigManager.load() + size_preset = self.settings.get("window_size_preset", "1050x850") + self.settings["window_size_preset"] = size_preset + if hasattr(self, 'view_mode'): + self.view_mode.set(self.settings.get("default_view_mode", "icons")) + + def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]: + """ + Calculates the minimum window size based on a preset string. + + Args: + preset: The size preset string (e.g., "1050x850"). + + Returns: + A tuple containing the minimum width and height. + """ + w, h = map(int, preset.split('x')) + return max(650, w - 400), max(450, h - 400) + + def reload_config_and_rebuild_ui(self) -> None: + """Reloads the configuration and rebuilds the entire UI.""" + self.load_settings() + + self.geometry(self.settings["window_size_preset"]) + min_width, min_height = self.get_min_size_from_preset( + self.settings["window_size_preset"]) + self.minsize(min_width, min_height) + width, height = map( + int, self.settings["window_size_preset"].split('x')) + LxTools.center_window_cross_platform(self, width, height) + + for widget in self.winfo_children(): + widget.destroy() + + self.style_manager = StyleManager(self) + self.file_op_manager = FileOperationsManager(self) + self.search_manager = SearchManager(self) + self.navigation_manager = NavigationManager(self) + self.view_manager = ViewManager(self) + self.widget_manager = WidgetManager(self, self.settings) + self.widget_manager.filename_entry.bind("", self.search_manager.execute_search) + self.view_manager._update_view_mode_buttons() + + self.responsive_buttons_hidden = None + self.update_idletasks() + self._handle_responsive_buttons(self.winfo_width()) + + self.update_animation_settings() + + if self.search_mode: + self.search_manager.show_search_results_treeview() + else: + self.navigation_manager.navigate_to(self.current_dir) + + def open_settings_dialog(self) -> None: + """Opens the settings dialog.""" + SettingsDialog(self, dialog_mode=self.dialog_mode) + + def update_animation_settings(self) -> None: + """Updates the search animation icon based on current settings.""" + use_pillow = self.settings.get('use_pillow_animation', False) + anim_type = self.settings.get('animation_type', 'double') + is_running = self.widget_manager.search_animation.running + if is_running: + self.widget_manager.search_animation.stop() + + self.widget_manager.search_animation.destroy() + self.widget_manager.search_animation = AnimatedIcon( + self.widget_manager.status_container, + width=23, + height=23, + use_pillow=use_pillow, + animation_type=anim_type, + color="#2a6fde", + highlight_color="#5195ff", + bg=self.style_manager.bottom_color + ) + self.widget_manager.search_animation.grid(row=0, column=0, sticky='w', padx=(0, 5), pady=(4,0)) + self.widget_manager.search_animation.bind("", lambda e: self.search_manager.activate_search()) + self.widget_manager.search_animation.bind("", self._show_tooltip) + self.widget_manager.search_animation.bind("", self._hide_tooltip) + + if is_running: + self.widget_manager.search_animation.start() + + def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage: + """ + Gets the appropriate icon for a given filename. + + Args: + filename: The name of the file. + size: The desired icon size ('large' or 'small'). + + Returns: + A PhotoImage object for the corresponding file type. + """ + ext = os.path.splitext(filename)[1].lower() + + if ext == '.py': + return self.icon_manager.get_icon(f'python_{size}') + if ext == '.pdf': + return self.icon_manager.get_icon(f'pdf_{size}') + if ext in ['.tar', '.zip', '.rar', '.7z', '.gz']: + return self.icon_manager.get_icon(f'archive_{size}') + if ext in ['.mp3', '.wav', '.ogg', '.flac']: + return self.icon_manager.get_icon(f'audio_{size}') + if ext in ['.mp4', '.mkv', '.avi', '.mov']: + return self.icon_manager.get_icon(f'video_{size}') if size == 'large' else self.icon_manager.get_icon( + 'video_small_file') + if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg']: + return self.icon_manager.get_icon(f'picture_{size}') + if ext == '.iso': + return self.icon_manager.get_icon(f'iso_{size}') + return self.icon_manager.get_icon(f'file_{size}') + + def on_window_resize(self, event: tk.Event) -> None: + """ + Handles the window resize event. + + Args: + event: The event object. + """ + if event.widget is self: + if self.view_mode.get() == "icons" and not self.search_mode: + new_width = self.widget_manager.file_list_frame.winfo_width() + if abs(new_width - self.last_width) > 50: + if self.resize_job: + self.after_cancel(self.resize_job) + + def repopulate_icons() -> None: + """Repopulates the file list icons.""" + self.update_idletasks() + self.view_manager.populate_files() + + self.resize_job = self.after(150, repopulate_icons) + self.last_width = new_width + + self._handle_responsive_buttons(event.width) + + def _handle_responsive_buttons(self, window_width: int) -> None: + """ + Shows or hides buttons based on the window width. + + Args: + window_width: The current width of the window. + """ + threshold = 850 + container = self.widget_manager.responsive_buttons_container + more_button = self.widget_manager.more_button + + should_be_hidden = window_width < threshold + + if should_be_hidden != self.responsive_buttons_hidden: + if should_be_hidden: + container.pack_forget() + more_button.pack(side="left", padx=5) + else: + more_button.pack_forget() + container.pack(side="left") + self.responsive_buttons_hidden = should_be_hidden + + def show_more_menu(self) -> None: + """Displays a 'more options' menu.""" + more_menu = tk.Menu(self, tearoff=0, background=self.style_manager.header, foreground=self.style_manager.color_foreground, + activebackground=self.style_manager.selection_color, activeforeground=self.style_manager.color_foreground, relief='flat', borderwidth=0) + + is_writable = os.access(self.current_dir, os.W_OK) + creation_state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED + + more_menu.add_command(label=LocaleStrings.UI["new_folder"], command=self.file_op_manager.create_new_folder, + image=self.icon_manager.get_icon('new_folder_small'), compound='left', state=creation_state) + more_menu.add_command(label=LocaleStrings.UI["new_document"], command=self.file_op_manager.create_new_file, + image=self.icon_manager.get_icon('new_document_small'), compound='left', state=creation_state) + more_menu.add_separator() + more_menu.add_command(label=LocaleStrings.VIEW["icon_view"], command=self.view_manager.set_icon_view, + image=self.icon_manager.get_icon('icon_view'), compound='left') + more_menu.add_command(label=LocaleStrings.VIEW["list_view"], command=self.view_manager.set_list_view, + image=self.icon_manager.get_icon('list_view'), compound='left') + more_menu.add_separator() + + hidden_files_label = LocaleStrings.UI["hide_hidden_files"] if self.show_hidden_files.get() else LocaleStrings.UI["show_hidden_files"] + hidden_files_icon = self.icon_manager.get_icon( + 'unhide') if self.show_hidden_files.get() else self.icon_manager.get_icon('hide') + more_menu.add_command(label=hidden_files_label, command=self.view_manager.toggle_hidden_files, + image=hidden_files_icon, compound='left') + + more_button = self.widget_manager.more_button + x = more_button.winfo_rootx() + y = more_button.winfo_rooty() + more_button.winfo_height() + more_menu.tk_popup(x, y) + + def on_sidebar_resize(self, event: tk.Event) -> None: + """ + Handles the sidebar resize event, adjusting button text visibility. + + Args: + event: The event object. + """ + current_width = event.width + threshold_width = 100 + + if current_width < threshold_width: + for btn, original_text in self.widget_manager.sidebar_buttons: + btn.config(text="", compound="top") + for btn, original_text in self.widget_manager.device_buttons: + btn.config(text="", compound="top") + else: + for btn, original_text in self.widget_manager.sidebar_buttons: + btn.config(text=original_text, compound="left") + for btn, original_text in self.widget_manager.device_buttons: + btn.config(text=original_text, compound="left") + + def _on_devices_enter(self, event: tk.Event) -> None: + """ + Shows the scrollbar when the mouse enters the devices area. + + Args: + event: The event object. + """ + self.widget_manager.devices_scrollbar.grid( + row=1, column=1, sticky="ns") + + def _on_devices_leave(self, event: tk.Event) -> None: + """ + Hides the scrollbar when the mouse leaves the devices area. + + Args: + event: The event object. + """ + x, y = event.x_root, event.y_root + widget_x = self.widget_manager.devices_canvas.winfo_rootx() + widget_y = self.widget_manager.devices_canvas.winfo_rooty() + widget_width = self.widget_manager.devices_canvas.winfo_width() + widget_height = self.widget_manager.devices_canvas.winfo_height() + + buffer = 5 + if not (widget_x - buffer <= x <= widget_x + widget_width + buffer and + widget_y - buffer <= y <= widget_y + widget_height + buffer): + self.widget_manager.devices_scrollbar.grid_remove() + + def toggle_recursive_search(self) -> None: + """Toggles the recursive search option on or off.""" + self.widget_manager.recursive_search.set( + not self.widget_manager.recursive_search.get()) + if self.widget_manager.recursive_search.get(): + self.widget_manager.recursive_button.configure( + style="Header.TButton.Active.Round") + else: + self.widget_manager.recursive_button.configure( + style="Header.TButton.Borderless.Round") + + def update_status_bar(self, selected_path: Optional[str] = None) -> None: + """ + Updates the status bar with disk usage and selected item information. + + Args: + selected_path: The path of the currently selected item. + """ + try: + total, used, free = shutil.disk_usage(self.current_dir) + free_str = self._format_size(free) + self.widget_manager.storage_label.config( + text=f"{LocaleStrings.CFD['free_space']}: {free_str}") + self.widget_manager.storage_bar['value'] = (used / total) * 100 + + status_text = "" + if selected_path and os.path.exists(selected_path): + if os.path.isdir(selected_path): + content_count = self.view_manager._get_folder_content_count( + selected_path) + if content_count is not None: + status_text = f"'{os.path.basename(selected_path)}' ({content_count} {LocaleStrings.CFD['entries']})" + else: + status_text = f"'{os.path.basename(selected_path)}'" + else: + size = os.path.getsize(selected_path) + size_str = self._format_size(size) + status_text = f"'{os.path.basename(selected_path)}' {LocaleStrings.VIEW['size']}: {size_str}" + self.widget_manager.search_status_label.config(text=status_text) + except FileNotFoundError: + self.widget_manager.search_status_label.config( + text=LocaleStrings.CFD["directory_not_found"]) + self.widget_manager.storage_label.config( + text=f"{LocaleStrings.CFD['free_space']}: {LocaleStrings.CFD['unknown']}") + self.widget_manager.storage_bar['value'] = 0 + + def on_open(self) -> None: + """Handles the 'Open' action, closing the dialog if a file is selected.""" + if self.selected_file and os.path.isfile(self.selected_file): + self.destroy() + + def on_save(self) -> None: + """Handles the 'Save' action, setting the selected file and closing the dialog.""" + file_name = self.widget_manager.filename_entry.get() + if file_name: + self.selected_file = os.path.join(self.current_dir, file_name) + self.destroy() + + def on_cancel(self) -> None: + """Handles the 'Cancel' action, clearing the selection and closing the dialog.""" + self.selected_file = None + self.destroy() + + def get_selected_file(self) -> Optional[str]: + """ + Returns the path of the selected file. + + Returns: + The selected file path, or None if no file was selected. + """ + return self.selected_file + + def update_action_buttons_state(self) -> None: + """Updates the state of action buttons (e.g., 'New Folder') based on directory permissions.""" + is_writable = os.access(self.current_dir, os.W_OK) + state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED + self.widget_manager.new_folder_button.config(state=state) + self.widget_manager.new_file_button.config(state=state) + + def _matches_filetype(self, filename: str) -> bool: + """ + Checks if a filename matches the current filetype filter. + + Args: + filename: The name of the file to check. + + Returns: + True if the file matches the filter, False otherwise. + """ + if self.current_filter_pattern == "*.*": + return True + + patterns = self.current_filter_pattern.lower().split() + fn_lower = filename.lower() + + for p in patterns: + if p.startswith('*.'): + if fn_lower.endswith(p[1:]): + return True + elif p.startswith('.'): + if fn_lower.endswith(p): + return True + else: + if fn_lower == p: + return True + return False + + def _format_size(self, size_bytes: Optional[int]) -> str: + """ + Formats a size in bytes into a human-readable string (KB, MB, GB). + + Args: + size_bytes: The size in bytes. + + Returns: + A formatted string representing the size. + """ + if size_bytes is None: + return "" + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024**2: + return f"{size_bytes/1024:.1f} KB" + if size_bytes < 1024**3: + return f"{size_bytes/1024**2:.1f} MB" + return f"{size_bytes/1024**3:.1f} GB" + + def shorten_text(self, text: str, max_len: int) -> str: + """ + Shortens a string to a maximum length, adding '...' if truncated. + + Args: + text: The text to shorten. + max_len: The maximum allowed length. + + Returns: + The shortened text. + """ + return text if len(text) <= max_len else text[:max_len-3] + "..." + + def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]: + """ + Retrieves a list of mounted devices on the system. + + Returns: + A list of tuples, where each tuple contains the display name, + mount point, and a boolean indicating if it's removable. + """ + devices: List[Tuple[str, str, bool]] = [] + root_disk_name: Optional[str] = None + try: + result = subprocess.run(['lsblk', '-J', '-o', 'NAME,MOUNTPOINT,FSTYPE,SIZE,RO,RM,TYPE,LABEL,PKNAME'], + capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + + for block_device in data.get('blockdevices', []): + if 'children' in block_device: + for child_device in block_device['children']: + if child_device.get('mountpoint') == '/': + root_disk_name = block_device.get('name') + break + if root_disk_name: + break + + for block_device in data.get('blockdevices', []): + if (block_device.get('mountpoint') and + block_device.get('type') not in ['loop', 'rom'] and + block_device.get('mountpoint') != '/'): + + if block_device.get('name').startswith(root_disk_name) and not block_device.get('rm', False): + pass + else: + name = block_device.get('name') + mountpoint = block_device.get('mountpoint') + label = block_device.get('label') + removable = block_device.get('rm', False) + + display_name = label if label else name + devices.append((display_name, mountpoint, removable)) + + if 'children' in block_device: + for child_device in block_device['children']: + if (child_device.get('mountpoint') and + child_device.get('type') not in ['loop', 'rom'] and + child_device.get('mountpoint') != '/'): + + if block_device.get('name') == root_disk_name and not child_device.get('rm', False): + pass + else: + name = child_device.get('name') + mountpoint = child_device.get('mountpoint') + label = child_device.get('label') + removable = child_device.get('rm', False) + + display_name = label if label else name + devices.append( + (display_name, mountpoint, removable)) + + except Exception as e: + print(f"Error getting mounted devices: {e}") + return devices + + def _show_tooltip(self, event: tk.Event) -> None: + """ + Displays a tooltip for the search animation icon. + + Args: + event: The event object. + """ + if hasattr(self, 'tooltip_window') and self.tooltip_window.winfo_exists(): + return + + tooltip_text = LocaleStrings.UI["start_search"] if not self.widget_manager.search_animation.running else LocaleStrings.UI["cancel_search"] + + x = self.widget_manager.search_animation.winfo_rootx() + 25 + y = self.widget_manager.search_animation.winfo_rooty() + 25 + self.tooltip_window = tk.Toplevel(self) + self.tooltip_window.wm_overrideredirect(True) + self.tooltip_window.wm_geometry(f"+{x}+{y}") + label = tk.Label(self.tooltip_window, text=tooltip_text, relief="solid", borderwidth=1) + label.pack() + + def _hide_tooltip(self, event: tk.Event) -> None: + """ + Hides the tooltip. + + Args: + event: The event object. + """ + if hasattr(self, 'tooltip_window') and self.tooltip_window.winfo_exists(): + self.tooltip_window.destroy() diff --git a/mainwindow.py b/mainwindow.py new file mode 100755 index 0000000..54b0a35 --- /dev/null +++ b/mainwindow.py @@ -0,0 +1,62 @@ +#!/usr/bin/python3 +import tkinter as tk +import os +from tkinter import ttk +from custom_file_dialog import CustomFileDialog + + +class GlotzMol(tk.Tk): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.geometry('800x400') + self.title("Custom File Dialog Test") + + container = ttk.Frame(self, padding=10) + container.pack(fill="x", anchor="n") + + ttk.Label(container, text="Ausgewählte Datei:").grid( + row=0, column=0, sticky="w") + + self.iso_path_entry = ttk.Entry(container) + self.iso_path_entry.grid( + row=1, column=0, columnspan=2, padx=(0, 10), pady=5, sticky="ew") + + self.open_button = ttk.Button( + container, text="Datei auswählen...", command=self.open_custom_dialog) + self.open_button.grid(row=1, column=2, pady=5, sticky="e") + + container.columnconfigure(0, weight=1) + + def open_custom_dialog(self): + + dialog = CustomFileDialog(self, + initial_dir=os.path.expanduser("~"), + filetypes=[("All Files", "*.*"), + ("Wireguard config Files", "*.conf") + ]) + + # This is the crucial part: wait for the dialog to be closed + self.wait_window(dialog) + + # Now, get the result + selected_path = dialog.get_selected_file() + + if selected_path: + self.iso_path_entry.delete(0, tk.END) + self.iso_path_entry.insert(0, selected_path) + print(f"Die ausgewählte Datei ist: {selected_path}") + else: + print("Keine Datei ausgewählt.") + + +if __name__ == "__main__": + root = GlotzMol() + theme_path = '/usr/share/TK-Themes' + style = ttk.Style(root) + root.tk.call('source', f"{theme_path}/water.tcl") + try: + root.tk.call('set_theme', 'dark') + except tk.TclError: + pass + root.mainloop()