commit 4
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
__pycache__/config_manager.cpython-312.pyc
Normal file
BIN
__pycache__/config_manager.cpython-312.pyc
Normal file
Binary file not shown.
541
animated_icon.py
541
animated_icon.py
@@ -1,541 +0,0 @@
|
||||
"""
|
||||
A Tkinter widget for displaying animated icons.
|
||||
|
||||
This module provides the AnimatedIcon class, a custom Tkinter Canvas widget
|
||||
that can display various types of animations. It supports both native Tkinter
|
||||
drawing and Pillow (PIL) for anti-aliased graphics if available.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from math import sin, cos, pi
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageTk
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
|
||||
"""Converts a hex color string to an RGB tuple."""
|
||||
hex_color = hex_color.lstrip('#')
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
class AnimatedIcon(tk.Canvas):
|
||||
"""A custom Tkinter Canvas widget for displaying animations."""
|
||||
|
||||
def __init__(self, master: tk.Misc, width: int = 20, height: int = 20, animation_type: str = "counter_arc", color: str = "#2a6fde", highlight_color: str = "#5195ff", use_pillow: bool = False, bg: Optional[str] = None) -> None:
|
||||
"""
|
||||
Initializes the AnimatedIcon widget.
|
||||
|
||||
Args:
|
||||
master: The parent widget.
|
||||
width (int): The width of the icon.
|
||||
height (int): The height of the icon.
|
||||
animation_type (str): The type of animation to display.
|
||||
Options: "counter_arc", "double_arc", "line", "blink".
|
||||
color (str): The primary color of the icon.
|
||||
highlight_color (str): The highlight color of the icon.
|
||||
use_pillow (bool): Whether to use Pillow for drawing if available.
|
||||
bg (str): The background color of the canvas.
|
||||
"""
|
||||
if bg is None:
|
||||
try:
|
||||
bg = master.cget("background")
|
||||
except tk.TclError:
|
||||
bg = "#f0f0f0" # Fallback color
|
||||
super().__init__(master, width=width, height=height, bg=bg, highlightthickness=0)
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.animation_type = animation_type
|
||||
self.color = color
|
||||
self.highlight_color = highlight_color
|
||||
self.use_pillow = use_pillow and PIL_AVAILABLE
|
||||
self.running = False
|
||||
self.is_disabled = False
|
||||
self.pause_count = 0
|
||||
self.angle = 0
|
||||
self.pulse_animation = False
|
||||
|
||||
self.color_rgb = _hex_to_rgb(self.color)
|
||||
self.highlight_color_rgb = _hex_to_rgb(self.highlight_color)
|
||||
|
||||
if self.use_pillow:
|
||||
self.image = Image.new(
|
||||
"RGBA", (width * 4, height * 4), (0, 0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.photo_image = None
|
||||
|
||||
def _draw_frame(self) -> None:
|
||||
"""Draws a single frame of the animation."""
|
||||
if self.use_pillow:
|
||||
self._draw_pillow_frame()
|
||||
else:
|
||||
self._draw_canvas_frame()
|
||||
|
||||
def _draw_canvas_frame(self) -> None:
|
||||
"""Draws a frame using native Tkinter canvas methods."""
|
||||
self.delete("all")
|
||||
if self.pulse_animation:
|
||||
self._draw_canvas_pulse()
|
||||
elif self.animation_type == "line":
|
||||
self._draw_canvas_line()
|
||||
elif self.animation_type == "double_arc":
|
||||
self._draw_canvas_double_arc()
|
||||
elif self.animation_type == "counter_arc":
|
||||
self._draw_canvas_counter_arc()
|
||||
elif self.animation_type == "blink":
|
||||
self._draw_canvas_blink()
|
||||
|
||||
def _draw_canvas_pulse(self) -> None:
|
||||
"""Draws the pulse animation using canvas methods."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
pulse_color = f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
if self.animation_type == "line":
|
||||
for i in range(8):
|
||||
angle = i * (pi / 4)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.2)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.2)
|
||||
end_x = center_x + cos(angle) * (self.width * 0.4)
|
||||
end_y = center_y + sin(angle) * (self.height * 0.4)
|
||||
self.create_line(start_x, start_y, end_x,
|
||||
end_y, fill=pulse_color, width=2)
|
||||
elif self.animation_type == "double_arc":
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
self.create_arc(bbox, start=0, extent=359.9,
|
||||
style=tk.ARC, outline=pulse_color, width=2)
|
||||
elif self.animation_type == "counter_arc":
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
self.create_arc(bbox_outer, start=0, extent=359.9,
|
||||
style=tk.ARC, outline=pulse_color, width=2)
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
self.create_arc(bbox_inner, start=0, extent=359.9,
|
||||
style=tk.ARC, outline=self.color, width=2)
|
||||
|
||||
def _draw_canvas_line(self) -> None:
|
||||
"""Draws the line animation using canvas methods."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
for i in range(8):
|
||||
angle = self.angle + i * (pi / 4)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.2)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.2)
|
||||
end_x = center_x + cos(angle) * (self.width * 0.4)
|
||||
end_y = center_y + sin(angle) * (self.height * 0.4)
|
||||
alpha = (cos(self.angle * 2 + i * (pi / 4)) + 1) / 2
|
||||
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
color = f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
self.create_line(start_x, start_y, end_x,
|
||||
end_y, fill=color, width=2)
|
||||
|
||||
def _draw_canvas_double_arc(self) -> None:
|
||||
"""Draws the double arc animation using canvas methods."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
|
||||
start_angle1 = -self.angle * 180 / pi
|
||||
extent1 = 120 + 60 * sin(-self.angle)
|
||||
self.create_arc(bbox, start=start_angle1, extent=extent1,
|
||||
style=tk.ARC, outline=self.highlight_color, width=2)
|
||||
|
||||
start_angle2 = (-self.angle + pi) * 180 / pi
|
||||
extent2 = 120 + 60 * sin(-self.angle + pi / 2)
|
||||
self.create_arc(bbox, start=start_angle2, extent=extent2,
|
||||
style=tk.ARC, outline=self.color, width=2)
|
||||
|
||||
def _draw_canvas_counter_arc(self) -> None:
|
||||
"""Draws the counter arc animation using canvas methods."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
start_angle1 = -self.angle * 180 / pi
|
||||
self.create_arc(bbox_outer, start=start_angle1, extent=150,
|
||||
style=tk.ARC, outline=self.highlight_color, width=2)
|
||||
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
start_angle2 = self.angle * 180 / pi + 60
|
||||
self.create_arc(bbox_inner, start=start_angle2, extent=150,
|
||||
style=tk.ARC, outline=self.color, width=2)
|
||||
|
||||
def _draw_canvas_blink(self) -> None:
|
||||
"""Draws the blink animation using canvas methods."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
blink_color = f"#{r:02x}{g:02x}{b:02x}"
|
||||
self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y +
|
||||
radius, start=0, extent=359.9, style=tk.ARC, outline=blink_color, width=4)
|
||||
|
||||
def _draw_pillow_frame(self) -> None:
|
||||
"""Draws a frame using Pillow for anti-aliased graphics."""
|
||||
self.draw.rectangle(
|
||||
[0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0))
|
||||
if self.pulse_animation:
|
||||
self._draw_pillow_pulse()
|
||||
elif self.animation_type == "line":
|
||||
self._draw_pillow_line()
|
||||
elif self.animation_type == "double_arc":
|
||||
self._draw_pillow_double_arc()
|
||||
elif self.animation_type == "counter_arc":
|
||||
self._draw_pillow_counter_arc()
|
||||
elif self.animation_type == "blink":
|
||||
self._draw_pillow_blink()
|
||||
|
||||
resized_image = self.image.resize(
|
||||
(self.width, self.height), Image.Resampling.LANCZOS)
|
||||
self.photo_image = ImageTk.PhotoImage(resized_image)
|
||||
self.delete("all")
|
||||
self.create_image(0, 0, anchor="nw", image=self.photo_image)
|
||||
|
||||
def _draw_pillow_pulse(self) -> None:
|
||||
"""Draws the pulse animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
alpha = (sin(self.angle * 5) + 1) / 2 # Faster pulse
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
pulse_color = (r, g, b)
|
||||
|
||||
if self.animation_type == "line":
|
||||
for i in range(12):
|
||||
angle = i * (pi / 6)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.8)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.8)
|
||||
end_x = center_x + cos(angle) * (self.width * 1.6)
|
||||
end_y = center_y + sin(angle) * (self.height * 1.6)
|
||||
self.draw.line([(start_x, start_y), (end_x, end_y)],
|
||||
fill=pulse_color, width=6, joint="curve")
|
||||
elif self.animation_type == "double_arc":
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
self.draw.arc(bbox, start=0, end=360, fill=pulse_color, width=5)
|
||||
elif self.animation_type == "counter_arc":
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
self.draw.arc(bbox_outer, start=0, end=360,
|
||||
fill=pulse_color, width=7)
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
self.draw.arc(bbox_inner, start=0, end=360,
|
||||
fill=self.color_rgb, width=7)
|
||||
|
||||
def _draw_pillow_line(self) -> None:
|
||||
"""Draws the line animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
for i in range(12):
|
||||
angle = self.angle + i * (pi / 6)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.8)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.8)
|
||||
end_x = center_x + cos(angle) * (self.width * 1.6)
|
||||
end_y = center_y + sin(angle) * (self.height * 1.6)
|
||||
alpha = (cos(self.angle * 2.5 + i * (pi / 6)) + 1) / 2
|
||||
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
color = (r, g, b)
|
||||
|
||||
self.draw.line([(start_x, start_y), (end_x, end_y)],
|
||||
fill=color, width=6, joint="curve")
|
||||
|
||||
def _draw_pillow_double_arc(self) -> None:
|
||||
"""Draws the double arc animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
|
||||
start_angle1 = self.angle * 180 / pi
|
||||
extent1 = 120 + 60 * sin(self.angle)
|
||||
self.draw.arc(bbox, start=start_angle1, end=start_angle1 +
|
||||
extent1, fill=self.highlight_color_rgb, width=5)
|
||||
|
||||
start_angle2 = (self.angle + pi) * 180 / pi
|
||||
extent2 = 120 + 60 * sin(self.angle + pi / 2)
|
||||
self.draw.arc(bbox, start=start_angle2, end=start_angle2 +
|
||||
extent2, fill=self.color_rgb, width=5)
|
||||
|
||||
def _draw_pillow_counter_arc(self) -> None:
|
||||
"""Draws the counter arc animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
start_angle1 = self.angle * 180 / pi
|
||||
self.draw.arc(bbox_outer, start=start_angle1, end=start_angle1 +
|
||||
150, fill=self.highlight_color_rgb, width=7)
|
||||
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
start_angle2 = -self.angle * 180 / pi + 60
|
||||
self.draw.arc(bbox_inner, start=start_angle2,
|
||||
end=start_angle2 + 150, fill=self.color_rgb, width=7)
|
||||
|
||||
def _draw_pillow_blink(self) -> None:
|
||||
"""Draws the blink animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
alpha = (sin(self.angle * 2) + 1) / 2 # Slower blinking speed
|
||||
r = int(
|
||||
alpha * (self.highlight_color_rgb[0] - self.color_rgb[0]) + self.color_rgb[0])
|
||||
g = int(
|
||||
alpha * (self.highlight_color_rgb[1] - self.color_rgb[1]) + self.color_rgb[1])
|
||||
b = int(
|
||||
alpha * (self.highlight_color_rgb[2] - self.color_rgb[2]) + self.color_rgb[2])
|
||||
blink_color = (r, g, b)
|
||||
self.draw.arc((center_x - radius, center_y - radius, center_x + radius,
|
||||
center_y + radius), start=0, end=360, fill=blink_color, width=10)
|
||||
|
||||
def _draw_stopped_frame(self) -> None:
|
||||
"""Draws the icon in its stopped (static) state."""
|
||||
self.delete("all")
|
||||
|
||||
original_highlight_color = self.highlight_color
|
||||
original_highlight_color_rgb = self.highlight_color_rgb
|
||||
if self.is_disabled:
|
||||
self.highlight_color = "#8f99aa"
|
||||
self.highlight_color_rgb = _hex_to_rgb(self.highlight_color)
|
||||
|
||||
try:
|
||||
if self.use_pillow:
|
||||
self._draw_pillow_stopped_frame()
|
||||
else:
|
||||
self._draw_canvas_stopped_frame()
|
||||
finally:
|
||||
if self.is_disabled:
|
||||
self.highlight_color = original_highlight_color
|
||||
self.highlight_color_rgb = original_highlight_color_rgb
|
||||
|
||||
def _draw_canvas_stopped_frame(self) -> None:
|
||||
"""Draws the stopped state using canvas methods."""
|
||||
if self.animation_type == "line":
|
||||
self._draw_canvas_line_stopped()
|
||||
elif self.animation_type == "double_arc":
|
||||
self._draw_canvas_double_arc_stopped()
|
||||
elif self.animation_type == "counter_arc":
|
||||
self._draw_canvas_counter_arc_stopped()
|
||||
elif self.animation_type == "blink":
|
||||
self._draw_canvas_blink_stopped()
|
||||
|
||||
def _draw_canvas_line_stopped(self) -> None:
|
||||
"""Draws the stopped state for the line animation."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
for i in range(8):
|
||||
angle = i * (pi / 4)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.2)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.2)
|
||||
end_x = center_x + cos(angle) * (self.width * 0.4)
|
||||
end_y = center_y + sin(angle) * (self.height * 0.4)
|
||||
self.create_line(start_x, start_y, end_x, end_y,
|
||||
fill=self.highlight_color, width=2)
|
||||
|
||||
def _draw_canvas_double_arc_stopped(self) -> None:
|
||||
"""Draws the stopped state for the double arc animation."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
self.create_arc(bbox, start=0, extent=359.9, style=tk.ARC,
|
||||
outline=self.highlight_color, width=2)
|
||||
|
||||
def _draw_canvas_counter_arc_stopped(self) -> None:
|
||||
"""Draws the stopped state for the counter arc animation."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
self.create_arc(bbox_outer, start=0, extent=359.9,
|
||||
style=tk.ARC, outline=self.highlight_color, width=2)
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
self.create_arc(bbox_inner, start=0, extent=359.9,
|
||||
style=tk.ARC, outline=self.color, width=2)
|
||||
|
||||
def _draw_canvas_blink_stopped(self) -> None:
|
||||
"""Draws the stopped state for the blink animation."""
|
||||
center_x, center_y = self.width / 2, self.height / 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
self.create_arc(center_x - radius, center_y - radius, center_x + radius, center_y +
|
||||
radius, start=0, extent=359.9, style=tk.ARC, outline=self.highlight_color, width=4)
|
||||
|
||||
def _draw_pillow_stopped_frame(self) -> None:
|
||||
"""Draws the stopped state using Pillow."""
|
||||
self.draw.rectangle(
|
||||
[0, 0, self.width * 4, self.height * 4], fill=(0, 0, 0, 0))
|
||||
if self.animation_type == "line":
|
||||
self._draw_pillow_line_stopped()
|
||||
elif self.animation_type == "double_arc":
|
||||
self._draw_pillow_double_arc_stopped()
|
||||
elif self.animation_type == "counter_arc":
|
||||
self._draw_pillow_counter_arc_stopped()
|
||||
elif self.animation_type == "blink":
|
||||
self._draw_pillow_blink_stopped()
|
||||
|
||||
resized_image = self.image.resize(
|
||||
(self.width, self.height), Image.Resampling.LANCZOS)
|
||||
self.photo_image = ImageTk.PhotoImage(resized_image)
|
||||
self.create_image(0, 0, anchor="nw", image=self.photo_image)
|
||||
|
||||
def _draw_pillow_line_stopped(self) -> None:
|
||||
"""Draws the stopped state for the line animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
for i in range(12):
|
||||
angle = i * (pi / 6)
|
||||
start_x = center_x + cos(angle) * (self.width * 0.8)
|
||||
start_y = center_y + sin(angle) * (self.height * 0.8)
|
||||
end_x = center_x + cos(angle) * (self.width * 1.6)
|
||||
end_y = center_y + sin(angle) * (self.height * 1.6)
|
||||
self.draw.line([(start_x, start_y), (end_x, end_y)],
|
||||
fill=self.highlight_color_rgb, width=6, joint="curve")
|
||||
|
||||
def _draw_pillow_double_arc_stopped(self) -> None:
|
||||
"""Draws the stopped state for the double arc animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
bbox = (center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius)
|
||||
self.draw.arc(bbox, start=0, end=360,
|
||||
fill=self.highlight_color_rgb, width=5)
|
||||
|
||||
def _draw_pillow_counter_arc_stopped(self) -> None:
|
||||
"""Draws the stopped state for the counter arc animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
radius_outer = min(center_x, center_y) * 0.8
|
||||
bbox_outer = (center_x - radius_outer, center_y - radius_outer,
|
||||
center_x + radius_outer, center_y + radius_outer)
|
||||
self.draw.arc(bbox_outer, start=0, end=360,
|
||||
fill=self.highlight_color_rgb, width=7)
|
||||
radius_inner = min(center_x, center_y) * 0.6
|
||||
bbox_inner = (center_x - radius_inner, center_y - radius_inner,
|
||||
center_x + radius_inner, center_y + radius_inner)
|
||||
self.draw.arc(bbox_inner, start=0, end=360,
|
||||
fill=self.color_rgb, width=7)
|
||||
|
||||
def _draw_pillow_blink_stopped(self) -> None:
|
||||
"""Draws the stopped state for the blink animation using Pillow."""
|
||||
center_x, center_y = self.width * 2, self.height * 2
|
||||
radius = min(center_x, center_y) * 0.8
|
||||
self.draw.arc((center_x - radius, center_y - radius, center_x + radius,
|
||||
center_y + radius), start=0, end=360, fill=self.highlight_color_rgb, width=10)
|
||||
|
||||
def _animate(self) -> None:
|
||||
"""The main animation loop."""
|
||||
if self.pause_count > 0 or not self.running or not self.winfo_exists():
|
||||
return
|
||||
|
||||
# Do not animate if a grab is active on a different window.
|
||||
try:
|
||||
toplevel = self.winfo_toplevel()
|
||||
grab_widget = toplevel.grab_current()
|
||||
if grab_widget is not None and grab_widget != toplevel:
|
||||
self.after(100, self._animate) # Check again after a short delay
|
||||
return
|
||||
except Exception:
|
||||
# This can happen if a grabbed widget (like a combobox dropdown)
|
||||
# is destroyed at the exact moment this check runs.
|
||||
# It's safest to just skip this animation frame.
|
||||
self.after(30, self._animate)
|
||||
return
|
||||
|
||||
self.angle += 0.1
|
||||
if self.angle > 2 * pi:
|
||||
self.angle -= 2 * pi
|
||||
self._draw_frame()
|
||||
self.after(30, self._animate)
|
||||
|
||||
def start(self, pulse: bool = False) -> None:
|
||||
"""
|
||||
Starts the animation.
|
||||
|
||||
Args:
|
||||
pulse (bool): If True, plays a pulsing animation instead of the main one.
|
||||
"""
|
||||
if not self.winfo_exists():
|
||||
return
|
||||
self.running = True
|
||||
self.is_disabled = False
|
||||
self.pulse_animation = pulse
|
||||
if self.pause_count == 0:
|
||||
self._animate()
|
||||
|
||||
def stop(self, status: Optional[str] = None) -> None:
|
||||
"""Stops the animation and shows the static 'stopped' frame."""
|
||||
if not self.winfo_exists():
|
||||
return
|
||||
self.running = False
|
||||
self.pulse_animation = False
|
||||
self.is_disabled = status == "DISABLE"
|
||||
self._draw_stopped_frame()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Stops the animation and clears the canvas."""
|
||||
if not self.winfo_exists():
|
||||
return
|
||||
self.running = False
|
||||
self.pulse_animation = False
|
||||
self.delete("all")
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pauses the animation and draws a static frame."""
|
||||
self.pause_count += 1
|
||||
self._draw_stopped_frame()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resumes the animation if the pause count is zero."""
|
||||
self.pause_count = max(0, self.pause_count - 1)
|
||||
if self.pause_count == 0 and self.running:
|
||||
self._animate()
|
||||
|
||||
def show_full_circle(self) -> None:
|
||||
"""Shows the static 'stopped' frame without starting the animation."""
|
||||
if not self.winfo_exists():
|
||||
return
|
||||
if not self.running:
|
||||
self._draw_stopped_frame()
|
||||
@@ -131,5 +131,8 @@ class Msg:
|
||||
"freq_weekly": _("Wöchentlich"),
|
||||
"freq_monthly": _("Monatlich"),
|
||||
"save": _("Speichern"),
|
||||
"projected_usage_label": _("Speicherverbrauch ca. nach dem Backup"),
|
||||
"header_title": _("PyImage Backup"),
|
||||
"header_subtitle": _("Ein einfaches Backup-Tool mit rsync"),
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class BackupManager:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGCONT)
|
||||
self.logger.log("Backup resumed.")
|
||||
|
||||
def start_restore_path(self, source_path: str, dest_path: str, is_system: bool, on_progress: Optional[Callable[[int], None]] = None, on_completion: Optional[Callable[[], None]] = None, on_error: Optional[Callable[[], None]] = None):
|
||||
def start_backup(self, source_path: str, dest_path: str, is_system: bool, on_progress: Optional[Callable[[int], None]] = None, on_completion: Optional[Callable[[], None]] = None, on_error: Optional[Callable[[], None]] = None):
|
||||
"""Starts a generic restore process for a specific path."""
|
||||
thread = threading.Thread(target=self._run_restore_path, args=(source_path, dest_path, is_system, on_progress, on_completion, on_error))
|
||||
thread.daemon = True
|
||||
@@ -153,3 +153,16 @@ class BackupManager:
|
||||
else:
|
||||
details[key.strip()] = value.strip()
|
||||
return details
|
||||
|
||||
def list_backups(self, base_backup_path: str) -> List[str]:
|
||||
"""
|
||||
Lists backup directories within the given base_backup_path.
|
||||
Assumes each subdirectory is a separate backup.
|
||||
"""
|
||||
backups = []
|
||||
if os.path.isdir(base_backup_path):
|
||||
for item in os.listdir(base_backup_path):
|
||||
full_path = os.path.join(base_backup_path, item)
|
||||
if os.path.isdir(full_path):
|
||||
backups.append(item)
|
||||
return sorted(backups, reverse=True) # Sort by name, assuming names are date-based
|
||||
|
||||
861
common_tools.py
861
common_tools.py
@@ -1,861 +0,0 @@
|
||||
" Classes Method and Functions for lx Apps "
|
||||
|
||||
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
|
||||
|
||||
|
||||
class CryptoUtil:
|
||||
"""
|
||||
This class is for the creation of the folders and files
|
||||
required by Wire-Py, as well as for decryption
|
||||
the tunnel from the user's home directory
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def decrypt(user) -> None:
|
||||
"""
|
||||
Starts SSL dencrypt
|
||||
"""
|
||||
process: CompletedProcess[str] = run(
|
||||
["pkexec", "/usr/local/bin/ssl_decrypt.py", "--user", user],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Output from Openssl Error
|
||||
if process.stderr:
|
||||
app_logger.log(process.stderr)
|
||||
|
||||
if process.returncode == 0:
|
||||
app_logger.log("Files successfully decrypted...")
|
||||
else:
|
||||
|
||||
app_logger.log(
|
||||
f"Error process decrypt: Code {process.returncode}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encrypt(user) -> None:
|
||||
"""
|
||||
Starts SSL encryption
|
||||
"""
|
||||
process: CompletedProcess[str] = run(
|
||||
["pkexec", "/usr/local/bin/ssl_encrypt.py", "--user", user],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Output from Openssl Error
|
||||
if process.stderr:
|
||||
app_logger.log(process.stderr)
|
||||
|
||||
if process.returncode == 0:
|
||||
app_logger.log("Files successfully encrypted...")
|
||||
else:
|
||||
app_logger.log(
|
||||
f"Error process encrypt: Code {process.returncode}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def find_key(key: str = "") -> bool:
|
||||
"""
|
||||
Checks if the private key already exists in the system using an external script.
|
||||
Returns True only if the full key is found exactly (no partial match).
|
||||
"""
|
||||
process: CompletedProcess[str] = run(
|
||||
["pkexec", "/usr/local/bin/match_found.py", key],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if "True" in process.stdout:
|
||||
return True
|
||||
elif "False" in process.stdout:
|
||||
return False
|
||||
app_logger.log(
|
||||
f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}"
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_valid_base64(key: str) -> bool:
|
||||
"""
|
||||
Validates if the input is a valid Base64 string (WireGuard private key format).
|
||||
Returns True only for non-empty strings that match the expected length.
|
||||
"""
|
||||
# Check for empty string
|
||||
if not key or key.strip() == "":
|
||||
return False
|
||||
|
||||
# Regex pattern to validate Base64: [A-Za-z0-9+/]+={0,2}
|
||||
base64_pattern = r"^[A-Za-z0-9+/]+={0,2}$"
|
||||
if not re.match(base64_pattern, key):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Decode and check length (WireGuard private keys are 32 bytes long)
|
||||
decoded = base64.b64decode(key)
|
||||
if len(decoded) != 32: # 32 bytes = 256 bits
|
||||
return False
|
||||
except Exception as e:
|
||||
app_logger.log(f"Error on decode Base64: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class LxTools:
|
||||
"""
|
||||
Class LinuxTools methods that can also be used for other apps
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def center_window_cross_platform(window, width, height):
|
||||
"""
|
||||
Centers a window on the primary monitor in a way that works on both X11 and Wayland
|
||||
|
||||
Args:
|
||||
window: The tkinter window to center
|
||||
width: Window width
|
||||
height: Window height
|
||||
"""
|
||||
# Calculate the position before showing the window
|
||||
|
||||
# First attempt: Try to use GDK if available (works on both X11 and Wayland)
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import Gdk
|
||||
|
||||
display = Gdk.Display.get_default()
|
||||
monitor = display.get_primary_monitor() or display.get_monitor(0)
|
||||
geometry = monitor.get_geometry()
|
||||
scale_factor = monitor.get_scale_factor()
|
||||
|
||||
# Calculate center position on the primary monitor
|
||||
x = geometry.x + (geometry.width - width // scale_factor) // 2
|
||||
y = geometry.y + (geometry.height - height // scale_factor) // 2
|
||||
|
||||
# Set window geometry
|
||||
window.geometry(f"{width}x{height}+{x}+{y}")
|
||||
return
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# Second attempt: Try xrandr for X11
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
output = subprocess.check_output(
|
||||
["xrandr", "--query"], universal_newlines=True
|
||||
)
|
||||
|
||||
# Parse the output to find the primary monitor
|
||||
primary_info = None
|
||||
for line in output.splitlines():
|
||||
if "primary" in line:
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
if "x" in part and "+" in part:
|
||||
primary_info = part
|
||||
break
|
||||
break
|
||||
|
||||
if primary_info:
|
||||
# Parse the geometry: WIDTH x HEIGHT+X+Y
|
||||
geometry = primary_info.split("+")
|
||||
dimensions = geometry[0].split("x")
|
||||
primary_width = int(dimensions[0])
|
||||
primary_height = int(dimensions[1])
|
||||
primary_x = int(geometry[1])
|
||||
primary_y = int(geometry[2])
|
||||
|
||||
# Calculate center position on the primary monitor
|
||||
x = primary_x + (primary_width - width) // 2
|
||||
y = primary_y + (primary_height - height) // 2
|
||||
|
||||
# Set window geometry
|
||||
window.geometry(f"{width}x{height}+{x}+{y}")
|
||||
return
|
||||
except (ImportError, IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# Final fallback: Use standard Tkinter method
|
||||
screen_width = window.winfo_screenwidth()
|
||||
screen_height = window.winfo_screenheight()
|
||||
|
||||
# Try to make an educated guess for multi-monitor setups
|
||||
# If screen width is much larger than height, assume multiple monitors side by side
|
||||
if (
|
||||
screen_width > screen_height * 1.8
|
||||
): # Heuristic for detecting multiple monitors
|
||||
# Assume the primary monitor is on the left half
|
||||
screen_width = screen_width // 2
|
||||
|
||||
x = (screen_width - width) // 2
|
||||
y = (screen_height - height) // 2
|
||||
window.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
@staticmethod
|
||||
def clean_files(tmp_dir: Path = None, file: Path = None) -> None:
|
||||
"""
|
||||
Deletes temporary files and directories for cleanup when exiting the application.
|
||||
|
||||
This method safely removes an optional directory defined by `AppConfig.TEMP_DIR`
|
||||
and a single file to free up resources at the end of the program's execution.
|
||||
All operations are performed securely, and errors such as `FileNotFoundError`
|
||||
are ignored if the target files or directories do not exist.
|
||||
:param tmp_dir: (Path, optional): Path to the temporary directory that should be deleted.
|
||||
If `None`, the value of `AppConfig.TEMP_DIR` is used.
|
||||
:param file: (Path, optional): Path to the file that should be deleted.
|
||||
If `None`, no additional file will be deleted.
|
||||
|
||||
Returns:
|
||||
None: The method does not return any value.
|
||||
"""
|
||||
|
||||
if tmp_dir is not None:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
try:
|
||||
if file is not None:
|
||||
Path.unlink(file)
|
||||
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def sigi(file_path: Optional[Path] = None, file: Optional[Path] = None) -> None:
|
||||
"""
|
||||
Function for cleanup after a program interruption
|
||||
|
||||
:param file: Optional - File to be deleted
|
||||
:param file_path: Optional - Directory to be deleted
|
||||
"""
|
||||
|
||||
def signal_handler(signum: int, frame: Any) -> NoReturn:
|
||||
"""
|
||||
Determines clear text names for signal numbers and handles signals
|
||||
|
||||
Args:
|
||||
signum: The signal number
|
||||
frame: The current stack frame
|
||||
|
||||
Returns:
|
||||
NoReturn since the function either exits the program or continues execution
|
||||
"""
|
||||
|
||||
signals_to_names_dict: Dict[int, str] = dict(
|
||||
(getattr(signal, n), n)
|
||||
for n in dir(signal)
|
||||
if n.startswith("SIG") and "_" not in n
|
||||
)
|
||||
|
||||
signal_name: str = signals_to_names_dict.get(
|
||||
signum, f"Unnamed signal: {signum}"
|
||||
)
|
||||
|
||||
# End program for certain signals, report to others only reception
|
||||
if signum in (signal.SIGINT, signal.SIGTERM):
|
||||
exit_code: int = 1
|
||||
app_logger.log(
|
||||
f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}."
|
||||
)
|
||||
LxTools.clean_files(file_path, file)
|
||||
app_logger.log("Breakdown by user...")
|
||||
sys.exit(exit_code)
|
||||
else:
|
||||
app_logger.log(f"Signal {signum} received and ignored.")
|
||||
LxTools.clean_files(file_path, file)
|
||||
app_logger.log("Process unexpectedly ended...")
|
||||
|
||||
# Register signal handlers for various signals
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGHUP, signal_handler)
|
||||
|
||||
|
||||
# ConfigManager with caching
|
||||
class ConfigManager:
|
||||
"""
|
||||
Universal class for managing configuration files with caching support.
|
||||
|
||||
This class provides a general solution to load, save, and manage configuration
|
||||
files across different projects. It uses a caching system to optimize access efficiency.
|
||||
The `init()` method initializes the configuration file path, while `load()` and `save()`
|
||||
synchronize data between the file and internal memory structures.
|
||||
|
||||
Key Features:
|
||||
- Caching to minimize I/O operations.
|
||||
- Default values for missing or corrupted configuration files.
|
||||
- Reusability across different projects and use cases.
|
||||
|
||||
The class is designed for central application configuration management, working closely
|
||||
with `ThemeManager` to dynamically manage themes or other settings.
|
||||
"""
|
||||
|
||||
_config = None
|
||||
_config_file = None
|
||||
|
||||
@classmethod
|
||||
def init(cls, config_file):
|
||||
"""Initial the Configmanager with the given config file"""
|
||||
cls._config_file = config_file
|
||||
cls._config = None # Reset the cache
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
"""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()
|
||||
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",
|
||||
}
|
||||
except (IndexError, FileNotFoundError):
|
||||
# DeDefault values in case of error
|
||||
cls._config = {
|
||||
"updates": "on",
|
||||
"theme": "light",
|
||||
"tooltips": "True", # Default Value as string!
|
||||
"autostart": "off",
|
||||
}
|
||||
return cls._config
|
||||
|
||||
@classmethod
|
||||
def save(cls):
|
||||
"""Save the config to the config file"""
|
||||
if cls._config:
|
||||
lines = [
|
||||
"# Configuration\n",
|
||||
f"{cls._config['updates']}\n",
|
||||
"# Theme\n",
|
||||
f"{cls._config['theme']}\n",
|
||||
"# Tooltips\n",
|
||||
f"{str(cls._config['tooltips'])}\n",
|
||||
"# Autostart\n",
|
||||
f"{cls._config['autostart']}\n",
|
||||
]
|
||||
Path(cls._config_file).write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def set(cls, key, value):
|
||||
"""Sets a configuration value and saves the change"""
|
||||
cls.load()
|
||||
cls._config[key] = value
|
||||
cls.save()
|
||||
|
||||
@classmethod
|
||||
def get(cls, key, default=None):
|
||||
"""Returns a configuration value"""
|
||||
config = cls.load()
|
||||
return config.get(key, default)
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
Class for central theme management and UI customization.
|
||||
|
||||
This static class allows dynamic adjustment of the application's appearance.
|
||||
The method `change_theme()` updates the current theme and saves
|
||||
the selection in the configuration file via `ConfigManager`.
|
||||
It ensures a consistent visual design across the entire project.
|
||||
|
||||
Key Features:
|
||||
- Central control over themes.
|
||||
- Automatic saving of theme settings to the configuration file.
|
||||
- Tight integration with `ConfigManager` for persistent storage of preferences.
|
||||
|
||||
The class is designed to apply themes consistently throughout the application,
|
||||
ensuring that changes are traceable and uniform across all parts of the project.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def change_theme(root, theme_in_use, theme_name=None):
|
||||
"""
|
||||
Change application theme centrally.
|
||||
|
||||
Args:
|
||||
root: The root Tkinter window.
|
||||
theme_in_use (str): The name of the theme to apply.
|
||||
theme_name (Optional[str]): The name of the theme to save in the config.
|
||||
If None, the theme is not saved.
|
||||
"""
|
||||
root.tk.call("set_theme", theme_in_use)
|
||||
if theme_in_use == theme_name:
|
||||
ConfigManager.set("theme", theme_in_use)
|
||||
|
||||
|
||||
class Tooltip:
|
||||
"""
|
||||
A flexible tooltip class for Tkinter widgets that supports dynamic activation/deactivation.
|
||||
|
||||
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, text, wraplength=250, state_var=None):
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.wraplength = wraplength
|
||||
self.state_var = state_var
|
||||
self.tooltip_window = None
|
||||
self.id = None
|
||||
self.update_bindings()
|
||||
if self.state_var:
|
||||
self.state_var.trace_add("write", self.update_bindings)
|
||||
|
||||
# Add bindings to the top-level window to hide the tooltip when the
|
||||
# main window loses focus or is iconified.
|
||||
toplevel = self.widget.winfo_toplevel()
|
||||
toplevel.bind("<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>")
|
||||
|
||||
if self.state_var is None or self.state_var.get():
|
||||
self.widget.bind("<Enter>", self.enter)
|
||||
self.widget.bind("<Leave>", self.leave)
|
||||
self.widget.bind("<ButtonPress>", self.leave)
|
||||
|
||||
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
|
||||
|
||||
if self.state_var is None or self.state_var.get():
|
||||
self.schedule()
|
||||
|
||||
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"+" + 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)
|
||||
|
||||
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")
|
||||
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',
|
||||
'fork_key_small': '32/fork_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',
|
||||
'fork_key_large': '48/fork_key.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',
|
||||
'fork_key_extralarge': '64/fork_key.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()
|
||||
63
config_manager.py
Normal file
63
config_manager.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages loading and saving of application settings in a JSON file."""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
"""
|
||||
Initializes the ConfigManager.
|
||||
|
||||
Args:
|
||||
file_path: The path to the configuration file.
|
||||
"""
|
||||
self.file_path = file_path
|
||||
self.settings: Dict[str, Any] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Loads the settings from the JSON file."""
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, 'r', encoding='utf-8') as f:
|
||||
self.settings = json.load(f)
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
print(f"Error loading config file {self.file_path}: {e}")
|
||||
self.settings = {}
|
||||
else:
|
||||
self.settings = {}
|
||||
|
||||
def save(self) -> None:
|
||||
"""Saves the current settings to the JSON file."""
|
||||
try:
|
||||
# Ensure the parent directory exists
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings, f, indent=4)
|
||||
except IOError as e:
|
||||
print(f"Error saving config file {self.file_path}: {e}")
|
||||
|
||||
def get_setting(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Gets a setting value.
|
||||
|
||||
Args:
|
||||
key: The key of the setting.
|
||||
default: The default value to return if the key is not found.
|
||||
|
||||
Returns:
|
||||
The value of the setting or the default value.
|
||||
"""
|
||||
return self.settings.get(key, default)
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Sets a setting value and immediately saves it to the file.
|
||||
|
||||
Args:
|
||||
key: The key of the setting.
|
||||
value: The value to set.
|
||||
"""
|
||||
self.settings[key] = value
|
||||
self.save()
|
||||
565
main_app.py
565
main_app.py
@@ -3,7 +3,7 @@ from tkinter import ttk
|
||||
import os
|
||||
import datetime
|
||||
import shutil
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageTk
|
||||
import threading
|
||||
@@ -15,10 +15,13 @@ from shared_libs.logger import app_logger
|
||||
from shared_libs.message import MessageDialog
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from shared_libs.common_tools import IconManager, ConfigManager
|
||||
from shared_libs.common_tools import IconManager
|
||||
from config_manager import ConfigManager
|
||||
from backup_manager import BackupManager
|
||||
from app_config import AppConfig, Msg
|
||||
|
||||
from pyimage_ui.scheduler_frame import SchedulerFrame
|
||||
from pyimage_ui.backup_content_frame import BackupContentFrame
|
||||
from pyimage_ui.header_frame import HeaderFrame
|
||||
|
||||
|
||||
class MainApplication(tk.Tk):
|
||||
@@ -28,7 +31,7 @@ class MainApplication(tk.Tk):
|
||||
AppConfig.ensure_directories()
|
||||
|
||||
self.title("PyImage Backup")
|
||||
self.geometry("1000x700")
|
||||
self.geometry("1000x800")
|
||||
|
||||
self.style = ttk.Style()
|
||||
self.tk.call("source", "/usr/share/TK-Themes/water.tcl")
|
||||
@@ -40,10 +43,10 @@ class MainApplication(tk.Tk):
|
||||
("active", "#3a526a")], foreground=[("active", "white")])
|
||||
self.style.layout("Sidebar.TButton", self.style.layout(
|
||||
"TButton.Borderless.Round"))
|
||||
|
||||
self.style.layout("Gray.TButton.Borderless", self.style.layout("TButton.Borderless.Round"))
|
||||
self.style.configure("Gray.TButton.Borderless", foreground="gray")
|
||||
|
||||
self.style.layout("Gray.TButton.Borderless",
|
||||
self.style.layout("TButton.Borderless.Round"))
|
||||
self.style.configure("Gray.TButton.Borderless", foreground="gray")
|
||||
|
||||
# --- Main Layout ---
|
||||
main_frame = ttk.Frame(self)
|
||||
@@ -60,17 +63,19 @@ class MainApplication(tk.Tk):
|
||||
self.content_frame = ttk.Frame(main_frame)
|
||||
self.content_frame.grid(row=0, column=1, sticky="nsew")
|
||||
self.content_frame.grid_rowconfigure(
|
||||
0, weight=0) # top_bar fixed height
|
||||
0, weight=0) # header fixed height
|
||||
self.content_frame.grid_rowconfigure(
|
||||
1, weight=1) # content expands
|
||||
1, weight=0) # top_bar fixed height
|
||||
self.content_frame.grid_rowconfigure(
|
||||
2, weight=0) # info/checkbox fixed height
|
||||
2, weight=1) # content expands
|
||||
self.content_frame.grid_rowconfigure(
|
||||
3, weight=0) # source_size_frame fixed height
|
||||
3, weight=0) # info/checkbox fixed height
|
||||
self.content_frame.grid_rowconfigure(
|
||||
4, weight=0) # target_size_frame fixed height
|
||||
4, weight=0) # source_size_frame fixed height
|
||||
self.content_frame.grid_rowconfigure(
|
||||
5, weight=0) # action_frame fixed height
|
||||
5, weight=0) # target_size_frame fixed height
|
||||
self.content_frame.grid_rowconfigure(
|
||||
6, weight=0) # action_frame fixed height
|
||||
self.content_frame.grid_columnconfigure(
|
||||
0, weight=1) # content expands horizontally
|
||||
|
||||
@@ -78,7 +83,14 @@ class MainApplication(tk.Tk):
|
||||
self.backup_manager = BackupManager(app_logger)
|
||||
self.queue = Queue()
|
||||
self.image_manager = IconManager()
|
||||
self.config_manager = ConfigManager(AppConfig.SETTINGS_FILE)
|
||||
self.calculating_animation = None
|
||||
self.destination_path = None
|
||||
self.source_size_bytes = 0
|
||||
self.destination_used_bytes = 0
|
||||
self.destination_total_bytes = 0
|
||||
self.left_canvas_data = {}
|
||||
self.right_canvas_data = {}
|
||||
|
||||
# --- Sidebar ---
|
||||
sidebar_buttons_frame = ttk.Frame(sidebar, style="Custom.TFrame")
|
||||
@@ -102,19 +114,29 @@ class MainApplication(tk.Tk):
|
||||
sidebar_buttons_frame, text="Zeitplanung", command=lambda: self._toggle_scheduler_frame(2), style="Sidebar.TButton")
|
||||
schedule_dialog_button.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
# Test button for CustomFileDialog
|
||||
test_dialog_button = ttk.Button(
|
||||
sidebar_buttons_frame, text="Test Dialog", command=self._test_dialog_button_click, style="Sidebar.TButton")
|
||||
test_dialog_button.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
# --- Header ---
|
||||
self.header_frame = HeaderFrame(self.content_frame, self.image_manager)
|
||||
self.header_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
# --- Main Content ---
|
||||
top_bar = ttk.Frame(self.content_frame)
|
||||
top_bar.grid(row=0, column=0, sticky="ew", pady=10)
|
||||
self.top_bar = ttk.Frame(self.content_frame)
|
||||
self.top_bar.grid(row=1, column=0, sticky="ew", pady=10)
|
||||
|
||||
# Top bar navigation (left side)
|
||||
top_nav_frame = ttk.Frame(top_bar)
|
||||
top_nav_frame = ttk.Frame(self.top_bar)
|
||||
top_nav_frame.pack(side=tk.LEFT)
|
||||
|
||||
nav_buttons_defs = [
|
||||
("Backup", lambda: self.toggle_mode("backup", 0)),
|
||||
("Restore", lambda: self.toggle_mode("restore", 1)),
|
||||
("Zeitplanung", lambda: self._toggle_scheduler_frame(2)),
|
||||
("Log", lambda: self._toggle_log_window(3)),
|
||||
("Backup Inhalt", lambda: self._toggle_backup_content_frame(2)),
|
||||
("Zeitplanung", lambda: self._toggle_scheduler_frame(3)),
|
||||
("Log", lambda: self._toggle_log_window(4)),
|
||||
]
|
||||
|
||||
self.nav_buttons = []
|
||||
@@ -137,138 +159,251 @@ class MainApplication(tk.Tk):
|
||||
side=tk.LEFT, fill=tk.Y, padx=2)
|
||||
|
||||
self.canvas_frame = ttk.Frame(self.content_frame)
|
||||
self.canvas_frame.grid(row=1, column=0, sticky="nsew")
|
||||
self.canvas_frame.grid(
|
||||
row=2, column=0, sticky="nsew", padx=10, pady=(15, 50))
|
||||
self.canvas_frame.grid_columnconfigure(0, weight=1)
|
||||
self.canvas_frame.grid_columnconfigure(2, weight=1)
|
||||
self.canvas_frame.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.left_canvas = tk.Canvas(
|
||||
self.canvas_frame, width=80, height=80, relief="solid", borderwidth=1)
|
||||
self.left_canvas.pack(side=tk.LEFT, fill=tk.BOTH,
|
||||
expand=True, padx=10, pady=10)
|
||||
self.canvas_frame, relief="solid", borderwidth=1)
|
||||
self.left_canvas.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.left_canvas.bind("<Configure>", self._redraw_left_canvas)
|
||||
|
||||
button_frame = ttk.Frame(self.canvas_frame)
|
||||
button_frame.pack(side=tk.LEFT, fill=tk.Y)
|
||||
button_frame.grid(row=0, column=1, padx=30)
|
||||
|
||||
self.mode_button_icon = self.image_manager.get_icon(
|
||||
"forward_extralarge")
|
||||
self.mode_button = ttk.Button(
|
||||
button_frame, image=self.mode_button_icon, command=self.toggle_mode)
|
||||
self.mode_button.pack(pady=200)
|
||||
button_frame, image=self.mode_button_icon, command=self.toggle_mode, style="TButton.Borderless.Round")
|
||||
self.mode_button.pack(pady=60)
|
||||
|
||||
self.right_canvas = tk.Canvas(
|
||||
self.canvas_frame, width=80, height=80, relief="solid", borderwidth=1)
|
||||
self.right_canvas.pack(side=tk.RIGHT, fill=tk.BOTH,
|
||||
expand=True, padx=10, pady=10)
|
||||
device_icon = self.image_manager.get_icon("device_extralarge")
|
||||
self.right_canvas.create_image(100, 100, image=device_icon)
|
||||
|
||||
self.canvas_frame, relief="solid", borderwidth=1)
|
||||
self.right_canvas.grid(row=0, column=2, sticky="nsew", padx=5, pady=5)
|
||||
self.right_canvas.bind("<Configure>", self._redraw_right_canvas)
|
||||
self.right_canvas.bind("<Button-1>", self.on_right_canvas_click)
|
||||
|
||||
# Set initial state for right canvas
|
||||
self.right_canvas_data = {
|
||||
'icon': 'hdd_extralarge',
|
||||
'folder': 'Ziel auswählen',
|
||||
'size': ''
|
||||
}
|
||||
|
||||
# --- Log Window ---
|
||||
self._setup_log_window()
|
||||
|
||||
|
||||
# --- Scheduler Frame ---
|
||||
self._setup_scheduler_frame()
|
||||
|
||||
|
||||
# --- Backup Content Frame ---
|
||||
self.backup_content_frame = BackupContentFrame(
|
||||
self.content_frame, self.backup_manager, padding=10)
|
||||
self.backup_content_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.backup_content_frame.grid_remove()
|
||||
|
||||
# --- Task Bar ---
|
||||
self._setup_task_bar()
|
||||
|
||||
# --- Size Indicator Frames ---
|
||||
self.source_size_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.source_size_frame.grid(row=3, column=0, sticky="ew")
|
||||
self.source_size_frame.grid_columnconfigure(1, weight=1)
|
||||
ttk.Label(self.source_size_frame, text="Quelle:").grid(row=0, column=0, sticky="w")
|
||||
self.source_size_canvas = tk.Canvas(self.source_size_frame, height=20, relief="solid", borderwidth=1)
|
||||
self.source_size_canvas.grid(row=0, column=1, sticky="ew", padx=5)
|
||||
self.source_size_label = ttk.Label(self.source_size_frame, text="0.00 GB / 0.00 GB")
|
||||
self.source_size_label.grid(row=0, column=2, sticky="e")
|
||||
self.source_size_frame = ttk.LabelFrame(
|
||||
self.content_frame, text="Quelle", padding=10)
|
||||
self.source_size_frame.grid(
|
||||
row=4, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.source_size_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.target_size_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.target_size_frame.grid(row=4, column=0, sticky="ew")
|
||||
self.target_size_frame.grid_columnconfigure(1, weight=1)
|
||||
ttk.Label(self.target_size_frame, text="Ziel:").grid(row=0, column=0, sticky="w")
|
||||
self.target_size_canvas = tk.Canvas(self.target_size_frame, height=20, relief="solid", borderwidth=1)
|
||||
self.target_size_canvas.grid(row=0, column=1, sticky="ew", padx=5)
|
||||
self.target_size_label = ttk.Label(self.target_size_frame, text="0.00 GB / 0.00 GB")
|
||||
self.target_size_label.grid(row=0, column=2, sticky="e")
|
||||
self.source_size_canvas = tk.Canvas(
|
||||
self.source_size_frame, height=20, relief="solid", borderwidth=1)
|
||||
self.source_size_canvas.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
self.action_frame.grid(row=5, column=0, sticky="ew")
|
||||
source_label_frame = ttk.Frame(self.source_size_frame)
|
||||
source_label_frame.grid(row=1, column=0, sticky="ew")
|
||||
self.source_size_label = ttk.Label(
|
||||
source_label_frame, text="0.00 GB / 0.00 GB")
|
||||
self.source_size_label.pack(side=tk.RIGHT)
|
||||
|
||||
self.target_size_frame = ttk.LabelFrame(
|
||||
self.content_frame, text=Msg.STR["projected_usage_label"], padding=10)
|
||||
self.target_size_frame.grid(
|
||||
row=5, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.target_size_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.target_size_canvas = tk.Canvas(
|
||||
self.target_size_frame, height=20, relief="solid", borderwidth=1)
|
||||
self.target_size_canvas.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
target_label_frame = ttk.Frame(self.target_size_frame)
|
||||
target_label_frame.grid(row=1, column=0, sticky="ew")
|
||||
self.target_size_label = ttk.Label(
|
||||
target_label_frame, text="0.00 GB / 0.00 GB")
|
||||
self.target_size_label.pack(side=tk.RIGHT)
|
||||
|
||||
self.mode = "backup"
|
||||
self._update_nav_buttons(0)
|
||||
self.after(100, self.on_sidebar_button_click, "Computer")
|
||||
self._process_queue()
|
||||
|
||||
# Load last destination
|
||||
last_dest = self.config_manager.get_setting("last_destination")
|
||||
if last_dest and os.path.isdir(last_dest):
|
||||
self._update_destination_info(last_dest)
|
||||
|
||||
def on_right_canvas_click(self, event):
|
||||
self.after(100, self._open_destination_dialog_from_canvas)
|
||||
|
||||
def _open_destination_dialog_from_canvas(self):
|
||||
dest = self._get_destination_from_dialog()
|
||||
if dest:
|
||||
# For now, we only handle local paths
|
||||
if os.path.isdir(dest):
|
||||
self._update_destination_info(dest)
|
||||
|
||||
def _test_dialog_button_click(self):
|
||||
dest = self._get_destination_from_dialog()
|
||||
if dest:
|
||||
print(f"Selected destination from test button: {dest}")
|
||||
if os.path.isdir(dest):
|
||||
self._update_destination_info(dest)
|
||||
else:
|
||||
print("Dialog cancelled from test button.")
|
||||
|
||||
def _update_destination_info(self, path):
|
||||
try:
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
self.destination_path = path
|
||||
self.destination_total_bytes = total
|
||||
self.destination_used_bytes = used
|
||||
|
||||
# Update right canvas data
|
||||
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
|
||||
self.right_canvas_data = {
|
||||
'icon': 'hdd_extralarge',
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'size': size_str
|
||||
}
|
||||
self._redraw_right_canvas()
|
||||
|
||||
self._update_target_projection()
|
||||
|
||||
# Save the last destination
|
||||
self.config_manager.set_setting("last_destination", path)
|
||||
|
||||
except FileNotFoundError:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=f"Der Pfad wurde nicht gefunden: {path}")
|
||||
|
||||
def _get_destination_from_dialog(self) -> Optional[str]:
|
||||
self.update_idletasks()
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
self.wait_window(dialog)
|
||||
dest = dialog.get_result()
|
||||
dialog.destroy() # Ensure dialog is destroyed
|
||||
return dest
|
||||
|
||||
def _redraw_left_canvas(self, event=None):
|
||||
canvas = self.left_canvas
|
||||
canvas.delete("all")
|
||||
width = canvas.winfo_width()
|
||||
height = canvas.winfo_height()
|
||||
|
||||
# Title
|
||||
canvas.create_text(10, 10, anchor="nw", text="Quelle", font=(
|
||||
AppConfig.UI_CONFIG["font_family"], 12, "bold"))
|
||||
|
||||
icon_name = self.left_canvas_data.get('icon')
|
||||
if icon_name:
|
||||
icon = self.image_manager.get_icon(icon_name)
|
||||
if icon:
|
||||
canvas.create_image(width / 2, 60, image=icon)
|
||||
|
||||
folder_name = self.left_canvas_data.get('folder', '')
|
||||
canvas.create_text(width / 2, 120, text=folder_name, font=(
|
||||
AppConfig.UI_CONFIG["font_family"], 14, "bold"))
|
||||
|
||||
size_text = self.left_canvas_data.get('size', '')
|
||||
if size_text:
|
||||
canvas.create_text(width / 2, 140, text=size_text)
|
||||
|
||||
if self.left_canvas_data.get('calculating', False):
|
||||
if self.calculating_animation:
|
||||
self.calculating_animation.place(
|
||||
x=width/2 + 50, y=140, anchor="center")
|
||||
|
||||
def _redraw_right_canvas(self, event=None):
|
||||
canvas = self.right_canvas
|
||||
canvas.delete("all")
|
||||
width = canvas.winfo_width()
|
||||
height = canvas.winfo_height()
|
||||
|
||||
# Title
|
||||
canvas.create_text(10, 10, anchor="nw", text="Ziel", font=(
|
||||
AppConfig.UI_CONFIG["font_family"], 12, "bold"))
|
||||
|
||||
icon_name = self.right_canvas_data.get('icon')
|
||||
if icon_name:
|
||||
icon = self.image_manager.get_icon(icon_name)
|
||||
if icon:
|
||||
canvas.create_image(width / 2, 60, image=icon)
|
||||
|
||||
folder_name = self.right_canvas_data.get('folder', '')
|
||||
canvas.create_text(width / 2, 120, text=folder_name, font=(
|
||||
AppConfig.UI_CONFIG["font_family"], 14, "bold"))
|
||||
|
||||
size_text = self.right_canvas_data.get('size', '')
|
||||
if size_text:
|
||||
canvas.create_text(width / 2, 140, text=size_text)
|
||||
|
||||
def _update_target_projection(self):
|
||||
if self.destination_total_bytes == 0:
|
||||
return
|
||||
|
||||
canvas = self.target_size_canvas
|
||||
canvas.delete("all")
|
||||
canvas_width = canvas.winfo_width()
|
||||
if canvas_width <= 1: # Canvas not yet drawn
|
||||
self.after(50, self._update_target_projection)
|
||||
return
|
||||
|
||||
# Draw current usage
|
||||
used_percentage = self.destination_used_bytes / self.destination_total_bytes
|
||||
used_width = canvas_width * used_percentage
|
||||
canvas.create_rectangle(
|
||||
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
|
||||
# Draw projected usage
|
||||
projected_percentage = self.source_size_bytes / self.destination_total_bytes
|
||||
projected_width = canvas_width * projected_percentage
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width,
|
||||
canvas.winfo_height(), fill="#ff8c00", outline="")
|
||||
|
||||
# Update label
|
||||
projected_total_used = self.destination_used_bytes + self.source_size_bytes
|
||||
self.target_size_label.config(
|
||||
text=f"{projected_total_used / (1024**3):.2f} GB / {self.destination_total_bytes / (1024**3):.2f} GB")
|
||||
|
||||
def _setup_log_window(self):
|
||||
self.log_frame = ttk.Frame(self.content_frame)
|
||||
self.log_window = LogWindow(self.log_frame)
|
||||
self.log_window.pack(fill=tk.BOTH, expand=True)
|
||||
app_logger.init_logger(self.log_window.log_message)
|
||||
self.log_frame.grid(row=1, column=0, sticky="nsew")
|
||||
self.log_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.log_frame.grid_remove()
|
||||
|
||||
def _setup_scheduler_frame(self):
|
||||
self.scheduler_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.scheduler_frame.grid(row=1, column=0, sticky="nsew")
|
||||
self.scheduler_frame = SchedulerFrame(
|
||||
self.content_frame, self.backup_manager, padding=10)
|
||||
self.scheduler_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.scheduler_frame.grid_remove()
|
||||
|
||||
# --- Jobs List View ---
|
||||
self.jobs_frame = ttk.LabelFrame(self.scheduler_frame, text="Geplante Jobs", padding=10)
|
||||
self.jobs_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("active", "type", "frequency", "destination", "sources")
|
||||
self.jobs_tree = ttk.Treeview(self.jobs_frame, columns=columns, show="headings")
|
||||
for col in columns:
|
||||
self.jobs_tree.heading(col, text=col.capitalize())
|
||||
self.jobs_tree.column(col, width=100)
|
||||
self.jobs_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
self._load_scheduled_jobs()
|
||||
|
||||
list_button_frame = ttk.Frame(self.jobs_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
ttk.Button(list_button_frame, text="Hinzufügen", command=self._toggle_scheduler_view).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(list_button_frame, text="Entfernen", command=self._remove_selected_job).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# --- Add Job View ---
|
||||
self.add_job_frame = ttk.LabelFrame(self.scheduler_frame, text="Neuer Job hinzufügen", padding=10)
|
||||
|
||||
self.backup_type = tk.StringVar(value="system")
|
||||
self.destination = tk.StringVar()
|
||||
self.user_sources = {
|
||||
Msg.STR["cat_images"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_documents"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_music"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_videos"]: tk.BooleanVar(value=False)
|
||||
}
|
||||
self.frequency = tk.StringVar(value="daily")
|
||||
|
||||
type_frame = ttk.LabelFrame(self.add_job_frame, text=Msg.STR["backup_type"], padding=10)
|
||||
type_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type, value="system", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type, value="user", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
|
||||
dest_frame = ttk.LabelFrame(self.add_job_frame, text=Msg.STR["dest_folder"], padding=10)
|
||||
dest_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Entry(dest_frame, textvariable=self.destination, state="readonly", width=50).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
ttk.Button(dest_frame, text=Msg.STR["browse"], command=self._select_destination).pack(side=tk.RIGHT)
|
||||
|
||||
self.user_sources_frame = ttk.LabelFrame(self.add_job_frame, text=Msg.STR["source_folders"], padding=10)
|
||||
for name, var in self.user_sources.items():
|
||||
ttk.Checkbutton(self.user_sources_frame, text=name, variable=var).pack(anchor=tk.W)
|
||||
self._toggle_user_sources()
|
||||
|
||||
freq_frame = ttk.LabelFrame(self.add_job_frame, text=Msg.STR["frequency"], padding=10)
|
||||
freq_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_daily"], variable=self.frequency, value="daily").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_weekly"], variable=self.frequency, value="weekly").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_monthly"], variable=self.frequency, value="monthly").pack(anchor=tk.W)
|
||||
|
||||
add_button_frame = ttk.Frame(self.add_job_frame)
|
||||
add_button_frame.pack(pady=10)
|
||||
ttk.Button(add_button_frame, text=Msg.STR["save"], command=self._save_job).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(add_button_frame, text="Zurück", command=self._toggle_scheduler_view).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def _setup_task_bar(self):
|
||||
self.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.info_checkbox_frame.grid(row=2, column=0, sticky="ew")
|
||||
self.info_checkbox_frame.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
self.info_label = ttk.Label(self.info_checkbox_frame, text="Info text about the current view.")
|
||||
self.info_label = ttk.Label(
|
||||
self.info_checkbox_frame, text="Info text about the current view.")
|
||||
self.info_label.pack(anchor=tk.W, fill=tk.X, pady=5)
|
||||
|
||||
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
@@ -276,21 +411,27 @@ class MainApplication(tk.Tk):
|
||||
self.vollbackup_var = tk.BooleanVar()
|
||||
self.inkrementell_var = tk.BooleanVar()
|
||||
self.testlauf_var = tk.BooleanVar()
|
||||
ttk.Checkbutton(checkbox_frame, text="Vollbackup", variable=self.vollbackup_var).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Checkbutton(checkbox_frame, text="Inkrementell", variable=self.inkrementell_var).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Checkbutton(checkbox_frame, text="Testlauf", variable=self.testlauf_var).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Checkbutton(checkbox_frame, text="Vollbackup",
|
||||
variable=self.vollbackup_var).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Checkbutton(checkbox_frame, text="Inkrementell",
|
||||
variable=self.inkrementell_var).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Checkbutton(checkbox_frame, text="Testlauf",
|
||||
variable=self.testlauf_var).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.action_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.action_frame.grid(row=3, column=0, sticky="ew")
|
||||
self.action_frame.grid(row=6, column=0, sticky="ew")
|
||||
|
||||
self.animated_icon = AnimatedIcon(self.action_frame, width=20, height=20, use_pillow=True)
|
||||
self.animated_icon = AnimatedIcon(
|
||||
self.action_frame, width=20, height=20, use_pillow=True)
|
||||
self.animated_icon.pack(side=tk.LEFT, padx=5)
|
||||
self.animated_icon.stop("DISABLE")
|
||||
|
||||
self.task_progress = ttk.Progressbar(self.action_frame, orient="horizontal", length=100, mode="determinate")
|
||||
self.task_progress = ttk.Progressbar(
|
||||
self.action_frame, orient="horizontal", length=100, mode="determinate")
|
||||
self.task_progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
||||
|
||||
self.start_pause_button = ttk.Button(self.action_frame, text="Start", command=self._toggle_start_pause)
|
||||
self.start_pause_button = ttk.Button(
|
||||
self.action_frame, text="Start", command=self._toggle_start_pause)
|
||||
self.start_pause_button.pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
def _toggle_scheduler_view(self):
|
||||
@@ -308,7 +449,8 @@ class MainApplication(tk.Tk):
|
||||
self.user_sources_frame.pack_forget()
|
||||
|
||||
def _select_destination(self):
|
||||
dialog = CustomFileDialog(self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
result = dialog.get_result()
|
||||
if result:
|
||||
self.destination.set(result)
|
||||
@@ -316,7 +458,8 @@ class MainApplication(tk.Tk):
|
||||
def _save_job(self):
|
||||
dest = self.destination.get()
|
||||
if not dest:
|
||||
MessageDialog(master=self, message_type="error", title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
job_type = self.backup_type.get()
|
||||
@@ -324,21 +467,24 @@ class MainApplication(tk.Tk):
|
||||
job_sources = []
|
||||
|
||||
if job_type == "user":
|
||||
job_sources = [name for name, var in self.user_sources.items() if var.get()]
|
||||
job_sources = [name for name,
|
||||
var in self.user_sources.items() if var.get()]
|
||||
if not job_sources:
|
||||
MessageDialog(master=self, message_type="error", title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
|
||||
return
|
||||
|
||||
script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "main_app.py"))
|
||||
script_path = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), "main_app.py"))
|
||||
command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\""
|
||||
if job_type == "user":
|
||||
command += f" --sources ";
|
||||
command += f" --sources "
|
||||
for s in job_sources:
|
||||
command += f'\"{s}\" ';
|
||||
|
||||
command += f'\"{s}\" '
|
||||
|
||||
comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}"
|
||||
if job_type == "user":
|
||||
comment += f"; sources:{ ','.join(job_sources)}"
|
||||
comment += f"; sources:{','.join(job_sources)}"
|
||||
|
||||
job_details = {
|
||||
"command": command,
|
||||
@@ -358,16 +504,18 @@ class MainApplication(tk.Tk):
|
||||
jobs = self.backup_manager.get_scheduled_jobs()
|
||||
for job in jobs:
|
||||
self.jobs_tree.insert("", "end", values=(
|
||||
job["active"], job["type"], job["frequency"], job["destination"], ", ".join(job["sources"])
|
||||
job["active"], job["type"], job["frequency"], job["destination"], ", ".join(
|
||||
job["sources"])
|
||||
), iid=job["id"])
|
||||
|
||||
def _remove_selected_job(self):
|
||||
selected_item = self.jobs_tree.focus()
|
||||
if not selected_item:
|
||||
MessageDialog(master=self, message_type="error", title="Fehler", text="Kein Job ausgewählt.")
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title="Fehler", text="Kein Job ausgewählt.")
|
||||
return
|
||||
|
||||
job_id = self.jobs_tree.item(selected_item)[ "values"][0]
|
||||
|
||||
job_id = self.jobs_tree.item(selected_item)["values"][0]
|
||||
self.backup_manager.remove_scheduled_job(job_id)
|
||||
self._load_scheduled_jobs()
|
||||
|
||||
@@ -390,7 +538,7 @@ class MainApplication(tk.Tk):
|
||||
self._start_system_backup("full")
|
||||
else:
|
||||
self._start_system_backup("incremental")
|
||||
else: # restore
|
||||
else: # restore
|
||||
# placeholder for restore
|
||||
pass
|
||||
elif self.start_pause_button["text"] == "Pause":
|
||||
@@ -425,8 +573,11 @@ class MainApplication(tk.Tk):
|
||||
self._update_nav_buttons(active_index)
|
||||
|
||||
self.log_frame.grid_remove()
|
||||
self.scheduler_frame.grid_remove()
|
||||
self.scheduler_frame.hide()
|
||||
self.backup_content_frame.hide()
|
||||
self.canvas_frame.grid()
|
||||
self.source_size_frame.grid()
|
||||
self.target_size_frame.grid()
|
||||
self._update_task_bar_visibility(self.mode)
|
||||
|
||||
if mode:
|
||||
@@ -442,14 +593,14 @@ class MainApplication(tk.Tk):
|
||||
self.mode_button_icon = self.image_manager.get_icon(
|
||||
"forward_extralarge")
|
||||
self.mode_button.config(image=self.mode_button_icon)
|
||||
self.info_label.config(text="Backup-Modus: Hier können Sie ein Backup starten.")
|
||||
self.info_label.config(
|
||||
text="Backup-Modus: Hier können Sie ein Backup starten.")
|
||||
else:
|
||||
self.mode_button_icon = self.image_manager.get_icon(
|
||||
"back_extralarge")
|
||||
self.mode_button.config(image=self.mode_button_icon)
|
||||
self.info_label.config(text="Restore-Modus: Hier können Sie eine Wiederherstellung starten.")
|
||||
|
||||
|
||||
self.info_label.config(
|
||||
text="Restore-Modus: Hier können Sie eine Wiederherstellung starten.")
|
||||
|
||||
def _load_exclude_patterns(self):
|
||||
try:
|
||||
@@ -465,8 +616,9 @@ class MainApplication(tk.Tk):
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(path, topdown=True):
|
||||
# Exclude directories
|
||||
dirnames[:] = [d for d in dirnames if not any(fnmatch.fnmatch(os.path.join(dirpath, d), pattern) for pattern in exclude_patterns)]
|
||||
|
||||
dirnames[:] = [d for d in dirnames if not any(fnmatch.fnmatch(
|
||||
os.path.join(dirpath, d), pattern) for pattern in exclude_patterns)]
|
||||
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not any(fnmatch.fnmatch(fp, pattern) for pattern in exclude_patterns):
|
||||
@@ -483,23 +635,37 @@ class MainApplication(tk.Tk):
|
||||
print(f"Folder not found for {button_text}")
|
||||
return
|
||||
|
||||
# --- Update Image and static text ---
|
||||
self.left_canvas.delete("all")
|
||||
self.left_canvas.create_text(10, 10, anchor="nw", text="Quelle", font=(AppConfig.UI_CONFIG["font_family"], 12, "bold"))
|
||||
self.left_canvas.create_text(self.left_canvas.winfo_width()/2, 240, text=button_text, font=(AppConfig.UI_CONFIG["font_family"], 14, "bold"), tags="current_folder_name")
|
||||
self.left_canvas.create_text(self.left_canvas.winfo_width()/2, 270, text="Calculating size...", tags="current_folder_size")
|
||||
# --- Stop previous animation ---
|
||||
if self.calculating_animation:
|
||||
self.calculating_animation.stop()
|
||||
self.calculating_animation.destroy()
|
||||
self.calculating_animation = None
|
||||
|
||||
# --- Update Image and static text ---
|
||||
icon_name = self.buttons_map[button_text]['icon']
|
||||
if icon_name:
|
||||
default_icon = self.image_manager.get_icon(icon_name)
|
||||
self.left_canvas.create_image(self.left_canvas.winfo_width()/2, 120, image=default_icon)
|
||||
self.left_canvas_data = {
|
||||
'icon': icon_name,
|
||||
'folder': button_text,
|
||||
'size': 'Calculating size...',
|
||||
'calculating': True
|
||||
}
|
||||
self._redraw_left_canvas()
|
||||
|
||||
# --- Show calculating animation ---
|
||||
if self.calculating_animation:
|
||||
self.calculating_animation.destroy()
|
||||
self.calculating_animation = AnimatedIcon(
|
||||
self.left_canvas, width=20, height=20, animation_type="counter_arc", use_pillow=True)
|
||||
self.calculating_animation.start()
|
||||
self._redraw_left_canvas() # Redraw to place animation
|
||||
|
||||
# --- Start folder size calculation in background ---
|
||||
exclude_patterns = []
|
||||
if button_text == "Computer":
|
||||
exclude_patterns = self._load_exclude_patterns()
|
||||
|
||||
thread = threading.Thread(target=self._get_folder_size_threaded, args=(folder_path, button_text, exclude_patterns))
|
||||
thread = threading.Thread(target=self._get_folder_size_threaded, args=(
|
||||
folder_path, button_text, exclude_patterns))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
@@ -507,45 +673,58 @@ class MainApplication(tk.Tk):
|
||||
try:
|
||||
message = self.queue.get_nowait()
|
||||
button_text, folder_size = message
|
||||
|
||||
current_text_id = self.left_canvas.find_withtag("current_folder_name")
|
||||
if current_text_id:
|
||||
current_text = self.left_canvas.itemcget(current_text_id[0], "text")
|
||||
if current_text == button_text:
|
||||
size_text_id = self.left_canvas.find_withtag("current_folder_size")
|
||||
if size_text_id:
|
||||
size_in_gb = folder_size / (1024**3)
|
||||
if size_in_gb >= 6:
|
||||
size_str = f"{size_in_gb:.2f} GB"
|
||||
else:
|
||||
size_str = f"{folder_size / (1024*1024):.2f} MB"
|
||||
self.left_canvas.itemconfig(size_text_id[0], text=size_str)
|
||||
self.source_size_bytes = folder_size
|
||||
|
||||
# Update the source progress bar
|
||||
total_disk_size, _, _ = shutil.disk_usage(AppConfig.FOLDER_PATHS[button_text])
|
||||
percentage = (folder_size / total_disk_size) * 100
|
||||
self.source_size_canvas.delete("all")
|
||||
fill_width = (self.source_size_canvas.winfo_width() / 100) * percentage
|
||||
self.source_size_canvas.create_rectangle(0, 0, fill_width, self.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
self.source_size_label.config(text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
|
||||
current_folder_name = self.left_canvas_data.get('folder')
|
||||
if current_folder_name == button_text:
|
||||
# Stop and remove animation and text
|
||||
if self.calculating_animation:
|
||||
self.calculating_animation.stop()
|
||||
self.calculating_animation.destroy()
|
||||
self.calculating_animation = None
|
||||
|
||||
# Display final size
|
||||
size_in_gb = folder_size / (1024**3)
|
||||
if size_in_gb >= 1:
|
||||
size_str = f"{size_in_gb:.2f} GB"
|
||||
else:
|
||||
size_str = f"{folder_size / (1024*1024):.2f} MB"
|
||||
|
||||
self.left_canvas_data['size'] = size_str
|
||||
self.left_canvas_data['calculating'] = False
|
||||
self._redraw_left_canvas()
|
||||
|
||||
# Update the source progress bar
|
||||
total_disk_size, _, _ = shutil.disk_usage(
|
||||
AppConfig.FOLDER_PATHS[button_text])
|
||||
percentage = (folder_size / total_disk_size) * 100
|
||||
self.source_size_canvas.delete("all")
|
||||
fill_width = (
|
||||
self.source_size_canvas.winfo_width() / 100) * percentage
|
||||
self.source_size_canvas.create_rectangle(
|
||||
0, 0, fill_width, self.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
self.source_size_label.config(
|
||||
text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
|
||||
|
||||
self._update_target_projection()
|
||||
|
||||
except Exception as e:
|
||||
pass # Queue is empty
|
||||
pass # Queue is empty
|
||||
finally:
|
||||
self.after(100, self._process_queue)
|
||||
|
||||
def _start_system_backup(self, mode):
|
||||
dialog = CustomFileDialog(self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
dest = dialog.get_result()
|
||||
dest = self._get_destination_from_dialog()
|
||||
if dest:
|
||||
self.backup_manager.start_restore_path("/", dest, True, on_progress=self._update_task_progress, on_error=self._on_backup_error)
|
||||
self.backup_manager.start_restore_path(
|
||||
"/", dest, True, on_progress=self._update_task_progress, on_error=self._on_backup_error)
|
||||
|
||||
def _start_user_backup(self, sources):
|
||||
dialog = CustomFileDialog(self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
dest = dialog.get_result()
|
||||
dest = self._get_destination_from_dialog()
|
||||
if dest:
|
||||
for source in sources:
|
||||
self.backup_manager.start_restore_path(source, dest, False, on_progress=self._update_task_progress, on_error=self._on_backup_error)
|
||||
self.backup_manager.start_restore_path(
|
||||
source, dest, False, on_progress=self._update_task_progress, on_error=self._on_backup_error)
|
||||
|
||||
def _update_task_progress(self, percentage):
|
||||
self.task_progress["value"] = percentage
|
||||
@@ -555,8 +734,12 @@ class MainApplication(tk.Tk):
|
||||
self._update_nav_buttons(active_index)
|
||||
|
||||
self.canvas_frame.grid_remove()
|
||||
self.scheduler_frame.grid_remove()
|
||||
self.scheduler_frame.hide()
|
||||
self.backup_content_frame.hide()
|
||||
self.source_size_frame.grid_remove()
|
||||
self.target_size_frame.grid_remove()
|
||||
self.log_frame.grid()
|
||||
self.top_bar.grid()
|
||||
self._update_task_bar_visibility("log")
|
||||
|
||||
def _toggle_scheduler_frame(self, active_index=None):
|
||||
@@ -565,8 +748,32 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.canvas_frame.grid_remove()
|
||||
self.log_frame.grid_remove()
|
||||
self.scheduler_frame.grid()
|
||||
self._load_scheduled_jobs()
|
||||
self.backup_content_frame.hide()
|
||||
self.source_size_frame.grid_remove()
|
||||
self.target_size_frame.grid_remove()
|
||||
self.scheduler_frame.show()
|
||||
self.top_bar.grid()
|
||||
self._update_task_bar_visibility("scheduler")
|
||||
|
||||
def _toggle_backup_content_frame(self, active_index=None):
|
||||
if active_index is not None:
|
||||
self._update_nav_buttons(active_index)
|
||||
|
||||
if not self.destination_path:
|
||||
MessageDialog(master=self, message_type="info",
|
||||
title="Info", text="Bitte wählen Sie zuerst einen Zielordner aus.")
|
||||
# Revert to backup view
|
||||
self.toggle_mode("backup", 0)
|
||||
return
|
||||
|
||||
self.canvas_frame.grid_remove()
|
||||
self.log_frame.grid_remove()
|
||||
self.scheduler_frame.hide()
|
||||
self.source_size_frame.grid_remove()
|
||||
self.target_size_frame.grid_remove()
|
||||
self.backup_content_frame.show(self.destination_path)
|
||||
self.top_bar.grid()
|
||||
# Similar visibility as scheduler
|
||||
self._update_task_bar_visibility("scheduler")
|
||||
|
||||
def quit(self):
|
||||
@@ -616,4 +823,4 @@ if __name__ == "__main__":
|
||||
else:
|
||||
# GUI mode
|
||||
app = MainApplication()
|
||||
app.mainloop()
|
||||
app.mainloop()
|
||||
|
||||
0
pyimage_ui/__init__.py
Normal file
0
pyimage_ui/__init__.py
Normal file
BIN
pyimage_ui/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
pyimage_ui/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
pyimage_ui/__pycache__/backup_content_frame.cpython-312.pyc
Normal file
BIN
pyimage_ui/__pycache__/backup_content_frame.cpython-312.pyc
Normal file
Binary file not shown.
BIN
pyimage_ui/__pycache__/header_frame.cpython-312.pyc
Normal file
BIN
pyimage_ui/__pycache__/header_frame.cpython-312.pyc
Normal file
Binary file not shown.
BIN
pyimage_ui/__pycache__/scheduler_frame.cpython-312.pyc
Normal file
BIN
pyimage_ui/__pycache__/scheduler_frame.cpython-312.pyc
Normal file
Binary file not shown.
BIN
pyimage_ui/__pycache__/task_bar_frame.cpython-312.pyc
Normal file
BIN
pyimage_ui/__pycache__/task_bar_frame.cpython-312.pyc
Normal file
Binary file not shown.
79
pyimage_ui/backup_content_frame.py
Normal file
79
pyimage_ui/backup_content_frame.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
class BackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
self.backup_path = None
|
||||
|
||||
# --- Backup Content List View ---
|
||||
self.content_frame = ttk.LabelFrame(
|
||||
self, text="Backup Inhalt", padding=10)
|
||||
self.content_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("name", "date", "size")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self.content_frame, columns=columns, show="headings")
|
||||
for col in columns:
|
||||
self.content_tree.heading(col, text=col.capitalize())
|
||||
self.content_tree.column(col, width=150)
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
ttk.Button(list_button_frame, text="Wiederherstellen",
|
||||
command=self._restore_selected).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(list_button_frame, text="Löschen",
|
||||
command=self._delete_selected).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def show(self, backup_path):
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
if backup_path and self.backup_path != backup_path:
|
||||
self.backup_path = backup_path
|
||||
self._load_backup_content()
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
||||
def _load_backup_content(self):
|
||||
for i in self.content_tree.get_children():
|
||||
self.content_tree.delete(i)
|
||||
|
||||
if not self.backup_path or not os.path.isdir(self.backup_path):
|
||||
return
|
||||
|
||||
# Use BackupManager to list backups
|
||||
backups = self.backup_manager.list_backups(self.backup_path)
|
||||
|
||||
for backup_name in backups:
|
||||
backup_date = ""
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", backup_name)
|
||||
if match:
|
||||
backup_date = match.group(1)
|
||||
|
||||
backup_size = ""
|
||||
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_name, backup_date, backup_size
|
||||
))
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
||||
63
pyimage_ui/header_frame.py
Normal file
63
pyimage_ui/header_frame.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from app_config import AppConfig, Msg
|
||||
from shared_libs.common_tools import IconManager
|
||||
|
||||
|
||||
class HeaderFrame(tk.Frame):
|
||||
def __init__(self, container, image_manager, **kwargs):
|
||||
super().__init__(container, bg="#455A64", **kwargs)
|
||||
|
||||
self.image_manager = image_manager
|
||||
|
||||
# Configure grid weights for internal layout
|
||||
self.columnconfigure(1, weight=1) # Make the middle column expand
|
||||
self.rowconfigure(0, weight=1) # Make the top row expand
|
||||
|
||||
# Left side: Icon and Main Title/Subtitle
|
||||
left_frame = tk.Frame(self, bg="#455A64")
|
||||
left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew")
|
||||
left_frame.columnconfigure(0, weight=1)
|
||||
left_frame.rowconfigure(0, weight=1)
|
||||
|
||||
icon_label = tk.Label(
|
||||
left_frame,
|
||||
image=self.image_manager.get_icon("backup_extralarge"), # Using a generic backup icon
|
||||
bg="#455A64",
|
||||
)
|
||||
icon_label.grid(row=0, column=0, sticky="e", padx=10, pady=5)
|
||||
|
||||
title_label = tk.Label(
|
||||
self,
|
||||
text=Msg.STR["header_title"],
|
||||
font=("Helvetica", 16, "bold"),
|
||||
fg="#ffffff",
|
||||
bg="#455A64",
|
||||
)
|
||||
title_label.grid(row=0, column=1, sticky="w", padx=(5, 20), pady=(15, 5))
|
||||
|
||||
subtitle_label = tk.Label(
|
||||
self,
|
||||
text=Msg.STR["header_subtitle"],
|
||||
font=("Helvetica", 10),
|
||||
fg="#bdc3c7",
|
||||
bg="#455A64",
|
||||
)
|
||||
subtitle_label.grid(row=1, column=1, sticky="w", padx=(5, 20), pady=(0, 10))
|
||||
|
||||
# Right side: Placeholder for future info or buttons
|
||||
right_frame = tk.Frame(self, bg="#455A64")
|
||||
right_frame.grid(row=0, column=2, rowspan=2, sticky="nsew")
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
right_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Example of content for the right side (can be removed or replaced)
|
||||
# info_label = tk.Label(
|
||||
# right_frame,
|
||||
# text="Some Info Here",
|
||||
# font=("Helvetica", 10),
|
||||
# fg="#ecf0f1",
|
||||
# bg="#455A64",
|
||||
# )
|
||||
# info_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
|
||||
183
pyimage_ui/scheduler_frame.py
Normal file
183
pyimage_ui/scheduler_frame.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
from app_config import Msg
|
||||
|
||||
|
||||
class SchedulerFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
# --- Jobs List View ---
|
||||
self.jobs_frame = ttk.LabelFrame(
|
||||
self, text="Geplante Jobs", padding=10)
|
||||
self.jobs_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("active", "type", "frequency", "destination", "sources")
|
||||
self.jobs_tree = ttk.Treeview(
|
||||
self.jobs_frame, columns=columns, show="headings")
|
||||
for col in columns:
|
||||
self.jobs_tree.heading(col, text=col.capitalize())
|
||||
self.jobs_tree.column(col, width=100)
|
||||
self.jobs_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
self._load_scheduled_jobs()
|
||||
|
||||
list_button_frame = ttk.Frame(self.jobs_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
ttk.Button(list_button_frame, text="Hinzufügen",
|
||||
command=self._toggle_scheduler_view).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(list_button_frame, text="Entfernen",
|
||||
command=self._remove_selected_job).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# --- Add Job View ---
|
||||
self.add_job_frame = ttk.LabelFrame(
|
||||
self, text="Neuer Job hinzufügen", padding=10)
|
||||
|
||||
self.backup_type = tk.StringVar(value="system")
|
||||
self.destination = tk.StringVar()
|
||||
self.user_sources = {
|
||||
Msg.STR["cat_images"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_documents"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_music"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_videos"]: tk.BooleanVar(value=False)
|
||||
}
|
||||
self.frequency = tk.StringVar(value="daily")
|
||||
|
||||
type_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["backup_type"], padding=10)
|
||||
type_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type,
|
||||
value="system", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type,
|
||||
value="user", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
|
||||
dest_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["dest_folder"], padding=10)
|
||||
dest_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Entry(dest_frame, textvariable=self.destination, state="readonly",
|
||||
width=50).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
ttk.Button(dest_frame, text=Msg.STR["browse"], command=self._select_destination).pack(
|
||||
side=tk.RIGHT)
|
||||
|
||||
self.user_sources_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["source_folders"], padding=10)
|
||||
for name, var in self.user_sources.items():
|
||||
ttk.Checkbutton(self.user_sources_frame, text=name,
|
||||
variable=var).pack(anchor=tk.W)
|
||||
self._toggle_user_sources()
|
||||
|
||||
freq_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["frequency"], padding=10)
|
||||
freq_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_daily"],
|
||||
variable=self.frequency, value="daily").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_weekly"],
|
||||
variable=self.frequency, value="weekly").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_monthly"],
|
||||
variable=self.frequency, value="monthly").pack(anchor=tk.W)
|
||||
|
||||
add_button_frame = ttk.Frame(self.add_job_frame)
|
||||
add_button_frame.pack(pady=10)
|
||||
ttk.Button(add_button_frame, text=Msg.STR["save"], command=self._save_job).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
ttk.Button(add_button_frame, text="Zurück",
|
||||
command=self._toggle_scheduler_view).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Initially, hide the add_job_frame
|
||||
self.add_job_frame.pack_forget()
|
||||
|
||||
def show(self):
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
self._load_scheduled_jobs()
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
||||
def _toggle_scheduler_view(self):
|
||||
if self.jobs_frame.winfo_ismapped():
|
||||
self.jobs_frame.pack_forget()
|
||||
self.add_job_frame.pack(fill=tk.BOTH, expand=True)
|
||||
else:
|
||||
self.add_job_frame.pack_forget()
|
||||
self.jobs_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def _toggle_user_sources(self):
|
||||
if self.backup_type.get() == "user":
|
||||
self.user_sources_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
else:
|
||||
self.user_sources_frame.pack_forget()
|
||||
|
||||
def _select_destination(self):
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
result = dialog.get_result()
|
||||
if result:
|
||||
self.destination.set(result)
|
||||
|
||||
def _save_job(self):
|
||||
dest = self.destination.get()
|
||||
if not dest:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
job_type = self.backup_type.get()
|
||||
job_frequency = self.frequency.get()
|
||||
job_sources = []
|
||||
|
||||
if job_type == "user":
|
||||
job_sources = [name for name,
|
||||
var in self.user_sources.items() if var.get()]
|
||||
if not job_sources:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
|
||||
return
|
||||
|
||||
script_path = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), "..", "main_app.py"))
|
||||
command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\""
|
||||
if job_type == "user":
|
||||
command += f" --sources "
|
||||
for s in job_sources:
|
||||
command += f'\"{s}\" ' # This line has an issue with escaping
|
||||
|
||||
comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}"
|
||||
if job_type == "user":
|
||||
comment += f"; sources:{','.join(job_sources)}"
|
||||
|
||||
job_details = {
|
||||
"command": command,
|
||||
"comment": comment,
|
||||
"type": job_type,
|
||||
"frequency": job_frequency,
|
||||
"destination": dest,
|
||||
"sources": job_sources
|
||||
}
|
||||
self.backup_manager.add_scheduled_job(job_details)
|
||||
self._load_scheduled_jobs()
|
||||
self._toggle_scheduler_view()
|
||||
|
||||
def _load_scheduled_jobs(self):
|
||||
for i in self.jobs_tree.get_children():
|
||||
self.jobs_tree.delete(i)
|
||||
jobs = self.backup_manager.get_scheduled_jobs()
|
||||
for job in jobs:
|
||||
self.jobs_tree.insert("", "end", values=(
|
||||
job["active"], job["type"], job["frequency"], job["destination"], ", ".join(
|
||||
job["sources"])
|
||||
), iid=job["id"])
|
||||
|
||||
def _remove_selected_job(self):
|
||||
selected_item = self.jobs_tree.focus()
|
||||
if not selected_item:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title="Fehler", text="Kein Job ausgewählt.")
|
||||
return
|
||||
|
||||
job_id = self.jobs_tree.item(selected_item)["values"][0]
|
||||
self.backup_manager.remove_scheduled_job(job_id)
|
||||
self._load_scheduled_jobs()
|
||||
Reference in New Issue
Block a user