DVR-Scan-Headless 1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dvr_scan/LICENSE +47 -0
- dvr_scan/__init__.py +55 -0
- dvr_scan/__main__.py +80 -0
- dvr_scan/app/__init__.py +10 -0
- dvr_scan/app/__main__.py +218 -0
- dvr_scan/app/about_window.py +137 -0
- dvr_scan/app/application.py +1700 -0
- dvr_scan/app/common.py +31 -0
- dvr_scan/app/region_editor.py +1416 -0
- dvr_scan/app/scan_window.py +258 -0
- dvr_scan/app/widgets.py +213 -0
- dvr_scan/cli.py +511 -0
- dvr_scan/config.py +607 -0
- dvr_scan/controller.py +215 -0
- dvr_scan/detector.py +124 -0
- dvr_scan/docs/404.html +1 -0
- dvr_scan/docs/assets/javascripts/bundle.88dd0f4e.min.js +15 -0
- dvr_scan/docs/assets/stylesheets/main.6f8fc17f.min.css +1 -0
- dvr_scan/docs/assets/stylesheets/palette.06af60db.min.css +1 -0
- dvr_scan/docs/changelog.html +1 -0
- dvr_scan/docs/docs.html +73 -0
- dvr_scan/docs/faq.html +13 -0
- dvr_scan/docs/guide.html +1 -0
- dvr_scan/docs/index.html +5 -0
- dvr_scan/docs/resources.html +30 -0
- dvr_scan/docs/style.min.css +1 -0
- dvr_scan/dvr-scan-logo.png +0 -0
- dvr_scan/dvr-scan.ico +0 -0
- dvr_scan/dvr-scan.png +0 -0
- dvr_scan/opencv_loader.py +37 -0
- dvr_scan/overlays.py +251 -0
- dvr_scan/platform.py +236 -0
- dvr_scan/region.py +65 -0
- dvr_scan/scanner.py +1303 -0
- dvr_scan/shared/__init__.py +287 -0
- dvr_scan/shared/cli.py +291 -0
- dvr_scan/shared/settings.py +72 -0
- dvr_scan/subtractor.py +163 -0
- dvr_scan/video_joiner.py +188 -0
- dvr_scan_headless-1.8.dist-info/METADATA +119 -0
- dvr_scan_headless-1.8.dist-info/RECORD +45 -0
- dvr_scan_headless-1.8.dist-info/WHEEL +5 -0
- dvr_scan_headless-1.8.dist-info/entry_points.txt +2 -0
- dvr_scan_headless-1.8.dist-info/licenses/LICENSE +47 -0
- dvr_scan_headless-1.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#
|
|
2
|
+
# DVR-Scan: Video Motion Event Detection & Extraction Tool
|
|
3
|
+
# --------------------------------------------------------------
|
|
4
|
+
# [ Site: https://www.dvr-scan.com/ ]
|
|
5
|
+
# [ Repo: https://github.com/Breakthrough/DVR-Scan ]
|
|
6
|
+
#
|
|
7
|
+
# Copyright (C) 2024 Brandon Castellano <http://www.bcastell.com>.
|
|
8
|
+
# DVR-Scan is licensed under the BSD 2-Clause License; see the included
|
|
9
|
+
# LICENSE file, or visit one of the above pages for details.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import tkinter as tk
|
|
15
|
+
import tkinter.messagebox as messagebox
|
|
16
|
+
import tkinter.ttk as ttk
|
|
17
|
+
import traceback
|
|
18
|
+
import typing as ty
|
|
19
|
+
from logging import getLogger
|
|
20
|
+
|
|
21
|
+
from scenedetect import FrameTimecode
|
|
22
|
+
from tqdm import tqdm
|
|
23
|
+
|
|
24
|
+
from dvr_scan.platform import open_path
|
|
25
|
+
from dvr_scan.shared import ScanSettings, init_scanner
|
|
26
|
+
|
|
27
|
+
TITLE = "Scanning..."
|
|
28
|
+
|
|
29
|
+
logger = getLogger("dvr_scan")
|
|
30
|
+
|
|
31
|
+
UI_UPDATE_RATE_MS = 50
|
|
32
|
+
STATS_UPDATE_RATE_NS = 250 * (1000 * 1000)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScanWindow:
|
|
36
|
+
def __init__(
|
|
37
|
+
self, root: tk.Tk, settings: ScanSettings, on_destroyed: ty.Callable[[], None], padding: int
|
|
38
|
+
):
|
|
39
|
+
self._root = tk.Toplevel(master=root)
|
|
40
|
+
self._root.withdraw()
|
|
41
|
+
self._root.title(TITLE)
|
|
42
|
+
self._root.resizable(True, True)
|
|
43
|
+
self._scanner = init_scanner(settings)
|
|
44
|
+
self._scanner.set_callbacks(
|
|
45
|
+
scan_started=self._on_scan_started,
|
|
46
|
+
processed_frame=self._on_processed_frame,
|
|
47
|
+
)
|
|
48
|
+
self._open_on_completion = (
|
|
49
|
+
settings.get("output-dir") if settings.get("open-output-dir") else None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self._root.bind("<<Shutdown>>", self.stop)
|
|
53
|
+
self._root.protocol("WM_DELETE_WINDOW", self.prompt_stop)
|
|
54
|
+
|
|
55
|
+
# Widgets
|
|
56
|
+
self._scan_thread = threading.Thread(target=self._do_scan)
|
|
57
|
+
self._on_destroyed = on_destroyed
|
|
58
|
+
|
|
59
|
+
self._progress = tk.IntVar(self._root, value=0)
|
|
60
|
+
self._progress_bar = ttk.Progressbar(self._root, variable=self._progress, maximum=10)
|
|
61
|
+
self._elapsed_label = tk.Label(self._root)
|
|
62
|
+
self._remaining_label = tk.Label(self._root)
|
|
63
|
+
self._stop_button = tk.Button(self._root, text="Stop", command=self.prompt_stop)
|
|
64
|
+
self._close_button = tk.Button(
|
|
65
|
+
self._root, text="Close", command=self._destroy, state=tk.DISABLED
|
|
66
|
+
)
|
|
67
|
+
self._events_label = tk.Label(self._root)
|
|
68
|
+
self._processed_label = tk.Label(self._root)
|
|
69
|
+
self._speed_label = tk.Label(self._root)
|
|
70
|
+
self._total_label = tk.Label(self._root)
|
|
71
|
+
|
|
72
|
+
# Layout
|
|
73
|
+
width = self._root.winfo_reqwidth()
|
|
74
|
+
self._root.columnconfigure(0, weight=1, minsize=width / 2)
|
|
75
|
+
self._root.columnconfigure(1, weight=1, minsize=width / 2)
|
|
76
|
+
self._root.rowconfigure(0, weight=1)
|
|
77
|
+
self._root.rowconfigure(1, weight=1)
|
|
78
|
+
self._root.rowconfigure(2, weight=1)
|
|
79
|
+
self._root.rowconfigure(3, weight=32)
|
|
80
|
+
self._root.rowconfigure(4, weight=1)
|
|
81
|
+
self._root.rowconfigure(5, weight=1)
|
|
82
|
+
self._root.rowconfigure(6, weight=1)
|
|
83
|
+
self._root.rowconfigure(7, weight=2)
|
|
84
|
+
self._root.minsize(
|
|
85
|
+
width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._events_label.grid(row=0, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0))
|
|
89
|
+
self._progress_bar.grid(sticky=tk.NSEW, row=1, columnspan=2, pady=padding, padx=padding)
|
|
90
|
+
self._elapsed_label.grid(row=5, column=1, sticky=tk.NE, padx=padding)
|
|
91
|
+
self._remaining_label.grid(row=2, column=0, columnspan=2, sticky=tk.NSEW)
|
|
92
|
+
self._processed_label.grid(row=4, column=0, sticky=tk.NW, padx=padding)
|
|
93
|
+
self._total_label.grid(row=5, column=0, sticky=tk.NW, padx=padding)
|
|
94
|
+
self._speed_label.grid(row=4, column=1, sticky=tk.NE, padx=padding)
|
|
95
|
+
frame = tk.Frame(self._root)
|
|
96
|
+
frame.rowconfigure(0, weight=1)
|
|
97
|
+
frame.columnconfigure(0, weight=1)
|
|
98
|
+
frame.columnconfigure(1, weight=1)
|
|
99
|
+
self._stop_button = tk.Button(frame, text="Stop", command=self.prompt_stop)
|
|
100
|
+
self._close_button = tk.Button(
|
|
101
|
+
frame, text="Close", command=self._destroy, state=tk.DISABLED
|
|
102
|
+
)
|
|
103
|
+
self._stop_button.grid(row=0, column=0, pady=padding, sticky=tk.NSEW, padx=padding)
|
|
104
|
+
self._close_button.grid(row=0, column=1, pady=padding, sticky=tk.NSEW, padx=padding)
|
|
105
|
+
frame.grid(row=7, column=0, columnspan=2, sticky=tk.NSEW)
|
|
106
|
+
|
|
107
|
+
self._scan_started = threading.Event()
|
|
108
|
+
self._scan_finished = threading.Event()
|
|
109
|
+
self._scan_exception = None
|
|
110
|
+
self._start_time = 0.0
|
|
111
|
+
self._last_stats_update_ns = 0
|
|
112
|
+
self._expected_num_frames = 0
|
|
113
|
+
self._num_events = 0
|
|
114
|
+
self._frames_processed = 0
|
|
115
|
+
self._elapsed = "00:00"
|
|
116
|
+
self._remaining = "N/A"
|
|
117
|
+
self._rate = "N/A"
|
|
118
|
+
self._padding = padding
|
|
119
|
+
|
|
120
|
+
def _update(self):
|
|
121
|
+
if self._scan_finished.is_set():
|
|
122
|
+
if self._scan_exception:
|
|
123
|
+
formatted_exception = "\n".join(traceback.format_exception(self._scan_exception))
|
|
124
|
+
logger.critical(f"error during scan:\n{formatted_exception}")
|
|
125
|
+
messagebox.showerror(
|
|
126
|
+
"Scan Error",
|
|
127
|
+
"Error during scanning. See log messages for more info."
|
|
128
|
+
f"\nSummary: {self._scan_exception}",
|
|
129
|
+
parent=self._root,
|
|
130
|
+
)
|
|
131
|
+
self._destroy()
|
|
132
|
+
return
|
|
133
|
+
else:
|
|
134
|
+
logger.debug("scan complete")
|
|
135
|
+
self._progress.set(self._expected_num_frames)
|
|
136
|
+
self._elapsed_label["text"] = f"Elapsed: {self._elapsed}"
|
|
137
|
+
self._remaining_label["text"] = "\n"
|
|
138
|
+
self._stop_button["state"] = tk.DISABLED
|
|
139
|
+
self._close_button["state"] = tk.NORMAL
|
|
140
|
+
self._processed_label["text"] = f"Processed: {self._expected_num_frames} frames"
|
|
141
|
+
self._speed_label["text"] = f"Rate: {self._rate} FPS"
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
if self._scan_started.is_set():
|
|
145
|
+
self._progress_bar = ttk.Progressbar(
|
|
146
|
+
self._root,
|
|
147
|
+
variable=self._progress,
|
|
148
|
+
maximum=self._expected_num_frames,
|
|
149
|
+
)
|
|
150
|
+
self._progress_bar.grid(
|
|
151
|
+
sticky=tk.NSEW,
|
|
152
|
+
row=1,
|
|
153
|
+
columnspan=2,
|
|
154
|
+
padx=self._padding,
|
|
155
|
+
pady=self._padding,
|
|
156
|
+
)
|
|
157
|
+
self._scan_started.clear()
|
|
158
|
+
|
|
159
|
+
# Frames processed is updated from the worker thread, but we don't care about stale values.
|
|
160
|
+
self._progress.set(self._frames_processed)
|
|
161
|
+
self._events_label["text"] = f"Events Found: {self._num_events}"
|
|
162
|
+
self._elapsed_label["text"] = f"Elapsed: {self._elapsed}"
|
|
163
|
+
self._remaining_label["text"] = f"Time Remaining:\n{self._remaining}"
|
|
164
|
+
self._processed_label["text"] = f"Processed: {self._frames_processed:4} frames"
|
|
165
|
+
self._total_label["text"] = f"Total: {self._expected_num_frames} frames"
|
|
166
|
+
self._speed_label["text"] = f"Rate: {self._rate} FPS"
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def _update_continuous(self):
|
|
170
|
+
if self._update():
|
|
171
|
+
self._root.after(UI_UPDATE_RATE_MS, self._update_continuous)
|
|
172
|
+
|
|
173
|
+
def _destroy(self):
|
|
174
|
+
logger.info("Stopping current scan.")
|
|
175
|
+
self._root.grab_release()
|
|
176
|
+
self._root.destroy()
|
|
177
|
+
self._on_destroyed()
|
|
178
|
+
# TODO: If there's a long running task this could hang the GUI, see if we can schedule this
|
|
179
|
+
# to occur in the parent.
|
|
180
|
+
self.stop()
|
|
181
|
+
logger.debug("root destroyed")
|
|
182
|
+
|
|
183
|
+
def prompt_stop(self):
|
|
184
|
+
if self._scanner.is_stopped():
|
|
185
|
+
return
|
|
186
|
+
if messagebox.askyesno(
|
|
187
|
+
title="Stop scan?",
|
|
188
|
+
message="Are you sure you want to stop the current scan?",
|
|
189
|
+
icon=messagebox.WARNING,
|
|
190
|
+
parent=self._root,
|
|
191
|
+
):
|
|
192
|
+
self._destroy()
|
|
193
|
+
|
|
194
|
+
def stop(self):
|
|
195
|
+
if not self._scanner.is_stopped():
|
|
196
|
+
logger.debug("stopping scan thread")
|
|
197
|
+
self._scanner.stop()
|
|
198
|
+
self._scan_thread.join()
|
|
199
|
+
|
|
200
|
+
def show(self):
|
|
201
|
+
logger.debug("starting scan thread showing scan window.")
|
|
202
|
+
self._scan_thread.start()
|
|
203
|
+
self._root.deiconify()
|
|
204
|
+
self._root.focus()
|
|
205
|
+
self._root.grab_set()
|
|
206
|
+
self._root.after(10, self._update_continuous)
|
|
207
|
+
self._root.wait_window()
|
|
208
|
+
|
|
209
|
+
def _on_scan_started(self, num_frames: int):
|
|
210
|
+
# We've now seeked to the start of the motion detection area and are processing frames.
|
|
211
|
+
# We also have an *estimate* of how many frames we'll be processing.
|
|
212
|
+
self._expected_num_frames = num_frames
|
|
213
|
+
self._scan_started.set()
|
|
214
|
+
# We set start time here to avoid including seek time in the overall FPS calculation.
|
|
215
|
+
self._start_time = time.time()
|
|
216
|
+
|
|
217
|
+
def _on_processed_frame(self, progress_bar: tqdm, num_events: int):
|
|
218
|
+
self._num_events = num_events
|
|
219
|
+
self._frames_processed += 1
|
|
220
|
+
curr = time.time_ns()
|
|
221
|
+
# Update internal state used for UI labels using the progress bar in the scanner.
|
|
222
|
+
if (curr - self._last_stats_update_ns) > STATS_UPDATE_RATE_NS:
|
|
223
|
+
self._last_stats_update_ns = curr
|
|
224
|
+
format_dict = progress_bar.format_dict
|
|
225
|
+
format_dict.update(bar_format="{elapsed} {remaining} {rate_fmt}")
|
|
226
|
+
values = tqdm.format_meter(**format_dict).strip().split(" ")
|
|
227
|
+
# We have to filter out empty entries after we split by spaces because the meter format
|
|
228
|
+
# adds padding spaces to align numbers.
|
|
229
|
+
values = list(filter(bool, values))
|
|
230
|
+
if len(values) == 4:
|
|
231
|
+
(self._elapsed, self._remaining, self._rate, _unit) = values
|
|
232
|
+
|
|
233
|
+
def _do_scan(self):
|
|
234
|
+
# We'll handle any errors below in the main Tkinter thread.
|
|
235
|
+
try:
|
|
236
|
+
result = self._scanner.scan()
|
|
237
|
+
self._frames_processed = result.num_frames
|
|
238
|
+
except Exception as ex: # noqa: E722
|
|
239
|
+
self._scan_exception = ex
|
|
240
|
+
finally:
|
|
241
|
+
self._scan_finished.set()
|
|
242
|
+
elapsed = time.time() - self._start_time
|
|
243
|
+
self._elapsed = (
|
|
244
|
+
FrameTimecode(elapsed, 1000.0)
|
|
245
|
+
.get_timecode(precision=0, use_rounding=True)
|
|
246
|
+
.removeprefix("00:")
|
|
247
|
+
)
|
|
248
|
+
# TODO: This rate will always be a tiny bit slower than the one shown in stdout since we
|
|
249
|
+
# use different timers. This can be fixed if we add a callback to the MotionScanner that
|
|
250
|
+
# we can use to access the progress bar the scanner created.
|
|
251
|
+
self._rate = (
|
|
252
|
+
"%.2f" % (float(self._frames_processed) / elapsed) if self._frames_processed else "N/A"
|
|
253
|
+
)
|
|
254
|
+
# Open the output folder on a successful scan. On error, or if the user stopped the scan,
|
|
255
|
+
# we don't open the window.
|
|
256
|
+
if self._open_on_completion and not self._scanner.is_stopped() and not self._scan_exception:
|
|
257
|
+
logger.debug("scan complete, opening output folder")
|
|
258
|
+
open_path(self._open_on_completion)
|
dvr_scan/app/widgets.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#
|
|
2
|
+
# DVR-Scan: Video Motion Event Detection & Extraction Tool
|
|
3
|
+
# --------------------------------------------------------------
|
|
4
|
+
# [ Site: https://www.dvr-scan.com/ ]
|
|
5
|
+
# [ Repo: https://github.com/Breakthrough/DVR-Scan ]
|
|
6
|
+
#
|
|
7
|
+
# Copyright (C) 2024 Brandon Castellano <http://www.bcastell.com>.
|
|
8
|
+
# DVR-Scan is licensed under the BSD 2-Clause License; see the included
|
|
9
|
+
# LICENSE file, or visit one of the above pages for details.
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
import tkinter as tk
|
|
13
|
+
import tkinter.colorchooser
|
|
14
|
+
import tkinter.ttk as ttk
|
|
15
|
+
import typing as ty
|
|
16
|
+
|
|
17
|
+
from scenedetect import FrameTimecode
|
|
18
|
+
|
|
19
|
+
from dvr_scan.config import RGBValue
|
|
20
|
+
|
|
21
|
+
PADDING = 8
|
|
22
|
+
SETTING_INPUT_WIDTH = 12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ColorPicker:
|
|
26
|
+
def __init__(
|
|
27
|
+
self, root: tk.Widget, initial_color: str = "#FF0000", padding=PADDING, sticky=tk.EW
|
|
28
|
+
):
|
|
29
|
+
self._frame = tk.Frame(root)
|
|
30
|
+
self._frame.columnconfigure(0, weight=1)
|
|
31
|
+
self._frame.columnconfigure(1, weight=1)
|
|
32
|
+
self._frame.rowconfigure(0, weight=1)
|
|
33
|
+
self._color = tk.Label(self._frame, bg=initial_color, width=2)
|
|
34
|
+
self._color.grid(row=0, column=0, sticky=sticky, padx=(0, padding))
|
|
35
|
+
|
|
36
|
+
def set_color():
|
|
37
|
+
color = tkinter.colorchooser.askcolor(self._color["bg"])
|
|
38
|
+
if color and color[1]:
|
|
39
|
+
self._color["bg"] = color[1]
|
|
40
|
+
|
|
41
|
+
self._set_button = tk.Button(self._frame, text="Set Color", command=set_color)
|
|
42
|
+
self._set_button.grid(row=0, column=1, sticky=sticky, padx=(padding, 0))
|
|
43
|
+
|
|
44
|
+
self.grid = self._frame.grid
|
|
45
|
+
|
|
46
|
+
def __setitem__(self, key, item):
|
|
47
|
+
self._set_button[key] = item
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, key):
|
|
50
|
+
return self._set_button[key]
|
|
51
|
+
|
|
52
|
+
def get(self) -> ty.Tuple[int, int, int]:
|
|
53
|
+
return RGBValue(f"0x{self._color['bg'][1:]}").value
|
|
54
|
+
|
|
55
|
+
def set(self, newval: ty.Tuple[int, int, int]):
|
|
56
|
+
color_code = (newval[0] << 16) + (newval[1] << 8) + newval[2]
|
|
57
|
+
self._color["bg"] = f"#{str(RGBValue(color_code))[2:]}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TimecodeEntry:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
root: tk.Widget,
|
|
64
|
+
value: str,
|
|
65
|
+
width: int = SETTING_INPUT_WIDTH,
|
|
66
|
+
):
|
|
67
|
+
value = str(value)
|
|
68
|
+
self._value = tk.StringVar(value=value)
|
|
69
|
+
self._last_valid = value
|
|
70
|
+
self._last_focus = value
|
|
71
|
+
self._entry = ttk.Entry(
|
|
72
|
+
root,
|
|
73
|
+
textvariable=self._value,
|
|
74
|
+
width=width,
|
|
75
|
+
validate="all",
|
|
76
|
+
validatecommand=(root.register(self._validate), "%P", "%V"),
|
|
77
|
+
)
|
|
78
|
+
self._entry.bind("<Return>", lambda _: self._refresh(clear_selection=True))
|
|
79
|
+
self._entry.bind("<Escape>", lambda _: self._cancel())
|
|
80
|
+
|
|
81
|
+
# Expose grid method from underlying widget
|
|
82
|
+
self.grid = self._entry.grid
|
|
83
|
+
self.grid_remove = self._entry.grid_remove
|
|
84
|
+
|
|
85
|
+
def __setitem__(self, key, item):
|
|
86
|
+
self._entry[key] = item
|
|
87
|
+
|
|
88
|
+
def __getitem__(self, key):
|
|
89
|
+
return self._entry[key]
|
|
90
|
+
|
|
91
|
+
def get(self) -> str:
|
|
92
|
+
return self._value.get()
|
|
93
|
+
|
|
94
|
+
def set(self, newval: str):
|
|
95
|
+
self._value.set(str(newval))
|
|
96
|
+
self._refresh(clear_selection=True)
|
|
97
|
+
|
|
98
|
+
def _refresh(self, clear_selection=False):
|
|
99
|
+
try:
|
|
100
|
+
newval = self._value.get()
|
|
101
|
+
|
|
102
|
+
if newval.isdigit():
|
|
103
|
+
newval = float(newval)
|
|
104
|
+
# TODO: There's a bug here in PySceneDetect when there are more than 3 :'s.
|
|
105
|
+
if isinstance(newval, str) and newval.count(":") >= 3:
|
|
106
|
+
raise ValueError()
|
|
107
|
+
validated = FrameTimecode(newval, 1000.0).get_timecode()
|
|
108
|
+
self._value.set(validated)
|
|
109
|
+
self._last_valid = validated
|
|
110
|
+
self._last_focus = validated
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
self._value.set(self._last_valid)
|
|
113
|
+
if clear_selection:
|
|
114
|
+
self._entry.selection_clear()
|
|
115
|
+
self._entry.icursor(0)
|
|
116
|
+
|
|
117
|
+
def _cancel(self):
|
|
118
|
+
self._value.set(self._last_focus)
|
|
119
|
+
self._refresh(clear_selection=True)
|
|
120
|
+
|
|
121
|
+
def _validate(self, newval: str, event: str):
|
|
122
|
+
if event == "focusin":
|
|
123
|
+
self._last_valid = newval
|
|
124
|
+
self._last_focus = newval
|
|
125
|
+
elif event == "focusout":
|
|
126
|
+
self._refresh()
|
|
127
|
+
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Spinbox:
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
root: tk.Widget,
|
|
135
|
+
value: str,
|
|
136
|
+
from_: float,
|
|
137
|
+
to: float,
|
|
138
|
+
increment: float,
|
|
139
|
+
width: int = SETTING_INPUT_WIDTH,
|
|
140
|
+
format: str = "%g",
|
|
141
|
+
suffix: ty.Optional[str] = None,
|
|
142
|
+
convert: ty.Callable[[str], ty.Any] = float,
|
|
143
|
+
**kwargs,
|
|
144
|
+
):
|
|
145
|
+
self._value = tk.StringVar(value=value)
|
|
146
|
+
self._format = format
|
|
147
|
+
self._suffix = suffix
|
|
148
|
+
if self._suffix:
|
|
149
|
+
self._format += self._suffix
|
|
150
|
+
self._last_valid = value
|
|
151
|
+
self._last_focus = value
|
|
152
|
+
self._from = from_
|
|
153
|
+
self._to = to
|
|
154
|
+
self._spinbox = ttk.Spinbox(
|
|
155
|
+
root,
|
|
156
|
+
from_=from_,
|
|
157
|
+
to=to,
|
|
158
|
+
increment=increment,
|
|
159
|
+
textvariable=self._value,
|
|
160
|
+
width=width,
|
|
161
|
+
format=self._format,
|
|
162
|
+
validate="all",
|
|
163
|
+
validatecommand=(root.register(self._validate), "%P", "%V"),
|
|
164
|
+
**kwargs,
|
|
165
|
+
)
|
|
166
|
+
self._spinbox.bind("<Return>", lambda _: self._refresh(clear_selection=True))
|
|
167
|
+
self._spinbox.bind("<Escape>", lambda _: self._cancel())
|
|
168
|
+
self._convert = convert
|
|
169
|
+
|
|
170
|
+
# Expose grid method from underlying widget
|
|
171
|
+
self.grid = self._spinbox.grid
|
|
172
|
+
|
|
173
|
+
def __setitem__(self, key, item):
|
|
174
|
+
self._spinbox[key] = item
|
|
175
|
+
|
|
176
|
+
def __getitem__(self, key):
|
|
177
|
+
return self._spinbox[key]
|
|
178
|
+
|
|
179
|
+
def get(self) -> str:
|
|
180
|
+
return self._value.get()
|
|
181
|
+
|
|
182
|
+
def set(self, newval: str):
|
|
183
|
+
self._value.set(str(newval))
|
|
184
|
+
self._refresh(clear_selection=True)
|
|
185
|
+
|
|
186
|
+
def _refresh(self, clear_selection=False):
|
|
187
|
+
try:
|
|
188
|
+
newval = self._value.get()
|
|
189
|
+
if self._suffix:
|
|
190
|
+
newval = newval.removesuffix(self._suffix)
|
|
191
|
+
newval = max(self._from, min(self._to, self._convert(newval)))
|
|
192
|
+
newval = self._format % newval
|
|
193
|
+
self._value.set(newval)
|
|
194
|
+
self._last_valid = newval
|
|
195
|
+
self._last_focus = newval
|
|
196
|
+
except ValueError:
|
|
197
|
+
self._value.set(self._last_valid)
|
|
198
|
+
if clear_selection:
|
|
199
|
+
self._spinbox.selection_clear()
|
|
200
|
+
self._spinbox.icursor(0)
|
|
201
|
+
|
|
202
|
+
def _cancel(self):
|
|
203
|
+
self._value.set(self._last_focus)
|
|
204
|
+
self._refresh(clear_selection=True)
|
|
205
|
+
|
|
206
|
+
def _validate(self, newval: str, event: str):
|
|
207
|
+
if event == "focusin":
|
|
208
|
+
self._last_valid = newval
|
|
209
|
+
self._last_focus = newval
|
|
210
|
+
elif event == "focusout":
|
|
211
|
+
self._refresh()
|
|
212
|
+
|
|
213
|
+
return True
|