# coding=utf-8 """自定义 UI 组件库。 全部组件基于 tk 原生控件 + Canvas 手绘实现,不依赖任何第三方主题包。 向后兼容说明: StatusBadge / PillButton / IconButton / NoticeBar / RoundCard / RoundedPreview / make_rounded_thumb / make_photo_rounded / make_section_card 的既有构造签名与公开属性(``_canvas``、``body``、``host``、 ``set()``、``config()``)一律保持可用,仅做增量扩展。 """ import tkinter as tk from tkinter import font as tkfont from PIL import Image, ImageDraw, ImageTk, ImageFont from ui.theme import ( COLORS, FONTS, RADIUS, SIZES, dish_gradient_for, hex_to_rgb, tone_pair, ) def _apply_transparent_bg(win, color): """让 Toplevel 的某个颜色变为透明,从而呈现真正的圆角。 仅 Windows 支持 ``-transparentcolor``;失败时退回同色方角,不影响功能。 """ try: win.wm_attributes("-transparentcolor", color) return True except Exception: return False # --------------------------------------------------------------------------- # 绘图基础工具 # --------------------------------------------------------------------------- def _round_rect(canvas, x1, y1, x2, y2, r=8, **kwargs): r = min(r, abs(x2 - x1) / 2, abs(y2 - y1) / 2) if r < 1: return canvas.create_rectangle(x1, y1, x2, y2, **kwargs) points = [ x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r, x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2, x1, y2, x1, y2 - r, x1, y1 + r, x1, y1, ] return canvas.create_polygon(points, smooth=True, **kwargs) def _text_width(canvas, text, font): tmp = canvas.create_text(0, 0, text=text or "", font=font, anchor="nw") bbox = canvas.bbox(tmp) canvas.delete(tmp) return (bbox[2] - bbox[0]) if bbox else 0 def _measure(text, font): """不依赖 canvas 的文本宽度测量(用于 Toplevel 浮层)。""" try: return tkfont.Font(font=font).measure(text or "") except Exception: return len(text or "") * 7 def _ellipsis(text, canvas, font, max_width): """近似 CSS text-overflow:ellipsis。""" text = text or "" if max_width <= 20: return "…" if _text_width(canvas, text, font) <= max_width: return text ell = "…" lo, hi = 0, len(text) best = ell while lo <= hi: mid = (lo + hi) // 2 candidate = text[:mid] + ell w = _text_width(canvas, candidate, font) if w <= max_width: best = candidate lo = mid + 1 else: hi = mid - 1 return best # --------------------------------------------------------------------------- # 提示气泡 # --------------------------------------------------------------------------- class Tooltip: """鼠标悬停提示气泡,绑定到任意 widget。""" _DELAY = 420 def __init__(self, widget, text="", delay=None): self.widget = widget self._text = text or "" self._delay = delay if delay is not None else self._DELAY self._job = None self._tip = None if self._text: widget.bind("", self._schedule, add="+") widget.bind("", self.hide, add="+") widget.bind("", self.hide, add="+") self._ref = self # 防止被 GC def set_text(self, text): self._text = text or "" def _schedule(self, _e=None): self.hide() if not self._text: return try: self._job = self.widget.after(self._delay, self._show) except Exception: self._job = None def _show(self): self._job = None if self._tip is not None or not self._text: return try: x = self.widget.winfo_rootx() + 12 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6 top = self.widget.winfo_toplevel() except Exception: return try: tip = tk.Toplevel(top) tip.overrideredirect(True) try: tip.attributes("-topmost", True) except Exception: pass font = FONTS["caption"] w = _measure(self._text, font) + 20 h = 26 cv = tk.Canvas(tip, width=w, height=h, highlightthickness=0, bd=0, bg=COLORS["tooltip_bg"]) cv.pack() _round_rect(cv, 0, 0, w - 1, h - 1, r=RADIUS["sm"], fill=COLORS["tooltip_bg"], outline="") cv.create_text(w // 2, h // 2, text=self._text, fill=COLORS["tooltip_fg"], font=font) tip.geometry(f"{w}x{h}+{x}+{y}") self._tip = tip except Exception: self._tip = None def hide(self, _e=None): if self._job: try: self.widget.after_cancel(self._job) except Exception: pass self._job = None if self._tip is not None: try: self._tip.destroy() except Exception: pass self._tip = None # --------------------------------------------------------------------------- # 轻提示浮层 # --------------------------------------------------------------------------- class Toast: """右上角非阻塞轻提示,自动淡出。每个 root 同时只保留一条。""" _instances = {} @classmethod def show(cls, root, text, tone="info", duration=2600): if root is None: return None try: root.update_idletasks() except Exception: return None cls._dismiss(root) try: win = tk.Toplevel(root) except Exception: return None win.overrideredirect(True) try: win.attributes("-topmost", True) except Exception: pass bg, fg = tone_pair(tone) glyph = {"ok": "✓", "success": "✓", "warn": "!", "err": "✕", "danger": "✕", "busy": "•"}.get(tone, "i") message = text or "" font = FONTS["ui"] width = _measure(message, font) + 76 height = 40 cv = tk.Canvas(win, width=width, height=height, highlightthickness=0, bd=0, bg=COLORS["bg"]) cv.pack() _apply_transparent_bg(win, COLORS["bg"]) _round_rect(cv, 0, 0, width - 1, height - 1, r=RADIUS["md"], fill=COLORS["surface"], outline=COLORS["border"]) _round_rect(cv, 4, 4, 4 + 22, 4 + 22, r=11, fill=bg, outline="") cv.create_text(15, 15, text=glyph, fill=fg, font=FONTS["h3"]) cv.create_text(38, height // 2, text=message, fill=COLORS["text"], font=font, anchor="w") try: rx = root.winfo_rootx() + root.winfo_width() - width - 28 ry = root.winfo_rooty() + 76 except Exception: rx, ry = 200, 100 win.geometry(f"{width}x{height}+{max(rx, 0)}+{max(ry, 0)}") cls._instances[id(root)] = win def _fade(step=0): if not win.winfo_exists(): return alpha = 1.0 - step / 10.0 if alpha <= 0: cls._dismiss(root) return try: win.attributes("-alpha", alpha) except Exception: pass win.after(40, lambda: _fade(step + 1)) try: win.after(max(600, int(duration)), _fade) except Exception: pass return win @classmethod def _dismiss(cls, root): win = cls._instances.pop(id(root), None) if win is not None: try: win.destroy() except Exception: pass # --------------------------------------------------------------------------- # 徽章 / 标签 # --------------------------------------------------------------------------- class StatusBadge(tk.Frame): """Pill 徽章:浅底 + 状态圆点 + 深色字。""" def __init__(self, master, text="", tone="ok", tooltip="", **kwargs): super().__init__(master, bg=kwargs.pop("bg", COLORS["header_bg"]), **kwargs) self._tone = tone self._text = text self._bg = self["bg"] self._canvas = tk.Canvas(self, height=SIZES["badge_h"], highlightthickness=0, bd=0, bg=self._bg) self._canvas.pack() if tooltip: self.tooltip = Tooltip(self._canvas, tooltip) self._draw() def _draw(self): bg, dot, fg = self._tone_colors() self._canvas.delete("all") font = FONTS["badge"] tw = _text_width(self._canvas, self._text, font) w = max(tw + 34, 62) h = SIZES["badge_h"] self._canvas.configure(width=w, height=h, bg=self._bg) _round_rect(self._canvas, 0, 0, w - 1, h - 1, r=h // 2, fill=bg, outline="") self._canvas.create_oval(10, h // 2 - 4, 18, h // 2 + 4, fill=dot, outline="") self._canvas.create_text(24, h // 2, text=self._text, fill=fg, font=font, anchor="w") def _tone_colors(self): soft, main = tone_pair(self._tone) return soft, main, main def set(self, text, tone=None): self._text = text if tone: self._tone = tone self._draw() class Chip(tk.Canvas): """小号状态标签(实心圆点 + 文案),用于结果列表。""" def __init__(self, master, text="", tone="info", bg=None, **kwargs): self._bg = bg or COLORS["surface"] h = 22 font = FONTS["caption"] w = len(text or "") * 12 + 30 super().__init__(master, width=w, height=h, highlightthickness=0, bd=0, bg=self._bg, **kwargs) self._tone = tone self._text = text self._draw() def _draw(self): bg, fg = tone_pair(self._tone) h = 22 font = FONTS["caption"] w = len(self._text or "") * 12 + 30 self.configure(width=w, height=h) self.delete("all") _round_rect(self, 0, 0, w - 1, h - 1, r=11, fill=bg, outline="") self.create_oval(9, h // 2 - 3, 15, h // 2 + 3, fill=fg, outline="") self.create_text(21, h // 2, text=self._text, fill=fg, font=font, anchor="w") def set_text(self, text, tone=None): self._text = text if tone: self._tone = tone self._draw() # --------------------------------------------------------------------------- # 按钮 # --------------------------------------------------------------------------- class PillButton(tk.Frame): """统一按钮。 variant: primary 实心蓝 / secondary 描边蓝 / ghost 灰 / danger 红 size: md(36) / sm(28) / lg(40) """ HEIGHT = SIZES["button_h"] def __init__(self, master, text="", variant="primary", command=None, width=None, icon="", size="md", tooltip="", **kwargs): bg = kwargs.pop("bg", COLORS.get("surface", COLORS["bg"])) super().__init__(master, bg=bg, **kwargs) self._text = text self._icon = icon self._variant = variant self._command = command self._hover = False self._pressed = False self._disabled = False self._fixed_w = width self._forced_w = None self._size = size self._height = {"sm": SIZES["button_sm_h"], "md": SIZES["button_h"], "lg": 40}.get(size, SIZES["button_h"]) self._canvas = tk.Canvas(self, height=self._height + 2, highlightthickness=0, bd=0, bg=bg) self._canvas.pack() self._canvas.bind("", self._on_enter) self._canvas.bind("", self._on_leave) self._canvas.bind("", self._on_press) self._canvas.bind("", self._on_release) self.bind("", self._on_frame_configure) if tooltip: self.tooltip = Tooltip(self._canvas, tooltip) self._draw() def _on_frame_configure(self, event=None): """当按钮被 pack(fill=X) 拉伸时,让胶囊背景跟随父容器宽度。 仅在确实由 fill 管理时才生效,避免与 pack 的自然尺寸互相追逐。 """ if event is None or event.width <= 1: return try: info = self.pack_info() except Exception: return if (info.get("fill") or "") not in ("x", "both"): return if self._forced_w is not None and abs(self._forced_w - event.width) <= 2: return self._forced_w = event.width self._draw() # -- 兼容 Tk 的 config/configure 用法 -- def config(self, **kwargs): if "state" in kwargs: st = kwargs.pop("state") self._disabled = st in (tk.DISABLED, "disabled") self._draw() if "text" in kwargs: self._text = kwargs.pop("text") self._draw() if "icon" in kwargs: self._icon = kwargs.pop("icon") self._draw() if kwargs: super().config(**kwargs) configure = config def set_variant(self, variant): """运行时切换按钮风格(如待学习库有积压时高亮)。""" self._variant = variant self._draw() def set_text(self, text): self._text = text self._draw() def _palette(self): c = COLORS if self._disabled: return c["disabled_bg"], "#FFFFFF", c["disabled_bg"], False if self._variant == "primary": if self._pressed: return c["primary_dark"], "#FFFFFF", c["primary_dark"], True if self._hover: return c["primary_dark"], "#FFFFFF", c["primary_dark"], True return c["primary"], "#FFFFFF", c["primary"], True if self._variant == "secondary": if self._pressed: return c["primary_border"], c["primary_dark"], c["primary"], True if self._hover: return c["primary_soft"], c["primary_dark"], c["primary"], True return c["surface"], c["primary"], c["primary_border"], True if self._variant == "danger": if self._hover or self._pressed: return c["danger_dark"], "#FFFFFF", c["danger_dark"], False return c["surface"], c["danger"], c["danger_border"], False # ghost if self._pressed: return c["border_strong"], c["text"], c["border_strong"], False if self._hover: return c["border"], c["text"], c["border"], False return c["neutral_soft"], c["text_body"], c["border"], False def _draw(self): fill, fg, border, shadow = self._palette() font = FONTS["button_lg"] if self._variant in ("primary", "danger") else FONTS["button"] if self._size == "sm": font = FONTS["button"] self._canvas.delete("all") label = f"{self._icon} {self._text}".strip() if self._icon else self._text tw = _text_width(self._canvas, label, font) pad_x = 12 if self._size == "sm" else 18 w = self._forced_w or self._fixed_w or (tw + pad_x * 2) w = max(w, 72 if self._size == "sm" else 88) if tw + 12 > w: label = _ellipsis(label, self._canvas, font, max(w - 12, 12)) h = self._height self._canvas.configure(width=w + 2, height=h + 2, bg=self["bg"]) if shadow and not self._disabled and not self._pressed: _round_rect(self._canvas, 2, 2, w + 1, h + 1, r=RADIUS["md"], fill=COLORS["shadow"], outline="") offset = 1 if (self._pressed and not self._disabled) else 0 _round_rect(self._canvas, 0, offset, w - 1, h - 1 + offset, r=RADIUS["md"], fill=fill, outline=border) self._canvas.create_text(w // 2, h // 2 + offset, text=label, fill=fg, font=font) def _on_enter(self, _e=None): if self._disabled: return self._hover = True self._draw() def _on_leave(self, _e=None): self._hover = False self._pressed = False self._draw() def _on_press(self, _e=None): if self._disabled or not self._command: return self._pressed = True self._draw() def _on_release(self, _e=None): if self._disabled or not self._command: self._pressed = False self._draw() return was = self._pressed self._pressed = False self._draw() if was: self._command() class IconButton(tk.Label): """小图标按钮(模板库清空等)。""" def __init__(self, master, text="🗑", command=None, tooltip="", tone="danger", **kwargs): bg = kwargs.pop("bg", COLORS.get("sidebar", COLORS["bg"])) super().__init__( master, text=text, bg=bg, fg=COLORS["muted"], font=("Segoe UI Symbol", 12), cursor="hand2", padx=6, pady=2, **kwargs, ) self._cmd = command self._bg = bg self._tone = tone self._hover_fg = COLORS["danger"] if tone == "danger" else COLORS["primary"] self._hover_bg = COLORS["danger_soft"] if tone == "danger" else COLORS["primary_soft"] self.bind("", self._fire) self.bind("", lambda e: self.config(fg=self._hover_fg, bg=self._hover_bg)) self.bind("", lambda e: self.config(fg=COLORS["muted"], bg=self._bg)) if tooltip: self.tooltip = Tooltip(self, tooltip) def _fire(self, _e=None): if self._cmd: self._cmd() # --------------------------------------------------------------------------- # 状态条 # --------------------------------------------------------------------------- class NoticeBar(tk.Frame): """底部状态条:固定 44px,单行省略号,busy 时显示旋转字符。""" HEIGHT = SIZES["notice_h"] def __init__(self, master, **kwargs): super().__init__(master, bg=kwargs.pop("bg", COLORS["bg"]), height=self.HEIGHT, **kwargs) self.pack_propagate(False) self._canvas = tk.Canvas(self, height=self.HEIGHT, highlightthickness=0, bd=0, bg=self["bg"]) self._canvas.pack(fill=tk.BOTH, expand=True) self._text = "就绪" self._tone = "ok" self._spin_job = None self._spin_i = 0 self.bind("", lambda e: self._draw()) self.after(40, self._draw) def set(self, text, tone="ok"): self._text = text self._tone = tone if tone == "busy": self._start_spin() else: self._stop_spin() self._draw() def _start_spin(self): if self._spin_job: return self._spin_i = 0 self._tick_spin() def _stop_spin(self): if self._spin_job: try: self.after_cancel(self._spin_job) except Exception: pass self._spin_job = None def _tick_spin(self): if self._tone != "busy": self._spin_job = None return frames = ["◐", "◓", "◑", "◒"] spin = frames[self._spin_i % 4] self._spin_i += 1 base = self._text display = f"{spin}{base[1:]}" if base.startswith("🔄") else f"{spin} {base}" self._draw(display_override=display) self._spin_job = self.after(120, self._tick_spin) def _palette(self): return tone_pair(self._tone) def _draw(self, display_override=None): bg, fg = self._palette() w = max(self.winfo_width(), 120) h = self.HEIGHT self._canvas.configure(width=w, height=h, bg=self["bg"]) self._canvas.delete("all") _round_rect(self._canvas, 0, 2, w - 1, h - 1, r=RADIUS["md"], fill=bg, outline="") raw = display_override if display_override is not None else self._text shown = _ellipsis(raw, self._canvas, FONTS["result"], w - 36) self._canvas.create_text(14, h // 2 + 1, text=shown, fill=fg, font=FONTS["result"], anchor="w") # --------------------------------------------------------------------------- # 卡片 # --------------------------------------------------------------------------- class RoundCard(tk.Frame): """圆角卡片:标题带蓝色竖条,可选副标题与右侧操作区。 公开属性: body —— 内容容器(保持与旧版一致) header —— 标题行容器,可继续 pack(side=RIGHT) 追加操作控件 """ def __init__(self, parent, title=None, radius=RADIUS["lg"], padding=12, expand_body=False, subtitle=None, **kwargs): super().__init__(parent, bg=kwargs.pop("bg", COLORS["bg"]), **kwargs) self._radius = radius self._padding = padding self._expand_body = expand_body self._hover = False self._canvas = tk.Canvas(self, bg=self["bg"], highlightthickness=0, bd=0) self._canvas.pack(fill=tk.BOTH, expand=True) self._inner = tk.Frame(self._canvas, bg=COLORS["surface"]) self.header = None if title: head = tk.Frame(self._inner, bg=COLORS["surface"]) head.pack(fill=tk.X, padx=padding, pady=(padding, 8)) bar = tk.Frame(head, bg=COLORS["primary"], width=3, height=16) bar.pack(side=tk.LEFT, padx=(0, 8)) bar.pack_propagate(False) title_box = tk.Frame(head, bg=COLORS["surface"]) title_box.pack(side=tk.LEFT) tk.Label( title_box, text=title, bg=COLORS["surface"], fg=COLORS["text"], font=FONTS["card_title"], ).pack(anchor=tk.W) if subtitle: tk.Label( title_box, text=subtitle, bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["small"], ).pack(anchor=tk.W) self.header = head self.body = tk.Frame(self._inner, bg=COLORS["surface"]) self.body.pack( fill=tk.BOTH if expand_body else tk.X, expand=expand_body, padx=padding, pady=(0, padding), ) else: self.body = tk.Frame(self._inner, bg=COLORS["surface"]) self.body.pack( fill=tk.BOTH if expand_body else tk.X, expand=expand_body, padx=padding, pady=padding, ) self._win = self._canvas.create_window(0, 0, window=self._inner, anchor="nw") self._canvas.bind("", self._on_configure) self._inner.bind("", self._on_inner_configure) def _on_inner_configure(self, _event=None): if self._expand_body: return self.update_idletasks() need = self._inner.winfo_reqheight() + 2 if need > 20 and abs(int(self._canvas.cget("height") or 0) - need) > 1: self._canvas.configure(height=need) def _on_configure(self, event): w, h = max(event.width, 40), max(event.height, 40) self._canvas.delete("bg") border = COLORS["primary_border"] if self._hover else COLORS["border"] _round_rect( self._canvas, 1, 2, w - 1, h - 1, r=self._radius, fill=COLORS["shadow"], outline="", tags="bg", ) _round_rect( self._canvas, 0, 0, w - 3, h - 3, r=self._radius, fill=COLORS["surface"], outline=border, tags="bg", ) self._canvas.tag_lower("bg") iw = max(w - 3, 20) ih = max(h - 3, 20) if self._expand_body else self._inner.winfo_reqheight() self._canvas.itemconfigure(self._win, width=iw, height=ih if self._expand_body else None) self._canvas.coords(self._win, 0, 0) def make_section_card(parent, title, compact=False, expand_body=False): card = RoundCard(parent, title=title, radius=RADIUS["lg"], padding=12, expand_body=expand_body) return card, card.body class Divider(tk.Frame): """细分隔线。""" def __init__(self, master, bg=None, padx=0, pady=8, **kwargs): host_bg = bg or COLORS.get("surface", COLORS["bg"]) super().__init__(master, bg=host_bg, **kwargs) line = tk.Frame(self, bg=COLORS["divider"], height=1) line.pack(fill=tk.X, padx=padx, pady=pady) class SectionTitle(tk.Frame): """小标题:蓝色竖条 + 文案,可选右侧尾随控件。""" def __init__(self, master, text="", bg=None, font=None, gap=8, **kwargs): host_bg = bg or COLORS.get("sidebar", COLORS["bg"]) super().__init__(master, bg=host_bg, **kwargs) bar = tk.Frame(self, bg=COLORS["primary"], width=3, height=16) bar.pack(side=tk.LEFT, padx=(0, gap)) bar.pack_propagate(False) self.label = tk.Label(self, text=text, bg=host_bg, fg=COLORS["text"], font=font or FONTS["section"]) self.label.pack(side=tk.LEFT) self.tail = tk.Frame(self, bg=host_bg) self.tail.pack(side=tk.RIGHT) class EmptyState(tk.Frame): """空状态占位:大字符 + 主文案 + 辅助说明。""" def __init__(self, master, text="暂无数据", hint="", glyph="◍", bg=None, glyph_font=None, pady=26, **kwargs): host_bg = bg or COLORS.get("surface", COLORS["bg"]) super().__init__(master, bg=host_bg, **kwargs) tk.Label(self, text=glyph, bg=host_bg, fg=COLORS["border_strong"], font=glyph_font or ("Segoe UI Symbol", 26)).pack(pady=(pady, 6)) tk.Label(self, text=text, bg=host_bg, fg=COLORS["text_body"], font=FONTS["ui"]).pack() if hint: tk.Label(self, text=hint, bg=host_bg, fg=COLORS["muted"], font=FONTS["small"], justify=tk.CENTER, wraplength=260).pack(pady=(4, pady)) # --------------------------------------------------------------------------- # 输入 # --------------------------------------------------------------------------- class SearchBox(tk.Frame): """带占位符与清除按钮的圆角搜索框。""" def __init__(self, master, placeholder="搜索…", command=None, width=200, bg=None, height=32, **kwargs): host_bg = bg or COLORS.get("sidebar", COLORS["bg"]) super().__init__(master, bg=host_bg, height=height, width=width, **kwargs) self.pack_propagate(False) self._placeholder = placeholder self._command = command self._has_placeholder = True self._bg = host_bg self._canvas = tk.Canvas(self, height=height, highlightthickness=0, bd=0, bg=host_bg) self._canvas.place(x=0, y=0, relwidth=1, relheight=1) self._icon = tk.Label(self, text="🔍", bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["small"]) self._icon.place(x=8, y=(height - 18) // 2) self._entry = tk.Entry( self, bd=0, highlightthickness=0, relief=tk.FLAT, bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["ui"], insertbackground=COLORS["text"], ) self._entry.place(x=28, y=(height - 20) // 2, relwidth=1, width=-60, height=20) self._clear = tk.Label(self, text="✕", bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["caption"], cursor="hand2") self._clear.bind("", lambda e: self.clear()) self._clear.bind("", lambda e: self._clear.config(fg=COLORS["danger"])) self._clear.bind("", lambda e: self._clear.config(fg=COLORS["muted"])) self._canvas.bind("", self._redraw) self._entry.bind("", self._on_focus_in) self._entry.bind("", self._on_focus_out) self._entry.bind("", self._on_key) self._entry.bind("", lambda e: self.clear()) self._show_placeholder() def _redraw(self, _e=None): h = max(self.winfo_height(), 26) w = max(self.winfo_width(), 40) self._canvas.delete("all") _round_rect(self._canvas, 0, 0, w - 1, h - 1, r=RADIUS["md"], fill=COLORS["surface"], outline=COLORS["border"]) self._canvas.tag_lower("all") def _show_placeholder(self): self._entry.delete(0, tk.END) self._entry.insert(0, self._placeholder) self._entry.config(fg=COLORS["muted"]) self._has_placeholder = True def _on_focus_in(self, _e=None): if self._has_placeholder: self._entry.delete(0, tk.END) self._entry.config(fg=COLORS["text"]) self._has_placeholder = False def _on_focus_out(self, _e=None): if not self._entry.get().strip(): self._show_placeholder() def _on_key(self, _e=None): text = self.get() if text: self._clear.place(relx=1.0, x=-24, y=(max(self.winfo_height(), 26) - 16) // 2) else: self._clear.place_forget() if self._command: self._command(text) def get(self): if self._has_placeholder: return "" return self._entry.get().strip() def clear(self, _e=None): self._show_placeholder() self._clear.place_forget() if self._command: self._command("") def bind_enter(self, callback): self._entry.bind("", lambda e: callback(self.get())) # --------------------------------------------------------------------------- # 数据展示 # --------------------------------------------------------------------------- class StatTile(tk.Frame): """指标小块:大数字 + 说明文字,圆角底。""" def __init__(self, master, value="0", label="", tone="info", bg=None, width=78, **kwargs): host_bg = bg or COLORS.get("surface", COLORS["bg"]) super().__init__(master, bg=host_bg, width=width, height=54, **kwargs) self.pack_propagate(False) self._tone = tone self._value = str(value) self._canvas = tk.Canvas(self, height=54, highlightthickness=0, bd=0, bg=host_bg) self._canvas.place(x=0, y=0, relwidth=1, relheight=1) self._value_lbl = tk.Label(self, text=self._value, bg=host_bg, fg=COLORS["primary"], font=FONTS["h1"]) self._value_lbl.place(relx=0.5, y=8, anchor="n") self._label_lbl = tk.Label(self, text=label, bg=host_bg, fg=COLORS["muted"], font=FONTS["caption"]) self._label_lbl.place(relx=0.5, y=32, anchor="n") self._canvas.bind("", self._redraw) def _redraw(self, _e=None): soft, main = tone_pair(self._tone) w = max(self.winfo_width(), 40) h = max(self.winfo_height(), 40) self._canvas.delete("all") _round_rect(self._canvas, 0, 0, w - 1, h - 1, r=RADIUS["md"], fill=soft, outline="") self._value_lbl.config(fg=main) def set(self, value, label=None): self._value_lbl.config(text=str(value)) if label is not None: self._label_lbl.config(text=label) # --------------------------------------------------------------------------- # 缩略图 # --------------------------------------------------------------------------- def _gradient_image(size, c1, c2): img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) a, b = hex_to_rgb(c1), hex_to_rgb(c2) for y in range(size): t = y / max(size - 1, 1) draw.line([(0, y), (size, y)], fill=tuple(int(a[i] * (1 - t) + b[i] * t) for i in range(3)) + (255,)) return img def _font_for(size): for name in ("msyh.ttc", "msyh.ttf", "simhei.ttf"): try: return ImageFont.truetype(name, size) except Exception: continue return ImageFont.load_default() def make_rounded_thumb(name, photo=None, size=42, radius=RADIUS["md"]): """圆角缩略图:优先使用真实图片,否则按菜名关键词生成渐变底 + 首字。 注意:历史签名 ``(name, photo, size)`` 保持不变;新增 radius 为可选参数。 """ img = None if photo is not None and isinstance(photo, str): try: img = Image.open(photo).convert("RGBA").resize((size, size), Image.Resampling.LANCZOS) except Exception: img = None if img is None: c1, c2 = dish_gradient_for(name) img = _gradient_image(size, c1, c2) mask = Image.new("L", (size, size), 0) ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=255) out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) out.paste(img, (0, 0)) out.putalpha(mask) if photo is None: draw = ImageDraw.Draw(out) ch = (name or "?")[:1] font = _font_for(max(12, int(size * 0.42))) bbox = draw.textbbox((0, 0), ch, font=font) tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] draw.text(((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]), ch, fill=(255, 255, 255, 235), font=font) return ImageTk.PhotoImage(out) def make_photo_rounded(path_or_image, size=42, radius=RADIUS["md"]): """真实缩略图裁成圆角。路径无效时返回 None,由调用方回退到渐变缩略图。""" try: if not isinstance(path_or_image, str) or not path_or_image: return None im = Image.open(path_or_image).convert("RGBA") # 等比缩放后居中裁剪,避免直接拉伸变形 side = min(im.size) left = (im.width - side) // 2 top = (im.height - side) // 2 im = im.crop((left, top, left + side, top + side)).resize((size, size), Image.Resampling.LANCZOS) mask = Image.new("L", (size, size), 0) ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=255) out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) out.paste(im, (0, 0)) out.putalpha(mask) return ImageTk.PhotoImage(out) except Exception: return None # --------------------------------------------------------------------------- # 预览区 # --------------------------------------------------------------------------- class RoundedPreview(tk.Frame): """预览区:径向暗色渐变 + 虚线边框,最小高度 300。""" MIN_H = SIZES["preview_min_h"] def __init__(self, parent, radius=RADIUS["lg"], **kwargs): super().__init__(parent, bg=COLORS["surface"], **kwargs) self._radius = radius self._canvas = tk.Canvas(self, bg=COLORS["surface"], highlightthickness=0, bd=0, height=self.MIN_H) self._canvas.pack(fill=tk.BOTH, expand=True) self.host = tk.Frame(self._canvas, bg=COLORS["preview_mid"]) self._win = self._canvas.create_window(0, 0, window=self.host, anchor="nw") self._empty = True self._bg_photo = None self._canvas.bind("", self._on_configure) def _make_radial(self, w, h): """低分辨率生成再放大:视觉几乎一致,但耗时降到 ~1/16。""" w, h = max(w, 40), max(h, 40) scale = 4 gw, gh = max(w // scale, 24), max(h // scale, 24) img = Image.new("RGB", (gw, gh), COLORS["preview_bg"]) px = img.load() cx, cy = gw * 0.5, gh * 0.42 max_r = (gw ** 2 + gh ** 2) ** 0.5 * 0.55 c_in = hex_to_rgb(COLORS["preview_hi"]) c_out = hex_to_rgb(COLORS["preview_bg"]) for y in range(gh): for x in range(gw): d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / max_r t = min(1.0, d) px[x, y] = tuple(int(c_in[i] * (1 - t) + c_out[i] * t) for i in range(3)) img = img.resize((w, h), Image.Resampling.BILINEAR) mask = Image.new("L", (w, h), 0) ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius=self._radius, fill=255) rgba = img.convert("RGBA") rgba.putalpha(mask) return ImageTk.PhotoImage(rgba) def _on_configure(self, event): w, h = max(event.width, 40), max(event.height, self.MIN_H) key = (w, h) if getattr(self, "_last_size", None) == key: return self._last_size = key self._canvas.delete("bg") self._bg_photo = self._make_radial(w, h) self._canvas.create_image(0, 0, image=self._bg_photo, anchor="nw", tags="bg") self._canvas.create_rectangle( 5, 5, w - 6, h - 6, outline=COLORS["preview_line"], dash=(4, 4), width=1, tags="bg", ) self._canvas.tag_lower("bg") self._canvas.itemconfigure(self._win, width=max(w - 2, 10), height=max(h - 2, 10)) self._canvas.coords(self._win, 1, 1) try: self.host.configure(bg=COLORS["preview_mid"]) for child in self.host.winfo_children(): if isinstance(child, tk.Label) and not child.cget("image"): child.configure(bg=COLORS["preview_mid"]) except Exception: pass __all__ = [ "Tooltip", "Toast", "StatusBadge", "Chip", "PillButton", "IconButton", "NoticeBar", "RoundCard", "Divider", "SectionTitle", "EmptyState", "SearchBox", "StatTile", "RoundedPreview", "make_section_card", "make_rounded_thumb", "make_photo_rounded", ]