diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 00000000..a8076a0d
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,5 @@
+[MAIN]
+ignore-patterns=.*_test.py
+
+[DESIGN]
+max-parents=99
diff --git a/Pipfile b/Pipfile
index 1dcb2304..eaaf5e44 100644
--- a/Pipfile
+++ b/Pipfile
@@ -31,6 +31,7 @@ rstcheck = "*"
sphinx-rtd-theme = "*"
sphinx-tabs = "*"
pytest-cov = "*"
+types-pillow = "*"
[requires]
python_version = "3.11"
diff --git a/Pipfile.lock b/Pipfile.lock
index e0cfbf39..958c2e12 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "2d589a3229bdfcdb297b6019ff6beca8ef439656ee810ade1be253449a876744"
+ "sha256": "f2033a0379476f6e0d660427c7354571f1cdd8c6ce493905621c57968cd421ff"
},
"pipfile-spec": 6,
"requires": {
@@ -382,11 +382,11 @@
},
"ipinfo": {
"hashes": [
- "sha256:3799a9731d28e697f5461c0d5f2c7fae97288e3972c537ca8cffb9c1bad1dd07",
- "sha256:e19633665dddefa94391c6796f059e7303201b44de78f1f266bd4be0a079d16c"
+ "sha256:30717a1866140f74e93aacbebeb962a7c7e39551521d03acafa89bd3b42b9226",
+ "sha256:30fe65e50fc8896c5e3adae7548f971c52e1cb7646e5088c35cca2f0a23f3be8"
],
"index": "pypi",
- "version": "==4.4.1"
+ "version": "==4.4.2"
},
"kiwisolver": {
"hashes": [
@@ -815,11 +815,11 @@
},
"setuptools": {
"hashes": [
- "sha256:41fa68ecac9e099122990d7437bc10683b966c32a591caa2824dffcffd5dea7a",
- "sha256:97a4a824325146ebc8dc29b0aa5f3b1eaa590a0f00cacbfdf81831670f07862d"
+ "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54",
+ "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"
],
"markers": "python_version >= '3.7'",
- "version": "==65.6.2"
+ "version": "==65.6.3"
},
"six": {
"hashes": [
@@ -838,11 +838,11 @@
},
"urllib3": {
"hashes": [
- "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e",
- "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"
+ "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
+ "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'",
- "version": "==1.26.12"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+ "version": "==1.26.13"
},
"watchdog": {
"hashes": [
@@ -1602,6 +1602,14 @@
],
"version": "==0.19.1.1"
},
+ "types-pillow": {
+ "hashes": [
+ "sha256:2a0323bdc0af126a7ba12d3a529a50f1d058e827cb475500f14994876ab7d863",
+ "sha256:b2d8a21b97857b69fbfdd5d0302e9a43ba4a9bdc625f4a6bdcd404aa175d86bc"
+ ],
+ "index": "pypi",
+ "version": "==9.3.0.2"
+ },
"types-python-dateutil": {
"hashes": [
"sha256:351a8ca9afd4aea662f87c1724d2e1ae59f9f5f99691be3b3b11d2393cd3aaa1",
@@ -1635,11 +1643,11 @@
},
"urllib3": {
"hashes": [
- "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e",
- "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"
+ "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
+ "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'",
- "version": "==1.26.12"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+ "version": "==1.26.13"
},
"wrapt": {
"hashes": [
diff --git a/main_window.py b/main_window.py
index bdbae85b..b577880d 100644
--- a/main_window.py
+++ b/main_window.py
@@ -18,70 +18,19 @@
import os
import sys
-from typing import Callable, Optional
-from tkinter import *
-import tkinter.ttk as ttk
+from tkinter import FALSE, Menu, TclError, Tk, Widget, ttk
import ttkwidgets #type: ignore
import ttkwidgets.font #type: ignore
-import PIL.Image as PILImage
from PIL import ImageTk
+from model import Model
import widgets
-import layouts
-callback = Optional[Callable[[], None]]
-
-class ViewModel:
- def __init__(self):
- ########################################
- ## Dropdown menu items
- self.on_menu_exit: callback = None
- self.do_menu_exit = lambda: self.on_menu_exit() if self.on_menu_exit is not None else None
- self.on_menu_docs: callback = None
- self.do_menu_docs = lambda: self.on_menu_docs() if self.on_menu_docs is not None else None
- self.on_menu_about: callback = None
- self.do_menu_about = lambda: self.on_menu_about() if self.on_menu_about is not None else None
- ########################################
- ## Buttons
- self.on_bg_import: callback = None
- self.do_bg_import = lambda: self.on_bg_import() if self.on_bg_import is not None else None
- self.on_bg_clear: callback = None
- self.do_bg_clear = lambda: self.on_bg_clear() if self.on_bg_clear is not None else None
- self.on_dolphin_export: callback = None
- self.do_dolphin_export = lambda: self.on_dolphin_export() if self.on_dolphin_export is not None else None
- ########################################
- ## Entry fields
- self.main_text = StringVar()
- self.time_text = StringVar()
- self.text_spacing = DoubleVar()
- self.heading1 = StringVar()
- self.heading2 = StringVar()
- # colors
- self.color_heading = StringVar()
- self.color_event = StringVar()
- self.color_even = StringVar()
- self.color_odd = StringVar()
- self.color_first = StringVar()
- self.color_second = StringVar()
- self.color_third = StringVar()
- self.color_bg = StringVar()
- # features
- self.num_lanes = IntVar()
- self.inhibit = BooleanVar()
- # Preview
- self.appearance_preview = widgets.ImageVar(PILImage.Image())
- # Directories
- self.startlist_dir = StringVar()
- self.startlist_contents = widgets.StartListVar([])
- self.results_dir = StringVar()
- self.results_contents = widgets.RaceResultVar([])
- # Run tab
- self.cc_status = widgets.ChromecastStatusVar([])
- self.scoreboard_preview = widgets.ImageVar(PILImage.Image())
class View(ttk.Frame):
- def __init__(self, root: Tk, vm: ViewModel):
+ '''Main window view definition'''
+ def __init__(self, root: Tk, vm: Model):
super().__init__(root)
self._root = root
self._vm = vm
@@ -101,13 +50,13 @@ def __init__(self, root: Tk, vm: ViewModel):
self.pack(side="top", fill="both", expand=True)
self._build_menu()
# App close button is same as Exit menu option
- root.protocol("WM_DELETE_WINDOW", vm.do_menu_exit)
+ root.protocol("WM_DELETE_WINDOW", vm.menu_exit.run)
- n = ttk.Notebook(self)
- n.pack(side="top", fill="both", expand=True)
- n.add(_appearanceTab(n, self._vm), text="Appearance", sticky="news")
- n.add(_dirsTab(n, self._vm), text="Directories", sticky="news")
- n.add(_runTab(n, self._vm), text="Run", sticky="news")
+ book = ttk.Notebook(self)
+ book.pack(side="top", fill="both", expand=True)
+ book.add(_appearanceTab(book, self._vm), text="Appearance", sticky="news")
+ book.add(_dirsTab(book, self._vm), text="Directories", sticky="news")
+ book.add(_runTab(book, self._vm), text="Run", sticky="news")
def _build_menu(self) -> None:
'''Creates the dropdown menus'''
@@ -117,16 +66,16 @@ def _build_menu(self) -> None:
# File menu
file_menu = Menu(menubar)
menubar.add_cascade(menu=file_menu, label='File', underline=0)
- file_menu.add_command(label='Exit', underline=1, command=self._vm.do_menu_exit)
+ file_menu.add_command(label='Exit', underline=1, command=self._vm.menu_exit.run)
# Help menu
help_menu = Menu(menubar)
menubar.add_cascade(menu=help_menu, label='Help', underline=0)
- help_menu.add_command(label='Documentation', underline=0, command=self._vm.do_menu_docs)
- help_menu.add_command(label='About', underline=0, command=self._vm.do_menu_about)
+ help_menu.add_command(label='Documentation', underline=0, command=self._vm.menu_docs.run)
+ help_menu.add_command(label='About', underline=0, command=self._vm.menu_about.run)
class _appearanceTab(ttk.Frame):
- def __init__(self, parent: Widget, vm: ViewModel) -> None:
+ def __init__(self, parent: Widget, vm: Model) -> None:
super().__init__(parent)
# super().__init__(parent, layouts.Orientation.VERTICAL)
self._vm = vm
@@ -141,11 +90,24 @@ def _fonts(self, parent: Widget) -> Widget:
frame = ttk.LabelFrame(parent, text="Fonts")
frame.columnconfigure(1, weight=1) # col 1 gets any extra space
ttk.Label(frame, text="Main font:", anchor="e").grid(column=0, row=0, sticky="news")
- ttkwidgets.font.FontFamilyDropdown(frame, lambda f: self._vm.main_text.set(f)).grid(column=1, row=0, sticky="news")
+ main_dd = ttkwidgets.font.FontFamilyDropdown(frame, self._vm.main_text.set)
+ main_dd.grid(column=1, row=0, sticky="news")
+ # Update dropdown if textvar is changed
+ self._vm.main_text.trace_add("write",
+ lambda _a, _b, _c: main_dd.set(self._vm.main_text.get()))
+ # Set initial value
+ main_dd.set(self._vm.main_text.get())
ttk.Label(frame, text="Time font:", anchor="e").grid(column=0, row=1, sticky="news")
- ttkwidgets.font.FontFamilyDropdown(frame, lambda f: self._vm.time_text.set(f)).grid(column=1, row=1, sticky="news")
+ time_dd = ttkwidgets.font.FontFamilyDropdown(frame, self._vm.time_text.set)
+ time_dd.grid(column=1, row=1, sticky="news")
+ # Update dropdown if textvar is changed
+ self._vm.time_text.trace_add("write",
+ lambda _a, _b, _c: time_dd.set(self._vm.time_text.get()))
+ # Set initial value
+ time_dd.set(self._vm.time_text.get())
ttk.Label(frame, text="Text spacing:", anchor="e").grid(column=0, row=2, sticky="news")
- ttk.Spinbox(frame, from_=0, to=1, increment=0.01, width=4, textvariable=self._vm.text_spacing).grid(column=1, row=2, sticky="nws")
+ ttk.Spinbox(frame, from_=0.8, to=2.0, increment=0.05, width=4, format="%0.2f",
+ textvariable=self._vm.text_spacing).grid(column=1, row=2, sticky="nws")
return frame
def _colors(self, parent: Widget) -> Widget:
@@ -154,20 +116,27 @@ def _colors(self, parent: Widget) -> Widget:
frame.columnconfigure(3, weight=1)
# 1st col
ttk.Label(frame, text="Heading:", anchor="e").grid(column=0, row=0, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_heading).grid(column=1, row=0, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_heading).grid(column=1,
+ row=0, sticky="nws")
ttk.Label(frame, text="Event:", anchor="e").grid(column=0, row=1, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_event).grid(column=1, row=1, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_event).grid(column=1,
+ row=1, sticky="nws")
ttk.Label(frame, text="Odd rows:", anchor="e").grid(column=0, row=2, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_odd).grid(column=1, row=2, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_odd).grid(column=1,
+ row=2, sticky="nws")
ttk.Label(frame, text="Even rows:", anchor="e").grid(column=0, row=3, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_even).grid(column=1, row=3, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_even).grid(column=1,
+ row=3, sticky="nws")
# 2nd col
ttk.Label(frame, text="1st place:", anchor="e").grid(column=2, row=0, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_first).grid(column=3, row=0, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_first).grid(column=3,
+ row=0, sticky="nws")
ttk.Label(frame, text="2nd place:", anchor="e").grid(column=2, row=1, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_second).grid(column=3, row=1, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_second).grid(column=3,
+ row=1, sticky="nws")
ttk.Label(frame, text="3rd place:", anchor="e").grid(column=2, row=2, sticky="news")
- widgets.ColorButton2(frame, color_var=self._vm.color_third).grid(column=3, row=2, sticky="nws")
+ widgets.ColorButton2(frame, color_var=self._vm.color_third).grid(column=3,
+ row=2, sticky="nws")
ttk.Label(frame, text="Background:", anchor="e").grid(column=2, row=3, sticky="news")
widgets.ColorButton2(frame, color_var=self._vm.color_bg).grid(column=3, row=3, sticky="nws")
# Bottom
@@ -176,8 +145,10 @@ def _colors(self, parent: Widget) -> Widget:
bottom.columnconfigure(1, weight=1)
bottom.columnconfigure(2, weight=1)
ttk.Label(bottom, text="Background image:", anchor="e").grid(column=0, row=0, sticky="news")
- ttk.Button(bottom, text="Import...", command=self._vm.do_bg_import).grid(column=1, row=0, sticky="news")
- ttk.Button(bottom, text="Clear", command=self._vm.do_bg_clear).grid(column=2, row=0, sticky="news")
+ ttk.Button(bottom, text="Import...", command=self._vm.bg_import.run).grid(column=1,
+ row=0, sticky="news")
+ ttk.Button(bottom, text="Clear", command=self._vm.bg_clear.run).grid(column=2,
+ row=0, sticky="news")
return frame
def _features(self, parent: Widget) -> Widget:
@@ -186,23 +157,24 @@ def _features(self, parent: Widget) -> Widget:
hc_frame = ttk.Frame(frame)
hc_frame.pack(side="top", fill="both")
ttk.Label(hc_frame, text="Heading color:", anchor="e").grid(column=0, row=0, sticky="news")
- widgets.ColorButton2(hc_frame, color_var=self._vm.color_heading).grid(column=1, row=0, sticky="nws")
+ widgets.ColorButton2(hc_frame, color_var=self._vm.color_heading).grid(column=1,
+ row=0, sticky="nws")
txt_frame = ttk.Frame(frame)
txt_frame.pack(side="top", fill="both")
txt_frame.columnconfigure(1, weight=1)
ttk.Label(txt_frame, text="Heading 1:", anchor="e").grid(column=0, row=1, sticky="news")
- ttk.Entry(txt_frame, textvariable=self._vm.heading1).grid(column=1, row=1, sticky="news")
- ttk.Label(txt_frame, text="Heading 2:", anchor="e").grid(column=0, row=2, sticky="news")
- ttk.Entry(txt_frame, textvariable=self._vm.heading2).grid(column=1, row=2, sticky="news")
+ ttk.Entry(txt_frame, textvariable=self._vm.heading).grid(column=1, row=1, sticky="news")
opt_frame = ttk.Frame(frame)
opt_frame.pack(side="top", fill="both")
opt_frame.columnconfigure(1, weight=1)
opt_frame.columnconfigure(3, weight=1)
ttk.Label(opt_frame, text="Lanes:", anchor="e").grid(column=0, row=0, sticky="news")
- ttk.Spinbox(opt_frame, from_=6, to=10, increment=1, width=3, textvariable=self._vm.num_lanes).grid(column=1, row=0, sticky="nws")
- ttk.Label(opt_frame, text="Suppress >0.3s:", anchor="e").grid(column=2, row=0, sticky="news")
+ ttk.Spinbox(opt_frame, from_=6, to=10, increment=1, width=3,
+ textvariable=self._vm.num_lanes).grid(column=1, row=0, sticky="nws")
+ ttk.Label(opt_frame, text="Suppress >0.3s:", anchor="e").grid(column=2,
+ row=0, sticky="news")
ttk.Checkbutton(opt_frame, variable=self._vm.inhibit).grid(column=3, row=0, sticky="nws")
return frame
@@ -213,7 +185,7 @@ def _preview(self, parent: Widget) -> Widget:
return frame
class _dirsTab(ttk.Frame):
- def __init__(self, parent: Widget, vm: ViewModel) -> None:
+ def __init__(self, parent: Widget, vm: Model) -> None:
super().__init__(parent)
self._vm = vm
self.columnconfigure(0, weight=1)
@@ -224,21 +196,26 @@ def __init__(self, parent: Widget, vm: ViewModel) -> None:
def _start_list(self, parent: Widget) -> Widget:
frame = ttk.LabelFrame(parent, text='Start lists')
- widgets.DirSelection(frame, self._vm.startlist_dir).grid(column=0, row=0, sticky="news", padx=1, pady=1)
- widgets.StartListTreeView(frame, self._vm.startlist_contents).grid(column=0, row=1, sticky="news", padx=1, pady=1)
- ttk.Button(frame, padding=(8, 0), text="Export events to Dolphin...", command=self._vm.do_dolphin_export).grid(column=0, row=2, padx=1, pady=1)
+ widgets.DirSelection(frame, self._vm.startlist_dir).grid(column=0, row=0,
+ sticky="news", padx=1, pady=1)
+ widgets.StartListTreeView(frame, self._vm.startlist_contents).grid(column=0,
+ row=1, sticky="news", padx=1, pady=1)
+ ttk.Button(frame, padding=(8, 0), text="Export events to Dolphin...",
+ command=self._vm.dolphin_export.run).grid(column=0, row=2, padx=1, pady=1)
frame.rowconfigure(1, weight=1)
return frame
def _race_results(self, parent: Widget) -> Widget:
frame = ttk.LabelFrame(parent, text='Race results')
- widgets.DirSelection(frame, self._vm.results_dir).grid(column=0, row=0, sticky="news", padx=1, pady=1)
- widgets.RaceResultTreeView(frame, self._vm.results_contents).grid(column=0, row=1, sticky="news", padx=1, pady=1)
+ widgets.DirSelection(frame, self._vm.results_dir).grid(column=0, row=0,
+ sticky="news", padx=1, pady=1)
+ widgets.RaceResultTreeView(frame, self._vm.results_contents).grid(column=0,
+ row=1, sticky="news", padx=1, pady=1)
frame.rowconfigure(1, weight=1)
return frame
class _runTab(ttk.Frame):
- def __init__(self, parent: Widget, vm: ViewModel) -> None:
+ def __init__(self, parent: Widget, vm: Model) -> None:
super().__init__(parent)
self._vm = vm
self.columnconfigure(0, weight=1)
diff --git a/model.py b/model.py
new file mode 100644
index 00000000..eb1a1240
--- /dev/null
+++ b/model.py
@@ -0,0 +1,110 @@
+# Wahoo! Results - https://github.com/JohnStrunk/wahoo-results
+# Copyright (C) 2022 - John D. Strunk
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+'''Data model'''
+
+from tkinter import BooleanVar, DoubleVar, IntVar, StringVar
+from typing import Callable, Set
+import PIL.Image as PILImage
+
+import widgets
+
+CallbackFn = Callable[[], None]
+
+class CallbackList:
+ '''A list of callback functions'''
+ _callbacks: Set[CallbackFn]
+ def __init__(self):
+ self._callbacks = set()
+ def run(self) -> None:
+ '''Invoke all registered callback functions'''
+ for func in self._callbacks:
+ func()
+ def add(self, callback) -> None:
+ '''Add a callback function to the set'''
+ self._callbacks.add(callback)
+ def remove(self, callback) -> None:
+ '''Remove a callback function from the set'''
+ self._callbacks.discard(callback)
+
+class Model: # pylint: disable=too-many-instance-attributes,too-few-public-methods
+ '''Defines the state variables (model) for the main UI'''
+
+ ## Colors from USA-S visual identity standards
+ PANTONE282_DKBLUE = "#041e42" # Primary
+ PANTONE200_RED = "#ba0c2f" # Primary
+ BLACK = "#000000" # Secondary
+ PANTONE428_LTGRAY = "#c1c6c8" # Secondary
+ PANTONE877METALIC_MDGRAY = "#8a8d8f" # Secondary
+ PANTONE281_MDBLUE = "#00205b"
+ PANTONE306_LTBLUE = "#00b3e4"
+ PANTONE871METALICGOLD = "#85754e"
+ PANTONE4505FLATGOLD = "#b1953a"
+
+ def __init__(self):
+ ########################################
+ ## Dropdown menu items
+ self.menu_exit = CallbackList()
+ self.menu_docs = CallbackList()
+ self.menu_about = CallbackList()
+ ########################################
+ ## Buttons
+ self.bg_import = CallbackList()
+ self.bg_clear = CallbackList()
+ self.dolphin_export = CallbackList()
+ ########################################
+ ## Entry fields
+ # Calibri (sans serif) is standard since Vista
+ # https://learn.microsoft.com/en-us/typography/font-list/calibri
+ # Also part of USA-S visual identity standards
+ # https://www.usaswimming.org/docs/default-source/marketingdocuments/usa-swimming-logo-standards-manual.pdf
+ self.main_text = StringVar(value="Calibri")
+ # Consolas (monospace) is standard since Vista
+ # https://learn.microsoft.com/en-us/typography/font-list/consolas
+ self.time_text = StringVar(value="Consolas")
+ self.text_spacing = DoubleVar(value=1.1)
+ self.heading = StringVar()
+ # colors
+ self.bg_image = StringVar()
+ self.color_heading = StringVar(value=self.PANTONE200_RED)
+ self.color_event = StringVar(value=self.PANTONE4505FLATGOLD)
+ self.color_even = StringVar(value=self.PANTONE877METALIC_MDGRAY)
+ self.color_odd = StringVar(value=self.PANTONE428_LTGRAY)
+ self.color_first = StringVar(value=self.PANTONE306_LTBLUE)
+ self.color_second = StringVar(value=self.PANTONE200_RED)
+ self.color_third = StringVar(value=self.PANTONE4505FLATGOLD)
+ self.color_bg = StringVar(value=self.BLACK)
+ # features
+ self.num_lanes = IntVar(value=10)
+ self.inhibit = BooleanVar(value=True)
+ # Preview
+ self.appearance_preview = widgets.ImageVar(PILImage.Image())
+ # Directories
+ self.startlist_dir = StringVar()
+ self.startlist_contents = widgets.StartListVar([])
+ self.results_dir = StringVar()
+ self.results_contents = widgets.RaceResultVar([])
+ # Run tab
+ self.cc_status = widgets.ChromecastStatusVar([])
+ self.scoreboard_preview = widgets.ImageVar(PILImage.Image())
+
+ def load(self, filename: str) -> None:
+ '''Load user's preferences'''
+ pass
+
+ def save(self, filename: str) -> None:
+ '''Save user's preferences'''
+ pass
diff --git a/newmain.py b/newmain.py
index 0c8872ff..b3d910d1 100644
--- a/newmain.py
+++ b/newmain.py
@@ -14,59 +14,87 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+'''Wahoo Results!'''
+
import datetime
-from tkinter import Tk, ttk
-import tkinter
-import platform
-from PIL import Image
+from tkinter import Tk, messagebox
from typing import List
import uuid
+from PIL import Image
import main_window
import imagecast
+from scoreboard import ScoreboardImage
+from template import get_template
import widgets
def main():
+ '''Main program'''
root = Tk()
- vm = main_window.ViewModel()
- main_window.View(root, vm)
+ model = main_window.Model()
+ model.load("wahoo-results.ini")
+ main_window.View(root, model)
# Exit menu exits app
- vm.on_menu_exit = lambda: root.destroy()
- vm.appearance_preview.set(Image.new(mode="RGBA", size=(1280, 720),
- color="green"))
+ def exit_fn():
+ try:
+ model.save("wahoo-results.ini")
+ except PermissionError as err:
+ messagebox.showerror(title="Error saving configuration",
+ message=f'Unable to write configuration file "{err.filename}". {err.strerror}',
+ detail="Please ensure the working directory is writable.")
+ root.destroy()
- def cb(name: str, _a, _b):
- v = root.getvar(name)
- print(f'Value: {v}')
+ model.menu_exit.add(exit_fn)
- vm.main_text.trace_add("write", cb)
-
- root.update()
-
- sl: widgets.StartListType = [
+ slist: widgets.StartListType = [
{'event': '102', 'desc': 'Girls 13&O 200 Free', 'heats': '12'},
{'event': '101', 'desc': 'Boys 13&O 200 Free', 'heats': '32'},
{'event': '110', 'desc': 'Boys 13&O 100 Free', 'heats': '2'},
]
- vm.startlist_contents.set(sl)
+ model.startlist_contents.set(slist)
- rr: widgets.RaceResultType = [
- {'meet': '10', 'event': '102', 'heat': '12', 'time': str(datetime.datetime(2022, 2, 3, 12, 37, 23))},
- {'meet': '9', 'event': '102', 'heat': '12', 'time': str(datetime.datetime(2023, 2, 3, 12, 37, 23))},
+ raceres: widgets.RaceResultType = [
+ {'meet': '10', 'event': '102', 'heat': '12',
+ 'time': str(datetime.datetime(2022, 2, 3, 12, 37, 23))},
+ {'meet': '9', 'event': '102', 'heat': '12',
+ 'time': str(datetime.datetime(2023, 2, 3, 12, 37, 23))},
]
- vm.results_contents.set(rr)
+ model.results_contents.set(raceres)
- cc: List[imagecast.DeviceStatus] = [
+ cc_devs: List[imagecast.DeviceStatus] = [
{'uuid': uuid.uuid4(), 'name': 'Living room', 'enabled': False},
{'uuid': uuid.uuid4(), 'name': 'Computer room', 'enabled': True},
{'uuid': uuid.uuid4(), 'name': 'Main scoreboard', 'enabled': False},
]
- vm.cc_status.set(cc)
- vm.scoreboard_preview.set(Image.new(mode="RGBA", size=(1280, 720),
+ model.cc_status.set(cc_devs)
+ model.scoreboard_preview.set(Image.new(mode="RGBA", size=(1280, 720),
color="brown"))
+ # Any time the "appearance settings" are changed, we should regenerate the
+ # scoreboard preview image
+ def update_preview(_a, _b, _c) -> None:
+ preview = ScoreboardImage((1280, 720), get_template(), model)
+ model.appearance_preview.set(preview.image)
+ for element in [
+ model.main_text,
+ model.time_text,
+ model.text_spacing,
+ model.heading,
+ model.bg_image,
+ model.color_heading,
+ model.color_event,
+ model.color_even,
+ model.color_odd,
+ model.color_first,
+ model.color_second,
+ model.color_third,
+ model.color_bg,
+ model.num_lanes,
+ ]: element.trace_add("write", update_preview)
+ update_preview(None, None, None)
+
root.mainloop()
if __name__ == "__main__":
diff --git a/pytest.ini b/pytest.ini
index 1f67767b..004c37a2 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,5 +1,5 @@
[pytest]
-addopts = --doctest-modules --mypy --pylint --pylint-ignore-patterns=".*_test.py" --ignore=build.py --cov=. --cov-config=.coveragerc --cov-report html --cov-report term --cov-report xml
+addopts = --doctest-modules --mypy --pylint --ignore=build.py --cov=. --cov-config=.coveragerc --cov-report html --cov-report term --cov-report xml
filterwarnings =
error
diff --git a/scoreboard.py b/scoreboard.py
new file mode 100644
index 00000000..32c8deec
--- /dev/null
+++ b/scoreboard.py
@@ -0,0 +1,259 @@
+# Wahoo! Results - https://github.com/JohnStrunk/wahoo-results
+# Copyright (C) 2022 - John D. Strunk
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+'''
+Generates an image of the scoreboard from a RaceTimes object.
+'''
+from typing import Optional, Tuple
+from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
+from matplotlib import font_manager # type: ignore
+
+from main_window import Model
+from racetimes import RaceTimes, RawTime
+from startlist import format_name, NameMode
+
+def waiting_screen(size: Tuple[int, int], model: Model) -> Image.Image:
+ '''Generate a "waiting" image to display on the scoreboard.'''
+ img = Image.new(mode="RGBA", size=size, color=model.color_bg.get())
+ center = (int(size[0]*0.5), int(size[1]*0.8))
+ normal = fontname_to_file(model.main_text.get())
+ font_size = 72
+ fnt = ImageFont.truetype(normal, font_size)
+ draw = ImageDraw.Draw(img)
+ color = model.color_event.get()
+ draw.text(center, "Waiting for results...", font=fnt, fill=color, anchor="ms")
+ return img
+
+class ScoreboardImage: #pylint: disable=too-many-instance-attributes
+ '''
+ Generate a scoreboard image from a RaceTimes object.
+
+ Parameters:
+
+ - size: A tuple representing the size of the image in pixels
+ - race: The RaceTimes object containing the race result (and optionally
+ the swimmer names/teams)
+ - model: The model state that contains the rendering preferences
+ '''
+
+ _BORDER_FRACTION = 0.05 # Fraction of image left as a border around all sides
+ _EVENT_SIZE = 'E:MMM'
+ _HEAT_SIZE = 'H:MM'
+ _img: Image.Image # The rendered image
+ _lanes: int # The number of lanes to display
+ _line_height: int # Height of a line of text (baseline to baseline), in px
+ _text_height: int # Height of actual text, in px
+ _normal_font: ImageFont.FreeTypeFont # Font for normal text
+ _time_font: ImageFont.FreeTypeFont # Font for printing times
+
+ def __init__(self, size: Tuple[int, int], race: RaceTimes, model: Model):
+ self._race = race
+ self._model = model
+ # We save the lane count once because it's used multiple times, and we
+ # want to ensure the value doesn't change while we're building the
+ # scoreboard image
+ self._lanes = model.num_lanes.get()
+ self._img = Image.new(mode="RGBA", size=size, color=model.color_bg.get())
+ self._add_bg_image()
+ self._load_fonts()
+ self._draw_header()
+ self._draw_lanes()
+
+ @property
+ def image(self) -> Image.Image:
+ '''The image of the scoreboard'''
+ return self._img
+
+ @property
+ def size(self):
+ '''Get the size of the image'''
+ return self._img.size
+
+ def _add_bg_image(self) -> None:
+ bg_image_filename = self._model.bg_image.get()
+ if bg_image_filename == "":
+ return # bg image not defined
+ try:
+ bg_image = Image.open(bg_image_filename)
+ except FileNotFoundError:
+ return # bg couldn't be found
+ except UnidentifiedImageError:
+ return # problem parsing bg image
+ bg_image = bg_image.resize(self.size, Image.BICUBIC)
+ self._img.alpha_composite(bg_image)
+
+ def _load_fonts(self) -> None:
+ usable_height = self.size[1] * (1 - (2 * self._BORDER_FRACTION))
+ lines = self._lanes
+ lines += 1 # Event num + Header
+ lines += 1 # Heat num + Event descr
+ lines += 1 # Name, team, time header
+ self._line_height = int(usable_height / lines)
+ scaled_height = self._line_height / self._model.text_spacing.get()
+ normal_f_file = fontname_to_file(self._model.main_text.get())
+ time_f_file = fontname_to_file(self._model.time_text.get())
+ self._normal_font = ImageFont.truetype(normal_f_file, int(scaled_height))
+ self._time_font = ImageFont.truetype(time_f_file, int(scaled_height))
+ draw = ImageDraw.Draw(self._img)
+ self._text_height = draw.textsize(self._EVENT_SIZE, self._normal_font)[1]
+
+ def _draw_header(self) -> None:
+ draw = ImageDraw.Draw(self._img)
+ edge_l = int(self.size[0] * self._BORDER_FRACTION)
+ edge_r = int(self.size[0] * (1 - self._BORDER_FRACTION))
+ width = edge_r - edge_l
+
+ # Line1 - E: 999 Heading text
+ draw.text((edge_l, self._baseline(1)), f"E:{self._race.event}", font=self._time_font,
+ anchor="ls", fill=self._model.color_event.get())
+ hstart = edge_l + draw.textsize(self._EVENT_SIZE, self._normal_font)[0]
+ hwidth = width - hstart
+ head_txt = self._model.heading.get()
+ while draw.textsize(head_txt, self._normal_font)[0] > hwidth:
+ head_txt = head_txt[:-1]
+ draw.text((edge_r, self._baseline(1)), head_txt, font=self._normal_font,
+ anchor="rs", fill=self._model.color_heading.get())
+
+ # Line2 - H: 99 Event description
+ draw.text((edge_l, self._baseline(2)), f"H:{self._race.heat}",
+ font=self._time_font, anchor="ls", fill=self._model.color_event.get())
+ dstart = edge_l + draw.textsize(self._HEAT_SIZE, self._normal_font)[0]
+ dwidth = width - dstart
+ desc_txt = self._race.event_name
+ while draw.textsize(desc_txt, self._normal_font)[0] > dwidth:
+ desc_txt = desc_txt[:-1]
+ draw.text((edge_r, self._baseline(2)), desc_txt, font=self._normal_font,
+ anchor="rs", fill=self._model.color_event.get())
+
+ def _draw_lanes(self) -> None: # pylint: disable=too-many-locals
+ draw = ImageDraw.Draw(self._img)
+ edge_l = int(self.size[0] * self._BORDER_FRACTION)
+ edge_r = int(self.size[0] * (1 - self._BORDER_FRACTION))
+ width = edge_r - edge_l
+ time_width = int(draw.textsize("00:00.00", self._time_font)[0] * 1.1)
+ idx_width = draw.textsize("L", self._normal_font)[0]
+ pl_width = draw.textsize("MMM", self._normal_font)[0]
+ name_width = width - time_width - idx_width - pl_width
+
+ # Lane title
+ baseline = self._baseline(3)
+ title_color = self._model.color_event.get()
+ draw.text((edge_l, baseline), "L", font=self._normal_font, anchor="ls", fill=title_color)
+ draw.text((edge_l + idx_width + pl_width, baseline), "Name",
+ font=self._normal_font, anchor="ls", fill=title_color)
+ draw.text((edge_r, baseline), "Time", font=self._normal_font, anchor="rs", fill=title_color)
+
+ # Lane data
+ for i in range(1, self._lanes + 1):
+ color = self._model.color_odd.get() if i % 2 else self._model.color_even.get()
+ line_num = 3 + i
+ # Lane
+ draw.text((edge_l + idx_width/2, self._baseline(line_num)),
+ f"{i}", font=self._normal_font, anchor="ms", fill=color)
+ # Place
+ pl_num = self._race.place(i)
+ pl_color = color
+ if pl_num == 1:
+ pl_color = self._model.color_first.get()
+ if pl_num == 2:
+ pl_color = self._model.color_second.get()
+ if pl_num == 3:
+ pl_color = self._model.color_third.get()
+ ptxt = format_place(pl_num)
+ draw.text((edge_l + idx_width + pl_width/2, self._baseline(line_num)),
+ ptxt, font=self._normal_font, anchor="ms", fill=pl_color)
+ # Name
+ name_variants = format_name(NameMode.NONE, self._race.name(i))
+ while draw.textsize(name_variants[0], self._normal_font)[0] > name_width:
+ name_variants.pop(0)
+ name = name_variants[0]
+ draw.text((edge_l + idx_width + pl_width, self._baseline(line_num)),
+ f"{name}", font=self._normal_font, anchor="ls", fill=color)
+ # Time
+ draw.text((edge_r, self._baseline(line_num)), self._time_text(i),
+ font=self._time_font, anchor="rs", fill=color)
+
+ def _time_text(self, lane: int) -> str:
+ if self._race.is_noshow(lane):
+ return "NS"
+ final_time = self._race.final_time(lane)
+ if final_time.value == RawTime("0"):
+ return ""
+ if not final_time.is_valid:
+ return "--:--.--"
+ return format_time(final_time.value)
+
+ def _baseline(self, line: int) -> int:
+ '''
+ Return the y-coordinate for the baseline of the n-th line of text from
+ the top.
+ '''
+ return int(self._img.size[1] * self._BORDER_FRACTION # skip top border
+ + line * self._line_height # move down to proper line
+ - (self._line_height - self._text_height)/2) # up 1/2 the inter-line space
+
+def format_time(seconds: RawTime) -> str:
+ '''
+ >>> format_time(RawTime('1.2'))
+ '01.20'
+ >>> format_time(RawTime('9.87'))
+ '09.87'
+ >>> format_time(RawTime('50'))
+ '50.00'
+ >>> format_time(RawTime('120.0'))
+ '2:00.00'
+ '''
+ sixty = RawTime('60')
+ minutes = seconds // sixty
+ seconds = seconds % sixty
+ if minutes == 0:
+ return f"{seconds:05.2f}"
+ return f"{minutes}:{seconds:05.2f}"
+
+def fontname_to_file(name: str) -> str:
+ '''Convert a font name (Roboto) to its corresponding file name'''
+ properties = font_manager.FontProperties(family=name, weight="bold")
+ filename = font_manager.findfont(properties)
+ return filename
+
+def format_place(place: Optional[int]) -> str:
+ '''
+ Turn a numerical place into the printable string representation.
+
+ >>> format_place(None)
+ ''
+ >>> format_place(0)
+ ''
+ >>> format_place(1)
+ '1st'
+ >>> format_place(2)
+ '2nd'
+ >>> format_place(3)
+ '3rd'
+ >>> format_place(6)
+ '6th'
+ '''
+ if place is None:
+ return ""
+ if place == 0:
+ return ""
+ if place == 1:
+ return "1st"
+ if place == 2:
+ return "2nd"
+ if place == 3:
+ return "3rd"
+ return f"{place}th"
diff --git a/template.py b/template.py
new file mode 100644
index 00000000..d862cd7f
--- /dev/null
+++ b/template.py
@@ -0,0 +1,102 @@
+# Wahoo! Results - https://github.com/JohnStrunk/wahoo-results
+# Copyright (C) 2022 - John D. Strunk
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+'''
+A scoreboard template for previews and theming
+'''
+
+from typing import List, Optional
+from racetimes import RaceTimes, RawTime
+from startlist import StartList
+
+def get_template() -> RaceTimes:
+ '''
+ Returns template data to create a scoreboard mockup
+
+ >>> from scoreboard import format_time
+ >>> t = get_template()
+ >>> t.event
+ 999
+ >>> t.final_time(1).is_valid
+ True
+ >>> format_time(t.final_time(1).value)
+ '99:50.99'
+ >>> t.final_time(2).is_valid
+ False
+ >>> t.final_time(3).is_valid
+ False
+ >>> t.name(3)
+ 'SWIMMER, NAMEOF A'
+ >>> t.team(3)
+ 'TEAM'
+ '''
+ race = _TemplateRace(2, RawTime("0.30"))
+ race.set_names(_TemplateStartList())
+ return race
+
+class _TemplateRace(RaceTimes):
+ @property
+ def event(self) -> int:
+ return 999
+
+ @property
+ def heat(self) -> int:
+ return 88
+
+ def raw_times(self, lane: int) -> List[Optional[RawTime]]:
+ if lane == 2:
+ return [None, None, None] # no-show
+ if lane == 3:
+ return [RawTime("1"), None, None] # Too few times -> invalid
+ time = RawTime("59.99") + RawTime("60") * RawTime("99")
+ time = time + lane - 10
+ return [time, time, time]
+
+class _TemplateStartList(StartList):
+ @property
+ def event_name(self) -> str:
+ return "GIRLS 15&O 200 MEDLEY RELAY"
+
+ def name(self, _heat: int, _lane: int) -> str:
+ return "SWIMMER, NAMEOF A"
+
+ def team(self, _heat: int, _lane: int) -> str:
+ return "TEAM"
+
+if __name__ == "__main__":
+ from tkinter import Tk
+ from scoreboard import ScoreboardImage, waiting_screen
+ from main_window import Model
+ root = Tk()
+ model = Model()
+
+ # model.color_bg.set("black")
+ # model.color_event.set("red")
+ # model.color_heading.set("yellow")
+ # model.color_even.set("white")
+ # model.color_odd.set("blue")
+ # model.color_first.set("#00ffff")
+ # model.color_second.set("#ff0000")
+ # model.color_third.set("#ffff00")
+ # model.main_text.set("Calibri")
+ # model.time_text.set("Consolas")
+ # model.text_spacing.set(14/12)
+ model.num_lanes.set(6)
+ # model.heading.set("2022 Swimtastic")
+
+ sboard = ScoreboardImage((1280, 720), get_template(), model)
+ sboard.image.save("image.png")
+ waiting_screen((1280, 720), model).save('image_wait.png')
diff --git a/widgets.py b/widgets.py
index bffaac38..d8c28908 100644
--- a/widgets.py
+++ b/widgets.py
@@ -20,12 +20,9 @@
import datetime
import os
-from tkinter import *
-from tkinter import colorchooser
-from tkinter import filedialog
-from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar, Union
-import tkinter.ttk as ttk
-import tkinter.tix as tix
+from tkinter import VERTICAL, Button, Canvas, StringVar, TclError, Variable, \
+ Widget, colorchooser, filedialog, ttk
+from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from PIL import ImageTk #type: ignore
import PIL.Image as PILImage
@@ -38,12 +35,13 @@
_T = TypeVar('_T')
class GVar(Variable, Generic[_T]):
- def __init__(self, value:_T, master=None):
- """Construct a generic variable.
+ '''
+ Create a generic variable in the flavor of StringVar, IntVar, etc.
- MASTER is the master widget.
- VALUE is the initial value for the variable
- """
+ - master: the master widget.
+ - value: the initial value for the variable
+ '''
+ def __init__(self, value:_T, master=None):
super().__init__(master=master, value=0)
self._value = value
@@ -75,6 +73,7 @@ def _btn_cb(self) -> None:
self.configure(bg=self._config.get_str(self._color_option))
def swatch(width: int, height: int, color: str) -> ImageTk.PhotoImage:
+ '''Generate a color swatch'''
img = PILImage.new("RGBA", (width, height), color)
return ImageTk.PhotoImage(img)
@@ -127,31 +126,34 @@ class StartListVar(GVar[StartListType]):
"""Holds an ordered start list."""
class StartListTreeView(ttk.Frame):
+ '''Widget to display a set of startlists'''
def __init__(self, parent: Widget, startlist: StartListVar):
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tv = ttk.Treeview(self, columns = ['event', 'desc', 'heats'])
- self.tv.grid(column=0, row=0, sticky="news")
- self.sb = ttk.Scrollbar(self, orient=VERTICAL, command=self.tv.yview)
- self.sb.grid(column=1, row=0, sticky="news")
- self.tv.configure(selectmode='none', show='headings', yscrollcommand=self.sb.set)
- self.tv.column('event', anchor='w', minwidth=50, width=50)
- self.tv.heading('event', anchor='w', text='Event')
- self.tv.column('desc', anchor='w', minwidth=200, width=200)
- self.tv.heading('desc', anchor='w', text='Description')
- self.tv.column('heats', anchor='w', minwidth=50, width=50)
- self.tv.heading('heats', anchor='w', text='Heats')
+ self.tview = ttk.Treeview(self, columns = ['event', 'desc', 'heats'])
+ self.tview.grid(column=0, row=0, sticky="news")
+ self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
+ self.scroll.grid(column=1, row=0, sticky="news")
+ self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
+ self.tview.column('event', anchor='w', minwidth=50, width=50)
+ self.tview.heading('event', anchor='w', text='Event')
+ self.tview.column('desc', anchor='w', minwidth=200, width=200)
+ self.tview.heading('desc', anchor='w', text='Description')
+ self.tview.column('heats', anchor='w', minwidth=50, width=50)
+ self.tview.heading('heats', anchor='w', text='Heats')
self.startlist = startlist
startlist.trace_add("write", lambda _a, _b, _c: self._update_contents())
def _update_contents(self):
- self.tv.delete(*self.tv.get_children())
+ self.tview.delete(*self.tview.get_children())
local_list = self.startlist.get()
for entry in local_list:
- self.tv.insert('', 'end', id=entry['event'], values=[entry['event'], entry['desc'], entry['heats']])
+ self.tview.insert('', 'end', id=entry['event'], values=[entry['event'],
+ entry['desc'], entry['heats']])
class DirSelection(ttk.Frame):
+ '''Directory selector widget'''
def __init__(self, parent: Widget, directory: StringVar):
super().__init__(parent)
self.dir = directory
@@ -172,73 +174,77 @@ class RaceResultVar(GVar[RaceResultType]):
"""Holds an ordered list of race results."""
class RaceResultTreeView(ttk.Frame):
+ '''Widget that displays a table of completed races'''
def __init__(self, parent: Widget, racelist: RaceResultVar):
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tv = ttk.Treeview(self, columns = ['meet', 'event', 'heat', 'time'])
- self.tv.grid(column=0, row=0, sticky="news")
- self.sb = ttk.Scrollbar(self, orient=VERTICAL, command=self.tv.yview)
- self.sb.grid(column=1, row=0, sticky="news")
- self.tv.configure(selectmode='none', show='headings', yscrollcommand=self.sb.set)
- self.tv.column('meet', anchor='w', minwidth=50, width=50)
- self.tv.heading('meet', anchor='w', text='Meet')
- self.tv.column('event', anchor='w', minwidth=50, width=50)
- self.tv.heading('event', anchor='w', text='Event')
- self.tv.column('heat', anchor='w', minwidth=50, width=50)
- self.tv.heading('heat', anchor='w', text='Heat')
- self.tv.column('time', anchor='w', minwidth=140, width=140)
- self.tv.heading('time', anchor='w', text='Time')
+ self.tview = ttk.Treeview(self, columns = ['meet', 'event', 'heat', 'time'])
+ self.tview.grid(column=0, row=0, sticky="news")
+ self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
+ self.scroll.grid(column=1, row=0, sticky="news")
+ self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
+ self.tview.column('meet', anchor='w', minwidth=50, width=50)
+ self.tview.heading('meet', anchor='w', text='Meet')
+ self.tview.column('event', anchor='w', minwidth=50, width=50)
+ self.tview.heading('event', anchor='w', text='Event')
+ self.tview.column('heat', anchor='w', minwidth=50, width=50)
+ self.tview.heading('heat', anchor='w', text='Heat')
+ self.tview.column('time', anchor='w', minwidth=140, width=140)
+ self.tview.heading('time', anchor='w', text='Time')
self.racelist = racelist
racelist.trace_add("write", lambda _a, _b, _c: self._update_contents())
def _update_contents(self):
- self.tv.delete(*self.tv.get_children())
+ self.tview.delete(*self.tview.get_children())
local_list = self.racelist.get()
- # TODO: Sort the list by date, descending
+ # Sort the list by date, descending
# https://stackoverflow.com/a/39359270
local_list.sort(key=lambda e: datetime.datetime.fromisoformat(e['time']), reverse=True)
for entry in local_list:
- self.tv.insert('', 'end', id=entry['time'], values=[entry['meet'], entry['event'], entry['heat'], entry['time']])
+ self.tview.insert('', 'end', id=entry['time'], values=[entry['meet'],
+ entry['event'], entry['heat'], entry['time']])
class ChromecastStatusVar(GVar[List[imagecast.DeviceStatus]]):
"""Holds a list of Chromecast devices and whether they are enabled"""
class ChromcastSelector(ttk.Frame):
+ '''Widget that allows enabling/disabling a set of Chromecast devices'''
def __init__(self, parent: Widget, statusvar: ChromecastStatusVar) -> None:
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tv = ttk.Treeview(self, columns = ['enabled', 'cc_name'])
- self.tv.grid(column=0, row=0, sticky="news")
- self.sb = ttk.Scrollbar(self, orient=VERTICAL, command=self.tv.yview)
- self.sb.grid(column=1, row=0, sticky="news")
- self.tv.configure(selectmode='none', show='headings', yscrollcommand=self.sb.set)
- self.tv.column('enabled', anchor='center', width=30)
- self.tv.heading('enabled', anchor='center', text='Enabled')
- self.tv.column('cc_name', anchor='w', minwidth=100)
- self.tv.heading('cc_name', anchor='w', text='Chromecast')
+ self.tview = ttk.Treeview(self, columns = ['enabled', 'cc_name'])
+ self.tview.grid(column=0, row=0, sticky="news")
+ self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
+ self.scroll.grid(column=1, row=0, sticky="news")
+ self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
+ self.tview.column('enabled', anchor='center', width=30)
+ self.tview.heading('enabled', anchor='center', text='Enabled')
+ self.tview.column('cc_name', anchor='w', minwidth=100)
+ self.tview.heading('cc_name', anchor='w', text='Chromecast')
self.devstatus = statusvar
self.devstatus.trace_add("write", lambda _a, _b, _c: self._update_contents())
# Needs to be the ButtonRelease event because the Button event happens
# before the focus is actually set/changed.
- self.tv.bind('', self._item_clicked)
+ self.tview.bind('', self._item_clicked)
self._update_contents()
-
+
def _update_contents(self) -> None:
- self.tv.delete(*self.tv.get_children())
+ self.tview.delete(*self.tview.get_children())
local_list = self.devstatus.get()
# Sort them by name for display
local_list.sort(key=lambda d: (d['name']))
for dev in local_list:
txt_status = "Yes" if dev['enabled'] else "No"
- self.tv.insert('', 'end', id=str(dev['uuid']), values=[txt_status, dev['name']])
+ self.tview.insert('', 'end', id=str(dev['uuid']), values=[txt_status, dev['name']])
+
def _item_clicked(self, _event) -> None:
- item = self.tv.focus()
+ item = self.tview.focus()
if len(item) == 0:
return
local_list = self.devstatus.get()
- for i in range(len(local_list)):
- if str(local_list[i]['uuid']) == item:
- local_list[i]['enabled'] = not local_list[i]['enabled']
+ for dev in local_list:
+ if str(dev['uuid']) == item:
+ dev['enabled'] = not dev['enabled']
self.devstatus.set(local_list)