989 lines
37 KiB
Python
989 lines
37 KiB
Python
# 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",
|
||
]
|