From 2f3201a6b1ebd5caa8bf6d599754fe299ff2f133 Mon Sep 17 00:00:00 2001 From: John Strunk Date: Sat, 26 Nov 2022 11:52:50 -0500 Subject: [PATCH] Add scoreboard rendering Signed-off-by: John Strunk --- .pylintrc | 5 + Pipfile | 1 + Pipfile.lock | 38 +++++--- main_window.py | 157 +++++++++++++----------------- model.py | 110 +++++++++++++++++++++ newmain.py | 78 ++++++++++----- pytest.ini | 2 +- scoreboard.py | 259 +++++++++++++++++++++++++++++++++++++++++++++++++ template.py | 102 +++++++++++++++++++ widgets.py | 120 ++++++++++++----------- 10 files changed, 684 insertions(+), 188 deletions(-) create mode 100644 .pylintrc create mode 100644 model.py create mode 100644 scoreboard.py create mode 100644 template.py 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)