新增GUI缩放时显示内容自适应功能
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
# Add PhotoImage to the import
|
|
||||||
from tkinter import ttk, filedialog, messagebox, PhotoImage
|
from tkinter import ttk, filedialog, messagebox, PhotoImage
|
||||||
|
import tkinter.font # Added for tk.font.Font metrics
|
||||||
import random
|
import random
|
||||||
# import time
|
# import time
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +14,14 @@ class RollCallApp:
|
|||||||
self.master = master
|
self.master = master
|
||||||
master.title("课堂随机点名系统")
|
master.title("课堂随机点名系统")
|
||||||
|
|
||||||
|
# --- Base dimensions for scaling ---
|
||||||
|
self._default_width = 1100
|
||||||
|
self._default_height = 700
|
||||||
|
master.minsize(width=self._default_width, height=self._default_height)
|
||||||
|
# --- ADDED LINE: Explicitly set initial geometry ---
|
||||||
|
master.geometry(f"{self._default_width}x{self._default_height}")
|
||||||
|
# --- END ADDED LINE ---
|
||||||
|
|
||||||
# --- Set Window Icon ---
|
# --- Set Window Icon ---
|
||||||
try:
|
try:
|
||||||
# Determine the base path (works for scripts and frozen executables)
|
# Determine the base path (works for scripts and frozen executables)
|
||||||
@@ -38,139 +46,137 @@ class RollCallApp:
|
|||||||
print(f"Warning: An unexpected error occurred while loading the icon: {e}. Using default icon.")
|
print(f"Warning: An unexpected error occurred while loading the icon: {e}. Using default icon.")
|
||||||
# --- End Set Window Icon ---
|
# --- End Set Window Icon ---
|
||||||
|
|
||||||
|
# --- Define Font Families ---
|
||||||
master.minsize(width=1100, height=700)
|
|
||||||
|
|
||||||
# --- Define Font Families (User specified) ---
|
|
||||||
self.font_family_ui = "微软雅黑"
|
self.font_family_ui = "微软雅黑"
|
||||||
self.font_family_display = "华文中宋"
|
self.font_family_display = "华文中宋"
|
||||||
|
|
||||||
# --- Define Font Sizes (Slightly adjusted for modern feel, easy to revert) ---
|
# --- Define BASE Font Sizes (will be scaled) ---
|
||||||
self.font_size_standard = 14
|
self.base_font_size_standard = 14
|
||||||
self.font_size_counter = 18
|
self.base_font_size_counter = 18
|
||||||
self.font_size_display = 42
|
self.base_font_size_display = 42
|
||||||
self.font_size_pinyin_display = 28 # New font size for Pinyin
|
self.base_font_size_pinyin_display = 28
|
||||||
self.font_size_title = 28
|
self.base_font_size_title = 28
|
||||||
|
self.base_font_size_footer = 11
|
||||||
|
self.base_font_size_button = self.base_font_size_standard # Buttons use standard size, effectively
|
||||||
|
|
||||||
|
# --- Current font sizes (initialized to base, updated by scaling) ---
|
||||||
|
self.font_size_standard = self.base_font_size_standard
|
||||||
|
self.font_size_counter = self.base_font_size_counter
|
||||||
|
self.font_size_display = self.base_font_size_display
|
||||||
|
self.font_size_pinyin_display = self.base_font_size_pinyin_display
|
||||||
|
self.font_size_title = self.base_font_size_title
|
||||||
|
self.font_size_footer = self.base_font_size_footer
|
||||||
|
self.font_size_button = self.base_font_size_button
|
||||||
|
|
||||||
|
|
||||||
|
# --- Font Tuples (will be updated by scaling) ---
|
||||||
|
# Initialized here, but _update_all_fonts_and_widgets will create them properly
|
||||||
self.font_title_tuple = (self.font_family_ui, self.font_size_title, "bold")
|
self.font_title_tuple = (self.font_family_ui, self.font_size_title, "bold")
|
||||||
self.font_counter_tuple = (self.font_family_ui, self.font_size_counter)
|
self.font_counter_tuple = (self.font_family_ui, self.font_size_counter)
|
||||||
self.font_display_tuple = (self.font_family_display, self.font_size_display, "bold")
|
self.font_display_tuple = (self.font_family_display, self.font_size_display, "bold")
|
||||||
self.font_pinyin_display_tuple = (self.font_family_display, self.font_size_pinyin_display, "bold") # New font tuple for Pinyin
|
self.font_pinyin_display_tuple = (self.font_family_display, self.font_size_pinyin_display, "bold")
|
||||||
self.font_standard_tuple = (self.font_family_ui, self.font_size_standard)
|
self.font_standard_tuple = (self.font_family_ui, self.font_size_standard)
|
||||||
self.font_button_tuple = (self.font_family_ui, self.font_size_standard, "bold")
|
self.font_button_tuple = (self.font_family_ui, self.font_size_button, "bold")
|
||||||
self.font_footer_tuple = (self.font_family_ui, 11)
|
self.font_footer_tuple = (self.font_family_ui, self.font_size_footer)
|
||||||
|
|
||||||
|
# --- Base widget dimension/padding values for scaling ---
|
||||||
|
self.base_pady_name_display = 15
|
||||||
|
self.base_button_padding = (12, 7) # (padx, pady) for TButton internal padding
|
||||||
|
self.base_entry_padding = (3, 3) # (internal padx, internal pady for ttk.Entry)
|
||||||
|
self.base_duration_slider_length = 250
|
||||||
|
self.base_speed_slider_length = 250
|
||||||
|
|
||||||
|
# --- Scaling related attributes ---
|
||||||
|
self.last_scale_factor = 1.0
|
||||||
|
self.resize_timer = None
|
||||||
|
self._fonts_scaled_at_least_once = False
|
||||||
|
|
||||||
|
|
||||||
# --- Style and Colors ---
|
# --- Style and Colors (remain mostly constant, fonts will be updated in style) ---
|
||||||
self.style = ttk.Style(master)
|
self.style = ttk.Style(master)
|
||||||
try:
|
try:
|
||||||
self.style.theme_use('clam')
|
self.style.theme_use('clam') # 'clam', 'alt', 'default', 'classic'
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
print("Clam theme not available, using default theme. Styling will still be applied.")
|
print("Clam theme not available, using default theme. Styling will still be applied.")
|
||||||
|
|
||||||
self.COLOR_BACKGROUND = "#ECEFF1"
|
self.COLOR_BACKGROUND = "#ECEFF1" # Light Grey Blue
|
||||||
self.COLOR_TEXT = "#263238"
|
self.COLOR_TEXT = "#263238" # Dark Grey Blue
|
||||||
self.COLOR_PRIMARY = "#007BFF"
|
self.COLOR_PRIMARY = "#007BFF" # Blue (Bootstrap Primary)
|
||||||
self.COLOR_PRIMARY_DARK = "#0056b3"
|
self.COLOR_PRIMARY_DARK = "#0056b3" # Darker Blue
|
||||||
self.COLOR_PRIMARY_HOVER = "#0069D9"
|
self.COLOR_PRIMARY_HOVER = "#0069D9" # Brighter Blue
|
||||||
self.COLOR_PRIMARY_TEXT = "#FFFFFF"
|
self.COLOR_PRIMARY_TEXT = "#FFFFFF" # White
|
||||||
self.COLOR_SECONDARY_BG = "#FFFFFF"
|
self.COLOR_SECONDARY_BG = "#FFFFFF" # White (for entry fields, etc.)
|
||||||
self.COLOR_BORDER = "#B0BEC5"
|
self.COLOR_BORDER = "#B0BEC5" # Light Grey Border
|
||||||
self.COLOR_DISABLED_FG = "#78909C"
|
self.COLOR_DISABLED_FG = "#78909C" # Grey for disabled text
|
||||||
self.COLOR_DISABLED_BG = "#CFD8DC"
|
self.COLOR_DISABLED_BG = "#CFD8DC" # Lighter Grey for disabled background
|
||||||
|
|
||||||
self.COLOR_NAME_DISPLAY_DEFAULT_BG = self.COLOR_SECONDARY_BG
|
self.COLOR_NAME_DISPLAY_DEFAULT_BG = self.COLOR_SECONDARY_BG
|
||||||
self.COLOR_NAME_DISPLAY_DEFAULT_FG = self.COLOR_TEXT
|
self.COLOR_NAME_DISPLAY_DEFAULT_FG = self.COLOR_TEXT
|
||||||
self.COLOR_NAME_DISPLAY_FLASH_BG = "#FFF9C4" # Pale Yellow (Material Design Yellow 100)
|
self.COLOR_NAME_DISPLAY_FLASH_BG = "#FFF9C4" # Pale Yellow
|
||||||
self.COLOR_NAME_DISPLAY_FLASH_FG = self.COLOR_TEXT
|
self.COLOR_NAME_DISPLAY_FLASH_FG = self.COLOR_TEXT
|
||||||
self.COLOR_NAME_DISPLAY_SELECTED_BG = "#C8E6C9" # Pale Green (Material Design Green 100)
|
self.COLOR_NAME_DISPLAY_SELECTED_BG = "#C8E6C9" # Pale Green
|
||||||
self.COLOR_NAME_DISPLAY_SELECTED_FG = "#2E7D32" # Dark Green (Material Design Green 800)
|
self.COLOR_NAME_DISPLAY_SELECTED_FG = "#2E7D32" # Dark Green
|
||||||
self.COLOR_NAME_DISPLAY_ABSENT_BG = "#FFCDD2" # Pale Red (Material Design Red 100)
|
self.COLOR_NAME_DISPLAY_ABSENT_BG = "#FFCDD2" # Pale Red
|
||||||
self.COLOR_NAME_DISPLAY_ABSENT_FG = "#C62828" # Dark Red (Material Design Red 800)
|
self.COLOR_NAME_DISPLAY_ABSENT_FG = "#C62828" # Dark Red
|
||||||
|
|
||||||
master.configure(bg=self.COLOR_BACKGROUND)
|
master.configure(bg=self.COLOR_BACKGROUND)
|
||||||
|
# Style configurations will be done in _update_all_fonts_and_widgets
|
||||||
self.style.configure(".", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT,
|
|
||||||
font=self.font_standard_tuple)
|
|
||||||
self.style.configure("TFrame", background=self.COLOR_BACKGROUND)
|
|
||||||
|
|
||||||
self.style.configure("TLabel", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT)
|
|
||||||
self.style.configure("Title.TLabel", font=self.font_title_tuple, foreground=self.COLOR_PRIMARY)
|
|
||||||
self.style.configure("Counter.TLabel", font=self.font_counter_tuple)
|
|
||||||
self.style.configure("Value.TLabel", font=self.font_standard_tuple)
|
|
||||||
self.style.configure("Footer.TLabel", font=self.font_footer_tuple, foreground="#546E7A")
|
|
||||||
|
|
||||||
self.style.configure("TButton", font=self.font_button_tuple, padding=(12, 7), relief="raised")
|
|
||||||
self.style.map("TButton",
|
|
||||||
foreground=[('disabled', self.COLOR_DISABLED_FG),
|
|
||||||
('pressed', self.COLOR_PRIMARY_TEXT),
|
|
||||||
('active', self.COLOR_PRIMARY_TEXT),
|
|
||||||
('!disabled', self.COLOR_PRIMARY_TEXT)],
|
|
||||||
background=[('disabled', self.COLOR_DISABLED_BG),
|
|
||||||
('pressed', self.COLOR_PRIMARY_DARK),
|
|
||||||
('active', self.COLOR_PRIMARY_HOVER),
|
|
||||||
('!disabled', self.COLOR_PRIMARY)],
|
|
||||||
relief=[('pressed', 'sunken'), ('!pressed', 'raised')])
|
|
||||||
|
|
||||||
self.style.configure("TEntry", font=self.font_standard_tuple, fieldbackground=self.COLOR_SECONDARY_BG,
|
|
||||||
relief="flat")
|
|
||||||
self.style.map("TEntry",
|
|
||||||
bordercolor=[('focus', self.COLOR_PRIMARY), ('!focus', self.COLOR_BORDER)],
|
|
||||||
borderwidth=[('focus', 1), ('!focus', 1)],
|
|
||||||
lightcolor=[('focus', self.COLOR_PRIMARY)])
|
|
||||||
|
|
||||||
self.style.configure("Horizontal.TScale", background=self.COLOR_BACKGROUND, troughcolor=self.COLOR_SECONDARY_BG)
|
|
||||||
|
|
||||||
# --- Data storage ---
|
# --- Data storage ---
|
||||||
self.all_students = []
|
self.all_students = []
|
||||||
self.remaining_students = []
|
self.remaining_students = []
|
||||||
self.called_students = []
|
self.called_students = []
|
||||||
self.absent_students = []
|
self.absent_students = []
|
||||||
self.current_student_info = None
|
self.current_student_info = None # Tuple: (id, name, pinyin)
|
||||||
self.flash_timer = None
|
self.flash_timer = None
|
||||||
self.stop_timer = None
|
self.stop_timer = None
|
||||||
self.is_flashing = False
|
self.is_flashing = False
|
||||||
self.file_path = tk.StringVar(value="尚未选择文件")
|
self.file_path = tk.StringVar(value="尚未选择文件")
|
||||||
self.flash_duration_var = tk.IntVar(value=1)
|
self.flash_duration_var = tk.IntVar(value=1) # Seconds
|
||||||
self.flash_interval_var = tk.IntVar(value=25)
|
self.flash_interval_var = tk.IntVar(value=25) # Milliseconds
|
||||||
|
|
||||||
# --- UI Elements ---
|
# --- UI Elements ---
|
||||||
|
# Title
|
||||||
self.title_label = ttk.Label(master, text="课堂随机点名系统", style="Title.TLabel")
|
self.title_label = ttk.Label(master, text="课堂随机点名系统", style="Title.TLabel")
|
||||||
self.title_label.pack(pady=(25, 15))
|
self.title_label.pack(pady=(25, 15)) # External padding, might not scale
|
||||||
|
|
||||||
|
# Counter
|
||||||
self.counter_label = ttk.Label(master, text="已点名:0/0人 (总人数:0)", style="Counter.TLabel")
|
self.counter_label = ttk.Label(master, text="已点名:0/0人 (总人数:0)", style="Counter.TLabel")
|
||||||
self.counter_label.pack(pady=(0, 20))
|
self.counter_label.pack(pady=(0, 20))
|
||||||
|
|
||||||
|
# Name Display Area
|
||||||
self.name_display_border_frame = tk.Frame(master, background=self.COLOR_BORDER, relief="flat", bd=1)
|
self.name_display_border_frame = tk.Frame(master, background=self.COLOR_BORDER, relief="flat", bd=1)
|
||||||
self.name_display_border_frame.pack(pady=10, padx=30, fill=tk.X)
|
self.name_display_border_frame.pack(pady=10, padx=30, fill=tk.X)
|
||||||
|
|
||||||
# Changed from tk.Label to tk.Text
|
|
||||||
self.name_display = tk.Text(self.name_display_border_frame,
|
self.name_display = tk.Text(self.name_display_border_frame,
|
||||||
# Default font for the widget, actual text parts will use tags
|
font=self.font_display_tuple, # Default, tags will override
|
||||||
font=self.font_display_tuple,
|
|
||||||
bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
|
bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
|
||||||
fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
|
fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
|
||||||
wrap=tk.NONE, # Prevent wrapping for a single line display
|
wrap=tk.NONE,
|
||||||
# height=1, # Let it auto-size based on font and pady
|
height=1, # Crucial for single line behavior
|
||||||
relief="flat", bd=0,
|
relief="flat", bd=0,
|
||||||
pady=15, # Internal padding, similar to ipady of Label
|
pady=self.base_pady_name_display, # Initial pady, will be scaled by _render_name_display
|
||||||
tabs=("1c", "center") # Attempt to center, though tags are better
|
tabs=("1c", "center") # For centering text content
|
||||||
)
|
)
|
||||||
self.name_display.tag_configure("main_name", font=self.font_display_tuple)
|
# Tags will be configured in _render_name_display (called by _update_all_fonts_and_widgets)
|
||||||
self.name_display.tag_configure("pinyin", font=self.font_pinyin_display_tuple)
|
self.name_display.pack(fill=tk.X, padx=1, pady=1)
|
||||||
self.name_display.tag_configure("center", justify='center')
|
|
||||||
self.name_display.pack(fill=tk.X, padx=1, pady=1) # Pack with small pad to show border
|
# Store current state for name_display refresh
|
||||||
self._set_name_display_text("请先加载名单", is_placeholder=True)
|
self.current_name_display_content_tuple = ("请先加载名单", "", "") # (base, pinyin, suffix)
|
||||||
# self.name_display.config(state=tk.DISABLED) # state set in _set_name_display_text
|
self.current_name_display_fg = self.COLOR_NAME_DISPLAY_DEFAULT_FG
|
||||||
|
self.current_name_display_bg = self.COLOR_NAME_DISPLAY_DEFAULT_BG
|
||||||
|
self.current_name_display_is_placeholder = True
|
||||||
|
# Initial text set after styles are configured by _update_all_fonts_and_widgets
|
||||||
|
|
||||||
|
# Flash Control Area
|
||||||
flash_control_frame = ttk.Frame(master, style="TFrame")
|
flash_control_frame = ttk.Frame(master, style="TFrame")
|
||||||
flash_control_frame.pack(pady=15, fill=tk.X, padx=30)
|
flash_control_frame.pack(pady=15, fill=tk.X, padx=30)
|
||||||
|
|
||||||
duration_frame = ttk.Frame(flash_control_frame) # Will inherit style from parent or "."
|
duration_frame = ttk.Frame(flash_control_frame) # Will inherit style
|
||||||
duration_frame.pack(pady=8, anchor='center')
|
duration_frame.pack(pady=8, anchor='center')
|
||||||
ttk.Label(duration_frame, text="闪烁时间 (秒):").pack(side=tk.LEFT, padx=(0,10))
|
ttk.Label(duration_frame, text="闪烁时间 (秒):").pack(side=tk.LEFT, padx=(0,10))
|
||||||
self.duration_slider = ttk.Scale(duration_frame, from_=1, to=5, orient=tk.HORIZONTAL,
|
self.duration_slider = ttk.Scale(duration_frame, from_=1, to=5, orient=tk.HORIZONTAL,
|
||||||
variable=self.flash_duration_var, length=250,
|
variable=self.flash_duration_var, length=self.base_duration_slider_length,
|
||||||
command=lambda v: self.flash_duration_var.set(int(float(v))),
|
command=lambda v: self.flash_duration_var.set(int(float(v))),
|
||||||
style="Horizontal.TScale")
|
style="Horizontal.TScale")
|
||||||
self.duration_slider.pack(side=tk.LEFT, padx=5)
|
self.duration_slider.pack(side=tk.LEFT, padx=5)
|
||||||
@@ -182,7 +188,7 @@ class RollCallApp:
|
|||||||
interval_frame.pack(pady=8, anchor='center')
|
interval_frame.pack(pady=8, anchor='center')
|
||||||
ttk.Label(interval_frame, text="闪烁间隔 (毫秒):").pack(side=tk.LEFT, padx=(0,10))
|
ttk.Label(interval_frame, text="闪烁间隔 (毫秒):").pack(side=tk.LEFT, padx=(0,10))
|
||||||
self.speed_interval_slider = ttk.Scale(interval_frame, from_=25, to=300, orient=tk.HORIZONTAL,
|
self.speed_interval_slider = ttk.Scale(interval_frame, from_=25, to=300, orient=tk.HORIZONTAL,
|
||||||
variable=self.flash_interval_var, length=250,
|
variable=self.flash_interval_var, length=self.base_speed_slider_length,
|
||||||
command=lambda v: self.flash_interval_var.set(int(float(v))),
|
command=lambda v: self.flash_interval_var.set(int(float(v))),
|
||||||
style="Horizontal.TScale")
|
style="Horizontal.TScale")
|
||||||
self.speed_interval_slider.pack(side=tk.LEFT, padx=5)
|
self.speed_interval_slider.pack(side=tk.LEFT, padx=5)
|
||||||
@@ -190,19 +196,18 @@ class RollCallApp:
|
|||||||
self.interval_label.pack(side=tk.LEFT)
|
self.interval_label.pack(side=tk.LEFT)
|
||||||
self.speed_interval_slider.config(state=tk.DISABLED)
|
self.speed_interval_slider.config(state=tk.DISABLED)
|
||||||
|
|
||||||
# --- File Selection Area ---
|
# File Selection Area
|
||||||
file_frame = ttk.Frame(master)
|
file_frame = ttk.Frame(master)
|
||||||
file_frame.pack(pady=20, padx=30)
|
file_frame.pack(pady=20, padx=30)
|
||||||
ttk.Label(file_frame, text="学生名单 (txt文件, 一行一个'姓名,学号')-->").pack(side=tk.LEFT, padx=(0,10))
|
ttk.Label(file_frame, text="学生名单 (txt文件, 一行一个'姓名,学号')-->").pack(side=tk.LEFT, padx=(0,10))
|
||||||
self.file_entry = ttk.Entry(file_frame, textvariable=self.file_path, width=25, state='readonly')
|
self.file_entry = ttk.Entry(file_frame, textvariable=self.file_path, width=25, state='readonly') # width in chars
|
||||||
self.file_entry.pack(side=tk.LEFT, padx=5, ipady=2) # Small ipady for entry height consistency
|
self.file_entry.pack(side=tk.LEFT, padx=5) # ipady is not for ttk.Entry, use style padding
|
||||||
self.load_button = ttk.Button(file_frame, text="选择文件", command=self.load_file)
|
self.load_button = ttk.Button(file_frame, text="选择文件", command=self.load_file)
|
||||||
self.load_button.pack(side=tk.LEFT, padx=(5,0))
|
self.load_button.pack(side=tk.LEFT, padx=(5,0))
|
||||||
|
|
||||||
# --- Control Button Area ---
|
# Control Button Area
|
||||||
button_frame = ttk.Frame(master)
|
button_frame = ttk.Frame(master)
|
||||||
button_frame.pack(pady=25)
|
button_frame.pack(pady=25)
|
||||||
# Grid padx/pady for spacing between buttons
|
|
||||||
self.start_button = ttk.Button(button_frame, text="开始点名", command=self.start_roll_call, state=tk.DISABLED)
|
self.start_button = ttk.Button(button_frame, text="开始点名", command=self.start_roll_call, state=tk.DISABLED)
|
||||||
self.start_button.grid(row=0, column=0, padx=10, pady=8)
|
self.start_button.grid(row=0, column=0, padx=10, pady=8)
|
||||||
self.mark_absent_button = ttk.Button(button_frame, text="标记未到场", command=self.mark_absent, state=tk.DISABLED)
|
self.mark_absent_button = ttk.Button(button_frame, text="标记未到场", command=self.mark_absent, state=tk.DISABLED)
|
||||||
@@ -212,61 +217,202 @@ class RollCallApp:
|
|||||||
self.clear_button = ttk.Button(button_frame, text="清空考勤数据", command=self.clear_data, state=tk.DISABLED)
|
self.clear_button = ttk.Button(button_frame, text="清空考勤数据", command=self.clear_data, state=tk.DISABLED)
|
||||||
self.clear_button.grid(row=1, column=1, padx=10, pady=8)
|
self.clear_button.grid(row=1, column=1, padx=10, pady=8)
|
||||||
|
|
||||||
# --- 底部版权信息 ---
|
# Footer
|
||||||
self.footer_label = ttk.Label(
|
self.footer_label = ttk.Label(
|
||||||
master,
|
master,
|
||||||
text="Ver. 2.3 @杨昱幸. All Rights Reserved.",
|
text="Ver. 2.4 @杨昱幸. All Rights Reserved.",
|
||||||
style="Footer.TLabel",
|
style="Footer.TLabel",
|
||||||
anchor='center'
|
anchor='center'
|
||||||
)
|
)
|
||||||
self.footer_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(15,10))
|
self.footer_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(15,10))
|
||||||
|
|
||||||
|
# Perform initial setup of fonts, styles, and dependent widgets
|
||||||
|
self._update_all_fonts_and_widgets(1.0) # Initial call with scale factor 1.0
|
||||||
|
# Set initial text for name_display AFTER styles are configured by the above call
|
||||||
|
self._set_name_display_text("请先加载名单", is_placeholder=True) # This will use _render_name_display
|
||||||
|
|
||||||
self.update_counter()
|
self.update_counter()
|
||||||
|
self.master.bind("<Configure>", self.on_window_resize)
|
||||||
|
|
||||||
|
|
||||||
|
def on_window_resize(self, event=None):
|
||||||
|
# Check if the event is for the master window itself, not a child widget's Configure event
|
||||||
|
if event and event.widget != self.master:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.resize_timer:
|
||||||
|
self.master.after_cancel(self.resize_timer)
|
||||||
|
|
||||||
|
# Pass current width/height to avoid issues with event object persistence or calls outside event loop
|
||||||
|
current_width = self.master.winfo_width()
|
||||||
|
current_height = self.master.winfo_height()
|
||||||
|
self.resize_timer = self.master.after(150, self._perform_resize_actions, current_width, current_height) # 150ms debounce
|
||||||
|
|
||||||
|
def _perform_resize_actions(self, current_width, current_height):
|
||||||
|
if current_width <= 1 or current_height <= 1: # Window might be iconified or not fully drawn
|
||||||
|
return
|
||||||
|
|
||||||
|
scale_w = current_width / self._default_width
|
||||||
|
scale_h = current_height / self._default_height
|
||||||
|
|
||||||
|
# Use average scaling factor, could also use min(scale_w, scale_h)
|
||||||
|
scale_factor = (scale_w + scale_h) / 2.0
|
||||||
|
# scale_factor = min(scale_w, scale_h)
|
||||||
|
|
||||||
|
# Prevent fonts from becoming too tiny or negative if window is drastically shrunk
|
||||||
|
if scale_factor < 0.2: # Arbitrary minimum scale factor
|
||||||
|
scale_factor = 0.2
|
||||||
|
|
||||||
|
# Avoid excessive updates if scale hasn't changed much
|
||||||
|
# (and it's not the very first scaling operation)
|
||||||
|
if abs(scale_factor - self.last_scale_factor) < 0.015 and self._fonts_scaled_at_least_once:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._update_all_fonts_and_widgets(scale_factor)
|
||||||
|
self.last_scale_factor = scale_factor
|
||||||
|
self._fonts_scaled_at_least_once = True
|
||||||
|
|
||||||
|
|
||||||
|
def _update_all_fonts_and_widgets(self, scale_factor):
|
||||||
|
MIN_FONT_SIZE = 7 # Absolute minimum font size for readability
|
||||||
|
|
||||||
|
# 1. Update font size attributes based on scale_factor
|
||||||
|
self.font_size_standard = max(MIN_FONT_SIZE, int(self.base_font_size_standard * scale_factor))
|
||||||
|
self.font_size_counter = max(MIN_FONT_SIZE, int(self.base_font_size_counter * scale_factor))
|
||||||
|
self.font_size_display = max(MIN_FONT_SIZE + 4, int(self.base_font_size_display * scale_factor)) # Display font should remain relatively large
|
||||||
|
self.font_size_pinyin_display = max(MIN_FONT_SIZE + 2, int(self.base_font_size_pinyin_display * scale_factor))
|
||||||
|
self.font_size_title = max(MIN_FONT_SIZE + 3, int(self.base_font_size_title * scale_factor))
|
||||||
|
self.font_size_footer = max(max(1, MIN_FONT_SIZE - 2), int(self.base_font_size_footer * scale_factor)) # Footer can be smaller
|
||||||
|
self.font_size_button = max(MIN_FONT_SIZE, int(self.base_font_size_button * scale_factor))
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Update font tuples
|
||||||
|
self.font_title_tuple = (self.font_family_ui, self.font_size_title, "bold")
|
||||||
|
self.font_counter_tuple = (self.font_family_ui, self.font_size_counter)
|
||||||
|
self.font_display_tuple = (self.font_family_display, self.font_size_display, "bold")
|
||||||
|
self.font_pinyin_display_tuple = (self.font_family_display, self.font_size_pinyin_display, "bold")
|
||||||
|
self.font_standard_tuple = (self.font_family_ui, self.font_size_standard)
|
||||||
|
self.font_button_tuple = (self.font_family_ui, self.font_size_button, "bold")
|
||||||
|
self.font_footer_tuple = (self.font_family_ui, self.font_size_footer)
|
||||||
|
|
||||||
|
# 3. Update widget-specific dimension attributes (paddings, lengths)
|
||||||
|
scaled_button_padding = (
|
||||||
|
max(3, int(self.base_button_padding[0] * scale_factor)), # padx
|
||||||
|
max(2, int(self.base_button_padding[1] * scale_factor)) # pady
|
||||||
|
)
|
||||||
|
scaled_entry_padding = ( # For ttk.Entry internal padding
|
||||||
|
max(1, int(self.base_entry_padding[0] * scale_factor)), # Horizontal padding
|
||||||
|
max(1, int(self.base_entry_padding[1] * scale_factor)) # Vertical padding
|
||||||
|
)
|
||||||
|
scaled_duration_slider_length = max(80, int(self.base_duration_slider_length * scale_factor))
|
||||||
|
scaled_speed_slider_length = max(80, int(self.base_speed_slider_length * scale_factor))
|
||||||
|
|
||||||
|
# 4. Reconfigure ttk Styles
|
||||||
|
self.style.configure(".", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT, font=self.font_standard_tuple)
|
||||||
|
self.style.configure("TFrame", background=self.COLOR_BACKGROUND)
|
||||||
|
self.style.configure("TLabel", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT, font=self.font_standard_tuple) # Default for labels
|
||||||
|
self.style.configure("Title.TLabel", font=self.font_title_tuple, foreground=self.COLOR_PRIMARY)
|
||||||
|
self.style.configure("Counter.TLabel", font=self.font_counter_tuple)
|
||||||
|
self.style.configure("Value.TLabel", font=self.font_standard_tuple) # For slider value labels
|
||||||
|
self.style.configure("Footer.TLabel", font=self.font_footer_tuple, foreground="#546E7A")
|
||||||
|
|
||||||
|
self.style.configure("TButton", font=self.font_button_tuple, padding=scaled_button_padding)
|
||||||
|
self.style.map("TButton",
|
||||||
|
foreground=[('disabled', self.COLOR_DISABLED_FG), ('pressed', self.COLOR_PRIMARY_TEXT),
|
||||||
|
('active', self.COLOR_PRIMARY_TEXT), ('!disabled', self.COLOR_PRIMARY_TEXT)],
|
||||||
|
background=[('disabled', self.COLOR_DISABLED_BG), ('pressed', self.COLOR_PRIMARY_DARK),
|
||||||
|
('active', self.COLOR_PRIMARY_HOVER), ('!disabled', self.COLOR_PRIMARY)],
|
||||||
|
relief=[('pressed', 'sunken'), ('!pressed', 'raised')])
|
||||||
|
|
||||||
|
self.style.configure("TEntry", font=self.font_standard_tuple, fieldbackground=self.COLOR_SECONDARY_BG,
|
||||||
|
relief="flat", padding=scaled_entry_padding)
|
||||||
|
self.style.map("TEntry",
|
||||||
|
bordercolor=[('focus', self.COLOR_PRIMARY), ('!focus', self.COLOR_BORDER)],
|
||||||
|
borderwidth=[('focus', 1), ('!focus', 1)],
|
||||||
|
lightcolor=[('focus', self.COLOR_PRIMARY)])
|
||||||
|
|
||||||
|
self.style.configure("Horizontal.TScale", background=self.COLOR_BACKGROUND, troughcolor=self.COLOR_SECONDARY_BG)
|
||||||
|
|
||||||
|
# 5. Reconfigure specific widget properties not fully covered by styles
|
||||||
|
self.duration_slider.config(length=scaled_duration_slider_length)
|
||||||
|
self.speed_interval_slider.config(length=scaled_speed_slider_length)
|
||||||
|
|
||||||
|
# 6. Update name_display's own font to ensure its height calculation for "height=1" is correct
|
||||||
|
self.name_display.config(font=self.font_display_tuple)
|
||||||
|
|
||||||
|
# 7. Re-render name_display (it uses its own font tuples and scaled pady)
|
||||||
|
# This method will use the newly updated font_tuples for tags,
|
||||||
|
# and the passed scale_factor for its internal pady.
|
||||||
|
# Its height=1 will now be based on the scaled font set in step 6.
|
||||||
|
# --- MODIFIED LINE: Pass current scale_factor ---
|
||||||
|
self._render_name_display(scale_factor)
|
||||||
|
# --- END MODIFIED LINE ---
|
||||||
|
|
||||||
|
|
||||||
def _set_name_display_text(self, text_content, fg_color=None, bg_color=None, is_placeholder=False):
|
def _set_name_display_text(self, text_content, fg_color=None, bg_color=None, is_placeholder=False):
|
||||||
|
# Store arguments for potential re-application on resize or content update
|
||||||
|
if is_placeholder:
|
||||||
|
self.current_name_display_content_tuple = (str(text_content), "", "") # Ensure (base, pinyin, suffix)
|
||||||
|
else:
|
||||||
|
# Ensure text_content is always a 3-tuple
|
||||||
|
if isinstance(text_content, tuple) and len(text_content) == 3:
|
||||||
|
self.current_name_display_content_tuple = text_content
|
||||||
|
else: # Fallback if format is unexpected, treat as placeholder
|
||||||
|
self.current_name_display_content_tuple = (str(text_content), "", "")
|
||||||
|
is_placeholder = True # Force placeholder rendering logic
|
||||||
|
|
||||||
|
self.current_name_display_fg = fg_color # Will default in _render if None
|
||||||
|
self.current_name_display_bg = bg_color # Will default in _render if None
|
||||||
|
self.current_name_display_is_placeholder = is_placeholder
|
||||||
|
|
||||||
|
# Call the rendering part
|
||||||
|
self._render_name_display() # Calls _render_name_display without explicit scale_factor
|
||||||
|
|
||||||
|
# --- MODIFIED METHOD: Accept scale_factor for pady calculation ---
|
||||||
|
def _render_name_display(self, scale_factor_for_pady=None):
|
||||||
|
if scale_factor_for_pady is None:
|
||||||
|
# If not called from a resize operation, use the last known overall scale factor
|
||||||
|
scale_factor_for_pady = self.last_scale_factor
|
||||||
|
# --- END MODIFIED METHOD ---
|
||||||
|
|
||||||
|
# This method uses the stored state (current_name_display_... attributes)
|
||||||
|
# and current scale factor (self.last_scale_factor or passed scale_factor_for_pady)
|
||||||
|
text_tuple = self.current_name_display_content_tuple
|
||||||
|
fg_color = self.current_name_display_fg
|
||||||
|
bg_color = self.current_name_display_bg
|
||||||
|
is_placeholder = self.current_name_display_is_placeholder
|
||||||
|
|
||||||
self.name_display.config(state=tk.NORMAL)
|
self.name_display.config(state=tk.NORMAL)
|
||||||
self.name_display.delete("1.0", tk.END)
|
self.name_display.delete("1.0", tk.END)
|
||||||
|
|
||||||
|
# Determine actual colors to use, applying defaults if necessary
|
||||||
current_bg_color = bg_color if bg_color is not None else self.COLOR_NAME_DISPLAY_DEFAULT_BG
|
current_bg_color = bg_color if bg_color is not None else self.COLOR_NAME_DISPLAY_DEFAULT_BG
|
||||||
current_fg_color = fg_color if fg_color is not None else self.COLOR_NAME_DISPLAY_DEFAULT_FG
|
current_fg_color = fg_color if fg_color is not None else self.COLOR_NAME_DISPLAY_DEFAULT_FG
|
||||||
|
|
||||||
self.name_display.config(bg=current_bg_color)
|
# Scale the internal pady of the Text widget
|
||||||
# Foreground for Text widget tags is better controlled by the tag itself if varied,
|
# --- MODIFIED LINE: Use scale_factor_for_pady ---
|
||||||
# or can be set on the widget if uniform. Here, tags will control fg for main/pinyin.
|
scaled_pady_name_display = max(3, int(self.base_pady_name_display * scale_factor_for_pady))
|
||||||
# For simplicity, let's assume main_name and pinyin will use the passed fg_color.
|
# --- END MODIFIED LINE ---
|
||||||
|
self.name_display.config(bg=current_bg_color, pady=scaled_pady_name_display)
|
||||||
|
|
||||||
|
# Font tuples (self.font_display_tuple, etc.) are assumed to be up-to-date from _update_all_fonts_and_widgets
|
||||||
self.name_display.tag_configure("main_name", font=self.font_display_tuple, foreground=current_fg_color)
|
self.name_display.tag_configure("main_name", font=self.font_display_tuple, foreground=current_fg_color)
|
||||||
self.name_display.tag_configure("pinyin", font=self.font_pinyin_display_tuple, foreground=current_fg_color)
|
self.name_display.tag_configure("pinyin", font=self.font_pinyin_display_tuple, foreground=current_fg_color)
|
||||||
# Suffix color will also be current_fg_color by using main_name tag.
|
self.name_display.tag_configure("center", justify='center')
|
||||||
|
|
||||||
if is_placeholder: # Simple string for placeholders
|
base_str, pinyin_str_val, suffix_str = "", "", "" # Ensure defined
|
||||||
self.name_display.insert(tk.END, str(text_content), ("center", "main_name"))
|
if is_placeholder:
|
||||||
else:
|
base_str = text_tuple[0] # Placeholder string is in base_str part of tuple
|
||||||
# text_content is expected to be a tuple: (base_str, pinyin_str, suffix_str)
|
|
||||||
base_str, pinyin_str_val, suffix_str = text_content
|
|
||||||
self.name_display.insert(tk.END, base_str, ("center", "main_name"))
|
self.name_display.insert(tk.END, base_str, ("center", "main_name"))
|
||||||
self.name_display.insert(tk.END, pinyin_str_val, ("center", "pinyin"))
|
else:
|
||||||
|
base_str, pinyin_str_val, suffix_str = text_tuple
|
||||||
|
self.name_display.insert(tk.END, base_str, ("center", "main_name"))
|
||||||
|
if pinyin_str_val: # Only insert if pinyin is not empty
|
||||||
|
self.name_display.insert(tk.END, pinyin_str_val, ("center", "pinyin"))
|
||||||
if suffix_str:
|
if suffix_str:
|
||||||
self.name_display.insert(tk.END, suffix_str, ("center", "main_name"))
|
self.name_display.insert(tk.END, suffix_str, ("center", "main_name"))
|
||||||
|
|
||||||
# Calculate required height for one line of the largest font + padding
|
self.name_display.config(height=1) # Keep it constrained to 1 line visually
|
||||||
# This is tricky. For now, rely on Text widget's auto-sizing with its internal pady.
|
|
||||||
# The height of the Text widget should be fixed to one line of the largest font.
|
|
||||||
# We might need to set a fixed height for self.name_display_border_frame or self.name_display itself.
|
|
||||||
# One approach is to ensure self.name_display shows one line effectively.
|
|
||||||
# If font_display_tuple size 42, linespace is X. pady=15. Total height=X+2*15.
|
|
||||||
# Set Text widget height to 1 to ensure it only shows one line, line height determined by tallest char.
|
|
||||||
# The `Text` widget's `height` parameter is in lines of its default font.
|
|
||||||
# If we want to ensure it visually fits one line of mixed (taller) fonts, it's complex.
|
|
||||||
# For now, we'll assume `wrap=tk.NONE` and `pady` manage this.
|
|
||||||
# After some testing, Text widget default height of 10 lines is too much.
|
|
||||||
# Setting height=1 for text widget:
|
|
||||||
font_obj = tk.font.Font(family=self.font_family_display, size=self.font_size_display, weight="bold")
|
|
||||||
linespace = font_obj.metrics("linespace")
|
|
||||||
widget_height_pixels = linespace + 2 * self.name_display.cget("pady") # pady is internal padding
|
|
||||||
# The Text widget's height is in lines of its *default* font. This is complex.
|
|
||||||
# Simplest is to set height=1 to force single-line behavior and let Tk handle line height.
|
|
||||||
self.name_display.config(height=1) # Try forcing to 1 line height.
|
|
||||||
|
|
||||||
self.name_display.config(state=tk.DISABLED)
|
self.name_display.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,272 +424,303 @@ class RollCallApp:
|
|||||||
if not filepath: return
|
if not filepath: return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._reset_data_full()
|
self._reset_data_full() # Clears all student data and resets UI related to it
|
||||||
self.file_path.set(os.path.basename(filepath))
|
self.file_path.set(os.path.basename(filepath))
|
||||||
loaded_students = []
|
loaded_students = []
|
||||||
invalid_lines = []
|
invalid_lines = [] # To store info about invalid lines
|
||||||
line_num = 0
|
line_num = 0
|
||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line_num += 1
|
line_num += 1
|
||||||
original_line = line
|
original_line = line # Keep original for error reporting
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line: continue
|
if not line: continue # Skip empty lines
|
||||||
parts = line.split(',', 1)
|
|
||||||
|
parts = line.split(',', 1) # Split only on the first comma
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
student_id_str = parts[0].strip()
|
student_id_str = parts[0].strip()
|
||||||
name = parts[1].strip()
|
name = parts[1].strip()
|
||||||
if student_id_str and name:
|
if student_id_str and name: # Ensure neither part is empty after stripping
|
||||||
pinyin_list = pinyin(name, style=Style.TONE)
|
# Generate Pinyin for the name
|
||||||
|
pinyin_list = pinyin(name, style=Style.TONE) # TONE gives pīnyīn
|
||||||
pinyin_str = "".join([item[0] for item in pinyin_list])
|
pinyin_str = "".join([item[0] for item in pinyin_list])
|
||||||
student_info = (student_id_str, name, pinyin_str)
|
student_info = (student_id_str, name, pinyin_str)
|
||||||
loaded_students.append(student_info)
|
loaded_students.append(student_info)
|
||||||
else: invalid_lines.append((line_num, "学号或姓名为空", original_line))
|
else:
|
||||||
else: invalid_lines.append((line_num, "格式错误", original_line))
|
invalid_lines.append((line_num, "学号或姓名为空 (student ID or name is empty)", original_line))
|
||||||
|
else:
|
||||||
|
invalid_lines.append((line_num, "格式错误 (incorrect format)", original_line))
|
||||||
|
|
||||||
if not loaded_students:
|
if not loaded_students:
|
||||||
messagebox.showwarning("警告", "文件未包含有效格式的学生信息 (学号,姓名)。")
|
messagebox.showwarning("警告 (Warning)", "文件未包含有效格式的学生信息 (学号,姓名)。\n(File does not contain student information in the valid format 'ID,Name'.)")
|
||||||
self._reset_ui_after_load_fail()
|
self._reset_ui_after_load_fail() # Reset UI to initial state
|
||||||
return
|
return
|
||||||
|
|
||||||
self.all_students = loaded_students
|
self.all_students = loaded_students
|
||||||
self.remaining_students = self.all_students[:]
|
self.remaining_students = self.all_students[:] # Copy for roll call
|
||||||
random.shuffle(self.remaining_students)
|
random.shuffle(self.remaining_students)
|
||||||
|
|
||||||
self._set_name_display_text("名单已加载,准备点名",
|
self._set_name_display_text("名单已加载,准备点名",
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
|
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
|
|
||||||
is_placeholder=True)
|
is_placeholder=True)
|
||||||
self.master.update_idletasks()
|
|
||||||
|
|
||||||
self.update_counter()
|
self.update_counter()
|
||||||
self.start_button.config(state=tk.NORMAL)
|
self.start_button.config(state=tk.NORMAL)
|
||||||
self.clear_button.config(state=tk.NORMAL)
|
self.clear_button.config(state=tk.NORMAL)
|
||||||
self.export_button.config(state=tk.DISABLED)
|
self.export_button.config(state=tk.DISABLED) # No absentees yet
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
self.mark_absent_button.config(state=tk.DISABLED) # No student selected yet
|
||||||
self.duration_slider.config(state=tk.NORMAL)
|
self.duration_slider.config(state=tk.NORMAL)
|
||||||
self.speed_interval_slider.config(state=tk.NORMAL)
|
self.speed_interval_slider.config(state=tk.NORMAL)
|
||||||
|
|
||||||
info_msg = f"成功加载 {len(self.all_students)} 位学生!"
|
# Feedback to user
|
||||||
|
info_msg = f"成功加载 {len(self.all_students)} 位学生!\n(Successfully loaded {len(self.all_students)} students!)"
|
||||||
if invalid_lines:
|
if invalid_lines:
|
||||||
info_msg += f"\n\n但有 {len(invalid_lines)} 行格式无效已被跳过:"
|
info_msg += f"\n\n但有 {len(invalid_lines)} 行格式无效已被跳过:\n(However, {len(invalid_lines)} lines with invalid format were skipped:)"
|
||||||
for i, (ln, reason, orig) in enumerate(invalid_lines[:5]):
|
for i, (ln, reason, orig) in enumerate(invalid_lines[:5]): # Show first 5 errors
|
||||||
info_msg += f"\n第{ln}行: {reason} -> '{orig.strip()}'"
|
info_msg += f"\n第{ln}行: {reason} -> '{orig.strip()}'"
|
||||||
if len(invalid_lines) > 5: info_msg += "\n..."
|
if len(invalid_lines) > 5:
|
||||||
messagebox.showwarning("加载部分成功", info_msg)
|
info_msg += "\n..."
|
||||||
|
messagebox.showwarning("加载部分成功 (Partial Load Success)", info_msg)
|
||||||
else:
|
else:
|
||||||
messagebox.showinfo("成功", info_msg)
|
messagebox.showinfo("成功 (Success)", info_msg)
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
messagebox.showerror("错误", f"文件未找到: {filepath}")
|
messagebox.showerror("错误 (Error)", f"文件未找到 (File not found): {filepath}")
|
||||||
self._reset_ui_after_load_fail()
|
self._reset_ui_after_load_fail()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("错误", f"加载文件时出错: {e}")
|
messagebox.showerror("错误 (Error)", f"加载文件时出错 (Error loading file): {e}")
|
||||||
self._reset_ui_after_load_fail()
|
self._reset_ui_after_load_fail()
|
||||||
|
|
||||||
def _reset_data_core(self):
|
def _reset_data_core(self):
|
||||||
|
# Resets roll call progress, keeps loaded student list (all_students)
|
||||||
self.remaining_students = []
|
self.remaining_students = []
|
||||||
self.called_students = []
|
self.called_students = []
|
||||||
self.absent_students = []
|
self.absent_students = []
|
||||||
self.current_student_info = None
|
self.current_student_info = None # Clear current selection
|
||||||
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
|
# Cancel any active timers
|
||||||
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
|
|
||||||
self.is_flashing = False
|
|
||||||
|
|
||||||
def _reset_data_full(self):
|
|
||||||
self._reset_data_core()
|
|
||||||
self.all_students = []
|
|
||||||
self.file_path.set("尚未选择文件")
|
|
||||||
|
|
||||||
def _reset_ui_after_load_fail(self):
|
|
||||||
self.file_path.set("尚未选择文件")
|
|
||||||
self._set_name_display_text("请先加载名单",
|
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
|
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
|
|
||||||
is_placeholder=True)
|
|
||||||
self.start_button.config(state=tk.DISABLED)
|
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
|
||||||
self.export_button.config(state=tk.DISABLED)
|
|
||||||
self.clear_button.config(state=tk.DISABLED)
|
|
||||||
self.duration_slider.config(state=tk.DISABLED)
|
|
||||||
self.speed_interval_slider.config(state=tk.DISABLED)
|
|
||||||
self.update_counter()
|
|
||||||
|
|
||||||
def update_counter(self):
|
|
||||||
total = len(self.all_students)
|
|
||||||
called_count = len(self.called_students) + len(self.absent_students)
|
|
||||||
self.counter_label.config(text=f"已点名:{called_count}/{total} 人")
|
|
||||||
|
|
||||||
def start_roll_call(self):
|
|
||||||
if self.is_flashing: return
|
|
||||||
if not self.remaining_students:
|
|
||||||
self._set_name_display_text("所有学生已点完!",
|
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG,
|
|
||||||
is_placeholder=True)
|
|
||||||
messagebox.showinfo("提示", "所有学生均已点名。")
|
|
||||||
self.start_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.start_button.config(state=tk.DISABLED)
|
|
||||||
self.load_button.config(state=tk.DISABLED)
|
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
|
||||||
self.clear_button.config(state=tk.DISABLED)
|
|
||||||
self.export_button.config(state=tk.DISABLED)
|
|
||||||
self.duration_slider.config(state=tk.DISABLED)
|
|
||||||
self.speed_interval_slider.config(state=tk.DISABLED)
|
|
||||||
|
|
||||||
self.is_flashing = True
|
|
||||||
# Background and text color for flashing will be set by _flash_name via _set_name_display_text
|
|
||||||
self._flash_name()
|
|
||||||
|
|
||||||
duration_ms = self.flash_duration_var.get() * 1000
|
|
||||||
self.stop_timer = self.master.after(duration_ms, self.stop_flashing)
|
|
||||||
|
|
||||||
def _flash_name(self):
|
|
||||||
if not self.is_flashing or not self.remaining_students:
|
|
||||||
if self.is_flashing:
|
|
||||||
self.stop_flashing() # Cleanly stop if conditions change
|
|
||||||
return
|
|
||||||
|
|
||||||
display_student = random.choice(self.remaining_students)
|
|
||||||
base_text = f"{display_student[0]},{display_student[1]},"
|
|
||||||
pinyin_text = display_student[2]
|
|
||||||
|
|
||||||
self._set_name_display_text((base_text, pinyin_text, ""),
|
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_FLASH_FG,
|
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_FLASH_BG)
|
|
||||||
|
|
||||||
flash_interval_ms = self.flash_interval_var.get()
|
|
||||||
self.flash_timer = self.master.after(flash_interval_ms, self._flash_name)
|
|
||||||
|
|
||||||
def stop_flashing(self):
|
|
||||||
if not self.is_flashing: return
|
|
||||||
|
|
||||||
if self.flash_timer:
|
if self.flash_timer:
|
||||||
self.master.after_cancel(self.flash_timer)
|
self.master.after_cancel(self.flash_timer)
|
||||||
self.flash_timer = None
|
self.flash_timer = None
|
||||||
if self.stop_timer:
|
if self.stop_timer:
|
||||||
self.master.after_cancel(self.stop_timer)
|
self.master.after_cancel(self.stop_timer)
|
||||||
self.stop_timer = None
|
self.stop_timer = None
|
||||||
|
self.is_flashing = False
|
||||||
|
|
||||||
|
def _reset_data_full(self):
|
||||||
|
# Resets everything including the loaded student list
|
||||||
|
self._reset_data_core() # Clear progress and timers
|
||||||
|
self.all_students = [] # Clear the main student list
|
||||||
|
self.file_path.set("尚未选择文件") # Reset file path display
|
||||||
|
|
||||||
|
def _reset_ui_after_load_fail(self):
|
||||||
|
self.file_path.set("尚未选择文件")
|
||||||
|
self._set_name_display_text("请先加载名单", is_placeholder=True)
|
||||||
|
self.start_button.config(state=tk.DISABLED)
|
||||||
|
self.mark_absent_button.config(state=tk.DISABLED)
|
||||||
|
self.export_button.config(state=tk.DISABLED)
|
||||||
|
self.clear_button.config(state=tk.DISABLED) # No data to clear if load failed
|
||||||
|
self.duration_slider.config(state=tk.DISABLED)
|
||||||
|
self.speed_interval_slider.config(state=tk.DISABLED)
|
||||||
|
self.update_counter() # Reset counter to 0/0
|
||||||
|
|
||||||
|
def update_counter(self):
|
||||||
|
total = len(self.all_students)
|
||||||
|
# Called = present (in called_students) + absent (in absent_students)
|
||||||
|
called_count = len(self.called_students) + len(self.absent_students)
|
||||||
|
# Style for Counter.TLabel will handle font, this just updates text
|
||||||
|
self.counter_label.config(text=f"已点名:{called_count}/{total} 人")
|
||||||
|
|
||||||
|
|
||||||
|
def start_roll_call(self):
|
||||||
|
if self.is_flashing: # Already flashing, do nothing
|
||||||
|
return
|
||||||
|
if not self.remaining_students:
|
||||||
|
self._set_name_display_text("所有学生已点完!",
|
||||||
|
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
||||||
|
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG,
|
||||||
|
is_placeholder=True)
|
||||||
|
messagebox.showinfo("提示 (Info)", "所有学生均已点名。\n(All students have been called.)")
|
||||||
|
self.start_button.config(state=tk.DISABLED) # No more students to call
|
||||||
|
self.mark_absent_button.config(state=tk.DISABLED) # No current student
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disable controls during flashing
|
||||||
|
self.start_button.config(state=tk.DISABLED)
|
||||||
|
self.load_button.config(state=tk.DISABLED)
|
||||||
|
self.mark_absent_button.config(state=tk.DISABLED)
|
||||||
|
self.clear_button.config(state=tk.DISABLED)
|
||||||
|
self.export_button.config(state=tk.DISABLED) # Potentially re-enable after selection
|
||||||
|
self.duration_slider.config(state=tk.DISABLED)
|
||||||
|
self.speed_interval_slider.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
self.is_flashing = True
|
||||||
|
self._flash_name() # Start the flashing animation
|
||||||
|
|
||||||
|
# Set timer to stop flashing
|
||||||
|
duration_ms = self.flash_duration_var.get() * 1000
|
||||||
|
self.stop_timer = self.master.after(duration_ms, self.stop_flashing)
|
||||||
|
|
||||||
|
def _flash_name(self):
|
||||||
|
if not self.is_flashing or not self.remaining_students: # Stop if state changes
|
||||||
|
if self.is_flashing: # If it was supposed to be flashing but no students
|
||||||
|
self.stop_flashing() # Cleanly stop
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick a random student from the remaining list for display during flash
|
||||||
|
display_student = random.choice(self.remaining_students)
|
||||||
|
# Format: "ID, Name, Pinyin"
|
||||||
|
base_text = f"{display_student[0]},{display_student[1]},"
|
||||||
|
pinyin_text = display_student[2] # Pinyin part
|
||||||
|
|
||||||
|
self._set_name_display_text((base_text, pinyin_text, ""), # No suffix during flash
|
||||||
|
fg_color=self.COLOR_NAME_DISPLAY_FLASH_FG,
|
||||||
|
bg_color=self.COLOR_NAME_DISPLAY_FLASH_BG)
|
||||||
|
|
||||||
|
# Schedule next flash frame
|
||||||
|
flash_interval_ms = self.flash_interval_var.get()
|
||||||
|
self.flash_timer = self.master.after(flash_interval_ms, self._flash_name)
|
||||||
|
|
||||||
|
def stop_flashing(self):
|
||||||
|
if not self.is_flashing: # Already stopped
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cancel timers
|
||||||
|
if self.flash_timer:
|
||||||
|
self.master.after_cancel(self.flash_timer)
|
||||||
|
self.flash_timer = None
|
||||||
|
if self.stop_timer: # This is the timer that called stop_flashing usually
|
||||||
|
self.master.after_cancel(self.stop_timer)
|
||||||
|
self.stop_timer = None
|
||||||
|
|
||||||
self.is_flashing = False
|
self.is_flashing = False
|
||||||
self._select_final_student()
|
self._select_final_student() # Proceed to select and display the chosen student
|
||||||
|
|
||||||
def _select_final_student(self):
|
def _select_final_student(self):
|
||||||
if self.is_flashing:
|
# This is called after flashing stops
|
||||||
self.is_flashing = False
|
if self.is_flashing: # Should be false here, but defensive check
|
||||||
|
self.is_flashing = False # Ensure it's false
|
||||||
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
|
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
|
||||||
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
|
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
|
||||||
|
|
||||||
|
# Re-enable relevant controls
|
||||||
self.load_button.config(state=tk.NORMAL)
|
self.load_button.config(state=tk.NORMAL)
|
||||||
self.clear_button.config(state=tk.NORMAL)
|
self.clear_button.config(state=tk.NORMAL)
|
||||||
can_enable_sliders = bool(self.all_students)
|
can_enable_sliders = bool(self.all_students) # Sliders require a loaded list
|
||||||
self.duration_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
|
self.duration_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
|
||||||
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
|
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
|
||||||
|
|
||||||
if not self.remaining_students:
|
if not self.remaining_students: # Should have been caught by start_roll_call, but double check
|
||||||
self._set_name_display_text("所有学生已点完!",
|
self._set_name_display_text("所有学生已点完!",
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG,
|
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG,
|
||||||
is_placeholder=True)
|
is_placeholder=True)
|
||||||
self.start_button.config(state=tk.DISABLED)
|
self.start_button.config(state=tk.DISABLED)
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
self.mark_absent_button.config(state=tk.DISABLED)
|
||||||
|
# Export might be enabled if there were absentees from previous calls
|
||||||
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED)
|
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.current_student_info = self.remaining_students.pop(0)
|
# Select the student (first from shuffled list)
|
||||||
self.called_students.append(self.current_student_info)
|
self.current_student_info = self.remaining_students.pop(0) # Get and remove
|
||||||
|
self.called_students.append(self.current_student_info) # Initially mark as present
|
||||||
|
|
||||||
|
# Prepare display text
|
||||||
base_text = f"{self.current_student_info[0]},{self.current_student_info[1]},"
|
base_text = f"{self.current_student_info[0]},{self.current_student_info[1]},"
|
||||||
pinyin_text = self.current_student_info[2]
|
pinyin_text = self.current_student_info[2]
|
||||||
suffix_text = ""
|
suffix_text = ""
|
||||||
|
|
||||||
if not self.remaining_students: # No more students left to call
|
if not self.remaining_students: # If this was the last student
|
||||||
suffix_text = " (最后一位)"
|
suffix_text = " (最后一位)"
|
||||||
self.start_button.config(state=tk.DISABLED)
|
self.start_button.config(state=tk.DISABLED) # No more students to call next
|
||||||
else:
|
else:
|
||||||
self.start_button.config(state=tk.NORMAL)
|
self.start_button.config(state=tk.NORMAL) # More students available
|
||||||
|
|
||||||
self._set_name_display_text((base_text, pinyin_text, suffix_text),
|
self._set_name_display_text((base_text, pinyin_text, suffix_text),
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG)
|
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG)
|
||||||
self.update_counter()
|
self.update_counter()
|
||||||
self.mark_absent_button.config(state=tk.NORMAL)
|
self.mark_absent_button.config(state=tk.NORMAL) # Can mark this student absent
|
||||||
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED)
|
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED)
|
||||||
|
|
||||||
|
|
||||||
def mark_absent(self):
|
def mark_absent(self):
|
||||||
if not self.current_student_info or self.is_flashing: return
|
if not self.current_student_info or self.is_flashing: # No student selected or still flashing
|
||||||
|
return
|
||||||
|
|
||||||
if self.current_student_info in self.called_students:
|
# Move student from 'called' (present) to 'absent'
|
||||||
|
if self.current_student_info in self.called_students: # Should be true if just selected
|
||||||
self.called_students.remove(self.current_student_info)
|
self.called_students.remove(self.current_student_info)
|
||||||
if self.current_student_info not in self.absent_students:
|
if self.current_student_info not in self.absent_students: # Avoid duplicates if somehow clicked twice
|
||||||
self.absent_students.append(self.current_student_info)
|
self.absent_students.append(self.current_student_info)
|
||||||
|
|
||||||
self.update_counter()
|
# Counter doesn't change total called, just internal lists shift, so no update_counter needed here
|
||||||
|
# If update_counter counts sum of called_students and absent_students, it remains correct.
|
||||||
|
|
||||||
|
# Update display for absent student
|
||||||
base_text = f"{self.current_student_info[0]},{self.current_student_info[1]},"
|
base_text = f"{self.current_student_info[0]},{self.current_student_info[1]},"
|
||||||
pinyin_text = self.current_student_info[2]
|
pinyin_text = self.current_student_info[2]
|
||||||
suffix_text = " [未到]"
|
suffix_text = " [未到]"
|
||||||
self._set_name_display_text((base_text, pinyin_text, suffix_text),
|
self._set_name_display_text((base_text, pinyin_text, suffix_text),
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_ABSENT_FG,
|
fg_color=self.COLOR_NAME_DISPLAY_ABSENT_FG,
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_ABSENT_BG)
|
bg_color=self.COLOR_NAME_DISPLAY_ABSENT_BG)
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
|
||||||
self.export_button.config(state=tk.NORMAL)
|
self.mark_absent_button.config(state=tk.DISABLED) # Cannot mark absent again
|
||||||
|
self.export_button.config(state=tk.NORMAL) # Enable export as there's at least one absentee
|
||||||
|
|
||||||
def export_absent_list(self):
|
def export_absent_list(self):
|
||||||
if not self.absent_students:
|
if not self.absent_students:
|
||||||
messagebox.showinfo("提示", "当前没有未到场的学生记录。")
|
messagebox.showinfo("提示 (Info)", "当前没有未到场的学生记录。\n(No absent students recorded.)")
|
||||||
return
|
return
|
||||||
|
|
||||||
today_str = date.today().strftime('%Y-%m-%d')
|
today_str = date.today().strftime('%Y-%m-%d')
|
||||||
default_filename = f"未到场名单_{today_str}.csv"
|
default_filename = f"未到场名单_{today_str}.csv"
|
||||||
|
|
||||||
filepath = filedialog.asksaveasfilename(
|
filepath = filedialog.asksaveasfilename(
|
||||||
title="保存未到场名单 (CSV)",
|
title="保存未到场名单 (CSV) / Save Absent List (CSV)",
|
||||||
defaultextension=".csv",
|
defaultextension=".csv",
|
||||||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||||
initialfile=default_filename
|
initialfile=default_filename
|
||||||
)
|
)
|
||||||
if not filepath: return
|
if not filepath: # User cancelled save dialog
|
||||||
|
|
||||||
try:
|
|
||||||
sorted_absent = sorted(self.absent_students, key=lambda x: x[0])
|
|
||||||
with open(filepath, 'w', encoding='utf-8-sig', newline='') as f:
|
|
||||||
writer = csv.writer(f)
|
|
||||||
writer.writerow(["学号", "姓名"])
|
|
||||||
for student in sorted_absent:
|
|
||||||
writer.writerow([student[0], student[1]])
|
|
||||||
messagebox.showinfo("导出成功", f"未到场名单已成功导出为 CSV 文件:\n{filepath}")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("导出失败", f"导出文件时出错: {e}")
|
|
||||||
|
|
||||||
def clear_data(self):
|
|
||||||
if not self.all_students:
|
|
||||||
messagebox.showinfo("提示", "尚未加载学生名单,无数据可清空。")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if messagebox.askyesno("确认", "确定要清空当前考勤数据吗?\n这将重置点名状态,但学生名单会保留。"):
|
try:
|
||||||
self._reset_data_core()
|
# Sort by student ID before exporting for consistency
|
||||||
|
sorted_absent = sorted(self.absent_students, key=lambda x: x[0]) # x[0] is student_id
|
||||||
|
with open(filepath, 'w', encoding='utf-8-sig', newline='') as f: # utf-8-sig for Excel compatibility
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["学号 (ID)", "姓名 (Name)"]) # Header row
|
||||||
|
for student in sorted_absent:
|
||||||
|
writer.writerow([student[0], student[1]]) # student[0]=ID, student[1]=Name
|
||||||
|
messagebox.showinfo("导出成功 (Export Successful)", f"未到场名单已成功导出为 CSV 文件:\n(Absent list exported successfully to CSV file:)\n{filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("导出失败 (Export Failed)", f"导出文件时出错 (Error exporting file): {e}")
|
||||||
|
|
||||||
|
def clear_data(self):
|
||||||
|
if not self.all_students: # No student list loaded
|
||||||
|
messagebox.showinfo("提示 (Info)", "尚未加载学生名单,无数据可清空。\n(No student list loaded, nothing to clear.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if messagebox.askyesno("确认 (Confirm)", "确定要清空当前考勤数据吗?\n这将重置点名状态,但学生名单会保留。\n(Are you sure you want to clear current attendance data? This will reset roll call status, but the student list will be kept.)"):
|
||||||
|
self._reset_data_core() # Reset progress, keep all_students
|
||||||
|
|
||||||
|
# Repopulate remaining_students from the master list
|
||||||
self.remaining_students = self.all_students[:]
|
self.remaining_students = self.all_students[:]
|
||||||
random.shuffle(self.remaining_students)
|
random.shuffle(self.remaining_students)
|
||||||
|
|
||||||
self._set_name_display_text("考勤数据已清空,可重新点名",
|
self._set_name_display_text("考勤数据已清空,可重新点名", is_placeholder=True)
|
||||||
fg_color=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
|
|
||||||
bg_color=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
|
|
||||||
is_placeholder=True)
|
|
||||||
|
|
||||||
can_enable_controls = bool(self.all_students)
|
# Reset UI states
|
||||||
|
can_enable_controls = bool(self.all_students) # Should be true if we reached here
|
||||||
self.start_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
self.start_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
||||||
self.mark_absent_button.config(state=tk.DISABLED)
|
self.mark_absent_button.config(state=tk.DISABLED) # No student selected
|
||||||
self.export_button.config(state=tk.DISABLED)
|
self.export_button.config(state=tk.DISABLED) # Absents are cleared
|
||||||
self.clear_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
self.clear_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED) # Still can clear if list exists
|
||||||
|
|
||||||
self.duration_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
self.duration_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
||||||
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
|
||||||
|
|
||||||
self.update_counter()
|
self.update_counter() # Reflects cleared data (0 called)
|
||||||
self.load_button.config(state=tk.NORMAL)
|
self.load_button.config(state=tk.NORMAL) # Always allow loading a new file
|
||||||
|
|
||||||
|
|
||||||
# --- Main program entry point ---
|
# --- Main program entry point ---
|
||||||
@@ -553,23 +730,14 @@ if __name__ == "__main__":
|
|||||||
import pypinyin
|
import pypinyin
|
||||||
except ImportError:
|
except ImportError:
|
||||||
missing_libs.append("pypinyin (`pip install pypinyin`)")
|
missing_libs.append("pypinyin (`pip install pypinyin`)")
|
||||||
# csv is standard, no need to check typically
|
|
||||||
# try:
|
|
||||||
# import csv
|
|
||||||
# except ImportError:
|
|
||||||
# missing_libs.append("csv (standard library module)")
|
|
||||||
|
|
||||||
if missing_libs:
|
if missing_libs:
|
||||||
# Temporary root for messagebox if main GUI cannot start
|
|
||||||
temp_root_for_error = tk.Tk()
|
temp_root_for_error = tk.Tk()
|
||||||
temp_root_for_error.withdraw() # Hide the temp window
|
temp_root_for_error.withdraw()
|
||||||
messagebox.showerror("依赖缺失", f"请先安装或确保以下库可用:\n" + "\n".join(missing_libs))
|
messagebox.showerror("依赖缺失 (Missing Dependencies)", f"请先安装或确保以下库可用:\n(Please install or ensure the following libraries are available:)\n" + "\n".join(missing_libs))
|
||||||
temp_root_for_error.destroy()
|
temp_root_for_error.destroy()
|
||||||
sys.exit(1) # Use sys.exit for clarity
|
sys.exit(1)
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
# It's good practice to also import tk.font for metrics if needed, but here it's just for one line.
|
|
||||||
import tkinter.font # Explicitly import for font metrics if used more extensively
|
|
||||||
|
|
||||||
app = RollCallApp(root)
|
app = RollCallApp(root)
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
Reference in New Issue
Block a user