新增GUI缩放时显示内容自适应功能

This commit is contained in:
2025-05-15 21:01:24 +08:00
parent 231ff556a3
commit 9a8938ab07

View File

@@ -1,6 +1,6 @@
import tkinter as tk
# Add PhotoImage to the import
from tkinter import ttk, filedialog, messagebox, PhotoImage
import tkinter.font # Added for tk.font.Font metrics
import random
# import time
import os
@@ -14,6 +14,14 @@ class RollCallApp:
self.master = master
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 ---
try:
# 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.")
# --- End Set Window Icon ---
master.minsize(width=1100, height=700)
# --- Define Font Families (User specified) ---
# --- Define Font Families ---
self.font_family_ui = "微软雅黑"
self.font_family_display = "华文中宋"
# --- Define Font Sizes (Slightly adjusted for modern feel, easy to revert) ---
self.font_size_standard = 14
self.font_size_counter = 18
self.font_size_display = 42
self.font_size_pinyin_display = 28 # New font size for Pinyin
self.font_size_title = 28
# --- Define BASE Font Sizes (will be scaled) ---
self.base_font_size_standard = 14
self.base_font_size_counter = 18
self.base_font_size_display = 42
self.base_font_size_pinyin_display = 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_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") # 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_button_tuple = (self.font_family_ui, self.font_size_standard, "bold")
self.font_footer_tuple = (self.font_family_ui, 11)
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)
# --- 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)
try:
self.style.theme_use('clam')
self.style.theme_use('clam') # 'clam', 'alt', 'default', 'classic'
except tk.TclError:
print("Clam theme not available, using default theme. Styling will still be applied.")
self.COLOR_BACKGROUND = "#ECEFF1"
self.COLOR_TEXT = "#263238"
self.COLOR_PRIMARY = "#007BFF"
self.COLOR_PRIMARY_DARK = "#0056b3"
self.COLOR_PRIMARY_HOVER = "#0069D9"
self.COLOR_PRIMARY_TEXT = "#FFFFFF"
self.COLOR_SECONDARY_BG = "#FFFFFF"
self.COLOR_BORDER = "#B0BEC5"
self.COLOR_DISABLED_FG = "#78909C"
self.COLOR_DISABLED_BG = "#CFD8DC"
self.COLOR_BACKGROUND = "#ECEFF1" # Light Grey Blue
self.COLOR_TEXT = "#263238" # Dark Grey Blue
self.COLOR_PRIMARY = "#007BFF" # Blue (Bootstrap Primary)
self.COLOR_PRIMARY_DARK = "#0056b3" # Darker Blue
self.COLOR_PRIMARY_HOVER = "#0069D9" # Brighter Blue
self.COLOR_PRIMARY_TEXT = "#FFFFFF" # White
self.COLOR_SECONDARY_BG = "#FFFFFF" # White (for entry fields, etc.)
self.COLOR_BORDER = "#B0BEC5" # Light Grey Border
self.COLOR_DISABLED_FG = "#78909C" # Grey for disabled text
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_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_SELECTED_BG = "#C8E6C9" # Pale Green (Material Design Green 100)
self.COLOR_NAME_DISPLAY_SELECTED_FG = "#2E7D32" # Dark Green (Material Design Green 800)
self.COLOR_NAME_DISPLAY_ABSENT_BG = "#FFCDD2" # Pale Red (Material Design Red 100)
self.COLOR_NAME_DISPLAY_ABSENT_FG = "#C62828" # Dark Red (Material Design Red 800)
self.COLOR_NAME_DISPLAY_SELECTED_BG = "#C8E6C9" # Pale Green
self.COLOR_NAME_DISPLAY_SELECTED_FG = "#2E7D32" # Dark Green
self.COLOR_NAME_DISPLAY_ABSENT_BG = "#FFCDD2" # Pale Red
self.COLOR_NAME_DISPLAY_ABSENT_FG = "#C62828" # Dark Red
master.configure(bg=self.COLOR_BACKGROUND)
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)
# Style configurations will be done in _update_all_fonts_and_widgets
# --- Data storage ---
self.all_students = []
self.remaining_students = []
self.called_students = []
self.absent_students = []
self.current_student_info = None
self.current_student_info = None # Tuple: (id, name, pinyin)
self.flash_timer = None
self.stop_timer = None
self.is_flashing = False
self.file_path = tk.StringVar(value="尚未选择文件")
self.flash_duration_var = tk.IntVar(value=1)
self.flash_interval_var = tk.IntVar(value=25)
self.flash_duration_var = tk.IntVar(value=1) # Seconds
self.flash_interval_var = tk.IntVar(value=25) # Milliseconds
# --- UI Elements ---
# Title
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.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.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,
# Default font for the widget, actual text parts will use tags
font=self.font_display_tuple,
font=self.font_display_tuple, # Default, tags will override
bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
wrap=tk.NONE, # Prevent wrapping for a single line display
# height=1, # Let it auto-size based on font and pady
wrap=tk.NONE,
height=1, # Crucial for single line behavior
relief="flat", bd=0,
pady=15, # Internal padding, similar to ipady of Label
tabs=("1c", "center") # Attempt to center, though tags are better
pady=self.base_pady_name_display, # Initial pady, will be scaled by _render_name_display
tabs=("1c", "center") # For centering text content
)
self.name_display.tag_configure("main_name", font=self.font_display_tuple)
self.name_display.tag_configure("pinyin", font=self.font_pinyin_display_tuple)
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
self._set_name_display_text("请先加载名单", is_placeholder=True)
# self.name_display.config(state=tk.DISABLED) # state set in _set_name_display_text
# Tags will be configured in _render_name_display (called by _update_all_fonts_and_widgets)
self.name_display.pack(fill=tk.X, padx=1, pady=1)
# Store current state for name_display refresh
self.current_name_display_content_tuple = ("请先加载名单", "", "") # (base, pinyin, suffix)
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.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')
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,
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))),
style="Horizontal.TScale")
self.duration_slider.pack(side=tk.LEFT, padx=5)
@@ -182,7 +188,7 @@ class RollCallApp:
interval_frame.pack(pady=8, anchor='center')
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,
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))),
style="Horizontal.TScale")
self.speed_interval_slider.pack(side=tk.LEFT, padx=5)
@@ -190,19 +196,18 @@ class RollCallApp:
self.interval_label.pack(side=tk.LEFT)
self.speed_interval_slider.config(state=tk.DISABLED)
# --- File Selection Area ---
# File Selection Area
file_frame = ttk.Frame(master)
file_frame.pack(pady=20, padx=30)
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.pack(side=tk.LEFT, padx=5, ipady=2) # Small ipady for entry height consistency
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 is not for ttk.Entry, use style padding
self.load_button = ttk.Button(file_frame, text="选择文件", command=self.load_file)
self.load_button.pack(side=tk.LEFT, padx=(5,0))
# --- Control Button Area ---
# Control Button Area
button_frame = ttk.Frame(master)
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.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)
@@ -212,61 +217,202 @@ class RollCallApp:
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)
# --- 底部版权信息 ---
# Footer
self.footer_label = ttk.Label(
master,
text="Ver. 2.3 @杨昱幸. All Rights Reserved.",
text="Ver. 2.4 @杨昱幸. All Rights Reserved.",
style="Footer.TLabel",
anchor='center'
)
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.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):
# 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.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_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)
# Foreground for Text widget tags is better controlled by the tag itself if varied,
# or can be set on the widget if uniform. Here, tags will control fg for main/pinyin.
# For simplicity, let's assume main_name and pinyin will use the passed fg_color.
# Scale the internal pady of the Text widget
# --- MODIFIED LINE: Use scale_factor_for_pady ---
scaled_pady_name_display = max(3, int(self.base_pady_name_display * scale_factor_for_pady))
# --- 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("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
self.name_display.insert(tk.END, str(text_content), ("center", "main_name"))
else:
# text_content is expected to be a tuple: (base_str, pinyin_str, suffix_str)
base_str, pinyin_str_val, suffix_str = text_content
base_str, pinyin_str_val, suffix_str = "", "", "" # Ensure defined
if is_placeholder:
base_str = text_tuple[0] # Placeholder string is in base_str part of tuple
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:
self.name_display.insert(tk.END, suffix_str, ("center", "main_name"))
# Calculate required height for one line of the largest font + padding
# 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(height=1) # Keep it constrained to 1 line visually
self.name_display.config(state=tk.DISABLED)
@@ -278,272 +424,303 @@ class RollCallApp:
if not filepath: return
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))
loaded_students = []
invalid_lines = []
invalid_lines = [] # To store info about invalid lines
line_num = 0
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line_num += 1
original_line = line
original_line = line # Keep original for error reporting
line = line.strip()
if not line: continue
parts = line.split(',', 1)
if not line: continue # Skip empty lines
parts = line.split(',', 1) # Split only on the first comma
if len(parts) == 2:
student_id_str = parts[0].strip()
name = parts[1].strip()
if student_id_str and name:
pinyin_list = pinyin(name, style=Style.TONE)
if student_id_str and name: # Ensure neither part is empty after stripping
# 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])
student_info = (student_id_str, name, pinyin_str)
loaded_students.append(student_info)
else: invalid_lines.append((line_num, "学号或姓名为空", original_line))
else: invalid_lines.append((line_num, "格式错误", original_line))
else:
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:
messagebox.showwarning("警告", "文件未包含有效格式的学生信息 (学号,姓名)。")
self._reset_ui_after_load_fail()
messagebox.showwarning("警告 (Warning)", "文件未包含有效格式的学生信息 (学号,姓名)。\n(File does not contain student information in the valid format 'ID,Name'.)")
self._reset_ui_after_load_fail() # Reset UI to initial state
return
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)
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.master.update_idletasks()
self.update_counter()
self.start_button.config(state=tk.NORMAL)
self.clear_button.config(state=tk.NORMAL)
self.export_button.config(state=tk.DISABLED)
self.mark_absent_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED) # No absentees yet
self.mark_absent_button.config(state=tk.DISABLED) # No student selected yet
self.duration_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:
info_msg += f"\n\n但有 {len(invalid_lines)} 行格式无效已被跳过:"
for i, (ln, reason, orig) in enumerate(invalid_lines[:5]):
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]): # Show first 5 errors
info_msg += f"\n{ln}行: {reason} -> '{orig.strip()}'"
if len(invalid_lines) > 5: info_msg += "\n..."
messagebox.showwarning("加载部分成功", info_msg)
if len(invalid_lines) > 5:
info_msg += "\n..."
messagebox.showwarning("加载部分成功 (Partial Load Success)", info_msg)
else:
messagebox.showinfo("成功", info_msg)
messagebox.showinfo("成功 (Success)", info_msg)
except FileNotFoundError:
messagebox.showerror("错误", f"文件未找到: {filepath}")
messagebox.showerror("错误 (Error)", f"文件未找到 (File not found): {filepath}")
self._reset_ui_after_load_fail()
except Exception as e:
messagebox.showerror("错误", f"加载文件时出错: {e}")
messagebox.showerror("错误 (Error)", f"加载文件时出错 (Error loading file): {e}")
self._reset_ui_after_load_fail()
def _reset_data_core(self):
# Resets roll call progress, keeps loaded student list (all_students)
self.remaining_students = []
self.called_students = []
self.absent_students = []
self.current_student_info = 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
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
self.current_student_info = None # Clear current selection
# Cancel any active timers
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
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._select_final_student()
self._select_final_student() # Proceed to select and display the chosen student
def _select_final_student(self):
if self.is_flashing:
self.is_flashing = False
# This is called after flashing stops
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.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.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.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("所有学生已点完!",
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG,
is_placeholder=True)
self.start_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)
return
self.current_student_info = self.remaining_students.pop(0)
self.called_students.append(self.current_student_info)
# Select the student (first from shuffled list)
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]}"
pinyin_text = self.current_student_info[2]
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 = " (最后一位)"
self.start_button.config(state=tk.DISABLED)
self.start_button.config(state=tk.DISABLED) # No more students to call next
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),
fg_color=self.COLOR_NAME_DISPLAY_SELECTED_FG,
bg_color=self.COLOR_NAME_DISPLAY_SELECTED_BG)
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)
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)
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.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]}"
pinyin_text = self.current_student_info[2]
suffix_text = " [未到]"
self._set_name_display_text((base_text, pinyin_text, suffix_text),
fg_color=self.COLOR_NAME_DISPLAY_ABSENT_FG,
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):
if not self.absent_students:
messagebox.showinfo("提示", "当前没有未到场的学生记录。")
messagebox.showinfo("提示 (Info)", "当前没有未到场的学生记录。\n(No absent students recorded.)")
return
today_str = date.today().strftime('%Y-%m-%d')
default_filename = f"未到场名单_{today_str}.csv"
filepath = filedialog.asksaveasfilename(
title="保存未到场名单 (CSV)",
title="保存未到场名单 (CSV) / Save Absent List (CSV)",
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
initialfile=default_filename
)
if not filepath: return
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("提示", "尚未加载学生名单,无数据可清空。")
if not filepath: # User cancelled save dialog
return
if messagebox.askyesno("确认", "确定要清空当前考勤数据吗?\n这将重置点名状态,但学生名单会保留。"):
self._reset_data_core()
try:
# 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[:]
random.shuffle(self.remaining_students)
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._set_name_display_text("考勤数据已清空,可重新点名", 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.mark_absent_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
self.clear_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
self.mark_absent_button.config(state=tk.DISABLED) # No student selected
self.export_button.config(state=tk.DISABLED) # Absents are cleared
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.speed_interval_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
self.update_counter()
self.load_button.config(state=tk.NORMAL)
self.update_counter() # Reflects cleared data (0 called)
self.load_button.config(state=tk.NORMAL) # Always allow loading a new file
# --- Main program entry point ---
@@ -553,23 +730,14 @@ if __name__ == "__main__":
import pypinyin
except ImportError:
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:
# Temporary root for messagebox if main GUI cannot start
temp_root_for_error = tk.Tk()
temp_root_for_error.withdraw() # Hide the temp window
messagebox.showerror("依赖缺失", f"请先安装或确保以下库可用:\n" + "\n".join(missing_libs))
temp_root_for_error.withdraw()
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()
sys.exit(1) # Use sys.exit for clarity
sys.exit(1)
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)
root.mainloop()