4 Commits

24 changed files with 1133 additions and 5421 deletions

View File

@@ -2,67 +2,8 @@ Changelog for shared_libs
## [Unreleased]
-
### Added
14.08.2025
- Added window on custom_file_dialog to query if there is
no other folder in the selected folder. So that the folder
can still be entered
- Fixes multi and dir mode in custom_file_dialog
- Add "select" in MessageDialog on list for Button and add grab_set()
after update_idletasks() to fix Error Traceback
### Added
13.08.2025
- Rename get methode and mode argument in custom_file_dialog
- Add new mode "multi" and "dir" on custom_file_dialog
### Added
12.08.2025
- New class loggers, animated icon, methods in common tools
improved and added new methods (contexmanager)
- Own FileDialog added(custom, this is exclusively for linux
An alternative to the X11 file dialogue that is otherwise opened
when working with python
- Reduced redundancy, logviewer fulll removed , add log_window and menu_bar
- replace tooltip animation with exist tooltip, search optimized, add new icons
copy and stair (for path folllow)
### Added
01.08.2025
- Add Icon Class to Central Image Management
- Tooltip Class replace
### Added
09.07.2025
- fix new icon for install Update
### Added
29.06.2025
- add new icon for install Update
- replace download with updater methode
- add methode for open lxtools_installer Appimage
- add german translation for logviewer
- add Info Window for user in delete logfile
bevore delete logfile.
### Added
15-06-2025

View File

@@ -4,10 +4,9 @@ Module Project for apps by git.ilunix.de
Examples with a Theme from Projekt Wire-Py
# Screenshots
[![info_example.png](https://fb.ilunix.de/api/public/dl/KtaTPMMq?inline=true)](https://fb.ilunix.de/share/KtaTPMMq)
[![error_example.png](https://fb.ilunix.de/api/public/dl/cRO_ksrM?inline=true)](https://fb.ilunix.de/share/cRO_ksrM)
[![warning_example.png](https://fb.ilunix.de/api/public/dl/1JEdSJcI?inline=true)](https://fb.ilunix.de/share/1JEdSJcI)
[![question_light_example.png](https://fb.ilunix.de/api/public/dl/XxNey7y7?inline=true)](https://fb.ilunix.de/share/1XxNey7y7)
[![question_dark_example.png](https://fb.ilunix.de/api/public/dl/4HCxiNwB?inline=true)](https://fb.ilunix.de/share/4HCxiNwB)
[![example_with_own_title_and_icon.png](https://fb.ilunix.de/api/public/dl/uui8b1xx?inline=true)](https://fb.ilunix.de/share/uui8b1xx)
[![logviewer_example.png](https://fb.ilunix.de/api/public/dl/54OM6wUC?inline=true)](https://fb.ilunix.de/share/54OM6wUC)
[![info_example.png](https://fb.ilunix.de/api/public/dl/KtaTPMMq=true)](https://fb.ilunix.de/share/KtaTPMMq)
[![error_example.png](https://fb.ilunix.de/api/public/dl/cRO_ksrM=true)](https://fb.ilunix.de/share/cRO_ksrM)
[![warning_example.png](https://fb.ilunix.de/api/public/dl/1JEdSJcI=true)](https://fb.ilunix.de/share/1JEdSJcI)
[![question_light_example.png](https://fb.ilunix.de/api/public/dl/XxNey7y7=true)](https://fb.ilunix.de/share/1XxNey7y7)
[![question_dark_example.png](https://fb.ilunix.de/api/public/dl/4HCxiNwB=true)](https://fb.ilunix.de/share/4HCxiNwB)
[![example_with_own_title_and_icon.png](https://fb.ilunix.de/api/public/dl/uui8b1xx=true)](https://fb.ilunix.de/share/uui8b1xx)

View File

@@ -1,3 +1,3 @@
#!/usr/bin/python3

View File

@@ -1,541 +0,0 @@
"""
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.is_disabled = False
self.pause_count = 0
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")
original_highlight_color = self.highlight_color
original_highlight_color_rgb = self.highlight_color_rgb
if self.is_disabled:
self.highlight_color = "#8f99aa"
self.highlight_color_rgb = _hex_to_rgb(self.highlight_color)
try:
if self.use_pillow:
self._draw_pillow_stopped_frame()
else:
self._draw_canvas_stopped_frame()
finally:
if self.is_disabled:
self.highlight_color = original_highlight_color
self.highlight_color_rgb = original_highlight_color_rgb
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.pause_count > 0 or not self.running or not self.winfo_exists():
return
# Do not animate if a grab is active on a different window.
try:
toplevel = self.winfo_toplevel()
grab_widget = toplevel.grab_current()
if grab_widget is not None and grab_widget != toplevel:
self.after(100, self._animate) # Check again after a short delay
return
except Exception:
# This can happen if a grabbed widget (like a combobox dropdown)
# is destroyed at the exact moment this check runs.
# It's safest to just skip this animation frame.
self.after(30, self._animate)
return
self.angle += 0.1
if self.angle > 2 * pi:
self.angle -= 2 * pi
self._draw_frame()
self.after(30, self._animate)
def start(self, pulse: bool = False) -> None:
"""
Starts the animation.
Args:
pulse (bool): If True, plays a pulsing animation instead of the main one.
"""
if not self.winfo_exists():
return
self.running = True
self.is_disabled = False
self.pulse_animation = pulse
if self.pause_count == 0:
self._animate()
def stop(self, status: Optional[str] = None) -> None:
"""Stops the animation and shows the static 'stopped' frame."""
if not self.winfo_exists():
return
self.running = False
self.pulse_animation = False
self.is_disabled = status == "DISABLE"
self._draw_stopped_frame()
def hide(self) -> None:
"""Stops the animation and clears the canvas."""
if not self.winfo_exists():
return
self.running = False
self.pulse_animation = False
self.delete("all")
def pause(self) -> None:
"""Pauses the animation and draws a static frame."""
self.pause_count += 1
self._draw_stopped_frame()
def resume(self) -> None:
"""Resumes the animation if the pause count is zero."""
self.pause_count = max(0, self.pause_count - 1)
if self.pause_count == 0 and self.running:
self._animate()
def show_full_circle(self) -> None:
"""Shows the static 'stopped' frame without starting the animation."""
if not self.winfo_exists():
return
if not self.running:
self._draw_stopped_frame()

View File

@@ -1,21 +1,16 @@
" Classes Method and Functions for lx Apps "
""" Classes Method and Functions for lx Apps """
import logging
import signal
import base64
from contextlib import contextmanager
from .logger import app_logger
from subprocess import CompletedProcess, run
import gettext
import locale
import re
import sys
import shutil
import tkinter as tk
from tkinter import ttk
import os
from typing import Optional, Dict, Any, NoReturn
from pathlib import Path
from tkinter import Toplevel
class CryptoUtil:
@@ -39,14 +34,14 @@ class CryptoUtil:
# Output from Openssl Error
if process.stderr:
app_logger.log(process.stderr)
logging.error(process.stderr, exc_info=True)
if process.returncode == 0:
app_logger.log("Files successfully decrypted...")
logging.info("Files successfully decrypted...", exc_info=True)
else:
app_logger.log(
f"Error process decrypt: Code {process.returncode}"
logging.error(
f"Error process decrypt: Code {process.returncode}", exc_info=True
)
@staticmethod
@@ -63,13 +58,13 @@ class CryptoUtil:
# Output from Openssl Error
if process.stderr:
app_logger.log(process.stderr)
logging.error(process.stderr, exc_info=True)
if process.returncode == 0:
app_logger.log("Files successfully encrypted...")
logging.info("Files successfully encrypted...", exc_info=True)
else:
app_logger.log(
f"Error process encrypt: Code {process.returncode}"
logging.error(
f"Error process encrypt: Code {process.returncode}", exc_info=True
)
@staticmethod
@@ -88,8 +83,9 @@ class CryptoUtil:
return True
elif "False" in process.stdout:
return False
app_logger.log(
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}"
logging.error(
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}",
exc_info=True,
)
return False
@@ -114,7 +110,7 @@ class CryptoUtil:
if len(decoded) != 32: # 32 bytes = 256 bits
return False
except Exception as e:
app_logger.log(f"Error on decode Base64: {e}")
logging.error(f"Error on decode Base64: {e}", exc_info=True)
return False
return True
@@ -277,16 +273,17 @@ class LxTools:
# End program for certain signals, report to others only reception
if signum in (signal.SIGINT, signal.SIGTERM):
exit_code: int = 1
app_logger.log(
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}."
logging.error(
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}.",
exc_info=True,
)
LxTools.clean_files(file_path, file)
app_logger.log("Breakdown by user...")
logging.info("Breakdown by user...")
sys.exit(exit_code)
else:
app_logger.log(f"Signal {signum} received and ignored.")
logging.info(f"Signal {signum} received and ignored.")
LxTools.clean_files(file_path, file)
app_logger.log("Process unexpectedly ended...")
logging.error("Process unexpectedly ended...")
# Register signal handlers for various signals
signal.signal(signal.SIGINT, signal_handler)
@@ -327,14 +324,14 @@ class ConfigManager:
"""Load the config file and return the config as dict"""
if not cls._config:
try:
lines = Path(cls._config_file).read_text(
encoding="utf-8").splitlines()
lines = Path(cls._config_file).read_text(encoding="utf-8").splitlines()
cls._config = {
"updates": lines[1].strip(),
"theme": lines[3].strip(),
"tooltips": lines[5].strip()
== "True", # is converted here to boolean!!!
"autostart": lines[7].strip() if len(lines) > 7 else "off",
"logfile": lines[9].strip(),
}
except (IndexError, FileNotFoundError):
# DeDefault values in case of error
@@ -343,6 +340,7 @@ class ConfigManager:
"theme": "light",
"tooltips": "True", # Default Value as string!
"autostart": "off",
"logfile": LOG_FILE_PATH,
}
return cls._config
@@ -359,6 +357,8 @@ class ConfigManager:
f"{str(cls._config['tooltips'])}\n",
"# Autostart\n",
f"{cls._config['autostart']}\n",
"# Logfile\n",
f"{cls._config['logfile']}\n",
]
Path(cls._config_file).write_text("".join(lines), encoding="utf-8")
@@ -396,463 +396,110 @@ class ThemeManager:
@staticmethod
def change_theme(root, theme_in_use, theme_name=None):
"""
Change application theme centrally.
Args:
root: The root Tkinter window.
theme_in_use (str): The name of the theme to apply.
theme_name (Optional[str]): The name of the theme to save in the config.
If None, the theme is not saved.
"""
"""Change application theme centrally"""
root.tk.call("set_theme", theme_in_use)
if theme_in_use == theme_name:
ConfigManager.set("theme", theme_in_use)
class Tooltip:
"""
A flexible tooltip class for Tkinter widgets that supports dynamic activation/deactivation.
"""Class for Tooltip
from common_tools.py import Tooltip
example: Tooltip(label, "Show tooltip on label")
example: Tooltip(button, "Show tooltip on button")
example: Tooltip(widget, "Text", state_var=tk.BooleanVar())
example: Tooltip(widget, "Text", state_var=tk.BooleanVar(), x_offset=20, y_offset=10)
This class provides customizable tooltips that appear when the mouse hovers over a widget.
It can be used for simple, always-active tooltips or for tooltips whose visibility is
controlled by a `tk.BooleanVar`, allowing for global enable/disable functionality.
Attributes:
widget (tk.Widget): The Tkinter widget to which the tooltip is attached.
text (str): The text to display in the tooltip.
wraplength (int): The maximum line length for the tooltip text before wrapping.
state_var (Optional[tk.BooleanVar]): An optional Tkinter BooleanVar that controls
the visibility of the tooltip. If True, the tooltip
is active; if False, it is inactive. If None, the
tooltip is always active.
tooltip_window (Optional[tk.Toplevel]): The Toplevel window used to display the tooltip.
id (Optional[str]): The ID of the `after` job used to schedule the tooltip display.
Usage Examples:
# 1. Simple Tooltip (always active):
# Tooltip(my_button, "This is a simple tooltip.")
# 2. State-Controlled Tooltip (can be enabled/disabled globally):
# tooltip_state = tk.BooleanVar(value=True)
# Tooltip(my_button, "This tooltip can be turned off!", state_var=tooltip_state)
# # To toggle visibility:
# # tooltip_state.set(False) # Tooltips will hide
# # tooltip_state.set(True) # Tooltips will show again
info: label and button are parent widgets.
NOTE: When using with state_var, pass the tk.BooleanVar object directly,
NOT its value. For example: use state_var=my_bool_var, NOT state_var=my_bool_var.get()
"""
def __init__(self, widget, text, wraplength=250, state_var=None):
self.widget = widget
self.text = text
self.wraplength = wraplength
def __init__(
self,
widget: Any,
text: str,
state_var: Optional[tk.BooleanVar] = None,
x_offset: int = 65,
y_offset: int = 40,
) -> None:
"""Tooltip Class"""
self.widget: Any = widget
self.text: str = text
self.tooltip_window: Optional[Toplevel] = None
self.state_var = state_var
self.tooltip_window = None
self.id = None
self.x_offset = x_offset
self.y_offset = y_offset
# Initial binding based on the current state
self.update_bindings()
if self.state_var:
# Add trace to the state_var if provided
if self.state_var is not None:
self.state_var.trace_add("write", self.update_bindings)
# Add bindings to the top-level window to hide the tooltip when the
# main window loses focus or is iconified.
toplevel = self.widget.winfo_toplevel()
toplevel.bind("<FocusOut>", self.leave, add="+")
toplevel.bind("<Unmap>", self.leave, add="+")
def update_bindings(self, *args):
"""
Updates the event bindings for the widget based on the current state_var.
If state_var is True or None, the <Enter>, <Leave>, and <ButtonPress> events
are bound to show/hide the tooltip. Otherwise, they are unbound.
"""
def update_bindings(self, *args) -> None:
"""Updates the bindings based on the current state"""
# Remove existing bindings first
self.widget.unbind("<Enter>")
self.widget.unbind("<Leave>")
self.widget.unbind("<ButtonPress>")
# Add new bindings if tooltips are enabled
if self.state_var is None or self.state_var.get():
self.widget.bind("<Enter>", self.enter)
self.widget.bind("<Leave>", self.leave)
self.widget.bind("<ButtonPress>", self.leave)
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def enter(self, event=None):
"""
Handles the <Enter> event. Schedules the tooltip to be shown after a delay
if tooltips are enabled (via state_var).
"""
# Do not show tooltips if a grab is active on a different window.
# This prevents tooltips from appearing over other modal dialogs.
toplevel = self.widget.winfo_toplevel()
grab_widget = toplevel.grab_current()
if grab_widget is not None and grab_widget != toplevel:
def show_tooltip(self, event: Optional[Any] = None) -> None:
"""Shows the tooltip"""
if self.tooltip_window or not self.text:
return
if self.state_var is None or self.state_var.get():
self.schedule()
x: int
y: int
cx: int
cy: int
def leave(self, event=None):
"""
Handles the <Leave> event. Unschedules any pending tooltip display
and immediately hides any visible tooltip.
"""
self.unschedule()
self.hide_tooltip()
def schedule(self):
"""
Schedules the `show_tooltip` method to be called after a short delay.
Cancels any previously scheduled calls to prevent flickering.
"""
self.unschedule()
self.id = self.widget.after(250, self.show_tooltip)
def unschedule(self):
"""
Cancels any pending `show_tooltip` calls.
"""
id = self.id
self.id = None
if id:
self.widget.after_cancel(id)
def show_tooltip(self, event=None):
"""
Displays the tooltip window. The tooltip is a Toplevel window containing a ttk.Label.
It is positioned near the widget and styled for readability.
"""
if self.tooltip_window:
return
text_to_show = self.text() if callable(self.text) else self.text
if not text_to_show:
return
try:
# Position the tooltip just below the widget.
# Using winfo_rootx/y is more reliable than bbox.
x = self.widget.winfo_rootx()
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
except tk.TclError:
# This can happen if the widget is destroyed while the tooltip is scheduled.
return
x, y, cx, cy = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + self.x_offset
y += self.widget.winfo_rooty() + self.y_offset
self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+" + str(x) + "+" + str(y))
label = ttk.Label(tw, text=text_to_show, justify=tk.LEFT, background="#FFFFE0", foreground="black",
relief=tk.SOLID, borderwidth=1, wraplength=self.wraplength, padding=(4, 2, 4, 2))
label.pack(ipadx=1)
tw.wm_geometry(f"+{x}+{y}")
def hide_tooltip(self):
"""
Hides and destroys the tooltip window if it is currently visible.
"""
tw = self.tooltip_window
self.tooltip_window = None
if tw:
tw.destroy()
label: tk.Label = tk.Label(
tw,
text=self.text,
background="lightgreen",
foreground="black",
relief="solid",
borderwidth=1,
padx=5,
pady=5,
)
label.grid()
self.tooltip_window.after(2200, lambda: tw.destroy())
def hide_tooltip(self, event: Optional[Any] = None) -> None:
"""Hides the tooltip"""
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
class LogConfig:
"""
A static class for configuring application-wide logging.
This class provides a convenient way to set up file-based logging for the application.
It ensures that log messages are written to a specified file with a consistent format.
Methods:
logger(file_path: str) -> None:
Configures the root logger to write messages to the specified file.
Usage Example:
# Assuming LOG_FILE_PATH is defined elsewhere (e.g., in a config file)
# LogConfig.logger(LOG_FILE_PATH)
# logging.info("This message will be written to the log file.")
"""
@staticmethod
def logger(file_path) -> None:
"""
Configures the root logger to write messages to the specified file.
Args:
file_path (str): The absolute path to the log file.
"""
file_handler = logging.FileHandler(
filename=f"{file_path}",
mode="a",
encoding="utf-8",
)
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s")
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.DEBUG)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Set the root logger level
logger.addHandler(file_handler)
class IconManager:
"""
A class for central management and loading of application icons.
This class loads Tkinter PhotoImage objects from a specified base path,
organizing them by logical names and providing a convenient way to retrieve them.
It handles potential errors during image loading by creating a blank image placeholder.
Attributes:
base_path (str): The base directory where icon subfolders (e.g., '16', '32', '48', '64') are located.
icons (Dict[str, tk.PhotoImage]): A dictionary storing loaded PhotoImage objects,
keyed by their logical names (e.g., 'computer_small', 'folder_large').
Methods:
get_icon(name: str) -> Optional[tk.PhotoImage]:
Retrieves a loaded icon by its logical name.
Usage Example:
# Initialize the IconManager with the path to your icon directory
# icon_manager = IconManager(base_path="/usr/share/icons/lx-icons/")
# Retrieve an icon
# computer_icon = icon_manager.get_icon("computer_small")
# if computer_icon:
# my_label = tk.Label(root, image=computer_icon)
# my_label.pack()
"""
def __init__(self, base_path='/usr/share/icons/lx-icons/'):
self.base_path = base_path
self.icons = {}
self._define_icon_paths()
self._load_all()
def _define_icon_paths(self):
self.icon_paths = {
# 16x16
'settings_16': '16/settings.png',
# 32x32
'back': '32/arrow-left.png',
'forward': '32/arrow-right.png',
'up': '32/arrow-up.png',
'copy': '32/copy.png',
'stair': '32/stair.png',
'star': '32/star.png',
'connect': '32/connect.png',
'audio_small': '32/audio.png',
'icon_view': '32/carrel.png',
'computer_small': '32/computer.png',
'device_small': '32/device.png',
'file_small': '32/document.png',
'download_error_small': '32/download_error.png',
'download_small': '32/download.png',
'error_small': '32/error.png',
'python_small': '32/file-python.png',
'documents_small': '32/folder-water-documents.png',
'downloads_small': '32/folder-water-download.png',
'music_small': '32/folder-water-music.png',
'pictures_small': '32/folder-water-pictures.png',
'folder_small': '32/folder-water.png',
'video_small': '32/folder-water-video.png',
'hide': '32/hide.png',
'home': '32/home.png',
'about': '32/about.png',
'info_small': '32/info.png',
'light_small': '32/light.png',
'dark_small': '32/dark.png',
'update_small': '32/update.png',
'no_update_small': '32/no_update.png',
'tooltip_small': '32/tip.png',
'no_tooltip_small': '32/no_tip.png',
'list_view': '32/list.png',
'log_small': '32/log.png',
'log_blue_small': '32/log_blue.png',
'lunix_tools_small': '32/Lunix_Tools.png',
'key_small': '32/lxtools_key.png',
'iso_small': '32/media-optical.png',
'new_document_small': '32/new-document.png',
'new_folder_small': '32/new-folder.png',
'pdf_small': '32/pdf.png',
'picture_small': '32/picture.png',
'question_mark_small': '32/question_mark.png',
'recursive_small': '32/recursive.png',
'search_small': '32/search.png',
'settings_small': '32/settings.png',
'settings-2_small': '32/settings-2.png',
'archive_small': '32/tar.png',
'unhide': '32/unhide.png',
'usb_small': '32/usb.png',
'video_small_file': '32/video.png',
'warning_small': '32/warning.png',
'export_small': '32/wg_export.png',
'import_small': '32/wg_import.png',
'message_small': '32/wg_msg.png',
'trash_small': '32/wg_trash.png',
'trash_small2': '32/trash.png',
'vpn_small': '32/wg_vpn.png',
'vpn_start_small': '32/wg_vpn-start.png',
'vpn_stop_small': '32/wg_vpn-stop.png',
# 48x48
'back_large': '48/arrow-left.png',
'forward_large': '48/arrow-right.png',
'up_large': '48/arrow-up.png',
'copy_large': '48/copy.png',
'stair_large': '48/stair.png',
'star_large': '48/star.png',
'connect_large': '48/connect.png',
'icon_view_large': '48/carrel.png',
'computer_large': '48/computer.png',
'device_large': '48/device.png',
'download_error_large': '48/download_error.png',
'download_large': '48/download.png',
'error_large': '48/error.png',
'documents_large': '48/folder-water-documents.png',
'downloads_large': '48/folder-water-download.png',
'music_large': '48/folder-water-music.png',
'pictures_large': '48/folder-water-pictures.png',
'folder_large_48': '48/folder-water.png',
'video_large_folder': '48/folder-water-video.png',
'hide_large': '48/hide.png',
'home_large': '48/home.png',
'info_large': '48/info.png',
'light_large': '48/light.png',
'dark_large': '48/dark.png',
'update_large': '48/update.png',
'no_update_large': '48/no_update.png',
'tooltip_large': '48/tip.png',
'no_tooltip_large': '48/no_tip.png',
'about_large': '48/about.png',
'list_view_large': '48/list.png',
'log_large': '48/log.png',
'log_blue_large': '48/log_blue.png',
'lunix_tools_large': '48/Lunix_Tools.png',
'new_document_large': '48/new-document.png',
'new_folder_large': '48/new-folder.png',
'question_mark_large': '48/question_mark.png',
'search_large_48': '48/search.png',
'settings_large': '48/settings.png',
'unhide_large': '48/unhide.png',
'usb_large': '48/usb.png',
'warning_large_48': '48/warning.png',
'export_large': '48/wg_export.png',
'import_large': '48/wg_import.png',
'message_large': '48/wg_msg.png',
'trash_large': '48/wg_trash.png',
'trash_large2': '48/trash.png',
'vpn_large': '48/wg_vpn.png',
'vpn_start_large': '48/wg_vpn-start.png',
'vpn_stop_large': '48/wg_vpn-stop.png',
# 64x64
'back_extralarge': '64/arrow-left.png',
'forward_extralarge': '64/arrow-right.png',
'up_extralarge': '64/arrow-up.png',
'copy_extralarge': '64/copy.png',
'stair_extralarge': '64/stair.png',
'star_extralarge': '64/star.png',
'connect_extralarge': '64/connect.png',
'audio_large': '64/audio.png',
'icon_view_extralarge': '64/carrel.png',
'computer_extralarge': '64/computer.png',
'device_extralarge': '64/device.png',
'file_large': '64/document.png',
'download_error_extralarge': '64/download_error.png',
'download_extralarge': '64/download.png',
'error_extralarge': '64/error.png',
'python_large': '64/file-python.png',
'documents_extralarge': '64/folder-water-documents.png',
'downloads_extralarge': '64/folder-water-download.png',
'music_extralarge': '64/folder-water-music.png',
'pictures_extralarge': '64/folder-water-pictures.png',
'folder_large': '64/folder-water.png',
'video_extralarge_folder': '64/folder-water-video.png',
'hide_extralarge': '64/hide.png',
'home_extralarge': '64/home.png',
'info_extralarge': '64/info.png',
'light_extralarge': '64/light.png',
'dark_extralarge': '64/dark.png',
'update_extralarge': '64/update.png',
'no_update_extralarge': '64/no_update.png',
'tooltip_extralarge': '64/tip.png',
'no_tooltip_extralarge': '64/no_tip.png',
'about_extralarge': '64/about.png',
'list_view_extralarge': '64/list.png',
'log_extralarge': '64/log.png',
'log_blue_extralarge': '64/log_blue.png',
'lunix_tools_extralarge': '64/Lunix_Tools.png',
'iso_large': '64/media-optical.png',
'new_document_extralarge': '64/new-document.png',
'new_folder_extralarge': '64/new-folder.png',
'pdf_large': '64/pdf.png',
'picture_large': '64/picture.png',
'question_mark_extralarge': '64/question_mark.png',
'recursive_large': '64/recursive.png',
'search_large': '64/search.png',
'settings_extralarge': '64/settings.png',
'archive_large': '64/tar.png',
'unhide_extralarge': '64/unhide.png',
'usb_extralarge': '64/usb.png',
'video_large': '64/video.png',
'warning_large': '64/warning.png',
'export_extralarge': '64/wg_export.png',
'import_extralarge': '64/wg_import.png',
'message_extralarge': '64/wg_msg.png',
'trash_extralarge': '64/wg_trash.png',
'trash_extralarge2': '64/trash.png',
'vpn_extralarge': '64/wg_vpn.png',
'vpn_start_extralarge': '64/wg_vpn-start.png',
'vpn_stop_extralarge': '64/wg_vpn-stop.png',
}
def _load_all(self):
for key, rel_path in self.icon_paths.items():
full_path = os.path.join(self.base_path, rel_path)
try:
self.icons[key] = tk.PhotoImage(file=full_path)
except tk.TclError as e:
print(f"Error loading icon '{key}' from '{full_path}': {e}")
size = 32 # Default size
if '16' in rel_path:
size = 16
elif '48' in rel_path:
size = 48
elif '64' in rel_path:
size = 64
self.icons[key] = tk.PhotoImage(width=size, height=size)
def get_icon(self, name):
return self.icons.get(name)
class Translate:
@staticmethod
def setup_translations(app_name: str, locale_dir="/usr/share/locale/") -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Returns:
The gettext translation function
"""
locale.bindtextdomain(app_name, locale_dir)
gettext.bindtextdomain(app_name, locale_dir)
gettext.textdomain(app_name)
return gettext.gettext
@contextmanager
def message_box_animation(animated_icon):
"""
A context manager to handle pausing and resuming an animated icon
around an operation like showing a message box.
Args:
animated_icon: The animated icon object with pause() and resume() methods.
"""
if animated_icon:
animated_icon.pause()
try:
yield
finally:
if animated_icon:
animated_icon.resume()

View File

@@ -1,5 +0,0 @@
# Gemini Project Configuration
## Language
Please respond in German.

View File

@@ -1 +0,0 @@
from .custom_file_dialog import CustomFileDialog

View File

@@ -1,303 +0,0 @@
#!/usr/bin/python3
"""App configuration for Custom File Dialog"""
import json
from pathlib import Path
from typing import Dict, Any, Optional, Type
from shared_libs.common_tools import Translate
# 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.
"""
# 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year
UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
VERSION: str = "v. 1.07.0125"
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),
}
_config: Optional[Dict[str, Any]] = None
_config_file: Path = CONFIG_DIR / "cfd_settings.json"
_bookmarks_file: Path = CONFIG_DIR / "cfd_bookmarks.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,
"keep_bookmarks_on_reset": True # Keep bookmarks when resetting settings
}
@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}")
@classmethod
def _ensure_bookmarks_file(cls: Type['CfdConfigManager']) -> None:
"""Ensures the bookmarks file exists."""
if not cls._bookmarks_file.exists():
try:
cls._bookmarks_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump({}, f, indent=4)
except IOError as e:
print(f"Error creating bookmarks file: {e}")
@classmethod
def load_bookmarks(cls: Type['CfdConfigManager']) -> Dict[str, Any]:
"""Loads bookmarks from the JSON file."""
cls._ensure_bookmarks_file()
try:
with open(cls._bookmarks_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
@classmethod
def save_bookmarks(cls: Type['CfdConfigManager'], bookmarks: Dict[str, Any]) -> None:
"""Saves the given bookmarks dictionary to the JSON file."""
try:
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump(bookmarks, f, indent=4)
except IOError as e:
print(f"Error saving bookmarks: {e}")
@classmethod
def add_bookmark(cls: Type['CfdConfigManager'], name: str, data: Dict[str, Any]) -> None:
"""Adds or updates a bookmark."""
bookmarks = cls.load_bookmarks()
bookmarks[name] = data
cls.save_bookmarks(bookmarks)
@classmethod
def remove_bookmark(cls: Type['CfdConfigManager'], name: str) -> None:
"""Removes a bookmark by name."""
bookmarks = cls.load_bookmarks()
if name in bookmarks:
del bookmarks[name]
cls.save_bookmarks(bookmarks)
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."),
"items_selected": _("items selected"),
"select_or_enter_title": _("Select or Enter?"),
"select_or_enter_prompt": _("The folder '{folder_name}' contains no subdirectories. Do you want to select this folder or enter it?"),
"select_button": _("Select"),
"enter_button": _("Enter"),
"cancel_button": _("Cancel"),
}
# 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"),
"install_new_version": _("Install new version {version}"),
"sftp_connection": _("SFTP Connection"),
"sftp_bookmarks": _("SFTP Bookmarks"),
"remove_bookmark": _("Remove Bookmark"),
}
# 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"),
"sftp_settings": _("SFTP Settings"),
"paramiko_not_found": _("Paramiko library not found."),
"sftp_disabled": _("SFTP functionality is disabled. Please install 'paramiko'."),
"paramiko_found": _("Paramiko library found. SFTP is enabled."),
"keep_sftp_bookmarks": _("Keep SFTP bookmarks on reset"),
}
# 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"),
}

View File

@@ -1,409 +0,0 @@
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.result or not isinstance(self.dialog.result, str):
return
selected_path = self.dialog.result
if self.dialog.current_fs_type == 'sftp':
item_name = os.path.basename(selected_path)
dialog = MessageDialog(
master=self.dialog,
title=LocaleStrings.FILE["confirm_delete_title"],
text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {LocaleStrings.FILE['delete_permanently']}?",
message_type="question"
)
if not dialog.show():
return
try:
if self.dialog.sftp_manager.path_is_dir(selected_path):
success, msg = self.dialog.sftp_manager.rm_recursive(selected_path)
else:
success, msg = self.dialog.sftp_manager.rm(selected_path)
if not success:
raise OSError(msg)
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()
return
# Local deletion logic
if not os.path.exists(selected_path):
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(selected_path)
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(selected_path)
else:
if os.path.isdir(selected_path):
shutil.rmtree(selected_path)
else:
os.remove(selected_path)
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 self.dialog.current_fs_type == 'sftp':
if is_folder:
success, msg = self.dialog.sftp_manager.mkdir(new_path)
if not success:
raise OSError(msg)
else:
success, msg = self.dialog.sftp_manager.touch(new_path)
if not success:
raise OSError(msg)
else:
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
path_exists = self.dialog.sftp_manager.exists if self.dialog.current_fs_type == 'sftp' else os.path.exists
while 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)),
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
self.dialog.context_menu.add_command(
label=LocaleStrings.UI["copy_path_to_clipboard"], command=lambda: self._copy_to_clipboard(item_path),
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
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),
image=self.dialog.icon_manager.get_icon('stair'), compound='left')
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]
self.start_rename(item_id)
else: # icon view
if item_path and item_frame:
self.start_rename(item_frame, item_path)
def start_rename(self, item_widget: Any, item_path: Optional[str] = None) -> 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, optional): The full path to the item being renamed.
Required for icon view.
"""
if self.dialog.view_mode.get() == "icons":
if item_path:
self._start_rename_icon_view(item_widget, item_path)
else: # list view
self._start_rename_list_view(item_widget) # item_widget is item_id
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()
self._finish_rename_logic(item_path, new_name)
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()
old_path = os.path.join(self.dialog.current_dir, item_text)
def finish_rename(event: tk.Event) -> None:
new_name = entry.get()
entry.destroy()
self._finish_rename_logic(old_path, new_name)
def cancel_rename(event: tk.Event) -> None:
entry.destroy()
entry.bind("<Return>", finish_rename)
entry.bind("<FocusOut>", finish_rename)
entry.bind("<Escape>", cancel_rename)
def _finish_rename_logic(self, old_path: str, new_name: str) -> None:
"""
Handles the core logic of renaming a file or folder after the user
submits the new name from an Entry widget.
Args:
old_path (str): The original full path of the item.
new_name (str): The new name for the item.
"""
new_path = os.path.join(self.dialog.current_dir, new_name)
old_name = os.path.basename(old_path)
if not new_name or new_path == old_path:
self.dialog.view_manager.populate_files(item_to_select=old_name)
return
if self.dialog.current_fs_type == 'sftp':
if self.dialog.sftp_manager.exists(new_path):
self.dialog.widget_manager.search_status_label.config(
text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}")
self.dialog.view_manager.populate_files(item_to_select=old_name)
return
try:
success, msg = self.dialog.sftp_manager.rename(old_path, new_path)
if not success:
raise OSError(msg)
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:
if os.path.exists(new_path):
self.dialog.widget_manager.search_status_label.config(
text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}")
self.dialog.view_manager.populate_files(item_to_select=old_name)
return
try:
os.rename(old_path, new_path)
self.dialog.view_manager.populate_files(item_to_select=new_name)
except Exception as e:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.FILE['error_renaming']}: {e}")
self.dialog.view_manager.populate_files()

View File

@@ -1,131 +0,0 @@
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()
# Clear previous selection state before populating new view
self.dialog.selected_item_frames.clear()
self.dialog.result = None
self.dialog.view_manager.populate_files(
item_to_select=file_to_select)
self.update_nav_buttons()
self.dialog.update_selection_info() # Use the new central update method
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._update_ui_after_navigation()
def go_forward(self) -> None:
"""Navigates to the next directory in the history."""
if self.dialog.history_pos < len(self.dialog.history) - 1:
self.dialog.history_pos += 1
self.dialog.current_dir = self.dialog.history[self.dialog.history_pos]
self._update_ui_after_navigation()
def go_up_level(self) -> None:
"""Navigates to the parent directory of the current directory."""
if self.dialog.current_fs_type == "sftp":
if self.dialog.current_dir and self.dialog.current_dir != "/":
new_path = self.dialog.current_dir.rsplit('/', 1)[0]
if not new_path:
new_path = "/"
self.navigate_to(new_path)
else:
new_path = os.path.dirname(self.dialog.current_dir)
if new_path != self.dialog.current_dir:
self.navigate_to(new_path)
def _update_ui_after_navigation(self) -> None:
"""Updates all necessary UI components after a navigation action."""
self.dialog.view_manager.populate_files()
self.update_nav_buttons()
self.dialog.update_selection_info()
self.dialog.update_action_buttons_state()
def update_nav_buttons(self) -> None:
"""Updates the state of the back and forward navigation buttons."""
self.dialog.widget_manager.back_button.config(
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)

View File

@@ -1,346 +0,0 @@
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
only if there is a search term present.
"""
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:
# Only execute search if there is text in the entry
if self.dialog.widget_manager.filename_entry.get().strip():
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])
search_successful = False
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:
nonlocal search_successful
if self.dialog.search_results:
search_successful = True
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:
search_successful = False
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, lambda: self.dialog.widget_manager.search_animation.stop(status="DISABLE" if not search_successful else None))
self.dialog.search_process = None
def show_search_results_treeview(self) -> None:
"""Displays the search results in a dedicated Treeview."""
if self.dialog.widget_manager.file_list_frame.winfo_exists():
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
widget.destroy()
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
tree_frame.pack(fill='both', expand=True)
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 not selection:
return
item = search_tree.item(selection[0])
filename = item['text'].strip()
directory = item['values'][0]
full_path = os.path.join(directory, filename)
if not os.path.exists(full_path):
return # Item no longer exists
if os.path.isdir(full_path):
# For directories, navigate into them
self.hide_search_bar()
self.dialog.navigation_manager.navigate_to(full_path)
else:
# For files, select it and close the dialog
self.dialog.selected_file = full_path
self.dialog.destroy()
search_tree.bind("<Double-1>", on_search_double_click)
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))

View File

@@ -1,218 +0,0 @@
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING
from .cfd_app_config import CfdConfigManager, LocaleStrings
from shared_libs.animated_icon import PIL_AVAILABLE
from .cfd_sftp_manager import PARAMIKO_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"))
self.keep_bookmarks_on_reset = tk.BooleanVar(
value=self.settings.get("keep_bookmarks_on_reset", True))
# --- 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)
# SFTP Settings
sftp_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["sftp_settings"], padding=10)
sftp_frame.pack(fill="x", pady=5)
if not PARAMIKO_AVAILABLE:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_not_found"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
ttk.Label(sftp_frame, text=LocaleStrings.SET["sftp_disabled"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
else:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_found"],
font=("TkDefaultFont", 9)).pack(anchor="w")
self.keep_bookmarks_checkbutton = ttk.Checkbutton(sftp_frame, text=LocaleStrings.SET["keep_sftp_bookmarks"],
variable=self.keep_bookmarks_on_reset)
self.keep_bookmarks_checkbutton.pack(anchor="w", pady=(5,0))
# 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(),
"keep_bookmarks_on_reset": self.keep_bookmarks_on_reset.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"))
self.keep_bookmarks_on_reset.set(defaults.get("keep_bookmarks_on_reset", True))

View File

@@ -1,151 +0,0 @@
# cfd_sftp_manager.py
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
paramiko = None
PARAMIKO_AVAILABLE = False
class SFTPManager:
def __init__(self):
self.client = None
self.sftp = None
def connect(self, host, port, username, password=None, key_file=None, passphrase=None):
if not PARAMIKO_AVAILABLE:
raise ImportError("Paramiko library is not installed. SFTP functionality is disabled.")
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=host,
port=port,
username=username,
password=password,
key_filename=key_file,
passphrase=passphrase,
timeout=10
)
self.sftp = self.client.open_sftp()
return True, "Connection successful."
except Exception as e:
self.client = None
self.sftp = None
return False, str(e)
def disconnect(self):
if self.sftp:
self.sftp.close()
self.sftp = None
if self.client:
self.client.close()
self.client = None
def list_directory(self, path):
if not self.sftp:
return [], "Not connected."
try:
items = self.sftp.listdir_attr(path)
# Sort directories first, then files
items.sort(key=lambda x: (not self.item_is_dir(x), x.filename.lower()))
return items, None
except Exception as e:
return [], str(e)
def item_is_dir(self, item):
# Helper to check if an SFTP attribute object is a directory
import stat
return stat.S_ISDIR(item.st_mode)
def path_is_dir(self, path):
if not self.sftp:
return False
try:
import stat
return stat.S_ISDIR(self.sftp.stat(path).st_mode)
except Exception:
return False
def exists(self, path):
if not self.sftp:
return False
try:
self.sftp.stat(path)
return True
except FileNotFoundError:
return False
except Exception:
return False
def mkdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.mkdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rmdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rm(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.remove(path)
return True, ""
except Exception as e:
return False, str(e)
def rename(self, old_path, new_path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rename(old_path, new_path)
return True, ""
except Exception as e:
return False, str(e)
def touch(self, path):
if not self.sftp:
return False, "Not connected."
try:
with self.sftp.open(path, 'w') as f:
pass
return True, ""
except Exception as e:
return False, str(e)
def rm_recursive(self, path):
if not self.sftp:
return False, "Not connected."
try:
items = self.sftp.listdir_attr(path)
for item in items:
remote_path = f"{path}/{item.filename}"
if self.item_is_dir(item):
success, msg = self.rm_recursive(remote_path)
if not success:
return False, msg
else:
self.sftp.remove(remote_path)
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
@property
def is_connected(self):
return self.sftp is not None

View File

@@ -1,562 +0,0 @@
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 shared_libs.animated_icon import AnimatedIcon, PIL_AVAILABLE
from .cfd_app_config import LocaleStrings
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
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.
"""
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:
self.dialog = dialog
self.setup_styles()
def setup_styles(self) -> None:
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:
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:
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_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_search_container = ttk.Frame(top_bar, style='Accent.TFrame')
path_search_container.grid(row=0, column=1, sticky="ew")
path_search_container.grid_columnconfigure(0, weight=1)
self.path_entry = ttk.Entry(path_search_container)
self.path_entry.grid(row=0, column=0, sticky="ew")
self.path_entry.bind(
"<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get()))
self.update_animation_icon = AnimatedIcon(
path_search_container,
width=20,
height=20,
animation_type="blink",
use_pillow=PIL_AVAILABLE,
bg=self.style_manager.header
)
self.update_animation_icon.grid(
row=0, column=1, sticky='e', padx=(5, 0))
self.update_animation_icon.grid_remove() # Initially hidden
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"])
sftp_icon = self.dialog.icon_manager.get_icon('connect')
if sftp_icon:
self.sftp_button = ttk.Button(self.responsive_buttons_container, image=sftp_icon,
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
else:
self.sftp_button = ttk.Button(self.responsive_buttons_container, text="SFTP",
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
self.sftp_button.pack(side="left", padx=5)
Tooltip(self.sftp_button, LocaleStrings.UI["sftp_connection"])
if not PARAMIKO_AVAILABLE:
self.sftp_button.config(state=tk.DISABLED)
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:
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(4, 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_sftp_bookmarks(sidebar_frame)
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=5, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_storage(sidebar_frame)
def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
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_sftp_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
self.sftp_bookmarks_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
self.sftp_bookmarks_frame.grid(row=4, column=0, sticky="nsew", padx=10)
self.sftp_bookmarks_frame.grid_columnconfigure(0, weight=1)
bookmarks = self.dialog.config_manager.load_bookmarks()
if not bookmarks:
return
ttk.Label(self.sftp_bookmarks_frame, text=LocaleStrings.UI["sftp_bookmarks"], 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.sftp_bookmark_buttons = []
sftp_bookmark_icon = self.dialog.icon_manager.get_icon('star')
row_counter = 1
for name, data in bookmarks.items():
if sftp_bookmark_icon:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", image=sftp_bookmark_icon, compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
else:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
btn.grid(row=row_counter, column=0, sticky="ew")
row_counter += 1
btn.bind("<Button-3>", lambda event,
n=name: self._show_sftp_bookmark_context_menu(event, n))
self.sftp_bookmark_buttons.append(btn)
def _show_sftp_bookmark_context_menu(self, event, name):
context_menu = tk.Menu(self.dialog, tearoff=0)
trash_icon = self.dialog.icon_manager.get_icon('trash_small2')
context_menu.add_command(
label=LocaleStrings.UI["remove_bookmark"],
image=trash_icon,
compound=tk.LEFT,
command=lambda: self.dialog.remove_sftp_bookmark(name))
context_menu.tk_popup(event.x_root, event.y_root)
def _setup_sidebar_devices(self, sidebar_frame: ttk.Frame) -> None:
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")
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)
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)
except (FileNotFoundError, PermissionError):
pass
def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None:
storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame")
storage_frame.grid(row=6, 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:
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')
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:
self.left_container.grid_rowconfigure(0, weight=1)
self.right_container.grid_rowconfigure(0, weight=1)
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
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
self.cancel_button.grid(in_=action_container, row=1, column=0)
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")
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:
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)
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")
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)
self._setup_bottom_bar()

View File

@@ -1,711 +0,0 @@
import os
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any, Dict
# 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 shared_libs.message import MessageDialog
from .cfd_app_config import CfdConfigManager, 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 _get_file_info_list(self) -> Tuple[List[Dict], Optional[str], Optional[str]]:
"""
Gets a sorted list of file information dictionaries from the current source.
"""
if self.dialog.current_fs_type == "sftp":
items, error = self.dialog.sftp_manager.list_directory(self.dialog.current_dir)
if error:
return [], error, None
file_info_list = []
import stat
for item in items:
if item.filename in ['.', '..']:
continue
is_dir = stat.S_ISDIR(item.st_mode)
# Manually construct SFTP path to ensure forward slashes
path = f"{self.dialog.current_dir}/{item.filename}".replace("//", "/")
file_info_list.append({
'name': item.filename,
'path': path,
'is_dir': is_dir,
'size': item.st_size,
'modified': item.st_mtime
})
return file_info_list, None, None
else:
try:
items = list(os.scandir(self.dialog.current_dir))
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
items.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
file_info_list = []
for item in items:
try:
stat_result = item.stat()
file_info_list.append({
'name': item.name,
'path': item.path,
'is_dir': item.is_dir(),
'size': stat_result.st_size,
'modified': stat_result.st_mtime
})
except (FileNotFoundError, PermissionError):
continue
return file_info_list, None, warning_message
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the main file display area.
"""
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.result = None
self.dialog.selected_item_frames.clear()
self.dialog.update_selection_info()
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_folder_content_count(self, folder_path: str) -> Optional[int]:
"""
Counts the number of items in a given folder.
"""
if self.dialog.current_fs_type == "sftp":
return None
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.
"""
while widget and not hasattr(widget, 'item_path'):
widget = widget.master
return getattr(widget, 'item_path', None)
def _is_dir(self, path: str) -> bool:
"""Checks if a given path is a directory, supporting both local and SFTP."""
if self.dialog.current_fs_type == 'sftp':
for item in self.dialog.all_items:
if item['path'] == path:
return item['is_dir']
return False
else:
return os.path.isdir(path)
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, event)
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._handle_item_double_click(item_path)
def _handle_icon_context_menu(self, event: tk.Event) -> None:
"""Handles a context menu request on an icon view item."""
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.
"""
self.dialog.all_items, error, warning = self._get_file_info_list()
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.
"""
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):
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
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._handle_item_double_click(p))
widget.bind("<Button-1>", lambda e, p=path,
f=item_frame: self.on_item_select(p, f, e))
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.
"""
self.dialog.all_items, error, warning = self._get_file_info_list()
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")
if self.dialog.dialog_mode == 'multi':
self.dialog.tree.config(selectmode="extended")
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.
"""
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):
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
if not is_dir and not self.dialog._matches_filetype(name):
continue
try:
modified_time = datetime.fromtimestamp(
file_info['modified']).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(file_info['size'])
item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time))
self.dialog.item_path_map[item_id] = path # Store path for later retrieval
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, event: Optional[tk.Event] = None) -> None:
"""
Handles the selection of an item in the icon view.
"""
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
return
if self.dialog.dialog_mode == 'multi':
ctrl_pressed = (event.state & 0x4) != 0 if event else False
if ctrl_pressed:
if item_frame in self.dialog.selected_item_frames:
item_frame.state(['!selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.remove(item_frame)
else:
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
else:
for f in self.dialog.selected_item_frames:
f.state(['!selected'])
for child in f.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.clear()
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
selected_paths = [frame.item_path for frame in self.dialog.selected_item_frames]
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else: # Single selection mode
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.update_selection_info(path)
self.dialog.search_manager.show_search_ready()
def on_list_select(self, event: tk.Event) -> None:
"""
Handles the selection of an item in the list view.
"""
selections = self.dialog.tree.selection()
if not selections:
self.dialog.result = [] if self.dialog.dialog_mode == 'multi' else None
self.dialog.update_selection_info()
return
if self.dialog.dialog_mode == 'multi':
selected_paths = []
for item_id in selections:
path = self.dialog.item_path_map.get(item_id)
if path:
selected_paths.append(path)
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else:
item_id = selections[0]
path = self.dialog.item_path_map.get(item_id)
if not path:
return
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
self.dialog.result = None
self.dialog.tree.selection_remove(item_id)
self.dialog.update_selection_info()
return
self.dialog.update_selection_info(path)
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)
path = self.dialog.item_path_map.get(iid)
if path:
self.dialog.file_op_manager._show_context_menu(event, path)
return "break"
def _handle_item_double_click(self, path: str) -> None:
"""
Handles the logic for a double-click on any item, regardless of view.
"""
if self._is_dir(path):
if self.dialog.dialog_mode == 'dir':
has_subdirs = False
try:
if self.dialog.current_fs_type == "sftp":
import stat
items, _ = self.dialog.sftp_manager.list_directory(path)
for item in items:
if item.filename not in ['.', '..'] and stat.S_ISDIR(item.st_mode):
has_subdirs = True
break
else:
for item in os.listdir(path):
if os.path.isdir(os.path.join(path, item)) and not item.startswith('.'):
has_subdirs = True
break
except OSError:
self.dialog.navigation_manager.navigate_to(path)
return
if has_subdirs:
self.dialog.navigation_manager.navigate_to(path)
else:
dialog = MessageDialog(
master=self.dialog,
message_type="ask",
title=LocaleStrings.CFD["select_or_enter_title"],
text=LocaleStrings.CFD["select_or_enter_prompt"].format(folder_name=os.path.basename(path)),
buttons=[
LocaleStrings.CFD["select_button"],
LocaleStrings.CFD["enter_button"],
LocaleStrings.CFD["cancel_button"],
]
)
choice = dialog.show()
if choice is True:
self.dialog.result = path
self.dialog.on_open()
elif choice is False:
self.dialog.navigation_manager.navigate_to(path)
else:
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode in ["open", "multi"]:
self.dialog.result = path
self.dialog.on_open()
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.
"""
selection = self.dialog.tree.selection()
if not selection:
return
item_id = selection[0]
path = self.dialog.item_path_map.get(item_id)
if path:
self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None:
"""
Programmatically selects a file in the current view.
"""
if self.dialog.view_mode.get() == "list":
for item_id, path in self.dialog.item_path_map.items():
if os.path.basename(path) == 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>")

View File

@@ -1,718 +0,0 @@
import os
import shutil
import tkinter as tk
import subprocess
import json
import threading
import webbrowser
from typing import Optional, List, Tuple, Dict, Union
import requests
from shared_libs.common_tools import IconManager, Tooltip, LxTools, message_box_animation
from shared_libs.gitea import GiteaUpdater, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError
from .cfd_app_config import CfdConfigManager, LocaleStrings
from .cfd_ui_setup import StyleManager, WidgetManager
from shared_libs.animated_icon import AnimatedIcon
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
from .cfd_sftp_manager import SFTPManager, PARAMIKO_AVAILABLE
from shared_libs.message import CredentialsDialog, MessageDialog, InputDialog
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,
mode: str = "open", title: str = LocaleStrings.CFD["title"]):
"""
Initializes the CustomFileDialog.
"""
super().__init__(parent)
self.current_fs_type = "local" # "local" or "sftp"
self.sftp_manager = SFTPManager()
self.config_manager = CfdConfigManager()
self.my_tool_tip: Optional[Tooltip] = None
self.dialog_mode: str = mode
self.gitea_api_url = CfdConfigManager.UPDATE_URL
self.lib_version = CfdConfigManager.VERSION
self.update_status: str = ""
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.result: Optional[Union[str, List[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.selected_item_frames: List[ttk.Frame] = []
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._initialize_managers()
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.widget_manager.home_button.config(command=self.go_to_local_home)
self.bind("<Key>", self.search_manager.show_search_bar)
if self.dialog_mode == "save":
self.bind("<Delete>", self.file_op_manager.delete_selected_item)
if self.gitea_api_url and self.lib_version:
self.update_thread = threading.Thread(
target=self.check_for_updates, daemon=True)
self.update_thread.start()
def _initialize_managers(self) -> None:
"""Initializes or re-initializes all the manager classes."""
self.style_manager: StyleManager = StyleManager(self)
self.file_op_manager: FileOperationsManager = FileOperationsManager(
self)
self.search_manager: SearchManager = SearchManager(self)
self.navigation_manager: NavigationManager = NavigationManager(self)
self.view_manager: ViewManager = ViewManager(self)
self.widget_manager: WidgetManager = WidgetManager(self, self.settings)
def 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.
"""
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._initialize_managers()
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 open_sftp_dialog(self):
if not PARAMIKO_AVAILABLE:
MessageDialog(message_type="error",
text="Paramiko library is not installed.").show()
return
dialog = CredentialsDialog(self)
credentials = dialog.show()
if credentials:
self.connect_sftp(credentials, is_new_connection=True)
def connect_sftp(self, credentials, is_new_connection: bool = False):
self.config(cursor="watch")
self.update_idletasks()
success, message = self.sftp_manager.connect(
host=credentials['host'],
port=credentials['port'],
username=credentials['username'],
password=credentials['password'],
key_file=credentials['key_file'],
passphrase=credentials['passphrase']
)
self.config(cursor="")
if success:
self.current_fs_type = "sftp"
self.widget_manager.sftp_button.config(
command=self.disconnect_sftp, style="Header.TButton.Active.Round")
initial_path = credentials.get("initial_path", "/")
self.navigation_manager.navigate_to(initial_path)
if is_new_connection:
save_bookmark = MessageDialog(
message_type="ask",
text="Connection successful. Save as bookmark?",
buttons=["Yes", "No"]
).show()
if save_bookmark:
bookmark_name = InputDialog(
self,
title="Save Bookmark",
prompt="Enter a name for the bookmark:",
initial_value=credentials['host']
).show()
if bookmark_name:
self.config_manager.add_bookmark(
bookmark_name, credentials)
self.reload_config_and_rebuild_ui()
else:
MessageDialog(message_type="error",
text=f"Connection failed: {message}").show()
def connect_sftp_bookmark(self, data):
self.connect_sftp(data, is_new_connection=False)
def remove_sftp_bookmark(self, name):
confirm = MessageDialog(
message_type="ask",
text=f"Remove bookmark '{name}'?",
buttons=["Yes", "No"]).show()
if confirm:
self.config_manager.remove_bookmark(name)
self.reload_config_and_rebuild_ui()
def disconnect_sftp(self):
self.sftp_manager.disconnect()
self.current_fs_type = "local"
self.widget_manager.sftp_button.config(
command=self.open_sftp_dialog, style="Header.TButton.Borderless.Round")
self.navigation_manager.navigate_to(os.path.expanduser("~"))
def go_to_local_home(self):
if self.current_fs_type == "sftp":
self.disconnect_sftp()
else:
self.navigation_manager.navigate_to(os.path.expanduser("~"))
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.my_tool_tip = Tooltip(
self.widget_manager.search_animation,
text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"]
)
if is_running:
self.widget_manager.search_animation.start()
def check_for_updates(self) -> None:
"""Checks for library updates via the Gitea API in a background thread."""
try:
new_version = GiteaUpdater.check_for_update(
self.gitea_api_url,
self.lib_version,
)
self.after(0, self.update_ui_for_update, new_version)
except (requests.exceptions.RequestException, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError):
self.after(0, self.update_ui_for_update, "ERROR")
except Exception:
self.after(0, self.update_ui_for_update, "ERROR")
def _run_installer(self, event: Optional[tk.Event] = None) -> None:
"""Runs the LxTools installer if it exists."""
installer_path = '/usr/local/bin/lxtools_installer'
if os.path.exists(installer_path):
try:
subprocess.Popen([installer_path])
self.widget_manager.search_status_label.config(
text="Installer started...")
except OSError as e:
self.widget_manager.search_status_label.config(
text=f"Error starting installer: {e}")
else:
self.widget_manager.search_status_label.config(
text=f"Installer not found at {installer_path}")
def update_ui_for_update(self, new_version: Optional[str]) -> None:
"""
Updates the UI based on the result of the library update check.
"""
self.update_status = new_version
icon = self.widget_manager.update_animation_icon
icon.grid_remove()
icon.hide()
if new_version is None or new_version == "ERROR":
return
icon.grid(row=0, column=2, sticky='e', padx=(10, 5))
tooltip_msg = LocaleStrings.UI["install_new_version"].format(
version=new_version)
icon.start()
icon.bind("<Button-1>", self._run_installer)
Tooltip(icon, tooltip_msg)
def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage:
"""
Gets the appropriate icon for a given filename.
"""
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.
"""
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.
"""
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.
"""
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.
"""
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.
"""
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_selection_info(self, status_info: Optional[str] = None) -> None:
"""
Updates status bar, filename entry, and result based on current selection.
"""
self._update_disk_usage()
status_text = ""
if self.dialog_mode == 'multi':
selected_paths = self.result if isinstance(
self.result, list) else []
self.widget_manager.filename_entry.delete(0, tk.END)
if selected_paths:
filenames = [
f'"{os.path.basename(p)}"' for p in selected_paths]
self.widget_manager.filename_entry.insert(
0, " ".join(filenames))
count = len(selected_paths)
status_text = f"{count} {LocaleStrings.CFD['items_selected']}"
else:
status_text = ""
else:
if status_info and (self.current_fs_type == 'sftp' or os.path.exists(status_info)):
self.result = status_info
self.widget_manager.filename_entry.delete(0, tk.END)
self.widget_manager.filename_entry.insert(
0, os.path.basename(status_info))
if self.view_manager._is_dir(status_info):
content_count = self.view_manager._get_folder_content_count(
status_info)
if content_count is not None:
status_text = f"'{os.path.basename(status_info)}' ({content_count} {LocaleStrings.CFD['entries']})"
else:
status_text = f"'{os.path.basename(status_info)}'"
else:
status_text = f"'{os.path.basename(status_info)}'"
elif status_info:
status_text = status_info
self.widget_manager.search_status_label.config(text=status_text)
self.update_action_buttons_state()
def _update_disk_usage(self) -> None:
"""Updates only the disk usage part of the status bar."""
if self.current_fs_type == "sftp":
self.widget_manager.storage_label.config(text="SFTP Storage: N/A")
self.widget_manager.storage_bar['value'] = 0
return
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
except FileNotFoundError:
self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: {LocaleStrings.CFD['unknown']}")
self.widget_manager.storage_bar['value'] = 0
self.widget_manager.search_status_label.config(
text=LocaleStrings.CFD["directory_not_found"])
def on_open(self) -> None:
"""Handles the 'Open' or 'OK' action based on the dialog mode."""
if self.dialog_mode == 'multi':
if self.result and isinstance(self.result, list) and self.result:
self.destroy()
return
selected_path = self.result
if not selected_path or not isinstance(selected_path, str):
return
if self.dialog_mode == 'dir':
if self.view_manager._is_dir(selected_path):
self.destroy()
elif self.dialog_mode == 'open':
if not self.view_manager._is_dir(selected_path):
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.result = 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.result = None
self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]:
"""Returns the result of the dialog."""
return self.result
def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons based on current context."""
new_folder_state = tk.DISABLED
new_file_state = tk.DISABLED
trash_state = tk.DISABLED
is_writable = False
if self.dialog_mode != "open":
if self.current_fs_type == 'sftp':
is_writable = True
else:
is_writable = os.access(self.current_dir, os.W_OK)
if is_writable:
new_folder_state = tk.NORMAL
new_file_state = tk.NORMAL
if self.dialog_mode == "save":
trash_state = tk.NORMAL
if hasattr(self.widget_manager, 'new_folder_button'):
self.widget_manager.new_folder_button.config(
state=new_folder_state)
if hasattr(self.widget_manager, 'new_file_button'):
self.widget_manager.new_file_button.config(state=new_file_state)
if hasattr(self.widget_manager, 'trash_button'):
self.widget_manager.trash_button.config(state=trash_state)
def _matches_filetype(self, filename: str) -> bool:
"""
Checks if a filename matches the current filetype filter.
"""
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).
"""
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.
"""
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.
"""
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

22
file_and_dir_ensure.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/python3
"""Utility functions for setting up the application."""
from logview_app_config import AppConfig
from pathlib import Path
# Logging
LOG_DIR = Path.home() / ".local/share/lxlogs"
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
LOG_FILE_PATH = LOG_DIR / "logviewer.log"
def prepare_app_environment() -> None:
"""Ensures that all required files and directories exist."""
AppConfig.ensure_directories()
AppConfig.create_default_settings()
AppConfig.ensure_log()
if __name__ == "__main__":
prepare_app_environment()

199
gitea.py
View File

@@ -1,131 +1,132 @@
#!/usr/bin/python3
"""
A streamlined module to check for updates from a Gitea repository API.
"""
import re
import gettext
import locale
import requests
from typing import Optional, Tuple
from pathlib import Path
import subprocess
import shutil
from shared_libs.message import MessageDialog
class GiteaUpdaterError(Exception):
"""Base exception for GiteaUpdater."""
pass
class GiteaApiUrlError(GiteaUpdaterError):
"""Raised when the Gitea API URL is invalid."""
pass
class GiteaVersionParseError(GiteaUpdaterError, ValueError):
"""Raised when a version string cannot be parsed."""
pass
class GiteaApiResponseError(GiteaUpdaterError):
"""Raised for invalid or unexpected API responses."""
pass
class GiteaUpdater:
class GiteaUpdate:
"""
Provides a clean interface to check for software updates via a Gitea API.
Calling download requests the download URL of the running script,
the taskbar image for the “Download OK” window, the taskbar image for the
“Download error” window, and the variable res
"""
@staticmethod
def _parse_version(version_string: str) -> Optional[Tuple[int, ...]]:
def api_down(update_api_url: str, version: str, update_setting: str = None) -> str:
"""
Parses a version string into a tuple of integers for comparison.
Handles prefixes like 'v. ' or 'v'.
It prioritizes parsing as a date-based version string with the format
<major>.<month>.<day><year_short> (e.g., "v. 2.08.1025").
If the string does not match this format, it falls back to a general
semantic versioning parsing (e.g., "v2.1.0").
Checks for updates via API
Args:
version_string: The version string (e.g., "v. 1.08.1325", "v2.1.0").
update_api_url: Update API URL
version: Current version
update_setting: Update setting from ConfigManager (on/off)
Returns:
A tuple of integers for comparison. For date-based versions, the
format is (major, year, month, day) to ensure correct comparison.
Returns None if parsing fails.
New version or status message
"""
# If updates are disabled, return immediately
if update_setting != "on":
return "False"
try:
# Remove common prefixes like 'v', 'v. ', etc.
cleaned_string = re.sub(r'^[vV\.\s]+', '', version_string)
parts = cleaned_string.split('.')
response: requests.Response = requests.get(update_api_url, timeout=10)
response.raise_for_status() # Raise exception for HTTP errors
# Try to parse as date-based version first
if len(parts) == 3:
day_year_str = parts[2]
if len(day_year_str) >= 3 and day_year_str.isdigit():
day_year_int = int(day_year_str)
major = int(parts[0])
month = int(parts[1])
response_data = response.json()
if not response_data:
return "No Updates"
day = day_year_int // 100
year = day_year_int % 100 + 2000
latest_version = response_data[0].get("tag_name")
if not latest_version:
return "Invalid API Response"
# Basic validation for date components
if 1 <= month <= 12 and 1 <= day <= 31:
return (major, year, month, day)
# Compare versions (strip 'v. ' prefix if present)
current_version = version[3:] if version.startswith("v. ") else version
# Fallback to standard version parsing for other formats (e.g., 2.1.0)
return tuple(map(int, parts))
if current_version != latest_version:
return latest_version
else:
return "No Updates"
except (ValueError, TypeError):
return None
except requests.exceptions.RequestException:
return "No Internet Connection!"
except (ValueError, KeyError, IndexError):
return "Invalid API Response"
@staticmethod
def check_for_update(api_url: str, current_version: str) -> Optional[str]:
def download(urld: str, res: str) -> None:
"""
Checks for a newer version of the application on Gitea.
Downloads new version of application
Args:
api_url: The Gitea API URL for releases.
current_version: The current version string of the application.
Returns:
The new version string if an update is available, otherwise None.
Raises:
GiteaApiUrlError: If the API URL is not provided.
GiteaVersionParseError: If the local or remote version string cannot be parsed.
GiteaApiResponseError: If the API response is invalid.
requests.exceptions.RequestException: For network or HTTP errors.
:param urld: Download URL
:param res: Result filename
"""
if not api_url:
raise GiteaApiUrlError("Gitea API URL is not provided.")
local_version_tuple = GiteaUpdater._parse_version(current_version)
if not local_version_tuple:
raise GiteaVersionParseError(
f"Could not parse local version string: {current_version}")
response = requests.get(api_url, timeout=10)
response.raise_for_status() # Raises HTTPError for 4xx/5xx
try:
data = response.json()
if not data:
return None # No releases found is not an error
to_down: str = f"wget -qP {Path.home()} {" "} {urld}"
result: int = subprocess.call(to_down, shell=True)
if result == 0:
shutil.chown(f"{Path.home()}/{res}.zip", 1000, 1000)
latest_tag_name = data[0].get("tag_name")
if not latest_tag_name:
raise GiteaApiResponseError(
"Invalid API response: 'tag_name' not found in the first release.")
MessageDialog("info", text=Msg.STR["ok_message"])
except (ValueError, IndexError, KeyError) as e:
raise GiteaApiResponseError(
f"Could not process the response from Gitea: {e}") from e
else:
remote_version_tuple = GiteaUpdater._parse_version(latest_tag_name)
if not remote_version_tuple:
raise GiteaVersionParseError(
f"Could not parse remote version string: {latest_tag_name}")
MessageDialog(
"error", text=Msg.STR["error_message"], title=Msg.STR["error_title"]
)
if remote_version_tuple > local_version_tuple:
return latest_tag_name
except subprocess.CalledProcessError:
return None
MessageDialog(
"error", text=Msg.STR["error_no_internet"], title=Msg.STR["error_title"]
)
class AppConfig:
# Localization
APP_NAME: str = "gitea"
LOCALE_DIR: Path = Path("/usr/share/locale/")
@staticmethod
def setup_translations() -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Returns:
The gettext translation function
"""
locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.textdomain(AppConfig.APP_NAME)
return gettext.gettext
# Images and icons paths
IMAGE_PATHS: dict[str, Path] = {
"icon_info": "/usr/share/icons/lx-icons/64/info.png",
"icon_error": "/usr/share/icons/lx-icons/64/error.png",
"icon_download": "/usr/share/icons/lx-icons/48/download.png",
"icon_download_error": "/usr/share/icons/lx-icons/48/download_error.png",
}
# here is initializing the class for translation strings
_ = AppConfig.setup_translations()
class Msg:
STR: dict[str, str] = {
# Strings for messages
"title": _("Download Successful"),
"ok_message": _("Your zip file is in home directory"),
"error_title": _("Download error"),
"error_message": _("Download failed! Please try again"),
"error_no_internet": _("Download failed! No internet connection!"),
}

View File

@@ -1,101 +0,0 @@
import tkinter as tk
from tkinter import ttk
from datetime import datetime
import typing
from shared_libs.common_tools import IconManager
if typing.TYPE_CHECKING:
from tkinter import Event
class LogWindow(ttk.Frame):
"""A Tkinter frame that provides a scrollable text widget for logging messages."""
def __init__(self, parent: tk.Misc, copy: str = "Copy") -> None:
"""
Initializes the LogWindow widget.
Args:
parent: The parent widget.
copy: The text label for the 'Copy' context menu item.
"""
super().__init__(parent)
self.icon_manager = IconManager()
log_container = tk.Frame(self)
# Let the main app control padding via the grid options
log_container.pack(fill="both", expand=True, padx=0, pady=0)
self.log_text: tk.Text = tk.Text(
log_container,
wrap=tk.WORD,
font=("Consolas", 9),
bg="#1e1e1e",
fg="#d4d4d4",
insertbackground="white",
selectbackground="#264f78",
height=10 # Give it a default height
)
log_scrollbar = ttk.Scrollbar(
log_container, orient="vertical", command=self.log_text.yview
)
self.log_text.configure(yscrollcommand=log_scrollbar.set)
self.log_text.pack(side="left", fill="both", expand=True)
log_scrollbar.pack(side="right", fill="y")
self.context_menu: tk.Menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(
label=copy, command=self.copy_text, image=self.icon_manager.get_icon('copy'), compound='left')
self.log_text.bind("<Button-3>", self.show_context_menu)
def log_message(self, message: str) -> None:
"""
Adds a timestamped message to the log view.
Args:
message: The message string to add.
"""
timestamp = datetime.now().strftime("%H:%M:%S")
log_entry = f"[{timestamp}] {message}\n"
self.log_text.insert(tk.END, log_entry)
self.log_text.see(tk.END)
self.log_text.update()
def show_text_menu(self, event: 'Event') -> None:
"""
Displays the context menu at the event's coordinates.
(Note: This seems to be a remnant, show_context_menu is used).
Args:
event: The tkinter event that triggered the menu.
"""
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def copy_text(self) -> None:
"""Copies the currently selected text in the log widget to the clipboard."""
try:
selected_text = self.log_text.selection_get()
self.clipboard_clear()
self.clipboard_append(selected_text)
except tk.TclError:
# No Text selected
pass
def show_context_menu(self, event: 'Event') -> None:
"""
Shows the right-click context menu.
Args:
event: The tkinter Button-3 event.
"""
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()

View File

@@ -1,33 +0,0 @@
import typing
from typing import Callable
class Logger:
"""A simple logger class that can be initialized with a logging function."""
def __init__(self) -> None:
"""Initializes the Logger, defaulting to the print function."""
self._log_func: Callable[[str], None] = print
def init_logger(self, log_func: Callable[[str], None]) -> None:
"""
Initializes the logger with a specific logging function.
Args:
log_func: The function to use for logging. It should accept a single
string argument.
"""
self._log_func = log_func
def log(self, message: str) -> None:
"""
Logs a message using the configured logging function.
Args:
message: The message to log.
"""
self._log_func(message)
# Global instance
app_logger: Logger = Logger()

146
logview_app_config.py Normal file
View File

@@ -0,0 +1,146 @@
"""Configuration for the LogViewer application."""
import gettext
import locale
from pathlib import Path
from typing import Dict, Any
class AppConfig:
"""Central configuration and system setup manager for the LogViewer application.
This class serves as a singleton-like container for all global configuration data,
including paths, UI settings, localization, versioning, and system-specific resources.
It ensures that required directories, files, and services are created and configured
before the application starts. Additionally, it provides tools for managing translations,
default settings, and autostart functionality to maintain a consistent user experience.
Key Responsibilities:
- Centralizes all configuration values (paths, UI preferences, localization).
- Ensures required directories and files exist on startup.
- Handles translation setup via `gettext` for multilingual support.
- Manages default settings file generation.
- Configures autostart services using systemd for user-specific launch behavior.
This class is used globally across the application to access configuration data
consistently and perform system-level setup tasks.
"""
# Logging
LOG_DIR = Path.home() / ".local/share/lxlogs"
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
LOG_FILE_PATH = LOG_DIR / "logviewer.log"
# Localization
APP_NAME: str = "logviewer"
LOCALE_DIR: Path = Path("/usr/share/locale/")
# Base paths
BASE_DIR: Path = Path.home()
CONFIG_DIR: Path = BASE_DIR / ".config/logviewer"
# Configuration files
SETTINGS_FILE: Path = CONFIG_DIR / "settings"
DEFAULT_SETTINGS: Dict[str, str] = {
"# Configuration": "on",
"# Theme": "light",
"# Tooltips": True,
"# Autostart": "off",
"# Logfile": LOG_FILE_PATH,
}
# Updates
# 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year
VERSION: str = "v. 1.06.0325"
UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
DOWNLOAD_URL: str = "https://git.ilunix.de/punix/shared_libs/archive"
# UI configuration
UI_CONFIG: Dict[str, Any] = {
"window_title2": "LogViewer",
"window_size": (600, 383),
"font_family": "Ubuntu",
"font_size": 11,
"resizable_window": (True, True),
}
# Images and icons paths
IMAGE_PATHS: Dict[str, Path] = {
"icon_info": "/usr/share/icons/lx-icons/64/info.png",
"icon_error": "/usr/share/icons/lx-icons/64/error.png",
"icon_log": "/usr/share/icons/lx-icons/48/log.png",
}
# System-dependent paths
SYSTEM_PATHS: Dict[str, Path] = {
"tcl_path": "/usr/share/TK-Themes",
}
@staticmethod
def setup_translations() -> gettext.gettext:
"""
Initialize translations and set the translation function
Special method for translating strings in this file
Returns:
The gettext translation function
"""
locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
gettext.textdomain(AppConfig.APP_NAME)
return gettext.gettext
@classmethod
def create_default_settings(cls) -> None:
"""Creates default settings if they don't exist"""
if not cls.SETTINGS_FILE.exists():
content = "\n".join(
f"[{k.upper()}]\n{v}" for k, v in cls.DEFAULT_SETTINGS.items()
)
cls.SETTINGS_FILE.write_text(content)
@classmethod
def ensure_directories(cls) -> None:
"""Ensures that all required directories exist"""
if not cls.CONFIG_DIR.exists():
cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@classmethod
def ensure_log(cls) -> None:
"""Ensures that the log file exists"""
if not cls.LOG_FILE_PATH.exists():
cls.LOG_FILE_PATH.touch()
# here is initializing the class for translation strings
_ = AppConfig.setup_translations()
class Msg:
"""
A utility class that provides centralized access to translated message strings.
This class contains a dictionary of message strings used throughout the Wire-Py application.
All strings are prepared for translation using gettext. The short key names make the code
more concise while maintaining readability.
Attributes:
STR (dict): A dictionary mapping short keys to translated message strings.
Keys are abbreviated for brevity but remain descriptive.
Usage:
Import this class and access messages using the dictionary:
`Msg.STR["sel_tl"]` returns the translated "Select tunnel" message.
Note:
Ensure that gettext translation is properly initialized before
accessing these strings to ensure correct localization.
"""
STR: Dict[str, str] = {
# Strings for messages
}
TTIP: Dict[str, str] = {
# Strings for Tooltips
"settings": _("Click for Settings"),
}

517
logviewer.py Executable file
View File

@@ -0,0 +1,517 @@
#!/usr/bin/python3
import argparse
import logging
import tkinter as tk
from tkinter import TclError, filedialog, ttk
from pathlib import Path
import webbrowser
from functools import partial
from shared_libs.gitea import GiteaUpdate
from shared_libs.message import MessageDialog
from shared_libs.common_tools import (
LogConfig,
ConfigManager,
ThemeManager,
LxTools,
Tooltip,
)
import sys
from file_and_dir_ensure import prepare_app_environment
class LogViewer(tk.Tk):
def __init__(self, modul_name):
super().__init__()
self.my_tool_tip = None
self.modul_name = modul_name # Save the module name
# from here the calls must be made with the module name
_ = modul_name.AppConfig.setup_translations()
self.x_width = modul_name.AppConfig.UI_CONFIG["window_size"][0]
self.y_height = modul_name.AppConfig.UI_CONFIG["window_size"][1]
# Set the window size
self.geometry(f"{self.x_width}x{self.y_height}")
self.minsize(
modul_name.AppConfig.UI_CONFIG["window_size"][0],
modul_name.AppConfig.UI_CONFIG["window_size"][1],
)
self.title(modul_name.AppConfig.UI_CONFIG["window_title2"])
self.tk.call(
"source", f"{modul_name.AppConfig.SYSTEM_PATHS['tcl_path']}/water.tcl"
)
ConfigManager.init(modul_name.AppConfig.SETTINGS_FILE)
theme = ConfigManager.get("theme")
ThemeManager.change_theme(self, theme)
LxTools.center_window_cross_platform(self, self.x_width, self.y_height)
self.createWidgets(_)
self.load_file(_, modul_name=modul_name)
self.log_icon = tk.PhotoImage(file=modul_name.AppConfig.IMAGE_PATHS["icon_log"])
self.iconphoto(True, self.log_icon)
self.grid_rowconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
# StringVar-Variables initialization
self.tooltip_state = tk.BooleanVar()
# Get value from configuration
state = ConfigManager.get("tooltips")
# NOTE: ConfigManager.get("tooltips") can return either a boolean value or a string,
# depending on whether the value was loaded from the file (bool) or the default value is used (string).
# The expression 'lines[5].strip() == "True"' in ConfigManager.load() converts the string to a boolean.
# Convert to boolean and set
if isinstance(state, bool):
# If it's already a boolean, use directly
self.tooltip_state.set(state)
else:
# If it's a string or something else
self.tooltip_state.set(str(state) == "True")
self.tooltip_label = (
tk.StringVar()
) # StringVar-Variable for tooltip label for view Disabled/Enabled
self.tooltip_update_label(modul_name, _)
self.update_label = tk.StringVar() # StringVar-Variable for update label
self.update_tooltip = (
tk.StringVar()
) # StringVar-Variable for update tooltip please not remove!
self.update_foreground = tk.StringVar(value="red")
# Frame for Menu
self.menu_frame = ttk.Frame(self)
self.menu_frame.configure(relief="flat")
if "'logview_app_config'" in f"{modul_name}".split():
self.menu_frame.grid(column=0, row=0, columnspan=4, sticky=tk.NSEW)
# App Menu
self.version_lb = ttk.Label(self.menu_frame, text=modul_name.AppConfig.VERSION)
self.version_lb.config(font=("Ubuntu", 11), foreground="#00c4ff")
self.version_lb.grid(column=0, row=0, rowspan=4, padx=10, pady=10)
Tooltip(
self.version_lb,
f"Version: {modul_name.AppConfig.VERSION[2:]}",
self.tooltip_state,
)
self.load_button = ttk.Button(
self.menu_frame,
text=_("Load Log"),
style="Toolbutton",
command=lambda: self.directory_load(modul_name, _),
)
self.load_button.grid(column=1, row=0)
self.options_btn = ttk.Menubutton(self.menu_frame, text=_("Options"))
self.options_btn.grid(column=2, row=0)
Tooltip(self.options_btn, modul_name.Msg.TTIP["settings"], self.tooltip_state)
self.set_update = tk.IntVar()
self.settings = tk.Menu(self, relief="flat")
self.options_btn.configure(menu=self.settings, style="Toolbutton")
self.settings.add_checkbutton(
label=_("Disable Updates"),
command=lambda: self.update_setting(self.set_update.get(), modul_name, _),
variable=self.set_update,
)
self.updates_lb = ttk.Label(self.menu_frame, textvariable=self.update_label)
self.updates_lb.grid(column=5, row=0, padx=10)
self.updates_lb.grid_remove()
self.update_label.trace_add("write", self.update_label_display)
self.update_foreground.trace_add("write", self.update_label_display)
res = GiteaUpdate.api_down(
modul_name.AppConfig.UPDATE_URL,
modul_name.AppConfig.VERSION,
ConfigManager.get("updates"),
)
self.update_ui_for_update(res, modul_name, _)
# Tooltip Menu
self.settings.add_command(
label=self.tooltip_label.get(),
command=lambda: self.tooltips_toggle(modul_name, _),
)
# Label show dark or light
self.theme_label = tk.StringVar()
self.update_theme_label(modul_name, _)
self.settings.add_command(
label=self.theme_label.get(),
command=lambda: self.on_theme_toggle(modul_name, _),
)
# About BTN Menu / Label
self.about_btn = ttk.Button(
self.menu_frame,
text=_("About"),
style="Toolbutton",
command=lambda: self.about(modul_name, _),
)
self.about_btn.grid(column=3, row=0)
self.readme = tk.Menu(self)
# self.grid_rowconfigure(0, weight=)
self.grid_rowconfigure(1, weight=25)
self.grid_columnconfigure(0, weight=1)
# Method that is called when the variable changes
def update_label_display(self, *args):
# Set the foreground color
self.updates_lb.configure(foreground=self.update_foreground.get())
# Show or hide the label based on whether it contains text
if self.update_label.get():
# Make sure the label is in the correct position every time it's shown
self.updates_lb.grid(column=5, row=0, padx=10)
else:
self.updates_lb.grid_remove()
# Update the labels based on the result
def update_ui_for_update(self, res, modul_name, _):
"""Update UI elements based on an update check result"""
# First, remove the update button if it exists to avoid conflicts
if hasattr(self, "update_btn"):
self.update_btn.grid_forget()
delattr(self, "update_btn")
if res == "False":
self.set_update.set(value=1)
self.update_label.set(_("Update search off"))
self.update_tooltip.set(_("Updates you have disabled"))
# Clear the foreground color as requested
self.update_foreground.set("")
# Set the tooltip for the label
Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state)
elif res == "No Internet Connection!":
self.update_label.set(_("No Server Connection!"))
self.update_foreground.set("red")
# Set the tooltip for "No Server Connection"
Tooltip(
self.updates_lb,
_("Could not connect to update server"),
self.tooltip_state,
)
elif res == "No Updates":
self.update_label.set(_("No Updates"))
self.update_tooltip.set(_("Congratulations! Wire-Py is up to date"))
self.update_foreground.set("")
# Set the tooltip for the label
Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state)
else:
self.set_update.set(value=0)
update_text = f"Update {res} {_('available!')}"
# Clear the label text since we'll show the button instead
self.update_label.set("")
# Create the update button
self.update_btn = ttk.Menubutton(self.menu_frame, text=update_text)
self.update_btn.grid(column=5, row=0, padx=0)
Tooltip(
self.update_btn, _("Click to download new version"), self.tooltip_state
)
self.download = tk.Menu(self, relief="flat")
self.update_btn.configure(menu=self.download, style="Toolbutton")
self.download.add_command(
label=_("Download"),
command=lambda: GiteaUpdate.download(
f"{modul_name.AppConfig.DOWNLOAD_URL}/{res}.zip", res
),
)
@staticmethod
def about(modul_name, _) -> None:
"""
a tk.Toplevel window
"""
msg_t = _(
"Logviewer a simple Gui for View Logfiles.\n\n"
"Logviewer is open source software written in Python.\n\n"
"Email: polunga40@unity-mail.de also likes for donation.\n\n"
"Use without warranty!\n"
)
MessageDialog(
"info",
text=msg_t,
buttons=["OK", "Go to Logviewer"],
commands=[
None, # Default on "OK"
partial(webbrowser.open, "https://git.ilunix.de/punix/shared_libs"),
],
icon=modul_name.AppConfig.IMAGE_PATHS["icon_log"],
title="Logviewer",
)
def update_setting(self, update_res, modul_name, _) -> None:
"""write off or on in file
Args:
update_res (int): argument that is passed contains 0 or 1
"""
if update_res == 1:
# Disable updates
ConfigManager.set("updates", "off")
# When updates are disabled, we know the result should be "False"
self.update_ui_for_update("False", modul_name, _)
else:
# Enable updates
ConfigManager.set("updates", "on")
# When enabling updates, we need to actually check for updates
try:
# Force a fresh check by passing "on" as the update setting
res = GiteaUpdate.api_down(
modul_name.AppConfig.UPDATE_URL, modul_name.AppConfig.VERSION, "on"
)
# Make sure the UI is updated regardless of the previous state
if hasattr(self, "update_btn"):
self.update_btn.grid_forget()
if hasattr(self, "updates_lb"):
self.updates_lb.grid_forget()
# Now update the UI with the fresh result
self.update_ui_for_update(res, modul_name, _)
except Exception as e:
logging.error(f"Error checking for updates: {e}")
# Fallback to a default message if there's an error
self.update_ui_for_update("No Internet Connection!", modul_name, _)
def tooltip_update_label(self, modul_name, _) -> None:
"""Updates the tooltip menu label based on the current tooltip status"""
# Set the menu text based on the current status
if self.tooltip_state.get():
# If tooltips are enabled, the menu option should be to disable them
self.tooltip_label.set(_("Disable Tooltips"))
else:
# If tooltips are disabled, the menu option should be to enable them
self.tooltip_label.set(_("Enable Tooltips"))
def tooltips_toggle(self, modul_name, _):
"""
Toggles the visibility of tooltips (on/off) and updates
the corresponding menu label. Inverts the current tooltip state
(`self.tooltip_state`), saves the new value in the configuration,
and applies the change immediately. Updates the menu entry's label to
reflect the new tooltip status (e.g., "Tooltips: On" or "Tooltips: Off").
"""
# Toggle the boolean state
new_bool_state = not self.tooltip_state.get()
# Save the converted value in the configuration
ConfigManager.set("tooltips", str(new_bool_state))
# Update the tooltip_state variable for immediate effect
self.tooltip_state.set(new_bool_state)
# Update the menu label
self.tooltip_update_label(modul_name, _)
# Update the menu entry - find the correct index
# This assumes it's the third item (index 2) in your menu
self.settings.entryconfigure(1, label=self.tooltip_label.get())
def update_theme_label(self, modul_name, _) -> None:
"""Update the theme label based on the current theme"""
current_theme = ConfigManager.get("theme")
if current_theme == "light":
self.theme_label.set(_("Dark"))
else:
self.theme_label.set(_("Light"))
def on_theme_toggle(self, modul_name, _) -> None:
"""Toggle between light and dark theme"""
current_theme = ConfigManager.get("theme")
new_theme = "dark" if current_theme == "light" else "light"
ThemeManager.change_theme(self, new_theme, new_theme)
self.update_theme_label(modul_name, _) # Update the theme label
# Update Menulfield
self.settings.entryconfigure(2, label=self.theme_label.get())
def createWidgets(self, _):
text_frame = ttk.Frame(self)
text_frame.grid(row=1, column=0, padx=5, pady=5, sticky=tk.NSEW)
text_frame.rowconfigure(0, weight=3)
text_frame.columnconfigure(0, weight=1)
next_frame = ttk.Frame(self)
next_frame.grid(row=2, column=0, sticky=tk.NSEW)
next_frame.rowconfigure(2, weight=1)
next_frame.columnconfigure(1, weight=1)
# Create a Text widget for displaying the log file
self.text_area = tk.Text(
text_frame, wrap=tk.WORD, padx=5, pady=5, relief="flat"
)
self.text_area.grid(row=0, column=0, sticky=tk.NSEW)
self.text_area.tag_configure(
"found-tag", foreground="yellow", background="green"
)
# Create a vertical scrollbar for the Text widget
v_scrollbar = ttk.Scrollbar(
text_frame, orient="vertical", command=self.text_area.yview
)
v_scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.text_area.configure(yscrollcommand=v_scrollbar.set)
self._entry = ttk.Entry(next_frame)
self._entry.bind("<Return>", lambda e: self._onFind())
self._entry.grid(row=0, column=1, padx=5, sticky=tk.EW)
# Add a context menu to the Text widget
self.context_menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(label=_("Copy"), command=self.copy_text)
self.context_menu.add_command(label=_("Paste"), command=self.paste_into_entry)
self.text_area.bind("<Button-3>", self.show_context_menu)
self._entry.bind("<Button-3>", self.show_context_menu)
search_button = ttk.Button(next_frame, text="Search", command=self._onFind)
search_button.grid(row=0, column=0, padx=5, pady=5, sticky=tk.EW)
delete_button = ttk.Button(
next_frame, text="Delete_Log", command=self.delete_file
)
delete_button.grid(row=0, column=2, padx=5, pady=5, sticky=tk.EW)
def show_text_menu(self, event):
try:
self.configure.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def copy_text(self):
try:
selected_text = self.text_area.selection_get()
self.clipboard_clear()
self.clipboard_append(selected_text)
except tk.TclError:
# No Text selected
pass
def show_context_menu(self, event):
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def paste_into_entry(self):
try:
text = self.clipboard_get()
self._entry.delete(0, tk.END)
self._entry.insert(tk.END, text)
except tk.TclError:
# No Text on Clipboard
pass
def _onFind(self):
searchText = self._entry.get()
if len(searchText) == 0:
return
# Set the search start position to the last found position (initial value: "1.0")
start_pos = self.last_search_pos if hasattr(self, "last_search_pos") else "1.0"
var = tk.IntVar()
foundIndex = self.text_area.search(
searchText,
start_pos,
stopindex=tk.END,
nocase=tk.YES,
count=var,
regexp=tk.YES,
)
if not foundIndex:
# No further entry found, reset to the beginning
self.last_search_pos = "1.0"
return
count = var.get()
lastIndex = self.text_area.index(f"{foundIndex} + {count}c")
# Remove and reapply highlighting
self.text_area.tag_remove("found-tag", "1.0", tk.END)
self.text_area.tag_add("found-tag", foundIndex, lastIndex)
# Update the start position for the next search
self.last_search_pos = lastIndex
self.text_area.see(foundIndex)
def delete_file(self, modul_name):
Path.unlink(modul_name.AppConfig.LOG_FILE_PATH)
modul_name.AppConfig.ensure_log()
def load_file(self, _, modul_name):
try:
if not modul_name.AppConfig.LOG_FILE_PATH:
return
with open(
modul_name.AppConfig.LOG_FILE_PATH, "r", encoding="utf-8"
) as file:
self.text_area.delete(1.0, tk.END)
self.text_area.insert(tk.END, file.read())
except Exception as e:
logging.error(_(f"A mistake occurred: {str(e)}"))
MessageDialog("error", _(f"A mistake occurred:\n{str(e)}\n"))
def directory_load(self, modul_name, _):
filepath = filedialog.askopenfilename(
initialdir=f"{Path.home() / ".local/share/lxlogs/"}",
title="Select a Logfile File",
filetypes=[("Logfiles", "*.log")],
)
try:
with open(filepath, "r", encoding="utf-8") as file:
self.text_area.delete(1.0, tk.END)
self.text_area.insert(tk.END, file.read())
except (IsADirectoryError, TypeError, FileNotFoundError):
print("File load: abort by user...")
except Exception as e:
logging.error(_(f"A mistake occurred: {e}"))
MessageDialog("error", _(f"A mistake occurred:\n{e}\n"))
def main():
# Create an ArgumentParser object
parser = argparse.ArgumentParser(
description="LogViewer with optional module loading."
)
parser.add_argument(
"--modul",
type=str,
default="logview_app_config",
help="Give the name of the module to load.",
)
args = parser.parse_args()
import importlib
try:
modul = importlib.import_module(args.modul)
except ModuleNotFoundError:
print(f"Modul '{args.modul}' not found")
print("For help use logviewer -h")
sys.exit(1)
except Exception as e:
print(f"Error load Modul: {str(e)}")
sys.exit(1)
prepare_app_environment()
app = LogViewer(modul)
LogConfig.logger(ConfigManager.get("logfile"))
"""
the hidden files are hidden in Filedialog
"""
try:
app.tk.call("tk_getOpenFile", "-foobarbaz")
except TclError:
pass
app.tk.call("set", "::tk::dialog::file::showHiddenBtn", "1")
app.tk.call("set", "::tk::dialog::file::showHiddenVar", "0")
app.mainloop()
if __name__ == "__main__":
main()

View File

@@ -1,301 +0,0 @@
import os
import subprocess
import threading
import webbrowser
import requests
from functools import partial
from pathlib import Path
from tkinter import ttk
import typing
from typing import Any, Callable, Optional
if typing.TYPE_CHECKING:
from tkinter import BooleanVar
from .logger import app_logger
from .common_tools import ConfigManager, Tooltip, message_box_animation
from .gitea import (
GiteaUpdater,
GiteaApiUrlError,
GiteaVersionParseError,
GiteaApiResponseError,
)
from .animated_icon import AnimatedIcon, PIL_AVAILABLE
from .message import MessageDialog
class MenuBar(ttk.Frame):
"""A reusable menu bar widget for tkinter applications."""
def __init__(
self,
container: ttk.Frame,
image_manager: 'Any',
tooltip_state: 'BooleanVar',
on_theme_toggle: 'Callable[[], None]',
toggle_log_window: 'Callable[[], None]',
app_version: str, # Replaces app_config.VERSION
msg_config: 'Any', # Contains .STR and .TTIP
about_icon_path: str,
about_url: str,
gitea_api_url: str,
**kwargs: 'Any',
) -> None:
"""
Initializes the MenuBar.
Args:
container: The parent widget.
image_manager: An object with a `get_icon` method to retrieve icons.
tooltip_state: A tkinter BooleanVar to control tooltip visibility.
on_theme_toggle: Callback function to toggle the application's theme.
toggle_log_window: Callback function to show/hide the log window.
app_version: The current version string of the application.
msg_config: Project-specific messages and tooltips. Must have STR and TTIP dicts.
about_icon_path: Filesystem path to the icon for the 'About' dialog.
about_url: URL for the project's repository or website.
gitea_api_url: The Gitea API URL for update checks.
**kwargs: Additional keyword arguments for the ttk.Frame.
"""
super().__init__(container, **kwargs)
self.image_manager = image_manager
self.tooltip_state = tooltip_state
self.on_theme_toggle_callback = on_theme_toggle
self.app_version = app_version # Store the application version
self.msg_config = msg_config # Store the messages and tooltips object
self.about_icon_path = about_icon_path
self.about_url = about_url
self.gitea_api_url = gitea_api_url
self.update_status: str = ""
# --- Horizontal button frame for settings ---
actions_frame = ttk.Frame(self)
actions_frame.grid(column=0, row=0, padx=(5, 10), sticky="w")
# --- Theme Button ---
self.theme_btn = ttk.Button(
actions_frame, command=self.theme_toggle, style="TButton.Borderless.Round"
)
self.theme_btn.grid(column=0, row=0, padx=(0, 2))
self.update_theme_icon()
Tooltip(self.theme_btn, self.msg_config.TTIP["theme_toggle"],
state_var=self.tooltip_state)
# --- Tooltip Button ---
self.tooltip_btn = ttk.Button(
actions_frame, command=self.tooltips_toggle, style="TButton.Borderless.Round"
)
self.tooltip_btn.grid(column=1, row=0, padx=(0, 2))
self.update_tooltip_icon()
Tooltip(self.tooltip_btn, self.msg_config.TTIP["tooltips_toggle"],
state_var=self.tooltip_state)
# --- Update Button ---
self.update_btn = ttk.Button(
actions_frame,
command=self.toggle_update_setting,
style="TButton.Borderless.Round",
)
self.update_btn.grid(column=2, row=0)
self.update_update_icon()
Tooltip(self.update_btn, self.msg_config.TTIP["updates_toggle"],
state_var=self.tooltip_state)
# --- Animated Icon for Updates ---
self.animated_icon_frame = ttk.Frame(actions_frame)
self.animated_icon_frame.grid(column=3, row=0, padx=(5, 0))
current_theme = ConfigManager.get("theme")
bg_color = "#ffffff" if current_theme == "light" else "#333333"
self.animated_icon = AnimatedIcon(
self.animated_icon_frame,
animation_type="blink",
use_pillow=PIL_AVAILABLE,
bg=bg_color,
)
self.animated_icon.pack()
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
self.animated_icon_frame.grid_remove() # Initially hidden
# Add a spacer column with weight to push subsequent buttons to the right
self.columnconfigure(1, weight=1)
# --- Log Button ---
self.log_btn = ttk.Button(
self,
image=self.image_manager.get_icon("log_blue_small"),
style="TButton.Borderless.Round",
command=toggle_log_window,
)
self.log_btn.grid(column=2, row=0, sticky="e")
Tooltip(self.log_btn,
self.msg_config.TTIP["show_log"], state_var=self.tooltip_state)
# --- About Button ---
self.about_btn = ttk.Button(
self,
image=self.image_manager.get_icon("about"),
style="TButton.Borderless.Round",
command=self.about,
)
self.about_btn.grid(column=3, row=0)
Tooltip(self.about_btn,
self.msg_config.TTIP["about_app"], state_var=self.tooltip_state)
# --- Start background update check ---
self.update_thread = threading.Thread(
target=self.check_for_updates, daemon=True)
self.update_thread.start()
def update_theme_icon(self) -> None:
"""Sets the theme button icon based on the current theme."""
current_theme = ConfigManager.get("theme")
icon_name = "dark_small" if current_theme == "light" else "light_small"
self.theme_btn.configure(image=self.image_manager.get_icon(icon_name))
def update_tooltip_icon(self) -> None:
"""Sets the tooltip button icon based on the tooltip state."""
icon_name = "tooltip_small" if self.tooltip_state.get() else "no_tooltip_small"
self.tooltip_btn.configure(
image=self.image_manager.get_icon(icon_name))
def update_update_icon(self) -> None:
"""Sets the update button icon based on the update setting."""
updates_on = ConfigManager.get("updates") == "on"
icon_name = "update_small" if updates_on else "no_update_small"
self.update_btn.configure(image=self.image_manager.get_icon(icon_name))
def theme_toggle(self) -> None:
"""Invokes the theme toggle callback."""
self.on_theme_toggle_callback()
def update_theme(self) -> None:
"""Updates theme-dependent widgets, like icon backgrounds."""
self.update_theme_icon()
current_theme = ConfigManager.get("theme")
bg_color = "#ffffff" if current_theme == "light" else "#333333"
self.animated_icon.configure(bg=bg_color)
def tooltips_toggle(self) -> None:
"""Toggles the tooltip state and updates the icon."""
new_bool_state = not self.tooltip_state.get()
ConfigManager.set("tooltips", str(new_bool_state))
self.tooltip_state.set(new_bool_state)
self.update_tooltip_icon()
def toggle_update_setting(self) -> None:
"""Toggles the automatic update setting and re-checks for updates."""
updates_on = ConfigManager.get("updates") == "on"
ConfigManager.set("updates", "off" if updates_on else "on")
self.update_update_icon()
# After changing the setting, re-run the check to update status
threading.Thread(target=self.check_for_updates, daemon=True).start()
def check_for_updates(self) -> None:
"""Checks for updates via the Gitea API in a background thread."""
if ConfigManager.get("updates") == "off":
self.after(0, self.update_ui_for_update, "DISABLED")
return
try:
new_version = GiteaUpdater.check_for_update(
self.gitea_api_url,
self.app_version,
)
self.after(0, self.update_ui_for_update, new_version)
except requests.exceptions.RequestException as e:
# Covers connection errors, timeouts, DNS errors, etc.
# Good indicator for "no internet" or "server unreachable"
app_logger.log(f"Network error during update check: {e}")
self.after(0, self.update_ui_for_update, "ERROR")
except (GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError) as e:
# Covers bad configuration or unexpected API changes
app_logger.log(f"Gitea API or version error: {e}")
self.after(0, self.update_ui_for_update, "ERROR")
except Exception as e:
# Catch any other unexpected errors
app_logger.log(f"Unexpected error during update check: {e}", level="error")
self.after(0, self.update_ui_for_update, "ERROR")
def update_ui_for_update(self, new_version: Optional[str]) -> None:
"""
Updates the UI based on the result of the update check.
Args:
new_version: The new version string if an update is available,
"DISABLED" if updates are off, "ERROR" if an error occurred,
or None if no update is available.
This string also serves as the update status.
"""
self.update_status = new_version
self.animated_icon_frame.grid_remove()
self.animated_icon.hide()
self.animated_icon_frame.grid()
tooltip_msg = ""
animated_icon_frame_state = "normal" # Default to normal
if new_version == "DISABLED":
tooltip_msg = self.msg_config.TTIP["updates_disabled"]
self.animated_icon.stop()
animated_icon_frame_state = "disabled"
elif new_version == "ERROR":
tooltip_msg = self.msg_config.TTIP["no_server_conn_tt"]
self.animated_icon.stop(status="DISABLE")
animated_icon_frame_state = "disabled"
elif new_version is None:
tooltip_msg = self.msg_config.TTIP["up_to_date"]
self.animated_icon.stop()
animated_icon_frame_state = "disabled"
else: # A new version string is returned, meaning an update is available
tooltip_msg = self.msg_config.TTIP["install_new_version"].format(version=new_version)
self.animated_icon.start()
animated_icon_frame_state = "normal"
# The update_btn (toggle updates on/off) should always be active
self.update_btn.config(state="normal")
if animated_icon_frame_state == "disabled":
self.animated_icon_frame.unbind("<Button-1>")
self.animated_icon.unbind("<Button-1>")
self.animated_icon_frame.config(cursor="arrow")
else:
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
self.animated_icon_frame.config(cursor="hand2")
Tooltip(self.animated_icon_frame, tooltip_msg,
state_var=self.tooltip_state)
def updater(self) -> None:
"""Runs the external installer script for updating the application."""
tmp_dir = Path("/tmp/lxtools")
Path.mkdir(tmp_dir, exist_ok=True)
os.chdir(tmp_dir)
with message_box_animation(self.animated_icon):
result = subprocess.run(
["/usr/local/bin/lxtools_installer"], check=False, capture_output=True, text=True
)
if result.returncode != 0:
MessageDialog("error", result.stderr or result.stdout).show()
def about(self) -> None:
"""Displays the application's About dialog."""
with message_box_animation(self.animated_icon):
MessageDialog(
"info",
self.msg_config.STR["about_msg"],
buttons=["OK", self.msg_config.STR["goto_git"]],
title=self.msg_config.STR["info"],
commands=[
None,
partial(webbrowser.open, self.about_url),
],
icon=self.about_icon,
wraplength=420,
).show()

View File

@@ -1,18 +1,109 @@
import os
from typing import List, Optional
from typing import List, Optional, Dict
import tkinter as tk
from tkinter import ttk
try:
from manager import LxTools
except (ModuleNotFoundError, NameError):
from shared_libs.common_tools import LxTools, IconManager
from shared_libs.common_tools import LxTools
class MessageDialog:
"""
A customizable message dialog window using tkinter for user interaction.
This class creates modal dialogs for displaying information, warnings, errors,
or questions to the user. It supports multiple button configurations, custom
icons, keyboard navigation, and command binding. The dialog is centered on the
screen and handles user interactions with focus management and accessibility.
Attributes:
message_type (str): Type of message ("info", "error", "warning", "ask").
text (str): Main message content to display.
buttons (List[str]): List of button labels (e.g., ["OK", "Cancel"]).
result (bool or None):
- True for positive actions (Yes, OK)
- False for negative actions (No, Cancel)
- None if "Cancel" was clicked with ≥3 buttons
icons: Dictionary mapping message types to tkinter.PhotoImage objects
Parameters:
message_type: Type of message dialog (default: "info")
text: Message content to display
buttons: List of button labels (default: ["OK"])
master: Parent tkinter window (optional)
commands: List of callables for each button (default: [None])
icon: Custom icon path (overrides default icons if provided)
title: Window title (default: derived from message_type)
font: Font tuple for text styling
wraplength: Text wrapping width in pixels
Methods:
_get_title(): Returns the default window title based on message type.
_load_icons(): Loads icons from system paths or fallback locations.
_on_button_click(button_text): Sets result and closes the dialog.
show(): Displays the dialog and waits for user response.
Example Usage:
1. Basic Info Dialog:
>>> MessageDialog(
... text="This is an information message.")
>>> result = dialog.show()
>>> print("User clicked OK:", result)
Notes:
My Favorite Example,
for simply information message:
>>> MessageDialog(text="This is an information message.")
>>> result = MessageDialog(text="This is an information message.").show()
Example Usage:
2. Error Dialog with Custom Command:
>>> def on_retry():
... print("User selected Retry")
>>> dialog = MessageDialog(
... message_type="error",
... text="An error occurred during processing.",
... buttons=["Retry", "Cancel"],
... commands=[on_retry, None],
... title="Critical Error"
... )
>>> result = dialog.show()
>>> print("User selected Retry:", result)
Example Usage:
3. And a special example for a "open link" button:
Be careful not to forget to import it into the script in which
this dialog is used!!! import webbrowser from functools import partial
>>> MessageDialog(
... "info"
... text="This is an information message.",
... buttons=["Yes", "Go to Exapmle"],
... commands=[
... None, # Default on "OK"
... partial(webbrowser.open, "https://exapmle.com"),
... ],
... icon="/pathh/to/custom/icon.png",
... title="Example",
... )
Notes:
- Returns None if "Cancel" was clicked with ≥3 buttons
- Supports keyboard navigation (Left/Right arrows and Enter)
- Dialog automatically centers on screen
- Result is False for window close (X) with 2 buttons
- Font and wraplength parameters enable text styling
"""
DEFAULT_ICON_PATH = "/usr/share/icons/lx-icons"
def __init__(
self,
message_type: str = "info",
@@ -25,50 +116,39 @@ class MessageDialog:
font: tuple = None,
wraplength: int = None,
):
self.message_type = message_type or "info"
self.message_type = message_type or "info" # Default is "info"
self.text = text
self.buttons = buttons
self.master = master
self.result: bool = False
self.result: bool = False # Default is False
self.icon_path = self._get_icon_path()
self.icon = icon
self.title = title
# Window creation
self.window = tk.Toplevel(master)
self.window.grab_set()
self.window.resizable(False, False)
ttk.Style().configure("TButton")
ttk.Style().configure("TButton", font=("Helvetica", 11), padding=5)
self.buttons_widgets = []
self.current_button_index = 0
self._load_icons()
icon_manager = IconManager()
self.icons = {
"error": icon_manager.get_icon("error_extralarge"),
"info": icon_manager.get_icon("info_extralarge"),
"warning": icon_manager.get_icon("warning_large"),
"ask": icon_manager.get_icon("question_mark_extralarge"),
}
if self.icon:
if isinstance(self.icon, str) and os.path.exists(self.icon):
try:
self.icons[self.message_type] = tk.PhotoImage(file=self.icon)
except tk.TclError as e:
print(f"Error loading custom icon from path '{self.icon}': {e}")
elif isinstance(self.icon, tk.PhotoImage):
self.icons[self.message_type] = self.icon
# Window title and icon
self.window.title(self._get_title() if not self.title else self.title)
window_icon = self.icons.get(self.message_type)
if window_icon:
self.window.iconphoto(False, window_icon)
self.window.iconphoto(False, self.icons[self.message_type])
# Layout
frame = ttk.Frame(self.window)
frame.pack(expand=True, fill="both", padx=15, pady=8)
# Grid-Configuration
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
frame.grid_columnconfigure(1, weight=3)
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
# Icon and Text
icon_label = ttk.Label(frame, image=self.icons[self.message_type])
pady_value = 5 if self.icon is not None else 15
icon_label.grid(
row=0, column=0, padx=(20, 10), pady=(pady_value, 15), sticky="nsew"
@@ -83,280 +163,170 @@ class MessageDialog:
font=font if font else ("Helvetica", 12),
pady=20,
)
text_label.grid(row=0, column=1, padx=(10, 20), sticky="nsew")
text_label.grid(
row=0,
column=1,
padx=(10, 20),
sticky="nsew",
)
# Create button frame
self.button_frame = ttk.Frame(frame)
self.button_frame.grid(row=1, columnspan=2, pady=(8, 10))
for i, btn_text in enumerate(buttons):
if commands and len(commands) > i and commands[i] is not None:
btn = ttk.Button(self.button_frame, text=btn_text, command=commands[i])
# Button with individual command
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=commands[i],
)
else:
btn = ttk.Button(self.button_frame, text=btn_text, command=lambda t=btn_text: self._on_button_click(t))
# Default button set self.result and close window
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=lambda t=btn_text: self._on_button_click(t),
)
padx_value = 50 if self.icon is not None and len(buttons) == 2 else 10
btn.pack(side="left" if i == 0 else "right", padx=padx_value, pady=5)
if i == 0: btn.focus_set()
btn.focus_set() if i == 0 else None # Set focus on first button
self.buttons_widgets.append(btn)
self.window.bind("<Return>", lambda event: self._on_enter_pressed())
self.window.bind("<Left>", lambda event: self._navigate_left())
self.window.bind("<Right>", lambda event: self._navigate_right())
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.attributes("-alpha", 0.0) # 100% Transparencence
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.window.protocol("WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel"))
def _get_title(self) -> str:
return {"error": "Error", "info": "Info", "ask": "Question", "warning": "Warning"}[self.message_type]
def _navigate_left(self):
if not self.buttons_widgets: return
self.current_button_index = (self.current_button_index - 1) % len(self.buttons_widgets)
self.buttons_widgets[self.current_button_index].focus_set()
def _navigate_right(self):
if not self.buttons_widgets: return
self.current_button_index = (self.current_button_index + 1) % len(self.buttons_widgets)
self.buttons_widgets[self.current_button_index].focus_set()
def _on_enter_pressed(self):
focused = self.window.focus_get()
if isinstance(focused, ttk.Button): focused.invoke()
def _on_button_click(self, button_text: str) -> None:
if len(self.buttons) >= 3 and button_text.lower() in ["cancel", "abort", "exit"]:
self.result = None
elif button_text.lower() in ["yes", "ok", "continue", "next", "start", "select"]:
self.result = True
else:
self.result = False
self.window.destroy()
def show(self) -> Optional[bool]:
self.window.wait_window()
return self.result
class CredentialsDialog:
"""
A dialog for securely entering SSH/SFTP credentials.
"""
def __init__(self, master: Optional[tk.Tk] = None, title: str = "SFTP Connection"):
self.master = master
self.result = None
self.window = tk.Toplevel(master)
self.window.title(title)
self.window.resizable(False, False)
style = ttk.Style(self.window)
style.configure("Creds.TEntry", padding=(5, 2))
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
frame.grid_columnconfigure(1, weight=1)
# Host
ttk.Label(frame, text="Host:").grid(row=0, column=0, sticky="w", pady=2)
self.host_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.host_entry.grid(row=0, column=1, sticky="ew", pady=2)
# Port
ttk.Label(frame, text="Port:").grid(row=1, column=0, sticky="w", pady=2)
self.port_entry = ttk.Entry(frame, width=10, style="Creds.TEntry")
self.port_entry.insert(0, "22")
self.port_entry.grid(row=1, column=1, sticky="w", pady=2)
# Username
ttk.Label(frame, text="Username:").grid(row=2, column=0, sticky="w", pady=2)
self.username_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.username_entry.grid(row=2, column=1, sticky="ew", pady=2)
# Initial Path
ttk.Label(frame, text="Initial Path:").grid(row=3, column=0, sticky="w", pady=2)
self.path_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.path_entry.insert(0, "~")
self.path_entry.grid(row=3, column=1, sticky="ew", pady=2)
# Auth Method
ttk.Label(frame, text="Auth Method:").grid(row=4, column=0, sticky="w", pady=5)
auth_frame = ttk.Frame(frame)
auth_frame.grid(row=4, column=1, sticky="w", pady=2)
self.auth_method = tk.StringVar(value="password")
ttk.Radiobutton(auth_frame, text="Password", variable=self.auth_method, value="password", command=self._toggle_auth_fields).pack(side="left")
ttk.Radiobutton(auth_frame, text="Key File", variable=self.auth_method, value="keyfile", command=self._toggle_auth_fields).pack(side="left", padx=10)
# Password
self.password_label = ttk.Label(frame, text="Password:")
self.password_label.grid(row=5, column=0, sticky="w", pady=2)
self.password_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.password_entry.grid(row=5, column=1, sticky="ew", pady=2)
# Key File
self.keyfile_label = ttk.Label(frame, text="Key File:")
self.keyfile_label.grid(row=6, column=0, sticky="w", pady=2)
key_frame = ttk.Frame(frame)
key_frame.grid(row=6, column=1, sticky="ew", pady=2)
self.keyfile_entry = ttk.Entry(key_frame, width=36, style="Creds.TEntry")
self.keyfile_entry.pack(side="left", fill="x", expand=True)
self.keyfile_button = ttk.Button(key_frame, text="", width=2, command=self._show_key_menu)
self.keyfile_button.pack(side="left", padx=(2,0))
# Passphrase
self.passphrase_label = ttk.Label(frame, text="Passphrase:")
self.passphrase_label.grid(row=7, column=0, sticky="w", pady=2)
self.passphrase_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.passphrase_entry.grid(row=7, column=1, sticky="ew", pady=2)
# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=8, column=1, sticky="e", pady=(15, 0))
connect_button = ttk.Button(button_frame, text="Connect", command=self._on_connect)
connect_button.pack(side="left", padx=5)
cancel_button = ttk.Button(button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left")
self._toggle_auth_fields()
self.window.bind("<Return>", lambda event: self._on_connect())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.host_entry.focus_set()
def _get_ssh_keys(self) -> List[str]:
ssh_path = os.path.expanduser("~/.ssh")
keys = []
if os.path.isdir(ssh_path):
try:
for item in os.listdir(ssh_path):
full_path = os.path.join(ssh_path, item)
if os.path.isfile(full_path) and not item.endswith('.pub') and 'known_hosts' not in item:
keys.append(full_path)
except OSError:
pass
return keys
def _show_key_menu(self):
keys = self._get_ssh_keys()
if not keys:
return
menu = tk.Menu(self.window, tearoff=0)
for key_path in keys:
menu.add_command(label=key_path, command=lambda k=key_path: self._select_key_from_menu(k))
x = self.keyfile_button.winfo_rootx()
y = self.keyfile_button.winfo_rooty() + self.keyfile_button.winfo_height()
menu.tk_popup(x, y)
def _select_key_from_menu(self, key_path):
self.keyfile_entry.delete(0, tk.END)
self.keyfile_entry.insert(0, key_path)
def _toggle_auth_fields(self):
method = self.auth_method.get()
if method == "password":
self.password_label.grid()
self.password_entry.grid()
self.keyfile_label.grid_remove()
self.keyfile_entry.master.grid_remove()
self.passphrase_label.grid_remove()
self.passphrase_entry.grid_remove()
else:
self.password_label.grid_remove()
self.password_entry.grid_remove()
self.keyfile_label.grid()
self.keyfile_entry.master.grid()
self.passphrase_label.grid()
self.passphrase_entry.grid()
self.window.update_idletasks()
self.window.geometry("")
def _on_connect(self):
self.result = {
"host": self.host_entry.get(),
"port": int(self.port_entry.get() or 22),
"username": self.username_entry.get(),
"initial_path": self.path_entry.get() or "/",
"password": self.password_entry.get() if self.auth_method.get() == "password" else None,
"key_file": self.keyfile_entry.get() if self.auth_method.get() == "keyfile" else None,
"passphrase": self.passphrase_entry.get() if self.auth_method.get() == "keyfile" else None,
}
self.window.destroy()
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[dict]:
self.window.wait_window()
return self.result
class InputDialog:
"""
A simple dialog for getting a single line of text input from the user.
"""
def __init__(self, parent, title: str, prompt: str, initial_value: str = ""):
self.result = None
self.window = tk.Toplevel(parent)
self.window.title(title)
self.window.transient(parent)
self.window.resizable(False, False)
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
ttk.Label(frame, text=prompt, wraplength=250).pack(pady=(0, 10))
self.entry = ttk.Entry(frame, width=40)
self.entry.insert(0, initial_value)
self.entry.pack(pady=5)
self.entry.focus_set()
self.entry.selection_range(0, tk.END)
button_frame = ttk.Frame(frame)
button_frame.pack(pady=(10, 0))
ok_button = ttk.Button(button_frame, text="OK", command=self._on_ok)
ok_button.pack(side="left", padx=5)
cancel_button = ttk.Button(
button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left", padx=5)
self.window.bind("<Return>", lambda e: self._on_ok())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
self.window.update() # Window update before centering!
LxTools.center_window_cross_platform(
self.window, self.window.winfo_width(), self.window.winfo_height()
)
def _on_ok(self):
self.result = self.entry.get()
if self.result:
self.window.destroy()
# Close Window on Cancel
self.window.protocol(
"WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel")
)
def _get_title(self) -> str:
return {
"error": "Error",
"info": "Info",
"ask": "Question",
"warning": "Warning",
}[self.message_type]
def _load_icons(self):
# Try to load the icon from the provided path
self.icons = {}
icon_paths: Dict[str, str] = {
"error": os.path.join(self.icon_path, "64/error.png"),
"info": os.path.join(self.icon_path, "64/info.png"),
"warning": os.path.join(self.icon_path, "64/warning.png"),
"ask": os.path.join(self.icon_path, "64/question_mark.png"),
}
fallback_paths: Dict[str, str] = {
"error": "./lx-icons/64/error.png",
"info": "./lx-icons/64/info.png",
"warning": "./lx-icons/64/warning.png",
"ask": "./lx-icons/64/question_mark.png",
}
for key in icon_paths:
try:
# Check if an individual icon is provided
if (
self.message_type == key
and self.icon is not None
and os.path.exists(self.icon)
):
try:
self.icons[key] = tk.PhotoImage(file=self.icon)
except Exception as e:
print(
f"Erro on loading individual icon '{key}': {e}\n",
"Try to use the default icon",
)
else:
# Check for standard path
if os.path.exists(icon_paths[key]):
self.icons[key] = tk.PhotoImage(file=icon_paths[key])
else:
if os.path.exists(fallback_paths[key]):
self.icons[key] = tk.PhotoImage(file=fallback_paths[key])
except Exception as e:
print(f"Error on load Icon '{[key]}': {e}")
self.icons[key] = tk.PhotoImage()
print(f"⚠️ No Icon found for '{key}'. Use standard Tkinter icon.")
return self.icons
def _get_icon_path(self) -> str:
"""Get the path to the default icon."""
if os.path.exists(self.DEFAULT_ICON_PATH):
return self.DEFAULT_ICON_PATH
else:
# Fallback to the directory of the script
return os.path.dirname(os.path.abspath(__file__))
def _navigate_left(self):
if not self.buttons_widgets:
return
self.current_button_index = (self.current_button_index - 1) % len(
self.buttons_widgets
)
self.buttons_widgets[self.current_button_index].focus_set()
def _navigate_right(self):
if not self.buttons_widgets:
return
self.current_button_index = (self.current_button_index + 1) % len(
self.buttons_widgets
)
self.buttons_widgets[self.current_button_index].focus_set()
def _on_enter_pressed(self):
focused = self.window.focus_get()
if isinstance(focused, ttk.Button):
focused.invoke()
def _on_button_click(self, button_text: str) -> None:
"""
Sets `self.result` based on the clicked button.
- Returns `None` if the button is "Cancel", "Abort", or "Exit" **and** there are 3 or more buttons.
- Returns `True` if the button is "Yes", "Ok", "Continue", "Next", or "Start".
- Returns `False` in all other cases (e.g., "No", closing with X, or fewer than 3 buttons).
"""
# Check: If there are 3+ buttons and the button text matches "Cancel", "Abort", or "Exit"
if len(self.buttons) >= 3 and button_text.lower() in [
"cancel",
"abort",
"exit",
]:
self.result = None
# Check: Button text is "Yes", "Ok", "Continue", "Next", or "Start"
elif button_text.lower() in ["yes", "ok", "continue", "next", "start"]:
self.result = True
else:
# Fallback for all other cases (e.g., "No", closing with X, or fewer than 3 buttons)
self.result = False
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[str]:
def show(self) -> Optional[bool]:
"""
Displays the dialog window and waits for user interaction.
Returns:
bool or None:
- `True` if "Yes", "Ok", etc. was clicked.
- `False` if "No" was clicked, or the window was closed with X (when there are 2 buttons).
- `None` if "Cancel", "Abort", or "Exit" was clicked **and** there are 3+ buttons,
or the window was closed with X (when there are 3+ buttons).
"""
self.window.wait_window()
return self.result