class Window: def __init__(self, workstation: WorkstationSettings, wallpaper: Image.Image): self._root = Tk() self._root.title("wallcrop") self._root.minsize(*_MINSIZE) self._show_monitor_labels = BooleanVar(self._root, value=False) self._show_unselected_area = BooleanVar(self._root, value=True) frame = Frame(self._root, padding=_PADDING) frame.grid(column=0, row=0, sticky="n w s e") self._root.columnconfigure(0, weight=1) # type: ignore self._root.rowconfigure(0, weight=1) # type: ignore frame_top = Frame(frame) frame_top.grid(column=0, row=0, sticky="n w s e") frame_bot = Frame(frame) frame_bot.grid(column=0, row=2, sticky="n w s e") self._selection = Selection(aspect_ratio=np.divide(*wallpaper.size)) self._selection.register_onchange_handler(self._reset_spinbox_values) self._selection_widget = SelectionWidget( parent=frame, workstation=workstation, wallpaper=wallpaper, selection=self._selection, ) self._selection_widget.set_show_monitor_labels( self._show_monitor_labels.get()) self._selection_widget.set_show_unselected_area( self._show_unselected_area.get()) self._selection_widget.grid(column=0, row=1, sticky="n w s e", pady=_PADDING) frame.columnconfigure(0, weight=1) # type: ignore frame.rowconfigure(1, weight=1) # type: ignore label_wallpaper = Label(frame_top, text=workstation.name) label_wallpaper.grid(column=0, row=0) # Center columns 1-6 on frame_bot. frame_bot.columnconfigure(0, weight=1) # type: ignore frame_bot.columnconfigure(7, weight=1) # type: ignore # TODO: Figure out how to not have spinbox show zero when using # increment/decrement buttons. label_selection_position_x = Label(frame_bot, text="X: ") label_selection_position_x.grid(column=1, row=0) self._spinbox_selection_position_x = Spinbox(frame_bot, width=5, validate="focusout") self._spinbox_selection_position_x.grid(column=2, row=0) label_selection_position_y = Label(frame_bot, text=" Y: ") label_selection_position_y.grid(column=3, row=0) self._spinbox_selection_position_y = Spinbox(frame_bot, width=5, validate="focusout") self._spinbox_selection_position_y.grid(column=4, row=0) label_selection_zoom = Label(frame_bot, text=" Zoom: ") label_selection_zoom.grid(column=5, row=0) self._spinbox_selection_zoom = Spinbox(frame_bot, width=5, validate="focusout") self._spinbox_selection_zoom.grid(column=6, row=0) self._bind_actions() self._set_up_menubar() self._reset_spinbox_values() def _bind_actions(self) -> None: self._root.bind("<Escape>", lambda _event: self.quit()) self._root.bind("<q>", lambda _event: self.quit()) self._root.bind( "<m>", lambda _event: self._show_monitor_labels.set(not ( self._show_monitor_labels.get())), ) self._root.bind( "<n>", lambda _event: self._show_unselected_area.set(not ( self._show_unselected_area.get())), ) self._root.bind("<i>", lambda _event: self._selection.zoom_increase()) self._root.bind( "<I>", lambda _event: self._selection.zoom_increase(precise=True)) self._root.bind("<o>", lambda _event: self._selection.zoom_decrease()) self._root.bind( "<O>", lambda _event: self._selection.zoom_decrease(precise=True)) self._root.bind("<h>", lambda _event: self._selection.move_left()) self._root.bind("<H>", lambda _event: self._selection.move_left(precise=True)) self._root.bind("<Left>", lambda _event: self._selection.move_left()) self._root.bind("<Shift-Left>", lambda _event: self._selection.move_left(precise=True)) self._root.bind("<l>", lambda _event: self._selection.move_right()) self._root.bind( "<L>", lambda _event: self._selection.move_right(precise=True)) self._root.bind("<Right>", lambda _event: self._selection.move_right()) self._root.bind( "<Shift-Right>", lambda _event: self._selection.move_right(precise=True)) self._root.bind("<k>", lambda _event: self._selection.move_up()) self._root.bind("<K>", lambda _event: self._selection.move_up(precise=True)) self._root.bind("<Up>", lambda _event: self._selection.move_up()) self._root.bind("<Shift-Up>", lambda _event: self._selection.move_up(precise=True)) self._root.bind("<j>", lambda _event: self._selection.move_down()) self._root.bind("<J>", lambda _event: self._selection.move_down(precise=True)) self._root.bind("<Down>", lambda _event: self._selection.move_down()) self._root.bind("<Shift-Down>", lambda _event: self._selection.move_down(precise=True)) self._spinbox_selection_position_x.configure( validatecommand=lambda *_args: self._set_selection_position_x()) self._spinbox_selection_position_x.bind( "<Return>", lambda _event: self._set_selection_position_x() # type: ignore ) self._spinbox_selection_position_x.bind( "<<Decrement>>", lambda _event: self._selection.move_left()) self._spinbox_selection_position_x.bind( "<<Increment>>", lambda _event: self._selection.move_right()) self._spinbox_selection_position_y.configure( validatecommand=lambda *_args: self._set_selection_position_y()) self._spinbox_selection_position_y.bind( "<Return>", lambda _event: self._set_selection_position_y() # type: ignore ) self._spinbox_selection_position_y.bind( "<<Decrement>>", lambda _event: self._selection.move_up()) self._spinbox_selection_position_y.bind( "<<Increment>>", lambda _event: self._selection.move_down()) self._spinbox_selection_zoom.configure( validatecommand=lambda *_args: self._set_selection_zoom()) self._spinbox_selection_zoom.bind( "<Return>", lambda _event: self._set_selection_zoom() # type: ignore ) self._spinbox_selection_zoom.bind( "<<Decrement>>", lambda _event: self._selection.zoom_decrease()) self._spinbox_selection_zoom.bind( "<<Increment>>", lambda _event: self._selection.zoom_increase()) self._show_monitor_labels.trace_add( "write", lambda *_args: self._selection_widget.set_show_monitor_labels( self._show_monitor_labels.get()), ) self._show_unselected_area.trace_add( "write", lambda *_args: self._selection_widget.set_show_unselected_area( self._show_unselected_area.get()), ) def _set_up_menubar(self) -> None: # TODO: check that this look good on macOS, as described here: # https://tkdocs.com/tutorial/menus.html#platformmenus self._root.option_add("*tearOff", False) menu = Menu(self._root) menu_file = Menu(menu) menu_file.add_command( # type: ignore label="Quit", underline=0, accelerator="Q, Escape", command=self.quit) menu.add_cascade(menu=menu_file, label="File", underline=0) # type: ignore menu_edit = Menu(menu) menu_edit.add_command( # type: ignore label="Move Left", underline=5, accelerator="H, Left", command=self._selection.move_left, ) menu_edit.add_command( # type: ignore label="Move Right", underline=5, accelerator="L, Right", command=self._selection.move_right, ) menu_edit.add_command( # type: ignore label="Move Up", underline=5, accelerator="K, Up", command=self._selection.move_up, ) menu_edit.add_command( # type: ignore label="Move Down", underline=5, accelerator="J, Down", command=self._selection.move_down, ) menu_edit.add_separator() # type: ignore menu_edit.add_command( # type: ignore label="Increase Zoom", underline=0, accelerator="I", command=self._selection.zoom_increase, ) menu_edit.add_command( # type: ignore label="Decrease Zoom", underline=10, accelerator="O", command=self._selection.zoom_decrease, ) menu.add_cascade(menu=menu_edit, label="Edit", underline=0) # type: ignore menu_view = Menu(menu) menu_view.add_checkbutton( # type: ignore label="Label Monitors", variable=self._show_monitor_labels, underline=6, accelerator="M", ) menu_view.add_checkbutton( # type: ignore label="Show Unselected", variable=self._show_unselected_area, underline=6, accelerator="N", ) menu.add_cascade(menu=menu_view, label="View", underline=0) # type: ignore menu_help = Menu(menu, name="help") menu_help.add_command( # type: ignore label="About Wallcrop", underline=0, command=lambda: messagebox.showinfo( parent=self._root, title="About Wallcrop", message=f"Wallcrop {wallcrop.__version__}", detail=("Copyright 2021 Lukas Schmelzeisen.\n" "Licensed under the Apache License, Version 2.0.\n" "https://github.com/lschmelzeisen/wallcrop/"), ), ) menu.add_cascade(menu=menu_help, label="Help", underline=3) # type: ignore self._root["menu"] = menu def mainloop(self) -> None: self._root.mainloop() def quit(self) -> None: self._root.destroy() def _set_selection_position_x(self) -> bool: try: value = float( self._spinbox_selection_position_x.get()) # type: ignore except ValueError: self._spinbox_selection_position_x.set(self._selection.position[0]) return False self._selection.set_position( np.array((value, self._selection.position[1]))) return True def _set_selection_position_y(self) -> bool: try: value = float( self._spinbox_selection_position_y.get()) # type: ignore except ValueError: self._spinbox_selection_position_y.set(self._selection.position[1]) return False self._selection.set_position( np.array((self._selection.position[0], value))) return True def _set_selection_zoom(self) -> bool: try: value = float(self._spinbox_selection_zoom.get()) # type: ignore except ValueError: self._spinbox_selection_zoom.set(self._selection.zoom) return False self._selection.set_zoom(value) return True def _reset_spinbox_values(self) -> None: self._spinbox_selection_position_x.set( f"{self._selection.position[0]:.3f}") self._spinbox_selection_position_y.set( f"{self._selection.position[1]:.3f}") self._spinbox_selection_zoom.set(f"{self._selection.zoom:.3f}")
class TkApp(Tk): """ The main Tk class for the gui of simplebackup """ def __init__(self, **kwargs): super().__init__() title = "Simple Backup | V" + __version__ self.wm_title(title) self.protocol("WM_DELETE_WINDOW", self.on_closing) self.__thread = None self.__files_found = 0 self.__files_copied = 0 config_fn = kwargs.get("config_fn", user_config_filepath()) self.__app_config = Config_Handler(config_fn) self.__curr_config = self.__app_config.default_config_i self.__menu = Menu(self) self.__menu_file = Menu(self.__menu, tearoff=0) self.__menu_file.add_command(label="Quit", command=self.quit) self.__menu_config = Menu(self.__menu, tearoff=0) self.__menu_config.add_command(label="New", command=self.new_config) self.__menu_config.add_command(label="Load", command=self.switch_config) self.__menu_config.add_command(label="Change Default", command=self.change_default_config) self.__menu_config.add_command(label="Rename Current", command=self.rename_curr_conf) self.__menu_config.add_separator() self.__menu_config.add_command(label="Delete Current", command=self.delete_current_config) self.__menu_config.add_command(label="Delete All", command=self.reset_config) self.__menu_help = Menu(self.__menu, tearoff=0) self.__menu_help.add_command(label="Check for Updates", command=self.show_update_popup) self.__menu_help.add_command(label="About", command=self.show_about_popup) self.__menu.add_cascade(label="File", menu=self.__menu_file) self.__menu.add_cascade(label="Config", menu=self.__menu_config) self.__menu.add_cascade(label="Help", menu=self.__menu_help) self.__title_l = Label(self, text=title, font=(16)) self.__curr_config_name_l = Label(self) self.__last_backup_l = Label(self) self.__set_versions_to_keep = Button( self, text="Set Versions To Keep", command=self.update_versions_to_keep) self.__versions_to_keep_l = Label(self) self.__inc_folder_bnt = Button(self, text="Include Another Folder", command=self.add_included_folder) self.__included_folders_lb = Listbox(self, height=4) self.__included_folders_lb.bind("<<ListboxSelect>>", self.remove_selected_included_folder) self.__included_folders_lb.bind('<FocusOut>', self.deselect_included_folder) self.__excl_folder_bnt = Button(self, text="Exclude Another Folder", command=self.add_excluded_folder) self.__excluded_folders_lb = Listbox(self, height=4) self.__excluded_folders_lb.bind("<<ListboxSelect>>", self.remove_selected_excluded_folder) self.__excluded_folders_lb.bind('<FocusOut>', self.deselect_excluded_folder) self.__backup_to_bnt = Button(self, text="Backup Folder", command=self.set_backup_folder) self.__backup_folder_l = Label(self) self.__use_tar_l = Label(self, text="Use Tar") self.__use_tar_var = BooleanVar(self) self.__use_tar_var.trace_add("write", self.use_tar_changed) self.__use_tar = Checkbutton(self, variable=self.__use_tar_var) self.__backup_start_bnt = Button(self, text="Start Backup", command=self.start_backup) self.__progress = Progressbar(self) self.__statusbar = Label(self, text="ok", relief=SUNKEN, anchor=W) self._load_display() self._layout() if self.__app_config.show_help: self.show_help_popup() def on_closing(self): """ called on window close """ if self.__files_found != self.__files_copied: if messagebox.askyesno("Backup Running", "Do you want to stop the backup?"): self.destroy() else: self.destroy() def _load_display(self): """ load the widgets with data from the current backup config, should be run after loading a config from file and at app launch """ self.__versions_to_keep = self.__app_config.get_versions_to_keep( self.__curr_config) self.__included_folders = self.__app_config.get_included_folders( self.__curr_config) self.__excluded_folders = self.__app_config.get_excluded_folders( self.__curr_config) self.__backup_location = self.__app_config.get_backup_path( self.__curr_config) curr_conf_name = self.__app_config.get_config_name(self.__curr_config) self.__curr_config_name_l.config(text=f"Config Name: {curr_conf_name}") self.__last_backup_l.config( text= f"Last Known Backup: {self.__app_config.get_human_last_backup(self.__curr_config)}" ) self.__versions_to_keep_l.config(text=self.__versions_to_keep) self.__included_folders_lb.delete(0, END) self.__included_folders_lb.insert(0, *self.__included_folders) self.__excluded_folders_lb.delete(0, END) self.__excluded_folders_lb.insert(0, *self.__excluded_folders) self.__backup_folder_l.config(text=str(self.__backup_location)) self.__use_tar_var.set( self.__app_config.get_use_tar(self.__curr_config)) def switch_config(self): """ switches what config to use for backup, asks the user for a config to load, then loads the display """ next_combo = ask_combobox("Load Config", "Config Name", self.__app_config.get_config_names()) if next_combo != None: self.__curr_config = next_combo self._load_display() def change_default_config(self): """ switches what config to use for the default backup, asks the user for a config to load """ next_combo = ask_combobox("Default Config", "Config Name", self.__app_config.get_config_names()) if next_combo != None: self.__app_config.default_config_i = next_combo def rename_curr_conf(self): """ rename a existing config, will ask the user in a popup string input """ new_name = simpledialog.askstring("Rename Config", "New Name") if new_name: self.__app_config.rename_config(self.__curr_config, new_name) self._load_display() def new_config(self): """ creates a new empty backup config, asks the user for config name """ name = simpledialog.askstring("New Config", "Config Name") if name: self.__app_config.create_config(name) def delete_current_config(self): """ deletes the current selected config, asks the user to confirm """ if messagebox.askyesno( "Confirm Delete", "Are you sure you want to delete the current config?"): self.__app_config.remove_config(self.__curr_config) self.__curr_config = self.__app_config.default_config_i self._load_display() def reset_config(self): """ resets all the user configs, asks the user to confirm """ if messagebox.askyesno( "Confirm Reset", "Are you sure you want to reset the all configurations?"): self.__app_config.reset_config() self.__curr_config = self.__app_config.default_config_i self._load_display() def use_tar_changed(self, *args): """ called each time the __use_tar_var is called """ self.__app_config.set_use_tar(self.__curr_config, self.__use_tar_var.get()) def update_versions_to_keep(self): """ update the number of versions to keep, asks the user for a integer """ new_val = simpledialog.askinteger( "Versions To Keep", "How many backups do you want to keep", minvalue=0) if new_val != self.__versions_to_keep and new_val != None: self.__versions_to_keep = new_val self.__app_config.set_versions_to_keep(self.__curr_config, self.__versions_to_keep) self.__versions_to_keep_l.config(text=self.__versions_to_keep) def deselect_included_folder(self, *args): """ deselects the selected element in included folder """ self.__included_folders_lb.selection_clear(0, END) def deselect_excluded_folder(self, *args): """ deselects the selected element in excluded folder """ self.__excluded_folders_lb.selection_clear(0, END) def add_included_folder(self): """ add a folder to include in the backup, will ask user for a directory """ folder = filedialog.askdirectory(initialdir="/", title="Select Folder To Backup") if folder: folder_path = Path(folder) if folder_path != self.__backup_location: self.__included_folders.append(folder_path) self.__included_folders_lb.insert(END, folder_path) self.__app_config.set_included_folders(self.__curr_config, self.__included_folders) else: messagebox.showwarning( title="Folder Same As Backup Path", message= "You selected a folder that was the same as the backup path!" ) def remove_selected_included_folder(self, *args): """ remove the currently selected item in the included folders ListBox, will ask the user to confirm """ curr_selection = self.__included_folders_lb.curselection() # check if there is a selection if curr_selection: if messagebox.askyesno("Confirm Delete", "Are you want to delete this folder?"): index_to_del = curr_selection[0] self.__included_folders.pop(index_to_del) self.__app_config.set_included_folders(self.__curr_config, self.__included_folders) self.__included_folders_lb.delete(index_to_del) self.deselect_included_folder() def add_excluded_folder(self): """ add a folder to exclude in the backup, will ask user for a directory """ folder = filedialog.askdirectory(initialdir="/", title="Select Folder To Exclude") if folder: folder_path = Path(folder) self.__excluded_folders.append(folder_path) self.__excluded_folders_lb.insert(END, folder_path) self.__app_config.set_excluded_folders(self.__curr_config, self.__excluded_folders) def remove_selected_excluded_folder(self, *args): """ remove the currently selected item in the excluded folders ListBox, will ask the user to confirm """ curr_selection = self.__excluded_folders_lb.curselection() # check if there is a selection if curr_selection: if messagebox.askyesno("Confirm Delete", "Are you want to delete this folder?"): index_to_del = curr_selection[0] self.__excluded_folders.pop(index_to_del) self.__app_config.set_excluded_folders(self.__curr_config, self.__excluded_folders) self.__excluded_folders_lb.delete(index_to_del) self.deselect_excluded_folder() def set_backup_folder(self): """ sets the backup folder by asking the user for a base directory """ folder = filedialog.askdirectory(initialdir="/", title="Select Where To Backup To") if folder: self.__backup_location = Path(folder) self.__backup_folder_l.config(text=folder) self.__app_config.set_backup_path(self.__curr_config, self.__backup_location) def enable_gui(self): """ enable the gui buttons, run when a backup has completed """ self.__set_versions_to_keep.config(state=NORMAL) self.__inc_folder_bnt.config(state=NORMAL) self.__included_folders_lb.config(state=NORMAL) self.__excl_folder_bnt.config(state=NORMAL) self.__excluded_folders_lb.config(state=NORMAL) self.__backup_to_bnt.config(state=NORMAL) self.__use_tar.config(state=NORMAL) self.__backup_start_bnt.config(state=NORMAL) def disable_gui(self): """ disable the gui buttons, run when a backup is started """ self.__set_versions_to_keep.config(state=DISABLED) self.__inc_folder_bnt.config(state=DISABLED) self.__included_folders_lb.config(state=DISABLED) self.__excl_folder_bnt.config(state=DISABLED) self.__excluded_folders_lb.config(state=DISABLED) self.__backup_to_bnt.config(state=DISABLED) self.__use_tar.config(state=DISABLED) self.__backup_start_bnt.config(state=DISABLED) def progress_find_incr(self, finished=False): """ increment the progress bar for finding files by 1 or mark as finished :param finished: mark the progressbar as finished """ if finished: self.__progress.config(mode="determinate") self.__progress.config(value=0, maximum=self.__files_found) self.__statusbar.config(text=f"Found {self.__files_found} Files") else: self.__files_found += 1 self.__progress.config(value=self.__files_found) self.__statusbar.config( text=f"Searching For Files, Found {self.__files_found} Files") def progress_copy_incr(self): """ increment the progress bar for copying files by 1 or mark as finished """ self.__files_copied += 1 self.__progress.config(value=self.__files_copied) self.__statusbar.config( text=f"Copying Files {self.__files_copied} of {self.__files_found}" ) if self.__files_copied == self.__files_found: self.__app_config.set_last_backup(self.__curr_config, datetime.utcnow()) self.__last_backup_l.config( text= f"Last Known Backup: {self.__app_config.get_human_last_backup(self.__curr_config)}" ) self.__statusbar.config(text=f"Finished Copying Files") messagebox.showinfo(title="Finished Copying Files", message="Finished copying all found files") # reset counters self.__files_found = 0 self.__files_copied = 0 self.__progress.config(value=0, maximum=100) self.enable_gui() def start_backup(self): """ starts the backup """ if not self.__backup_location: # no backup location was selected messagebox.showwarning( title="Backup Location Not Selected", message="You did not select a backup location!") elif not self.__included_folders: # no folders where found to backup messagebox.showwarning( title="No Folders To Backup", message="You did not add any folders to backup!") else: # basic checks passed self.disable_gui() # prep for search of files self.__progress.config(mode="indeterminate") self.__statusbar.config(text=f"Searching For Files") self.__thread = BackupThread( self.__included_folders, self.__excluded_folders, self.__backup_location, self.__versions_to_keep, self.progress_find_incr, self.progress_copy_incr, self.handle_error_message, self.__use_tar_var.get()) # start the background backup thread so GUI wont appear frozen self.__thread.start() def show_about_popup(self): """ show the about popup """ messagebox.showinfo( "About", "simplebackup V" + __version__ + """ is cross-platform backup program written in python. This app was made by enchant97/Leo Spratt. It is licenced under GPL-3.0""") def show_update_popup(self): """ open the default webbrowser to the update url """ webbrowser.open(UPDATE_URL) def show_help_popup(self): messagebox.showinfo( "Welcome", """Welcome to simplebackup, here is some help to get you started: \nIncluding a folder to backup - Press the 'Include Folder' button to add a folder to backup - Remove a entry by clicking on the list below \nExcluding a folder from the backup - Press the 'Exclude Folder' button to skip a folder to backup - Remove a entry by clicking on the list below \nSetting where backups are stored - Click the 'Backup Folder' button to set where backups should be placed \nMultiple backup configs Use the 'Config' button in the titlebar to change varius settings like creating a new config \nVersions to keep This will be the number of backup to keep in the backup folder """) self.__app_config.show_help = False def handle_error_message(self, error_type: ERROR_TYPES): self.__statusbar.config(text="Failed") if error_type is ERROR_TYPES.NO_BACKUP_WRITE_PERMISION: messagebox.showerror("No Write Permission", ERROR_TYPES.NO_BACKUP_WRITE_PERMISION.value) elif error_type is ERROR_TYPES.NO_BACKUP_READ_PERMISION: messagebox.showerror("No Read Permission", ERROR_TYPES.NO_BACKUP_READ_PERMISION.value) elif error_type is ERROR_TYPES.NO_FILES_FOUND_TO_BACKUP: messagebox.showerror("No Files Found", ERROR_TYPES.NO_FILES_FOUND_TO_BACKUP.value) elif error_type is ERROR_TYPES.NO_BACKUP_PATH_FOUND: messagebox.showerror("No Backup Path Found", ERROR_TYPES.NO_BACKUP_PATH_FOUND.value) self.__progress.config(mode="determinate") self.enable_gui() def _layout(self): self.config(menu=self.__menu) self.__title_l.pack(fill=X, pady=10, padx=5) self.__curr_config_name_l.pack(fill=X, padx=5) self.__last_backup_l.pack(fill=X, padx=5) self.__set_versions_to_keep.pack(fill=X, padx=5) self.__versions_to_keep_l.pack(fill=X, padx=5) self.__inc_folder_bnt.pack(fill=X, padx=5) self.__included_folders_lb.pack(fill=X, padx=5) self.__excl_folder_bnt.pack(fill=X, padx=5) self.__excluded_folders_lb.pack(fill=X, padx=5) self.__backup_to_bnt.pack(fill=X, padx=5) self.__backup_folder_l.pack(fill=X, padx=5) self.__use_tar_l.pack(fill=X, padx=5) self.__use_tar.pack(fill=X, padx=5) self.__backup_start_bnt.pack(fill=X, padx=5) self.__progress.pack(fill=X) self.__statusbar.pack(side=BOTTOM, fill=X) self.wm_minsize(300, self.winfo_height()) self.wm_resizable(True, False)