Compare commits
143 Commits
5391549ff3
...
14-08-2025
Author | SHA1 | Date | |
---|---|---|---|
cc48f874ac | |||
27f74e6a77 | |||
ba38ea4b87 | |||
ff1aede356 | |||
f565132074 | |||
d548b545e3 | |||
66202310ec | |||
d79e4c9e01 | |||
0ef94de077 | |||
fbc3c8e051 | |||
1d05f5088f | |||
2c2163b936 | |||
16937faf91 | |||
24e8ed1dff | |||
c1fe8b62e1 | |||
c5626073c9 | |||
0d3eff772e | |||
864ad63bf8 | |||
eb893d197a | |||
b44c7b96d3 | |||
13f5f1f4fd | |||
fb794538d8 | |||
3cf91ba58f | |||
ab14b4ffa3 | |||
6fe090e9e5 | |||
8b4068fdc7 | |||
ba6ef7385a | |||
dc51bf6f2c | |||
80aebe3bab | |||
941ac4334b | |||
d124c24533 | |||
30c2c3b901 | |||
5a41d6b1fd | |||
4e7ef8d348 | |||
14a50616a3 | |||
246addc34b | |||
b18bf7fe85 | |||
b8d46fb547 | |||
d392e1e608 | |||
f06d1b6652 | |||
f47f18d48c | |||
497978ef95 | |||
7956d4e393 | |||
cb6c513622 | |||
2f504658a3 | |||
cef383ca74 | |||
287ebfd1d0 | |||
11bcf5cc7a | |||
9e495cc73c | |||
482eaae591 | |||
43ac132ec8 | |||
de1cc4a03d | |||
f6d86d7e42 | |||
fda52d52d4 | |||
2e934c62e4 | |||
3d2ffcc69e | |||
053b0c22c5 | |||
9c2b72345c | |||
e5d10dded6 | |||
b1bd928a76 | |||
e059efc1cf | |||
aae8f4431a | |||
1d6137ed44 | |||
b71e1cc79c | |||
bbfdc3efac | |||
84c5405df7 | |||
1168ea8ecf | |||
a8a55574f5 | |||
9252b0d23f | |||
2e6eb57b55 | |||
b170b4094e | |||
29480a0096 | |||
f21d09c6a6 | |||
c8db431c06 | |||
160d8acafb | |||
f2b6c330fa | |||
3005d17f03 | |||
17fe3455b8 | |||
d20a941c8c | |||
0e280e30e2 | |||
6f9a7c922c | |||
6f8b0b290c | |||
0d7ab8d73d | |||
2c28c94961 | |||
30c200918d | |||
2880e0d7a1 | |||
94c32ddd9e | |||
e211072cc2 | |||
1ca1264101 | |||
b1394e0f62 | |||
369605be8a | |||
77ae398761 | |||
2b09721fec | |||
07751e5c9a | |||
b350e562fa | |||
af7dcc31e4 | |||
e3bb68f7e2 | |||
2404a60b6c | |||
13b54fd5c6 | |||
8536e2c463 | |||
78b93f66be | |||
f1f85d36c9 | |||
c010bd53cb | |||
8a4d3d70c9 | |||
4ca52c2dc9 | |||
e535a42d3e | |||
0b7a85424a | |||
98b16e664b | |||
9314548928 | |||
592b68eb88 | |||
80940f6119 | |||
d97fade936 | |||
7cc2658433 | |||
3ddbf026da | |||
9fd0410e5a | |||
4757df1710 | |||
0d82a91e69 | |||
dfa9b033e8 | |||
37117fc943 | |||
49a097c525 | |||
dbae4dbb88 | |||
1b7a6b3411 | |||
7ab63d2a63 | |||
11f8d0e2fd | |||
872513f348 | |||
cad67a0c35 | |||
6e83f6499c | |||
a0da1f501c | |||
d7c4c0088b | |||
1dd22ef0f8 | |||
22f7649d01 | |||
3250410f54 | |||
f9421fc602 | |||
06774c0653 | |||
ff970973e2 | |||
6faf65ad08 | |||
0d694adc2d | |||
ec76940dca | |||
6242dd7b0d | |||
703d2dfc4a | |||
52f782b4e8 | |||
7351100e55 | |||
c1580f6ace |
69
Changelog
69
Changelog
@@ -2,9 +2,74 @@ Changelog for shared_libs
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- add Info Window for user in delete logfile
|
||||
bevore delete logfile.
|
||||
-
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
15-06-2025
|
||||
|
||||
- Update MessageDialog Class description
|
||||
- import LxTools with try exception.
|
||||
|
||||
### Added
|
||||
14-06-2025
|
||||
|
||||
|
12
README.md
12
README.md
@@ -1,3 +1,13 @@
|
||||
# shared_libs
|
||||
|
||||
Module Project for apps by git.ilunix.de
|
||||
Module Project for apps by git.ilunix.de
|
||||
Examples with a Theme from Projekt Wire-Py
|
||||
|
||||
# Screenshots
|
||||
[](https://fb.ilunix.de/share/KtaTPMMq)
|
||||
[](https://fb.ilunix.de/share/cRO_ksrM)
|
||||
[](https://fb.ilunix.de/share/1JEdSJcI)
|
||||
[](https://fb.ilunix.de/share/1XxNey7y7)
|
||||
[](https://fb.ilunix.de/share/4HCxiNwB)
|
||||
[](https://fb.ilunix.de/share/uui8b1xx)
|
||||
[](https://fb.ilunix.de/share/54OM6wUC)
|
||||
|
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
|
||||
|
||||
|
541
animated_icon.py
Normal file
541
animated_icon.py
Normal file
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
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()
|
537
common_tools.py
537
common_tools.py
@@ -1,16 +1,21 @@
|
||||
""" 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:
|
||||
@@ -34,14 +39,14 @@ class CryptoUtil:
|
||||
|
||||
# Output from Openssl Error
|
||||
if process.stderr:
|
||||
logging.error(process.stderr, exc_info=True)
|
||||
app_logger.log(process.stderr)
|
||||
|
||||
if process.returncode == 0:
|
||||
logging.info("Files successfully decrypted...", exc_info=True)
|
||||
app_logger.log("Files successfully decrypted...")
|
||||
else:
|
||||
|
||||
logging.error(
|
||||
f"Error process decrypt: Code {process.returncode}", exc_info=True
|
||||
app_logger.log(
|
||||
f"Error process decrypt: Code {process.returncode}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -58,13 +63,13 @@ class CryptoUtil:
|
||||
|
||||
# Output from Openssl Error
|
||||
if process.stderr:
|
||||
logging.error(process.stderr, exc_info=True)
|
||||
app_logger.log(process.stderr)
|
||||
|
||||
if process.returncode == 0:
|
||||
logging.info("Files successfully encrypted...", exc_info=True)
|
||||
app_logger.log("Files successfully encrypted...")
|
||||
else:
|
||||
logging.error(
|
||||
f"Error process encrypt: Code {process.returncode}", exc_info=True
|
||||
app_logger.log(
|
||||
f"Error process encrypt: Code {process.returncode}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -83,9 +88,8 @@ class CryptoUtil:
|
||||
return True
|
||||
elif "False" in process.stdout:
|
||||
return False
|
||||
logging.error(
|
||||
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}",
|
||||
exc_info=True,
|
||||
app_logger.log(
|
||||
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -110,7 +114,7 @@ class CryptoUtil:
|
||||
if len(decoded) != 32: # 32 bytes = 256 bits
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"Error on decode Base64: {e}", exc_info=True)
|
||||
app_logger.log(f"Error on decode Base64: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -273,17 +277,16 @@ class LxTools:
|
||||
# End program for certain signals, report to others only reception
|
||||
if signum in (signal.SIGINT, signal.SIGTERM):
|
||||
exit_code: int = 1
|
||||
logging.error(
|
||||
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}.",
|
||||
exc_info=True,
|
||||
app_logger.log(
|
||||
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}."
|
||||
)
|
||||
LxTools.clean_files(file_path, file)
|
||||
logging.info("Breakdown by user...")
|
||||
app_logger.log("Breakdown by user...")
|
||||
sys.exit(exit_code)
|
||||
else:
|
||||
logging.info(f"Signal {signum} received and ignored.")
|
||||
app_logger.log(f"Signal {signum} received and ignored.")
|
||||
LxTools.clean_files(file_path, file)
|
||||
logging.error("Process unexpectedly ended...")
|
||||
app_logger.log("Process unexpectedly ended...")
|
||||
|
||||
# Register signal handlers for various signals
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
@@ -324,14 +327,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
|
||||
@@ -340,7 +343,6 @@ class ConfigManager:
|
||||
"theme": "light",
|
||||
"tooltips": "True", # Default Value as string!
|
||||
"autostart": "off",
|
||||
"logfile": LOG_FILE_PATH,
|
||||
}
|
||||
return cls._config
|
||||
|
||||
@@ -357,8 +359,6 @@ 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,110 +396,463 @@ class ThemeManager:
|
||||
|
||||
@staticmethod
|
||||
def change_theme(root, theme_in_use, theme_name=None):
|
||||
"""Change application theme centrally"""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
root.tk.call("set_theme", theme_in_use)
|
||||
if theme_in_use == theme_name:
|
||||
ConfigManager.set("theme", theme_in_use)
|
||||
|
||||
|
||||
class Tooltip:
|
||||
"""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)
|
||||
"""
|
||||
A flexible tooltip class for Tkinter widgets that supports dynamic activation/deactivation.
|
||||
|
||||
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()
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
def __init__(self, widget, text, wraplength=250, state_var=None):
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.wraplength = wraplength
|
||||
self.state_var = state_var
|
||||
self.x_offset = x_offset
|
||||
self.y_offset = y_offset
|
||||
|
||||
# Initial binding based on the current state
|
||||
self.tooltip_window = None
|
||||
self.id = None
|
||||
self.update_bindings()
|
||||
|
||||
# Add trace to the state_var if provided
|
||||
if self.state_var is not None:
|
||||
if self.state_var:
|
||||
self.state_var.trace_add("write", self.update_bindings)
|
||||
|
||||
def update_bindings(self, *args) -> None:
|
||||
"""Updates the bindings based on the current state"""
|
||||
# Remove existing bindings first
|
||||
# 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.
|
||||
"""
|
||||
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.show_tooltip)
|
||||
self.widget.bind("<Leave>", self.hide_tooltip)
|
||||
self.widget.bind("<Enter>", self.enter)
|
||||
self.widget.bind("<Leave>", self.leave)
|
||||
self.widget.bind("<ButtonPress>", self.leave)
|
||||
|
||||
def show_tooltip(self, event: Optional[Any] = None) -> None:
|
||||
"""Shows the tooltip"""
|
||||
if self.tooltip_window or not self.text:
|
||||
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:
|
||||
return
|
||||
|
||||
x: int
|
||||
y: int
|
||||
cx: int
|
||||
cy: int
|
||||
if self.state_var is None or self.state_var.get():
|
||||
self.schedule()
|
||||
|
||||
x, y, cx, cy = self.widget.bbox("insert")
|
||||
x += self.widget.winfo_rootx() + self.x_offset
|
||||
y += self.widget.winfo_rooty() + self.y_offset
|
||||
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
|
||||
|
||||
self.tooltip_window = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(True)
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
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)
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
|
5
custom_file_dialog/GEMINI.md
Normal file
5
custom_file_dialog/GEMINI.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Gemini Project Configuration
|
||||
|
||||
## Language
|
||||
Please respond in German.
|
||||
|
1
custom_file_dialog/__init__.py
Normal file
1
custom_file_dialog/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .custom_file_dialog import CustomFileDialog
|
303
custom_file_dialog/cfd_app_config.py
Executable file
303
custom_file_dialog/cfd_app_config.py
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/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"),
|
||||
}
|
409
custom_file_dialog/cfd_file_operations.py
Normal file
409
custom_file_dialog/cfd_file_operations.py
Normal file
@@ -0,0 +1,409 @@
|
||||
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()
|
131
custom_file_dialog/cfd_navigation_manager.py
Normal file
131
custom_file_dialog/cfd_navigation_manager.py
Normal file
@@ -0,0 +1,131 @@
|
||||
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)
|
346
custom_file_dialog/cfd_search_manager.py
Normal file
346
custom_file_dialog/cfd_search_manager.py
Normal file
@@ -0,0 +1,346 @@
|
||||
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))
|
218
custom_file_dialog/cfd_settings_dialog.py
Normal file
218
custom_file_dialog/cfd_settings_dialog.py
Normal file
@@ -0,0 +1,218 @@
|
||||
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))
|
151
custom_file_dialog/cfd_sftp_manager.py
Normal file
151
custom_file_dialog/cfd_sftp_manager.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# 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
|
562
custom_file_dialog/cfd_ui_setup.py
Normal file
562
custom_file_dialog/cfd_ui_setup.py
Normal file
@@ -0,0 +1,562 @@
|
||||
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()
|
711
custom_file_dialog/cfd_view_manager.py
Normal file
711
custom_file_dialog/cfd_view_manager.py
Normal file
@@ -0,0 +1,711 @@
|
||||
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>")
|
718
custom_file_dialog/custom_file_dialog.py
Normal file
718
custom_file_dialog/custom_file_dialog.py
Normal file
@@ -0,0 +1,718 @@
|
||||
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
|
@@ -1,22 +0,0 @@
|
||||
#!/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()
|
197
gitea.py
197
gitea.py
@@ -1,132 +1,131 @@
|
||||
#!/usr/bin/python3
|
||||
import gettext
|
||||
import locale
|
||||
"""
|
||||
A streamlined module to check for updates from a Gitea repository API.
|
||||
"""
|
||||
import re
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shutil
|
||||
from shared_libs.message import MessageDialog
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class GiteaUpdate:
|
||||
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:
|
||||
"""
|
||||
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
|
||||
Provides a clean interface to check for software updates via a Gitea API.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def api_down(update_api_url: str, version: str, update_setting: str = None) -> str:
|
||||
def _parse_version(version_string: str) -> Optional[Tuple[int, ...]]:
|
||||
"""
|
||||
Checks for updates via API
|
||||
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").
|
||||
|
||||
Args:
|
||||
update_api_url: Update API URL
|
||||
version: Current version
|
||||
update_setting: Update setting from ConfigManager (on/off)
|
||||
version_string: The version string (e.g., "v. 1.08.1325", "v2.1.0").
|
||||
|
||||
Returns:
|
||||
New version or status message
|
||||
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.
|
||||
"""
|
||||
# If updates are disabled, return immediately
|
||||
if update_setting != "on":
|
||||
return "False"
|
||||
|
||||
try:
|
||||
response: requests.Response = requests.get(update_api_url, timeout=10)
|
||||
response.raise_for_status() # Raise exception for HTTP errors
|
||||
# Remove common prefixes like 'v', 'v. ', etc.
|
||||
cleaned_string = re.sub(r'^[vV\.\s]+', '', version_string)
|
||||
parts = cleaned_string.split('.')
|
||||
|
||||
response_data = response.json()
|
||||
if not response_data:
|
||||
return "No Updates"
|
||||
# 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])
|
||||
|
||||
latest_version = response_data[0].get("tag_name")
|
||||
if not latest_version:
|
||||
return "Invalid API Response"
|
||||
day = day_year_int // 100
|
||||
year = day_year_int % 100 + 2000
|
||||
|
||||
# Compare versions (strip 'v. ' prefix if present)
|
||||
current_version = version[3:] if version.startswith("v. ") else version
|
||||
# Basic validation for date components
|
||||
if 1 <= month <= 12 and 1 <= day <= 31:
|
||||
return (major, year, month, day)
|
||||
|
||||
if current_version != latest_version:
|
||||
return latest_version
|
||||
else:
|
||||
return "No Updates"
|
||||
# Fallback to standard version parsing for other formats (e.g., 2.1.0)
|
||||
return tuple(map(int, parts))
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return "No Internet Connection!"
|
||||
except (ValueError, KeyError, IndexError):
|
||||
return "Invalid API Response"
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def download(urld: str, res: str) -> None:
|
||||
def check_for_update(api_url: str, current_version: str) -> Optional[str]:
|
||||
"""
|
||||
Downloads new version of application
|
||||
Checks for a newer version of the application on Gitea.
|
||||
|
||||
:param urld: Download URL
|
||||
:param res: Result filename
|
||||
"""
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
MessageDialog("info", text=Msg.STR["ok_message"])
|
||||
|
||||
else:
|
||||
|
||||
MessageDialog(
|
||||
"error", text=Msg.STR["error_message"], title=Msg.STR["error_title"]
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
|
||||
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
|
||||
Args:
|
||||
api_url: The Gitea API URL for releases.
|
||||
current_version: The current version string of the application.
|
||||
|
||||
Returns:
|
||||
The gettext translation function
|
||||
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.
|
||||
"""
|
||||
locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
|
||||
gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR)
|
||||
gettext.textdomain(AppConfig.APP_NAME)
|
||||
return gettext.gettext
|
||||
if not api_url:
|
||||
raise GiteaApiUrlError("Gitea API URL is not provided.")
|
||||
|
||||
# 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",
|
||||
}
|
||||
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
|
||||
|
||||
# here is initializing the class for translation strings
|
||||
_ = AppConfig.setup_translations()
|
||||
try:
|
||||
data = response.json()
|
||||
if not data:
|
||||
return None # No releases found is not an error
|
||||
|
||||
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.")
|
||||
|
||||
class Msg:
|
||||
except (ValueError, IndexError, KeyError) as e:
|
||||
raise GiteaApiResponseError(
|
||||
f"Could not process the response from Gitea: {e}") from e
|
||||
|
||||
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!"),
|
||||
}
|
||||
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}")
|
||||
|
||||
if remote_version_tuple > local_version_tuple:
|
||||
return latest_tag_name
|
||||
|
||||
return None
|
||||
|
101
log_window.py
Normal file
101
log_window.py
Normal file
@@ -0,0 +1,101 @@
|
||||
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()
|
33
logger.py
Normal file
33
logger.py
Normal file
@@ -0,0 +1,33 @@
|
||||
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()
|
@@ -1,146 +0,0 @@
|
||||
"""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"),
|
||||
}
|
518
logviewer.py
518
logviewer.py
@@ -1,518 +0,0 @@
|
||||
#!/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
|
||||
import webbrowser
|
||||
|
||||
|
||||
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()
|
301
menu_bar.py
Normal file
301
menu_bar.py
Normal file
@@ -0,0 +1,301 @@
|
||||
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()
|
564
message.py
564
message.py
@@ -1,160 +1,18 @@
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
from typing import List, Optional
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from shared_libs.common_tools import LxTools
|
||||
|
||||
"""
|
||||
####################################################
|
||||
Attention! MessageDialog returns different values.
|
||||
From 3 buttons with Cancel, Cancel and the Close (x)
|
||||
None returns. otherwise always False.
|
||||
####################################################
|
||||
Usage Examples
|
||||
1. Basic Info Dialog
|
||||
from tkinter import Tk
|
||||
|
||||
root = Tk()
|
||||
dialog = MessageDialog(
|
||||
message_type="info",
|
||||
text="This is an information message.",
|
||||
buttons=["OK"],
|
||||
master=root,
|
||||
)
|
||||
result = dialog.show()
|
||||
print("User clicked OK:", result)
|
||||
|
||||
-----------------------------------------------------
|
||||
My Favorite Example,
|
||||
for simply information message:
|
||||
|
||||
MessageDialog(text="This is an information message.")
|
||||
result = MessageDialog(text="This is an information message.").show()
|
||||
-----------------------------------------------------
|
||||
Explanation: if you need the return value e.g. in the vaiable result,
|
||||
you need to add .show(). otherwise only if no root.mainloop z.b is used to test the window.
|
||||
#####################################################
|
||||
|
||||
2. Error Dialog with Custom Icon and Command
|
||||
def on_cancel():
|
||||
print("User canceled the operation.")
|
||||
|
||||
root = Tk()
|
||||
result = MessageDialog(
|
||||
message_type="error",
|
||||
text="An error occurred during processing.",
|
||||
buttons=["Retry", "Cancel"],
|
||||
commands=[None, on_cancel],
|
||||
icon="/path/to/custom/error_icon.png",
|
||||
title="Critical Error"
|
||||
).show()
|
||||
|
||||
print("User clicked Retry:", result)
|
||||
|
||||
-----------------------------------------------------
|
||||
My Favorite Example,
|
||||
for simply Error message:
|
||||
|
||||
MessageDialog(
|
||||
"error",
|
||||
text="An error occurred during processing.",
|
||||
).show()
|
||||
|
||||
#####################################################
|
||||
|
||||
3. Confirmation Dialog with Yes/No Buttons
|
||||
def on_confirm():
|
||||
print("User confirmed the action.")
|
||||
|
||||
root = Tk()
|
||||
dialog = MessageDialog(
|
||||
message_type="ask",
|
||||
text="Are you sure you want to proceed?",
|
||||
buttons=["Yes", "No"],
|
||||
commands=[on_confirm, None], # Either use comando or work with the values True and False
|
||||
)
|
||||
result = dialog.show()
|
||||
print("User confirmed:", result)
|
||||
-----------------------------------------------------
|
||||
|
||||
My Favorite Example,
|
||||
for simply Question message:
|
||||
|
||||
dialog = MessageDialog(
|
||||
"ask",
|
||||
text="Are you sure you want to proceed?",
|
||||
buttons=["Yes", "No"]
|
||||
).show()
|
||||
#####################################################
|
||||
|
||||
4. Warning Dialog with Custom Title
|
||||
|
||||
root = Tk()
|
||||
dialog = MessageDialog(
|
||||
message_type="warning",
|
||||
text="This action cannot be undone.",
|
||||
buttons=["Proceed", "Cancel"],
|
||||
title="Warning: Irreversible Action"
|
||||
)
|
||||
result = dialog.show()
|
||||
print("User proceeded:", result)
|
||||
-----------------------------------------------------
|
||||
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
|
||||
|
||||
dialog = MessageDialog(
|
||||
"ask",
|
||||
text="Are you sure you want to proceed?",
|
||||
buttons=["Yes", "Go to Exapmle"],
|
||||
commands=[
|
||||
None, # Default on "OK"
|
||||
partial(webbrowser.open, "https://exapmle.com"),
|
||||
],
|
||||
icon="/pathh/to/custom/icon.png",
|
||||
title="Example",
|
||||
).show()
|
||||
|
||||
|
||||
In all dialogues, a font can also be specified as a tuple. With font=("ubuntu", 11)
|
||||
and wraplength=300, the text is automatically wrapped.
|
||||
"""
|
||||
try:
|
||||
from manager import LxTools
|
||||
except (ModuleNotFoundError, NameError):
|
||||
from shared_libs.common_tools import LxTools, IconManager
|
||||
|
||||
|
||||
class MessageDialog:
|
||||
"""
|
||||
A customizable message dialog window using tkinter.
|
||||
|
||||
This class creates modal dialogs for displaying information, warnings, errors,
|
||||
or questions to the user. It supports multiple button configurations and custom
|
||||
icons. The dialog is centered on the screen and handles user interactions.
|
||||
|
||||
Attributes:
|
||||
message_type (str): Type of message ("info", "error", "warning", "ask").
|
||||
text (str): Main message content.
|
||||
buttons (List[str]): List of button labels (e.g., ["OK", "Cancel"]).
|
||||
result (bool): True if the user clicked a positive button (like "Yes" or "OK"), else False.
|
||||
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).
|
||||
|
||||
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 (returns self.result).
|
||||
A customizable message dialog window using tkinter for user interaction.
|
||||
"""
|
||||
|
||||
DEFAULT_ICON_PATH = "/usr/share/icons/lx-icons"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_type: str = "info",
|
||||
@@ -167,39 +25,50 @@ class MessageDialog:
|
||||
font: tuple = None,
|
||||
wraplength: int = None,
|
||||
):
|
||||
self.message_type = message_type or "info" # Default is "info"
|
||||
self.message_type = message_type or "info"
|
||||
self.text = text
|
||||
self.buttons = buttons
|
||||
self.master = master
|
||||
self.result: bool = False # Default is False
|
||||
self.result: bool = 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", font=("Helvetica", 11), padding=5)
|
||||
ttk.Style().configure("TButton")
|
||||
self.buttons_widgets = []
|
||||
self.current_button_index = 0
|
||||
self._load_icons()
|
||||
|
||||
# Window title and icon
|
||||
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
|
||||
|
||||
self.window.title(self._get_title() if not self.title else self.title)
|
||||
self.window.iconphoto(False, self.icons[self.message_type])
|
||||
window_icon = self.icons.get(self.message_type)
|
||||
if window_icon:
|
||||
self.window.iconphoto(False, window_icon)
|
||||
|
||||
# Layout
|
||||
frame = ttk.Frame(self.window)
|
||||
frame.pack(expand=True, fill="both")
|
||||
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 and Text
|
||||
icon_label = ttk.Label(frame, image=self.icons[self.message_type])
|
||||
icon_label = ttk.Label(frame, image=self.icons.get(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"
|
||||
@@ -214,171 +83,280 @@ class MessageDialog:
|
||||
font=font if font else ("Helvetica", 12),
|
||||
pady=20,
|
||||
)
|
||||
text_label.grid(
|
||||
row=0,
|
||||
column=1,
|
||||
padx=(10, 20),
|
||||
pady=(8, 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=(15, 10))
|
||||
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:
|
||||
# Button with individual command
|
||||
btn = ttk.Button(
|
||||
self.button_frame,
|
||||
text=btn_text,
|
||||
command=commands[i],
|
||||
)
|
||||
btn = ttk.Button(self.button_frame, text=btn_text, command=commands[i])
|
||||
else:
|
||||
# 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),
|
||||
)
|
||||
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=15)
|
||||
btn.focus_set() if i == 0 else None # Set focus on first button
|
||||
btn.pack(side="left" if i == 0 else "right", padx=padx_value, pady=5)
|
||||
if i == 0: btn.focus_set()
|
||||
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.attributes("-alpha", 0.0) # 100% Transparencence
|
||||
self.window.grab_set()
|
||||
self.window.attributes("-alpha", 0.0)
|
||||
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
|
||||
self.window.update() # Window update before centering!
|
||||
LxTools.center_window_cross_platform(
|
||||
self.window, self.window.winfo_width(), self.window.winfo_height()
|
||||
)
|
||||
|
||||
# Close Window on Cancel
|
||||
self.window.protocol(
|
||||
"WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel")
|
||||
)
|
||||
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 _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:
|
||||
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__))
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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()
|
||||
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",
|
||||
]:
|
||||
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"]:
|
||||
elif button_text.lower() in ["yes", "ok", "continue", "next", "start", "select"]:
|
||||
self.result = True
|
||||
else:
|
||||
# Fallback for all other cases (e.g., "No", closing with X, or fewer than 3 buttons)
|
||||
self.result = False
|
||||
|
||||
self.window.destroy()
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
def _on_cancel(self):
|
||||
self.result = None
|
||||
self.window.destroy()
|
||||
|
||||
def show(self) -> Optional[str]:
|
||||
self.window.wait_window()
|
||||
return self.result
|
||||
|
Reference in New Issue
Block a user