replace tooltip animation with exist tooltip, redundancy reduced, search optimized, add new icons copy and stair (for path folllow)

This commit is contained in:
2025-08-13 01:05:57 +02:00
parent dc51bf6f2c
commit ba6ef7385a
7 changed files with 150 additions and 631 deletions

View File

@@ -453,21 +453,21 @@ class AnimatedIcon(tk.Canvas):
def _animate(self) -> None: def _animate(self) -> None:
"""The main animation loop.""" """The main animation loop."""
if self.running: if not self.winfo_exists() or not self.running:
try: return
# 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
self.angle += 0.1 # Do not animate if a grab is active on a different window.
if self.angle > 2 * pi: toplevel = self.winfo_toplevel()
self.angle -= 2 * pi grab_widget = toplevel.grab_current()
self._draw_frame() if grab_widget is not None and grab_widget != toplevel:
self.after(30, self._animate) 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: def start(self, pulse: bool = False) -> None:
""" """
@@ -476,6 +476,8 @@ class AnimatedIcon(tk.Canvas):
Args: Args:
pulse (bool): If True, plays a pulsing animation instead of the main one. pulse (bool): If True, plays a pulsing animation instead of the main one.
""" """
if not self.winfo_exists():
return
if not self.running: if not self.running:
self.pulse_animation = pulse self.pulse_animation = pulse
self.running = True self.running = True
@@ -483,497 +485,23 @@ class AnimatedIcon(tk.Canvas):
def stop(self) -> None: def stop(self) -> None:
"""Stops the animation and shows the static 'stopped' frame.""" """Stops the animation and shows the static 'stopped' frame."""
if not self.winfo_exists():
return
self.running = False self.running = False
self.pulse_animation = False self.pulse_animation = False
self._draw_stopped_frame() self._draw_stopped_frame()
def hide(self) -> None: def hide(self) -> None:
"""Stops the animation and clears the canvas.""" """Stops the animation and clears the canvas."""
if not self.winfo_exists():
return
self.running = False self.running = False
self.pulse_animation = False self.pulse_animation = False
self.delete("all") self.delete("all")
def show_full_circle(self) -> None: def show_full_circle(self) -> None:
"""Shows the static 'stopped' frame without starting the animation.""" """Shows the static 'stopped' frame without starting the animation."""
if not self.running: if not self.winfo_exists():
self._draw_stopped_frame() return
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: if not self.running:
self._draw_stopped_frame() self._draw_stopped_frame()

View File

@@ -452,6 +452,12 @@ class Tooltip:
if self.state_var: if self.state_var:
self.state_var.trace_add("write", self.update_bindings) 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("<FocusOut>", self.leave, add="+")
toplevel.bind("<Unmap>", self.leave, add="+")
def update_bindings(self, *args): def update_bindings(self, *args):
""" """
Updates the event bindings for the widget based on the current state_var. Updates the event bindings for the widget based on the current state_var.
@@ -472,13 +478,12 @@ class Tooltip:
Handles the <Enter> event. Schedules the tooltip to be shown after a delay Handles the <Enter> event. Schedules the tooltip to be shown after a delay
if tooltips are enabled (via state_var). if tooltips are enabled (via state_var).
""" """
try: # Do not show tooltips if a grab is active on a different window.
# Check if a modal dialog has the grab on the entire application # This prevents tooltips from appearing over other modal dialogs.
if self.winfo_toplevel().grab_current() is not None: toplevel = self.widget.winfo_toplevel()
return grab_widget = toplevel.grab_current()
except Exception: if grab_widget is not None and grab_widget != toplevel:
# This can happen if no grab is active. We can safely ignore it. return
pass
if self.state_var is None or self.state_var.get(): if self.state_var is None or self.state_var.get():
self.schedule() self.schedule()
@@ -513,15 +518,26 @@ class Tooltip:
Displays the tooltip window. The tooltip is a Toplevel window containing a ttk.Label. Displays the tooltip window. The tooltip is a Toplevel window containing a ttk.Label.
It is positioned near the widget and styled for readability. It is positioned near the widget and styled for readability.
""" """
if self.tooltip_window or not self.text: if self.tooltip_window:
return return
x, y, _, _ = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + 25 text_to_show = self.text() if callable(self.text) else self.text
y += self.widget.winfo_rooty() + 20 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) self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True) tw.wm_overrideredirect(True)
tw.wm_geometry(f"+" + str(x) + "+" + str(y)) 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)) relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2))
label.pack(ipadx=1) label.pack(ipadx=1)
@@ -617,6 +633,8 @@ class IconManager:
'back': '32/arrow-left.png', 'back': '32/arrow-left.png',
'forward': '32/arrow-right.png', 'forward': '32/arrow-right.png',
'up': '32/arrow-up.png', 'up': '32/arrow-up.png',
'copy': '32/copy.png',
'stair': '32/stair.png',
'audio_small': '32/audio.png', 'audio_small': '32/audio.png',
'icon_view': '32/carrel.png', 'icon_view': '32/carrel.png',
'computer_small': '32/computer.png', 'computer_small': '32/computer.png',
@@ -675,6 +693,8 @@ class IconManager:
'back_large': '48/arrow-left.png', 'back_large': '48/arrow-left.png',
'forward_large': '48/arrow-right.png', 'forward_large': '48/arrow-right.png',
'up_large': '48/arrow-up.png', 'up_large': '48/arrow-up.png',
'copy': '48/copy.png',
'stair': '48/stair.png',
'icon_view_large': '48/carrel.png', 'icon_view_large': '48/carrel.png',
'computer_large': '48/computer.png', 'computer_large': '48/computer.png',
'device_large': '48/device.png', 'device_large': '48/device.png',
@@ -722,6 +742,8 @@ class IconManager:
'back_extralarge': '64/arrow-left.png', 'back_extralarge': '64/arrow-left.png',
'forward_extralarge': '64/arrow-right.png', 'forward_extralarge': '64/arrow-right.png',
'up_extralarge': '64/arrow-up.png', 'up_extralarge': '64/arrow-up.png',
'copy': '64/copy.png',
'stair': '64/stair.png',
'audio_large': '64/audio.png', 'audio_large': '64/audio.png',
'icon_view_extralarge': '64/carrel.png', 'icon_view_extralarge': '64/carrel.png',
'computer_extralarge': '64/computer.png', 'computer_extralarge': '64/computer.png',

View File

@@ -214,14 +214,12 @@ class FileOperationsManager:
if not self.dialog.tree.selection(): if not self.dialog.tree.selection():
return return
item_id = self.dialog.tree.selection()[0] item_id = self.dialog.tree.selection()[0]
item_path = os.path.join( self.start_rename(item_id)
self.dialog.current_dir, self.dialog.tree.item(item_id, "text").strip())
self.start_rename(item_id, item_path)
else: # icon view else: # icon view
if item_path and item_frame: if item_path and item_frame:
self.start_rename(item_frame, item_path) 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. Starts the renaming UI for an item.
@@ -230,10 +228,12 @@ class FileOperationsManager:
Args: Args:
item_widget: The widget representing the item (item_id for list view, item_widget: The widget representing the item (item_id for list view,
item_frame for icon 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": 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 else: # list view
self._start_rename_list_view(item_widget) # item_widget is item_id 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: def finish_rename(event: tk.Event) -> None:
new_name = entry.get() new_name = entry.get()
new_path = os.path.join(self.dialog.current_dir, new_name) self._finish_rename_logic(item_path, 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: def cancel_rename(event: tk.Event) -> None:
self.dialog.view_manager.populate_files() self.dialog.view_manager.populate_files()
@@ -312,30 +294,12 @@ class FileOperationsManager:
entry.select_range(0, tk.END) entry.select_range(0, tk.END)
entry.focus_set() entry.focus_set()
old_path = os.path.join(self.dialog.current_dir, item_text)
def finish_rename(event: tk.Event) -> None: def finish_rename(event: tk.Event) -> None:
new_name = entry.get() 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() entry.destroy()
self._finish_rename_logic(old_path, new_name)
def cancel_rename(event: tk.Event) -> None: def cancel_rename(event: tk.Event) -> None:
entry.destroy() entry.destroy()
@@ -343,3 +307,33 @@ class FileOperationsManager:
entry.bind("<Return>", finish_rename) entry.bind("<Return>", finish_rename)
entry.bind("<FocusOut>", finish_rename) entry.bind("<FocusOut>", finish_rename)
entry.bind("<Escape>", cancel_rename) entry.bind("<Escape>", 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()

View File

@@ -90,20 +90,14 @@ class NavigationManager:
if self.dialog.history_pos > 0: if self.dialog.history_pos > 0:
self.dialog.history_pos -= 1 self.dialog.history_pos -= 1
self.dialog.current_dir = self.dialog.history[self.dialog.history_pos] self.dialog.current_dir = self.dialog.history[self.dialog.history_pos]
self.dialog.view_manager.populate_files() self._update_ui_after_navigation()
self.update_nav_buttons()
self.dialog.update_status_bar()
self.dialog.update_action_buttons_state()
def go_forward(self) -> None: def go_forward(self) -> None:
"""Navigates to the next directory in the history.""" """Navigates to the next directory in the history."""
if self.dialog.history_pos < len(self.dialog.history) - 1: if self.dialog.history_pos < len(self.dialog.history) - 1:
self.dialog.history_pos += 1 self.dialog.history_pos += 1
self.dialog.current_dir = self.dialog.history[self.dialog.history_pos] self.dialog.current_dir = self.dialog.history[self.dialog.history_pos]
self.dialog.view_manager.populate_files() self._update_ui_after_navigation()
self.update_nav_buttons()
self.dialog.update_status_bar()
self.dialog.update_action_buttons_state()
def go_up_level(self) -> None: def go_up_level(self) -> None:
"""Navigates to the parent directory of the current directory.""" """Navigates to the parent directory of the current directory."""
@@ -111,6 +105,13 @@ class NavigationManager:
if new_path != self.dialog.current_dir: if new_path != self.dialog.current_dir:
self.navigate_to(new_path) 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: def update_nav_buttons(self) -> None:
"""Updates the state of the back and forward navigation buttons.""" """Updates the state of the back and forward navigation buttons."""
self.dialog.widget_manager.back_button.config( self.dialog.widget_manager.back_button.config(

View File

@@ -201,8 +201,9 @@ class SearchManager:
def show_search_results_treeview(self) -> None: def show_search_results_treeview(self) -> None:
"""Displays the search results in a dedicated Treeview.""" """Displays the search results in a dedicated Treeview."""
for widget in self.dialog.widget_manager.file_list_frame.winfo_children(): if self.dialog.widget_manager.file_list_frame.winfo_exists():
widget.destroy() 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 = ttk.Frame(self.dialog.widget_manager.file_list_frame)
tree_frame.pack(fill='both', expand=True) tree_frame.pack(fill='both', expand=True)
@@ -280,14 +281,25 @@ class SearchManager:
def on_search_double_click(event: tk.Event) -> None: def on_search_double_click(event: tk.Event) -> None:
selection = search_tree.selection() selection = search_tree.selection()
if selection: if not selection:
item = search_tree.item(selection[0]) return
filename = item['text'].strip()
directory = item['values'][0]
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.hide_search_bar()
self.dialog.navigation_manager.navigate_to( self.dialog.navigation_manager.navigate_to(full_path)
directory, file_to_select=filename) else:
# For files, select it and close the dialog
self.dialog.selected_file = full_path
self.dialog.destroy()
search_tree.bind("<Double-1>", on_search_double_click) search_tree.bind("<Double-1>", on_search_double_click)

View File

@@ -126,7 +126,7 @@ class ViewManager:
"""Handles a double click on an icon view item.""" """Handles a double click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget) item_path = self._get_item_path_from_widget(event.widget)
if item_path: 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: def _handle_icon_context_menu(self, event: tk.Event) -> None:
"""Handles a context menu request on an icon view item.""" """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]: for widget in [item_frame, icon_label, name_label]:
widget.bind("<Double-Button-1>", lambda e, widget.bind("<Double-Button-1>", lambda e,
p=path: self.on_item_double_click(p)) p=path: self._handle_item_double_click(p))
widget.bind("<Button-1>", lambda e, p=path, widget.bind("<Button-1>", lambda e, p=path,
f=item_frame: self.on_item_select(p, f)) f=item_frame: self.on_item_select(p, f))
widget.bind("<ButtonRelease-3>", lambda e, widget.bind("<ButtonRelease-3>", lambda e,
@@ -485,12 +485,13 @@ class ViewManager:
self.dialog.file_op_manager._show_context_menu(event, item_path) self.dialog.file_op_manager._show_context_menu(event, item_path)
return "break" 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: 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): if os.path.isdir(path):
self.dialog.navigation_manager.navigate_to(path) self.dialog.navigation_manager.navigate_to(path)
@@ -510,15 +511,7 @@ class ViewManager:
item_id = self.dialog.tree.selection()[0] item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip() item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text) path = os.path.join(self.dialog.current_dir, item_text)
if os.path.isdir(path): self._handle_item_double_click(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: def _select_file_in_view(self, filename: str) -> None:
""" """

View File

@@ -22,6 +22,15 @@ class CustomFileDialog(tk.Toplevel):
directory navigation, search, and file operations. 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, def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
filetypes: Optional[List[Tuple[str, str]]] = None, filetypes: Optional[List[Tuple[str, str]]] = None,
dialog_mode: str = "open", title: str = LocaleStrings.CFD["title"]): 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.search_process: Optional[subprocess.Popen] = None
self.icon_manager: IconManager = IconManager() self.icon_manager: IconManager = IconManager()
self.style_manager: StyleManager = StyleManager(self) self._initialize_managers()
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.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search) "<Return>", self.search_manager.execute_search)
@@ -150,12 +151,8 @@ class CustomFileDialog(tk.Toplevel):
for widget in self.winfo_children(): for widget in self.winfo_children():
widget.destroy() widget.destroy()
self.style_manager = StyleManager(self) self._initialize_managers()
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.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search) "<Return>", self.search_manager.execute_search)
self.view_manager._update_view_mode_buttons() 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)) row=0, column=0, sticky='w', padx=(0, 5), pady=(4, 0))
self.widget_manager.search_animation.bind( self.widget_manager.search_animation.bind(
"<Button-1>", lambda e: self.search_manager.activate_search()) "<Button-1>", lambda e: self.search_manager.activate_search())
self.widget_manager.search_animation.bind(
"<Enter>", self._show_tooltip) self.my_tool_tip = Tooltip(
self.widget_manager.search_animation.bind( self.widget_manager.search_animation,
"<Leave>", self._hide_tooltip) text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"]
)
if is_running: if is_running:
self.widget_manager.search_animation.start() self.widget_manager.search_animation.start()
@@ -564,33 +562,4 @@ class CustomFileDialog(tk.Toplevel):
print(f"Error getting mounted devices: {e}") print(f"Error getting mounted devices: {e}")
return devices 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()