Dish_Recognition/ui/widgets.py

989 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self.hide, add="+")
widget.bind("<Button-1>", 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("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self._on_press)
self._canvas.bind("<ButtonRelease-1>", self._on_release)
self.bind("<Configure>", 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("<Button-1>", self._fire)
self.bind("<Enter>", lambda e: self.config(fg=self._hover_fg, bg=self._hover_bg))
self.bind("<Leave>", 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("<Configure>", 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("<Configure>", self._on_configure)
self._inner.bind("<Configure>", 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("<Button-1>", lambda e: self.clear())
self._clear.bind("<Enter>", lambda e: self._clear.config(fg=COLORS["danger"]))
self._clear.bind("<Leave>", lambda e: self._clear.config(fg=COLORS["muted"]))
self._canvas.bind("<Configure>", self._redraw)
self._entry.bind("<FocusIn>", self._on_focus_in)
self._entry.bind("<FocusOut>", self._on_focus_out)
self._entry.bind("<KeyRelease>", self._on_key)
self._entry.bind("<Escape>", 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("<Return>", 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("<Configure>", 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("<Configure>", 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",
]