Files
Py-Backup/pyimage_ui/drawing.py
Désiré Werner Menrath 0b9c58410f feat: Rework backup list and space calculation
- Improves the backup list display with chronological sorting, colored grouping for full/incremental backups, and dedicated time column.
- Changes backup folder naming to a consistent dd-mm-yyyy_HH:MM:SS format.
- Fixes bug where backup size was not displayed.
- Adds detection for encrypted backup containers, showing them correctly in the list.
- Hardens destination space check:
  - Considers extra space needed for compressed backups.
  - Disables start button if projected usage is > 95% or exceeds total disk space.
2025-09-04 14:20:57 +02:00

418 lines
18 KiB
Python

# pyimage/ui/drawing.py
import tkinter as tk
from pbp_app_config import AppConfig, Msg
import os
import threading
from shared_libs.animated_icon import AnimatedIcon
class Drawing:
def __init__(self, app):
self.app = app
def _start_calculating_animation(self, canvas):
# Stop animation for the specific canvas
self._stop_calculating_animation(canvas)
animation_type = self.app.config_manager.get_setting(
"calculation_animation_type", "double_arc")
if canvas == self.app.left_canvas:
self.app.left_canvas_animation = AnimatedIcon(
canvas, width=20, height=20, animation_type=animation_type, use_pillow=True)
self.app.left_canvas_animation.start()
else: # right_canvas
self.app.right_canvas_animation = AnimatedIcon(
canvas, width=20, height=20, animation_type=animation_type, use_pillow=True)
self.app.right_canvas_animation.start()
def _stop_calculating_animation(self, canvas=None):
if canvas is None: # Stop all
if self.app.left_canvas_animation:
self.app.left_canvas_animation.stop()
self.app.left_canvas_animation.destroy()
self.app.left_canvas_animation = None
if self.app.right_canvas_animation:
self.app.right_canvas_animation.stop()
self.app.right_canvas_animation.destroy()
self.app.right_canvas_animation = None
return
animation_to_stop = None
if canvas == self.app.left_canvas:
animation_to_stop = self.app.left_canvas_animation
self.app.left_canvas_animation = None
else:
animation_to_stop = self.app.right_canvas_animation
self.app.right_canvas_animation = None
if animation_to_stop:
animation_to_stop.stop()
animation_to_stop.destroy()
def redraw_left_canvas(self, event=None):
canvas = self.app.left_canvas
canvas.delete("all")
width = canvas.winfo_width()
height = canvas.winfo_height()
left_canvas_title = Msg.STR["source"] if self.app.mode == "backup" else Msg.STR["destination"]
canvas.create_text(10, 10, anchor="nw", text=left_canvas_title, font=(
AppConfig.UI_CONFIG["font_family"], 12, "bold"))
icon_name = self.app.left_canvas_data.get('icon')
if icon_name:
icon = self.app.image_manager.get_icon(icon_name)
if icon:
canvas.create_image(width / 2, 60, image=icon)
folder_name = self.app.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.app.left_canvas_data.get('size', '')
if size_text:
canvas.create_text(width / 2, 140, text=size_text)
extra_info = self.app.left_canvas_data.get('extra_info', '')
if extra_info:
canvas.create_text(width / 2, 160, text=extra_info, font=(
AppConfig.UI_CONFIG["font_family"], 12, "bold"), fill="black")
path_display = self.app.left_canvas_data.get('path_display', '')
if path_display:
canvas.create_text(width / 2, 180, text=path_display, font=(
AppConfig.UI_CONFIG["font_family"], 10), fill="gray")
if self.app.left_canvas_data.get('calculating', False):
if self.app.left_canvas_animation:
self.app.left_canvas_animation.place(
x=width/2 + 70, y=140, anchor="center")
def redraw_right_canvas(self, event=None):
"""Dispatches to the correct drawing method based on the current mode."""
if self.app.mode == "backup":
self.redraw_right_canvas_backup(event)
else: # "restore"
self.redraw_right_canvas_restore(event)
def redraw_right_canvas_backup(self, event=None):
canvas = self.app.right_canvas
canvas.delete("all")
width = canvas.winfo_width()
height = canvas.winfo_height()
right_canvas_title = Msg.STR["destination"]
canvas.create_text(10, 10, anchor="nw", text=right_canvas_title, font=(
AppConfig.UI_CONFIG["font_family"], 12, "bold"))
icon_name = 'hdd_extralarge'
if icon_name:
icon = self.app.image_manager.get_icon(icon_name)
if icon:
canvas.create_image(width / 2, 60, image=icon)
folder_name = self.app.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.app.right_canvas_data.get('size', '')
if size_text:
canvas.create_text(width / 2, 140, text=size_text)
path_display = self.app.right_canvas_data.get('path_display', '')
if path_display:
canvas.create_text(width / 2, 160, text=path_display, font=(
AppConfig.UI_CONFIG["font_family"], 10), fill="gray")
def redraw_right_canvas_restore(self, event=None):
canvas = self.app.right_canvas
canvas.delete("all")
width = canvas.winfo_width()
height = canvas.winfo_height()
right_canvas_title = Msg.STR["source"]
canvas.create_text(10, 10, anchor="nw", text=right_canvas_title, font=(
AppConfig.UI_CONFIG["font_family"], 12, "bold"))
icon_name = 'hdd_extralarge'
if icon_name:
icon = self.app.image_manager.get_icon(icon_name)
if icon:
canvas.create_image(width / 2, 60, image=icon)
folder_name = self.app.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.app.right_canvas_data.get('size', '')
if size_text:
canvas.create_text(width / 2, 140, text=size_text)
path_display = self.app.right_canvas_data.get('path_display', '')
if path_display:
canvas.create_text(width / 2, 160, text=path_display, font=(
AppConfig.UI_CONFIG["font_family"], 10), fill="gray")
if self.app.right_canvas_data.get('calculating', False):
if self.app.right_canvas_animation:
self.app.right_canvas_animation.place(
x=width/2 + 70, y=140, anchor="center")
def start_backup_calculation_display(self):
self._start_calculating_animation(self.app.left_canvas)
self.redraw_left_canvas()
def calculate_restore_folder_size(self):
if self.app.right_calculation_thread and self.app.right_calculation_thread.is_alive():
self.app.right_calculation_stop_event.set()
self.app.start_pause_button.config(state="disabled")
path_to_calculate = self.app.right_canvas_data.get('path_display')
if path_to_calculate and os.path.isdir(path_to_calculate):
self.app.right_canvas_data['calculating'] = True
self.app.right_canvas_data['size'] = Msg.STR['calculating_size']
self._start_calculating_animation(self.app.right_canvas)
self.redraw_right_canvas_restore()
self.app.right_calculation_stop_event = threading.Event()
self.app.right_calculation_thread = threading.Thread(target=self._calculate_and_update_restore_size, args=(
path_to_calculate, self.app.right_calculation_stop_event), daemon=True)
self.app.right_calculation_thread.start()
def _calculate_and_update_restore_size(self, path, stop_event):
total_size = 0
try:
for dirpath, dirnames, filenames in os.walk(path):
if stop_event.is_set():
return
for f in filenames:
if stop_event.is_set():
return
fp = os.path.join(dirpath, f)
if not os.path.islink(fp):
try:
total_size += os.path.getsize(fp)
except OSError:
pass # Ignore files that can't be accessed
except OSError as e:
print(f"Error calculating size for {path}: {e}")
size_text = "Error"
else:
if stop_event.is_set():
return
size_gb = total_size / (1024**3)
size_text = f"{size_gb:.2f} GB"
self.app.restore_source_size_bytes = total_size
def update_ui():
if stop_event.is_set():
return
self.app.right_canvas_data['size'] = size_text
self.app.right_canvas_data['calculating'] = False
self._stop_calculating_animation(self.app.right_canvas)
self.redraw_right_canvas_restore()
if not self.app.left_canvas_data.get('calculating', False):
self.app.start_pause_button.config(state="normal")
if not stop_event.is_set():
self.app.after(0, update_ui)
def update_target_projection(self):
if self.app.mode == "restore":
self.update_restore_projection_before()
self.update_restore_projection_after()
return
if self.app.destination_total_bytes == 0:
return
canvas = self.app.target_size_canvas
canvas.delete("all")
canvas_width = canvas.winfo_width()
if canvas_width <= 1:
self.app.after(50, self.update_target_projection)
return
# Determine required space, considering compression
required_space = self.app.source_size_bytes
if self.app.compressed_var.get():
required_space *= 2 # Double the space for compression process
projected_total_used = self.app.destination_used_bytes + required_space
if self.app.destination_total_bytes > 0:
projected_total_percentage = projected_total_used / self.app.destination_total_bytes
else:
projected_total_percentage = 0
info_font = (AppConfig.UI_CONFIG["font_family"], 10, "bold")
info_messages = []
# First, check for critical space issues
if projected_total_used > self.app.destination_total_bytes or projected_total_percentage >= 0.95:
self.app.start_pause_button.config(state="disabled")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#D32F2F", outline="") # Red bar
info_messages.append(Msg.STR["warning_not_enough_space"])
elif projected_total_percentage >= 0.90:
self.app.start_pause_button.config(state="normal")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#E8740C", outline="") # Orange bar
info_messages.append(Msg.STR["warning_space_over_90_percent"])
else:
# Only enable the button if the source is not larger than the partition itself
if not self.app.source_larger_than_partition:
self.app.start_pause_button.config(state="normal")
else:
self.app.start_pause_button.config(state="disabled")
used_percentage = self.app.destination_used_bytes / self.app.destination_total_bytes
used_width = canvas_width * used_percentage
canvas.create_rectangle(0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
# Draw the projected part only if there is space
projected_percentage = self.app.source_size_bytes / self.app.destination_total_bytes
projected_width = canvas_width * projected_percentage
canvas.create_rectangle(used_width, 0, used_width + projected_width, canvas.winfo_height(), fill="#50E6FF", outline="")
# Add other informational messages if no critical warnings are present
if not info_messages:
if self.app.source_larger_than_partition:
info_messages.append(Msg.STR["warning_source_larger_than_partition"])
elif self.app.is_first_backup:
info_messages.append(Msg.STR["ready_for_first_backup"])
elif self.app.mode == "backup":
info_messages.append(Msg.STR["backup_mode_info"])
else:
info_messages.append(Msg.STR["restore_mode_info"])
self.app.info_label.config(text="\n".join(info_messages), font=info_font)
self.app.target_size_label.config(
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
def update_restore_projection_before(self):
if self.app.destination_total_bytes == 0:
return
canvas = self.app.restore_size_canvas_before
canvas.delete("all")
canvas_width = canvas.winfo_width()
if canvas_width <= 1:
self.app.after(50, self.update_restore_projection_before)
return
used_percentage = self.app.destination_used_bytes / \
self.app.destination_total_bytes
used_width = canvas_width * used_percentage
canvas.create_rectangle(
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
self.app.restore_size_label_before.config(
text=f"{self.app.destination_used_bytes / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
def update_restore_projection_after(self):
if self.app.destination_total_bytes == 0:
return
canvas = self.app.restore_size_canvas_after
canvas.delete("all")
canvas_width = canvas.winfo_width()
if canvas_width <= 1:
self.app.after(50, self.update_restore_projection_after)
return
destination_folder_bytes = getattr(
self.app, 'restore_destination_folder_size_bytes', 0)
source_bytes = getattr(self.app, 'restore_source_size_bytes', 0)
size_diff_bytes = source_bytes - destination_folder_bytes
projected_total_used = self.app.destination_used_bytes + size_diff_bytes
if self.app.destination_total_bytes > 0:
projected_total_percentage = projected_total_used / \
self.app.destination_total_bytes
else:
projected_total_percentage = 0
if projected_total_percentage >= 0.95:
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(),
fill="#ff0000", outline="") # Red bar
elif projected_total_percentage >= 0.90:
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(),
fill="#ff8c00", outline="") # Orange bar
else:
if self.app.destination_total_bytes > 0:
used_percentage = self.app.destination_used_bytes / \
self.app.destination_total_bytes
used_width = canvas_width * used_percentage
canvas.create_rectangle(
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
if size_diff_bytes > 0:
projected_percentage = size_diff_bytes / self.app.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="")
elif size_diff_bytes < 0:
final_used_width = canvas_width * \
(projected_total_used / self.app.destination_total_bytes)
canvas.delete("all")
canvas.create_rectangle(
0, 0, final_used_width, canvas.winfo_height(), fill="#0078d7", outline="")
size_diff_gb = size_diff_bytes / (1024**3)
diff_text = f"+{size_diff_gb:.2f} GB" if size_diff_gb >= 0 else f"{size_diff_gb:.2f} GB"
diff_color = "blue"
self.app.restore_size_label_diff.config(
text=diff_text, foreground=diff_color)
self.app.restore_size_label_after.config(
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
def update_nav_buttons(self, active_index):
for i, button in enumerate(self.app.nav_buttons):
if i == active_index:
button.configure(style="Toolbutton")
self.app.nav_progress_bars[i].pack(side=tk.BOTTOM, fill=tk.X)
self.app.nav_progress_bars[i]['value'] = 100
else:
button.configure(style="Gray.Toolbutton")
self.app.nav_progress_bars[i].pack_forget()
def reset_projection_canvases(self):
# Reset data variables related to size calculations
self.app.source_size_bytes = 0
self.app.restore_source_size_bytes = 0
self.app.restore_destination_folder_size_bytes = 0
self.app.source_larger_than_partition = False
# Clear backup mode canvases and labels
self.app.source_size_canvas.delete("all")
self.app.target_size_canvas.delete("all")
self.app.source_size_label.config(text="0.00 GB / 0.00 GB")
self.app.target_size_label.config(text="0.00 GB / 0.00 GB")
# Clear restore mode canvases and labels
self.app.restore_size_canvas_before.delete("all")
self.app.restore_size_canvas_after.delete("all")
self.app.restore_size_label_before.config(text="0.00 GB / 0.00 GB")
self.app.restore_size_label_after.config(text="0.00 GB / 0.00 GB")
if hasattr(self.app, 'restore_size_label_diff'):
self.app.restore_size_label_diff.config(text="")
# Also reset the main info label
self.app.info_label.config(text="")
def reset_all_canvases(self):
self.reset_projection_canvases()
self.app.left_canvas.delete("all")
self.app.right_canvas.delete("all")
self.app.left_canvas_data.clear()
self.app.right_canvas_data.clear()
self.redraw_left_canvas()
self.redraw_right_canvas()