Skip to content

Commit

Permalink
[app] Display scanning progress in real time
Browse files Browse the repository at this point in the history
Add fake workload for now that simulates a long running task, displaying
the progress it makes in real-time, without interrupting the other parts
of the GUI.
  • Loading branch information
Breakthrough committed Jan 2, 2025
1 parent 5557986 commit 8d4f269
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 20 deletions.
6 changes: 0 additions & 6 deletions dvr_scan/app/about_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
"""

import importlib.resources as resources
import os
import os.path
import tkinter as tk
import tkinter.filedialog
import tkinter.messagebox
Expand Down Expand Up @@ -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()
Expand All @@ -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()

Expand Down
81 changes: 67 additions & 14 deletions dvr_scan/app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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("<<StartScan>>")
)
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:
Expand Down Expand Up @@ -370,22 +389,44 @@ 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("<<StartScan>>", 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,
)
file_menu.add_separator()
file_menu.add_command(
label="Quit",
command=self._on_delete,
)

settings_menu = tk.Menu(root_menu)
Expand Down Expand Up @@ -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):
Expand All @@ -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("<<Shutdown>>")
# 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()
117 changes: 117 additions & 0 deletions dvr_scan/app/scan_window.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.bcastell.com>.
# 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("<<ProgressUpdate>>", lambda _: self._on_progress())
self._root.bind("<<ScanComplete>>", lambda _: self._on_complete())
self._root.minsize(width=self._root.winfo_reqwidth(), height=self._root.winfo_reqheight())

self._root.bind("<<Shutdown>>", 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("<<ProgressUpdate>>")
time.sleep(0.3)
logger.debug("scan complete" if complete else "scan NOT complete")
self._root.event_generate("<<ScanComplete>>")

0 comments on commit 8d4f269

Please sign in to comment.