From ba6ef7385a3197072043f3b20b875e24246f8420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A9sir=C3=A9=20Werner=20Menrath?= Date: Wed, 13 Aug 2025 01:05:57 +0200 Subject: [PATCH] replace tooltip animation with exist tooltip, redundancy reduced, search optimized, add new icons copy and stair (for path folllow) --- animated_icon.py | 516 +------------------ common_tools.py | 46 +- custom_file_dialog/cfd_file_operations.py | 86 ++-- custom_file_dialog/cfd_navigation_manager.py | 17 +- custom_file_dialog/cfd_search_manager.py | 28 +- custom_file_dialog/cfd_view_manager.py | 21 +- custom_file_dialog/custom_file_dialog.py | 67 +-- 7 files changed, 150 insertions(+), 631 deletions(-) diff --git a/animated_icon.py b/animated_icon.py index b8fd4f7..9885e93 100644 --- a/animated_icon.py +++ b/animated_icon.py @@ -453,21 +453,21 @@ class AnimatedIcon(tk.Canvas): def _animate(self) -> None: """The main animation loop.""" - if self.running: - try: - # Check if a modal dialog has the grab on the entire application - if self.winfo_toplevel().grab_current() is not None: - self.after(100, self._animate) # Check again after a short delay - return - except Exception: - # This can happen if no grab is active. We can safely ignore it. - pass + if not self.winfo_exists() or not self.running: + return - self.angle += 0.1 - if self.angle > 2 * pi: - self.angle -= 2 * pi - self._draw_frame() - self.after(30, self._animate) + # Do not animate if a grab is active on a different window. + toplevel = self.winfo_toplevel() + grab_widget = toplevel.grab_current() + if grab_widget is not None and grab_widget != toplevel: + self.after(100, self._animate) # Check again after a short delay + return + + 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: """ @@ -476,6 +476,8 @@ class AnimatedIcon(tk.Canvas): Args: pulse (bool): If True, plays a pulsing animation instead of the main one. """ + if not self.winfo_exists(): + return if not self.running: self.pulse_animation = pulse self.running = True @@ -483,497 +485,23 @@ class AnimatedIcon(tk.Canvas): def stop(self) -> None: """Stops the animation and shows the static 'stopped' frame.""" + if not self.winfo_exists(): + return self.running = False self.pulse_animation = False self._draw_stopped_frame() def hide(self) -> None: """Stops the animation and clears the canvas.""" + if not self.winfo_exists(): + return 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() - - -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.winfo_exists(): + return if not self.running: self._draw_stopped_frame() diff --git a/common_tools.py b/common_tools.py index 77dcef2..1f9a001 100755 --- a/common_tools.py +++ b/common_tools.py @@ -452,6 +452,12 @@ class Tooltip: if self.state_var: self.state_var.trace_add("write", self.update_bindings) + # Add bindings to the top-level window to hide the tooltip when the + # main window loses focus or is iconified. + toplevel = self.widget.winfo_toplevel() + toplevel.bind("", self.leave, add="+") + toplevel.bind("", self.leave, add="+") + def update_bindings(self, *args): """ Updates the event bindings for the widget based on the current state_var. @@ -472,13 +478,12 @@ class Tooltip: Handles the event. Schedules the tooltip to be shown after a delay if tooltips are enabled (via state_var). """ - try: - # Check if a modal dialog has the grab on the entire application - if self.winfo_toplevel().grab_current() is not None: - return - except Exception: - # This can happen if no grab is active. We can safely ignore it. - pass + # Do not show tooltips if a grab is active on a different window. + # This prevents tooltips from appearing over other modal dialogs. + toplevel = self.widget.winfo_toplevel() + grab_widget = toplevel.grab_current() + if grab_widget is not None and grab_widget != toplevel: + return if self.state_var is None or self.state_var.get(): self.schedule() @@ -513,15 +518,26 @@ class Tooltip: Displays the tooltip window. The tooltip is a Toplevel window containing a ttk.Label. It is positioned near the widget and styled for readability. """ - if self.tooltip_window or not self.text: + if self.tooltip_window: return - x, y, _, _ = self.widget.bbox("insert") - x += self.widget.winfo_rootx() + 25 - y += self.widget.winfo_rooty() + 20 + + text_to_show = self.text() if callable(self.text) else self.text + if not text_to_show: + return + + try: + # Position the tooltip just below the widget. + # Using winfo_rootx/y is more reliable than bbox. + x = self.widget.winfo_rootx() + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 + except tk.TclError: + # This can happen if the widget is destroyed while the tooltip is scheduled. + return + self.tooltip_window = tw = tk.Toplevel(self.widget) tw.wm_overrideredirect(True) tw.wm_geometry(f"+" + str(x) + "+" + str(y)) - label = ttk.Label(tw, text=self.text, justify=tk.LEFT, background="#FFFFE0", foreground="black", + label = ttk.Label(tw, text=text_to_show, justify=tk.LEFT, background="#FFFFE0", foreground="black", relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2)) label.pack(ipadx=1) @@ -617,6 +633,8 @@ class IconManager: 'back': '32/arrow-left.png', 'forward': '32/arrow-right.png', 'up': '32/arrow-up.png', + 'copy': '32/copy.png', + 'stair': '32/stair.png', 'audio_small': '32/audio.png', 'icon_view': '32/carrel.png', 'computer_small': '32/computer.png', @@ -675,6 +693,8 @@ class IconManager: 'back_large': '48/arrow-left.png', 'forward_large': '48/arrow-right.png', 'up_large': '48/arrow-up.png', + 'copy': '48/copy.png', + 'stair': '48/stair.png', 'icon_view_large': '48/carrel.png', 'computer_large': '48/computer.png', 'device_large': '48/device.png', @@ -722,6 +742,8 @@ class IconManager: 'back_extralarge': '64/arrow-left.png', 'forward_extralarge': '64/arrow-right.png', 'up_extralarge': '64/arrow-up.png', + 'copy': '64/copy.png', + 'stair': '64/stair.png', 'audio_large': '64/audio.png', 'icon_view_extralarge': '64/carrel.png', 'computer_extralarge': '64/computer.png', diff --git a/custom_file_dialog/cfd_file_operations.py b/custom_file_dialog/cfd_file_operations.py index 0a8d7ea..0f2ddda 100644 --- a/custom_file_dialog/cfd_file_operations.py +++ b/custom_file_dialog/cfd_file_operations.py @@ -214,14 +214,12 @@ class FileOperationsManager: 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) + self.start_rename(item_id) 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: + def start_rename(self, item_widget: Any, item_path: Optional[str] = None) -> None: """ Starts the renaming UI for an item. @@ -230,10 +228,12 @@ class FileOperationsManager: 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. + item_path (str, optional): The full path to the item being renamed. + Required for icon view. """ if self.dialog.view_mode.get() == "icons": - self._start_rename_icon_view(item_widget, item_path) + if item_path: + self._start_rename_icon_view(item_widget, item_path) else: # list view self._start_rename_list_view(item_widget) # item_widget is item_id @@ -258,25 +258,7 @@ class FileOperationsManager: 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)) + self._finish_rename_logic(item_path, new_name) def cancel_rename(event: tk.Event) -> None: self.dialog.view_manager.populate_files() @@ -312,30 +294,12 @@ class FileOperationsManager: entry.select_range(0, tk.END) entry.focus_set() + old_path = os.path.join(self.dialog.current_dir, item_text) + 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() + self._finish_rename_logic(old_path, new_name) def cancel_rename(event: tk.Event) -> None: entry.destroy() @@ -343,3 +307,33 @@ class FileOperationsManager: entry.bind("", finish_rename) entry.bind("", finish_rename) entry.bind("", cancel_rename) + + def _finish_rename_logic(self, old_path: str, new_name: str) -> None: + """ + Handles the core logic of renaming a file or folder after the user + submits the new name from an Entry widget. + + Args: + old_path (str): The original full path of the item. + new_name (str): The new name for the item. + """ + new_path = os.path.join(self.dialog.current_dir, new_name) + old_name = os.path.basename(old_path) + + if not new_name or new_path == old_path: + self.dialog.view_manager.populate_files(item_to_select=old_name) + return + + 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=old_name) + return + + 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() diff --git a/custom_file_dialog/cfd_navigation_manager.py b/custom_file_dialog/cfd_navigation_manager.py index 6881a58..f8a2bf6 100644 --- a/custom_file_dialog/cfd_navigation_manager.py +++ b/custom_file_dialog/cfd_navigation_manager.py @@ -90,20 +90,14 @@ class NavigationManager: 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() + self._update_ui_after_navigation() 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() + self._update_ui_after_navigation() def go_up_level(self) -> None: """Navigates to the parent directory of the current directory.""" @@ -111,6 +105,13 @@ class NavigationManager: if new_path != self.dialog.current_dir: self.navigate_to(new_path) + def _update_ui_after_navigation(self) -> None: + """Updates all necessary UI components after a navigation action.""" + self.dialog.view_manager.populate_files() + self.update_nav_buttons() + self.dialog.update_status_bar() + self.dialog.update_action_buttons_state() + def update_nav_buttons(self) -> None: """Updates the state of the back and forward navigation buttons.""" self.dialog.widget_manager.back_button.config( diff --git a/custom_file_dialog/cfd_search_manager.py b/custom_file_dialog/cfd_search_manager.py index 345ae11..a35a539 100644 --- a/custom_file_dialog/cfd_search_manager.py +++ b/custom_file_dialog/cfd_search_manager.py @@ -201,8 +201,9 @@ class SearchManager: 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() + if self.dialog.widget_manager.file_list_frame.winfo_exists(): + 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) @@ -280,14 +281,25 @@ class SearchManager: 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] + if not selection: + return + item = search_tree.item(selection[0]) + filename = item['text'].strip() + directory = item['values'][0] + full_path = os.path.join(directory, filename) + + if not os.path.exists(full_path): + return # Item no longer exists + + if os.path.isdir(full_path): + # For directories, navigate into them self.hide_search_bar() - self.dialog.navigation_manager.navigate_to( - directory, file_to_select=filename) + self.dialog.navigation_manager.navigate_to(full_path) + else: + # For files, select it and close the dialog + self.dialog.selected_file = full_path + self.dialog.destroy() search_tree.bind("", on_search_double_click) diff --git a/custom_file_dialog/cfd_view_manager.py b/custom_file_dialog/cfd_view_manager.py index 01711c4..cd4a98e 100644 --- a/custom_file_dialog/cfd_view_manager.py +++ b/custom_file_dialog/cfd_view_manager.py @@ -126,7 +126,7 @@ class ViewManager: """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) + self._handle_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.""" @@ -281,7 +281,7 @@ class ViewManager: for widget in [item_frame, icon_label, name_label]: widget.bind("", lambda e, - p=path: self.on_item_double_click(p)) + p=path: self._handle_item_double_click(p)) widget.bind("", lambda e, p=path, f=item_frame: self.on_item_select(p, f)) widget.bind("", lambda e, @@ -485,12 +485,13 @@ class ViewManager: self.dialog.file_op_manager._show_context_menu(event, item_path) return "break" - def on_item_double_click(self, path: str) -> None: + def _handle_item_double_click(self, path: str) -> None: """ - Handles a double-click on an icon view item. + Handles the logic for a double-click on any item, regardless of view. + Navigates into directories or selects files. Args: - path (str): The path of the double-clicked item. + path (str): The full path of the double-clicked item. """ if os.path.isdir(path): self.dialog.navigation_manager.navigate_to(path) @@ -510,15 +511,7 @@ class ViewManager: 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() + self._handle_item_double_click(path) def _select_file_in_view(self, filename: str) -> None: """ diff --git a/custom_file_dialog/custom_file_dialog.py b/custom_file_dialog/custom_file_dialog.py index e5e5753..d1fc007 100644 --- a/custom_file_dialog/custom_file_dialog.py +++ b/custom_file_dialog/custom_file_dialog.py @@ -22,6 +22,15 @@ class CustomFileDialog(tk.Toplevel): directory navigation, search, and file operations. """ + def _initialize_managers(self) -> None: + """Initializes or re-initializes all the manager classes.""" + 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) + 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"]): @@ -80,15 +89,7 @@ class CustomFileDialog(tk.Toplevel): 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._initialize_managers() self.widget_manager.filename_entry.bind( "", self.search_manager.execute_search) @@ -150,12 +151,8 @@ class CustomFileDialog(tk.Toplevel): 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._initialize_managers() + self.widget_manager.filename_entry.bind( "", self.search_manager.execute_search) self.view_manager._update_view_mode_buttons() @@ -198,10 +195,11 @@ class CustomFileDialog(tk.Toplevel): 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) + + self.my_tool_tip = Tooltip( + self.widget_manager.search_animation, + text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"] + ) if is_running: self.widget_manager.search_animation.start() @@ -564,33 +562,4 @@ class CustomFileDialog(tk.Toplevel): 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() +