import logging import threading from datetime import datetime import tkinter as tk import ttkbootstrap as ttk # 与 app.logging_setup 共享的映射;放在这里避免循环 import _GUI_LEVEL_TO_LOG = { "debug": logging.DEBUG, "info": logging.INFO, "success": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR, "separator": logging.INFO, "blank": logging.DEBUG, } def _theme_colors(): style = ttk.Style() colors = style.colors return { "bg": colors.bg, "fg": colors.fg, "muted": colors.secondary, "accent": colors.info, "warning": colors.warning, "error": colors.danger, "success": colors.success, "text_bg": colors.inputbg, "text_fg": colors.inputfg, } def _normalize_hex(hex_color: str, fallback: str = "#808080") -> str: """把颜色字符串规范化为 #RRGGBB;非法输入回退到 fallback。""" if not isinstance(hex_color, str): return fallback c = hex_color.strip() if not c: return fallback if c.startswith("#"): c = c[1:] if len(c) == 3: c = "".join(ch * 2 for ch in c) if len(c) != 6: return fallback try: int(c, 16) except ValueError: return fallback return f"#{c}" def _hex_to_rgb(hex_color: str): c = _normalize_hex(hex_color, fallback="#808080").lstrip("#") return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16) def _mix(hex_a: str, hex_b: str, ratio: float) -> str: ratio = max(0.0, min(1.0, ratio)) r1, g1, b1 = _hex_to_rgb(hex_a) r2, g2, b2 = _hex_to_rgb(hex_b) r = int(r1 * (1 - ratio) + r2 * ratio) g = int(g1 * (1 - ratio) + g2 * ratio) b = int(b1 * (1 - ratio) + b2 * ratio) return f"#{r:02x}{g:02x}{b:02x}" def _is_dark(hex_color: str) -> bool: r, g, b = _hex_to_rgb(hex_color) return (r * 299 + g * 587 + b * 114) / 1000 < 128 def _auto_text_color(bg_hex: str, fg_hint: str) -> str: """根据背景亮度给出稳定可读的文本颜色。""" bg_hex = _normalize_hex(bg_hex, fallback="#f5f5f5") fg_hint = _normalize_hex(fg_hint, fallback="#202020") if _is_dark(bg_hex): return _mix("#ffffff", fg_hint, 0.25) return _mix("#000000", fg_hint, 0.25) class PQLogGUI(ttk.Frame): VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"} def __init__(self, parent): super().__init__(parent) self._line_count = 0 self._max_lines = 1500 # 与 stdlib logging 联通的 logger,由 logging_setup 配置 file handler self._logger = logging.getLogger("pq.gui") self.create_widgets() def create_widgets(self): log_frame = ttk.LabelFrame(self, text="测试日志") log_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) toolbar = ttk.Frame(log_frame) toolbar.pack(fill=tk.X, padx=6, pady=(6, 2)) self.log_summary_var = tk.StringVar(value="0 条日志") ttk.Label( toolbar, textvariable=self.log_summary_var, bootstyle="secondary", ).pack(side=tk.LEFT) ttk.Button( toolbar, text="清空日志", command=self.clear_log, bootstyle="secondary-outline", width=10, ).pack(side=tk.RIGHT) text_container = ttk.Frame(log_frame) text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) palette = _theme_colors() self.log_text = tk.Text( text_container, height=10, width=50, wrap=tk.WORD, font=("Consolas", 10), bg=palette["text_bg"], fg=palette["text_fg"], relief=tk.FLAT, bd=0, padx=10, pady=8, spacing1=2, spacing3=2, insertbackground=palette["text_fg"], ) self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT) log_scrollbar = ttk.Scrollbar(text_container, command=self.log_text.yview) log_scrollbar.pack(fill=tk.Y, side=tk.RIGHT) self.log_text.config(yscrollcommand=log_scrollbar.set) self._configure_tags() self.log_text.config(state=tk.DISABLED) def log(self, message, level="info", _from_logging=False): if threading.current_thread() is not threading.main_thread(): self.after(0, self.log, message, level, _from_logging) return text = "" if message is None else str(message) normalized_level = self._normalize_level(level, text) # 转发到 stdlib logging,落到文件(_from_logging=True 表示来源已是 # logging,不再回写避免重复)。带 _from_gui 标记,TkLogHandler 会跳过。 if not _from_logging and normalized_level != "blank": log_level = _GUI_LEVEL_TO_LOG.get(normalized_level, logging.INFO) try: self._logger.log( log_level, text, extra={"_from_gui": True} ) except Exception: pass self.log_text.config(state=tk.NORMAL) self._append_message(text, normalized_level) self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) def clear_log(self): if threading.current_thread() is not threading.main_thread(): self.after(0, self.clear_log) return self.log_text.config(state=tk.NORMAL) self.log_text.delete(1.0, tk.END) self.log_text.config(state=tk.DISABLED) self._line_count = 0 self._update_summary() def _configure_tags(self): palette = _theme_colors() bg = self.log_text.cget("bg") or palette["text_bg"] or palette["bg"] base_fg = _auto_text_color(bg, palette["fg"]) muted_fg = _mix(base_fg, bg, 0.45) debug_level_color = _mix(palette["accent"], base_fg, 0.35) debug_msg_color = _mix(palette["accent"], base_fg, 0.50) self.log_text.tag_configure("timestamp", foreground=palette["muted"]) self.log_text.tag_configure("level_info", foreground=palette["accent"]) self.log_text.tag_configure("level_success", foreground=palette["success"]) self.log_text.tag_configure("level_warning", foreground=palette["warning"]) self.log_text.tag_configure("level_error", foreground=palette["error"]) self.log_text.tag_configure("level_debug", foreground=debug_level_color) self.log_text.tag_configure("message", foreground=base_fg) self.log_text.tag_configure("message_success", foreground=palette["success"]) self.log_text.tag_configure("message_warning", foreground=palette["warning"]) self.log_text.tag_configure("message_error", foreground=palette["error"]) self.log_text.tag_configure("message_debug", foreground=debug_msg_color) self.log_text.tag_configure("separator", foreground=muted_fg) self.log_text.tag_configure("traceback", foreground=palette["error"]) self.log_text.tag_configure("blank", spacing1=4, spacing3=4) def refresh_log_theme(self): """主题切换后刷新日志控件的背景和字体颜色。""" if threading.current_thread() is not threading.main_thread(): self.after(0, self.refresh_log_theme) return palette = _theme_colors() bg = palette["text_bg"] fg_hint = palette["text_fg"] if palette["text_fg"] else palette["fg"] fg = _auto_text_color(bg, fg_hint) self.log_text.configure( bg=bg, fg=fg, insertbackground=fg, ) self._configure_tags() def _append_message(self, message, level): lines = message.splitlines() or [""] for line in lines: self._append_line(line, level) self._trim_excess_lines() self._update_summary() def _append_line(self, line, level): timestamp = datetime.now().strftime("%H:%M:%S") rendered = "" if line is None else str(line).strip() if level == "blank" or not rendered: self.log_text.insert(tk.END, "\n", ("blank",)) self._line_count += 1 return if level == "separator": self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",)) self.log_text.insert(tk.END, "[SECTION] ", ("level_info",)) self.log_text.insert(tk.END, rendered + "\n", ("separator",)) self._line_count += 1 return level_tag = f"level_{level}" level_label = level.upper().ljust(7) if level == "error" and rendered.startswith("Traceback"): message_tag = "traceback" elif level in {"success", "warning", "error", "debug"}: message_tag = f"message_{level}" else: message_tag = "message" self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",)) self.log_text.insert(tk.END, f"[{level_label}] ", (level_tag,)) self.log_text.insert(tk.END, rendered + "\n", (message_tag,)) self._line_count += 1 def _normalize_level(self, level, message): normalized = "info" if level is None else str(level).strip().lower() if normalized not in self.VALID_LEVELS: normalized = "info" if normalized == "info" and (message is None or str(message).strip() == ""): return "blank" return normalized def _trim_excess_lines(self): overflow = self._line_count - self._max_lines if overflow <= 0: return self.log_text.delete("1.0", f"{overflow + 1}.0") self._line_count = self._max_lines def _update_summary(self): self.log_summary_var.set(f"{self._line_count} 条日志")