diff --git a/RollCall_App/课堂随机点名.py b/RollCall_App/课堂随机点名.py index 67414a8..6866cbd 100644 --- a/RollCall_App/课堂随机点名.py +++ b/RollCall_App/课堂随机点名.py @@ -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("", 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() \ No newline at end of file