Merge remote-tracking branch 'customfiledialog-repo/main' into 4-06-2025
This commit is contained in:
5
GEMINI.md
Normal file
5
GEMINI.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Gemini Project Configuration
|
||||
|
||||
## Language
|
||||
Please respond in German.
|
||||
|
BIN
__pycache__/animated_icon.cpython-312.pyc
Normal file
BIN
__pycache__/animated_icon.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_animated_icon.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_animated_icon.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_app_config.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_app_config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_file_operations.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_file_operations.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_navigation_manager.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_navigation_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_search_manager.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_search_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_settings_dialog.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_settings_dialog.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_ui_setup.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_ui_setup.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/cfd_view_manager.cpython-312.pyc
Normal file
BIN
__pycache__/cfd_view_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/custom_file_dialog.cpython-312.pyc
Normal file
BIN
__pycache__/custom_file_dialog.cpython-312.pyc
Normal file
Binary file not shown.
829
animated_icon.py
Normal file
829
animated_icon.py
Normal file
@@ -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()
|
504
cfd_app_config.py
Executable file
504
cfd_app_config.py
Executable file
@@ -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"),
|
||||
}
|
339
cfd_file_operations.py
Normal file
339
cfd_file_operations.py
Normal file
@@ -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("<Return>", finish_rename)
|
||||
entry.bind("<FocusOut>", finish_rename)
|
||||
entry.bind("<Escape>", 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("<Return>", finish_rename)
|
||||
entry.bind("<FocusOut>", finish_rename)
|
||||
entry.bind("<Escape>", cancel_rename)
|
||||
|
117
cfd_navigation_manager.py
Normal file
117
cfd_navigation_manager.py
Normal file
@@ -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)
|
307
cfd_search_manager.py
Normal file
307
cfd_search_manager.py
Normal file
@@ -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("<Escape>")
|
||||
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("<<TreeviewSelect>>", 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("<Double-1>", 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("<ButtonRelease-3>", 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))
|
194
cfd_settings_dialog.py
Normal file
194
cfd_settings_dialog.py
Normal file
@@ -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"))
|
506
cfd_ui_setup.py
Normal file
506
cfd_ui_setup.py
Normal file
@@ -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("<Return>", 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("<Configure>", 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("<Enter>", self.dialog._on_devices_enter)
|
||||
self.devices_canvas.bind("<Leave>", self.dialog._on_devices_leave)
|
||||
self.devices_scrollable_frame.bind(
|
||||
"<Enter>", self.dialog._on_devices_enter)
|
||||
self.devices_scrollable_frame.bind(
|
||||
"<Leave>", 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("<Configure>", lambda e: self.devices_canvas.configure(
|
||||
scrollregion=self.devices_canvas.bbox("all")))
|
||||
self.devices_canvas.bind("<Configure>", _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("<MouseWheel>", _on_devices_mouse_wheel)
|
||||
widget.bind("<Button-4>", _on_devices_mouse_wheel)
|
||||
widget.bind("<Button-5>", _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("<MouseWheel>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-4>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-5>", _on_devices_mouse_wheel)
|
||||
w.bind("<Enter>", self.dialog._on_devices_enter)
|
||||
w.bind("<Leave>", 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("<MouseWheel>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-4>", _on_devices_mouse_wheel)
|
||||
w.bind("<Button-5>", _on_devices_mouse_wheel)
|
||||
w.bind("<Enter>", self.dialog._on_devices_enter)
|
||||
w.bind("<Leave>", 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("<Button-1>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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("<Configure>", self.dialog.on_window_resize)
|
||||
|
||||
# --- Bottom Bar ---
|
||||
self._setup_bottom_bar()
|
||||
|
603
cfd_view_manager.py
Normal file
603
cfd_view_manager.py
Normal file
@@ -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("<Configure>", 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("<MouseWheel>", _on_mouse_wheel)
|
||||
widget.bind("<Button-4>", _on_mouse_wheel)
|
||||
widget.bind("<Button-5>", _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("<Double-Button-1>", lambda e,
|
||||
p=path: self.on_item_double_click(p))
|
||||
widget.bind("<Button-1>", lambda e, p=path,
|
||||
f=item_frame: self.on_item_select(p, f))
|
||||
widget.bind("<ButtonRelease-3>", lambda e,
|
||||
p=path: self.dialog.file_op_manager._show_context_menu(e, p))
|
||||
widget.bind("<F2>", lambda e, p=path,
|
||||
f=item_frame: self.dialog.file_op_manager.on_rename_request(e, p, f))
|
||||
widget.bind("<MouseWheel>", scroll_handler)
|
||||
widget.bind("<Button-4>", scroll_handler)
|
||||
widget.bind("<Button-5>", 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("<Double-1>", self.on_list_double_click)
|
||||
self.dialog.tree.bind("<<TreeviewSelect>>", self.on_list_select)
|
||||
self.dialog.tree.bind("<F2>", self.dialog.file_op_manager.on_rename_request)
|
||||
self.dialog.tree.bind("<ButtonRelease-3>", 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("<MouseWheel>")
|
||||
self.dialog.unbind_all("<Button-4>")
|
||||
self.dialog.unbind_all("<Button-5>")
|
588
custom_file_dialog.py
Normal file
588
custom_file_dialog.py
Normal file
@@ -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("<Return>", 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(
|
||||
"<Return>", self.navigation_manager.handle_path_entry_return)
|
||||
|
||||
self.bind("<Key>", self.search_manager.show_search_bar)
|
||||
|
||||
if self.dialog_mode == "save":
|
||||
self.bind("<Delete>", 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("<Return>", 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("<Button-1>", lambda e: self.search_manager.activate_search())
|
||||
self.widget_manager.search_animation.bind("<Enter>", self._show_tooltip)
|
||||
self.widget_manager.search_animation.bind("<Leave>", 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()
|
62
mainwindow.py
Executable file
62
mainwindow.py
Executable file
@@ -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()
|
Reference in New Issue
Block a user