diff --git a/dvr_scan/app/about_window.py b/dvr_scan/app/about_window.py index 5352e8e..b1ae481 100644 --- a/dvr_scan/app/about_window.py +++ b/dvr_scan/app/about_window.py @@ -24,8 +24,6 @@ """ import importlib.resources as resources -import os -import os.path import tkinter as tk import tkinter.filedialog import tkinter.messagebox @@ -133,8 +131,6 @@ def show(self, root: tk.Tk): # can we query widget height? root.grab_release() - if os == "nt": - root.attributes("-disabled", True) window.transient(root) window.focus() @@ -143,8 +139,6 @@ def show(self, root: tk.Tk): def dismiss(): window.grab_release() window.destroy() - if os == "nt": - root.attributes("-disabled", False) root.grab_set() root.focus() diff --git a/dvr_scan/app/application.py b/dvr_scan/app/application.py index 3ec1670..b1ecf73 100644 --- a/dvr_scan/app/application.py +++ b/dvr_scan/app/application.py @@ -20,6 +20,7 @@ from dvr_scan.app.about_window import AboutWindow from dvr_scan.app.common import register_icon +from dvr_scan.app.scan_window import ScanWindow from dvr_scan.config import CONFIG_MAP WINDOW_TITLE = "DVR-Scan" @@ -322,21 +323,39 @@ def __init__(self, root: tk.Widget): class ScanArea: - def __init__(self, root: tk.Widget): - root.columnconfigure(0, weight=1) - root.columnconfigure(1, weight=2) + def __init__(self, root: tk.Tk, frame: tk.Widget): + frame.columnconfigure(0, weight=1) + frame.columnconfigure(1, weight=2) - ttk.Button(root, text="Start").grid( - row=0, column=0, sticky=tk.NSEW, ipady=PADDING, pady=(0, PADDING) + self._start_button = ttk.Button( + frame, text="Start", command=lambda: root.event_generate("<>") ) - self._scan_only = tk.BooleanVar(root, value=False) - ttk.Checkbutton( - root, + self._start_button.grid( + row=0, + column=0, + sticky=tk.NSEW, + ipady=PADDING, + pady=(0, PADDING), + ) + self._scan_only = tk.BooleanVar(frame, value=False) + self._scan_only_button = ttk.Checkbutton( + frame, text="Scan Only", variable=self._scan_only, onvalue=True, offvalue=False, - ).grid(row=1, column=0, sticky=tk.W) + ) + self._scan_only_button.grid(row=1, column=0, sticky=tk.W) + + def disable(self): + self._start_button["text"] = "Scanning..." + self._start_button["state"] = tk.DISABLED + self._scan_only_button["state"] = tk.DISABLED + + def enable(self): + self._start_button["text"] = "Start" + self._start_button["state"] = tk.NORMAL + self._scan_only_button["state"] = tk.NORMAL class Application: @@ -370,15 +389,36 @@ def __init__(self): output_frame.grid(row=2, sticky=tk.EW, padx=PADDING, pady=(PADDING, 0)) scan_frame = ttk.Labelframe(self._root, text="Scan", padding=PADDING) - self._scan = ScanArea(scan_frame) + self._scan = ScanArea(self._root, scan_frame) scan_frame.grid(row=3, sticky=tk.EW, padx=PADDING, pady=PADDING) + self._scan_window: ty.Optional[ScanWindow] = None + self._root.bind("<>", lambda _: self._start_new_scan()) + self._root.protocol("WM_DELETE_WINDOW", self._on_delete) + + def _start_new_scan(self): + assert self._scan_window is None + + def on_scan_window_close(): + logger.debug("scan window closed, removing window and restoring focus") + self._scan_window = None + self._scan.enable() + self._root.deiconify() + self._root.grab_set() + self._root.focus() + + self._scan.disable() + self._scan_window = ScanWindow(self._root, on_scan_window_close) + self._root.grab_release() + self._scan_window.show() + def _create_menubar(self): root_menu = tk.Menu(self._root) self._root["menu"] = root_menu file_menu = tk.Menu(root_menu) root_menu.add_cascade(menu=file_menu, label="File", underline=0) + file_menu.add_command( label="Start Scan", underline=1, @@ -386,6 +426,7 @@ def _create_menubar(self): file_menu.add_separator() file_menu.add_command( label="Quit", + command=self._on_delete, ) settings_menu = tk.Menu(root_menu) @@ -416,13 +457,13 @@ def _create_menubar(self): command=lambda: webbrowser.open_new_tab("www.dvr-scan.com/guide"), underline=0, ) - help_menu.add_command( - label="Debug Log", command=lambda: AboutWindow().show(root=self._root), underline=0 - ) + help_menu.add_command(label="Debug Log", underline=0) help_menu.add_separator() help_menu.add_command( - label="About DVR-Scan", command=lambda: AboutWindow().show(root=self._root), underline=0 + label="About DVR-Scan", + command=lambda: AboutWindow().show(root=self._root), + underline=0, ) def run(self): @@ -431,3 +472,15 @@ def run(self): self._root.focus() self._root.grab_release() self._root.mainloop() + + def _on_delete(self): + logger.debug("shutting down") + if self._scan_window is not None: + # NOTE: We do not actually wait here, + logger.debug("waiting for worker threads") + # Signal all active worker threads to start shutting down. + self._root.event_generate("<>") + # Make sure they actually have stopped. + self._root.after(0, lambda: self._scan_window.stop()) + self._root.after(0, lambda: self._root.destroy()) + self._root.withdraw() diff --git a/dvr_scan/app/scan_window.py b/dvr_scan/app/scan_window.py new file mode 100644 index 0000000..9480cef --- /dev/null +++ b/dvr_scan/app/scan_window.py @@ -0,0 +1,117 @@ +# +# DVR-Scan: Video Motion Event Detection & Extraction Tool +# -------------------------------------------------------------- +# [ Site: https://www.dvr-scan.com/ ] +# [ Repo: https://github.com/Breakthrough/DVR-Scan ] +# +# Copyright (C) 2024 Brandon Castellano . +# DVR-Scan is licensed under the BSD 2-Clause License; see the included +# LICENSE file, or visit one of the above pages for details. +# + +import threading +import time +import tkinter as tk +import tkinter.messagebox as messagebox +import tkinter.ttk as ttk +import typing as ty +from logging import getLogger + +TITLE = "Scanning..." + +logger = getLogger("dvr_scan") + + +class ScanWindow: + def __init__(self, root: tk.Tk, on_destroyed: ty.Callable[[], None]): + self._root = tk.Toplevel(master=root) + self._root.withdraw() + self._root.title(TITLE) + self._root.resizable(True, True) + self._root.minsize(320, 240) + + # Widgets + self._scan_thread = threading.Thread(target=self._do_scan) + self._stop_scan = threading.Event() + self._paused = threading.Event() + self._state_lock = threading.Lock() + self._on_destroyed = on_destroyed + + self._progress = tk.IntVar(self._root, value=0) + self._progress_bar = ttk.Progressbar(self._root, variable=self._progress, maximum=10) + + # Layout + self._root.columnconfigure(0, weight=1) + self._root.columnconfigure(1, weight=1) + self._progress_bar.grid(sticky=tk.NSEW, row=0, columnspan=2) + self._root.bind("<>", lambda _: self._on_progress()) + self._root.bind("<>", lambda _: self._on_complete()) + self._root.minsize(width=self._root.winfo_reqwidth(), height=self._root.winfo_reqheight()) + + self._root.bind("<>", self.stop) + self._root.protocol("WM_DELETE_WINDOW", self.prompt_stop) + + self._stop_button = tk.Button(self._root, text="Stop", command=self.prompt_stop) + self._stop_button.grid(row=1, column=0) + + self._close_button = tk.Button( + self._root, text="Close", command=self._destroy, state=tk.DISABLED + ) + self._close_button.grid(row=1, column=1) + + self._prompt_shown = False + + def _on_complete(self): + logger.debug("scan completed") + self._stop_button["state"] = tk.DISABLED + self._close_button["state"] = tk.NORMAL + + def _destroy(self): + self._root.after(10, self.stop) + with self._state_lock: + self._root.destroy() + self._root = None + self._on_destroyed() + + def prompt_stop(self): + if self._stop_scan.is_set(): + return + if messagebox.askyesno( + title="Stop scan?", + message="Are you sure you want to stop the current scan?", + icon=messagebox.WARNING, + ): + self._destroy() + + def stop(self): + if not self._stop_scan.is_set(): + self._stop_scan.set() + self._scan_thread.join() + + def _on_progress(self): + self._progress.set(self._progress.get() + 1) + + def show(self): + logger.debug("starting scan thread showing scan window.") + self._scan_thread.start() + self._root.focus() + self._root.grab_set() + self._root.deiconify() + self._root.wait_window() + + def _do_scan(self): + complete = True + for _ in range(10): + if self._stop_scan.is_set(): + complete = False + logger.debug("stop event set") + break + logger.debug("ping...") + with self._state_lock: + if self._root is None: + logger.debug("root was destroyed, bailing") + return + self._root.event_generate("<>") + time.sleep(0.3) + logger.debug("scan complete" if complete else "scan NOT complete") + self._root.event_generate("<>")