|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
8 | 8 | import math |
| 9 | +import tkinter as tk |
9 | 10 | from datetime import date, datetime |
10 | 11 | from tkinter import ttk |
11 | 12 |
|
@@ -336,3 +337,104 @@ def on_click(idx: int) -> None: |
336 | 337 |
|
337 | 338 | for i, col in enumerate(columns): |
338 | 339 | tree.heading(col, command=lambda i=i: on_click(i)) |
| 340 | + |
| 341 | + |
| 342 | +def _attach_cell_tooltip( |
| 343 | + tree: ttk.Treeview, |
| 344 | + columns, |
| 345 | + headers, |
| 346 | + *, |
| 347 | + tooltip_headers: set[str] | None = None, |
| 348 | + delay_ms: int = 300, |
| 349 | +) -> None: |
| 350 | + """给 Treeview 指定列加悬浮完整文本提示. |
| 351 | +
|
| 352 | + 主要用于"标签"、"复核建议"这类长文本列。tooltip 内容直接取当前单元格 |
| 353 | + display value, 因此表头排序后也能自然跟随行移动。 |
| 354 | + """ |
| 355 | + targets = set(tooltip_headers or headers) |
| 356 | + state = {"tip": None, "after": None, "cell": None} |
| 357 | + |
| 358 | + def _cancel_after() -> None: |
| 359 | + after_id = state.get("after") |
| 360 | + if after_id is not None: |
| 361 | + try: |
| 362 | + tree.after_cancel(after_id) |
| 363 | + except Exception: |
| 364 | + pass |
| 365 | + state["after"] = None |
| 366 | + |
| 367 | + def _hide(_event=None) -> None: |
| 368 | + _cancel_after() |
| 369 | + tip = state.get("tip") |
| 370 | + if tip is not None: |
| 371 | + try: |
| 372 | + tip.destroy() |
| 373 | + except Exception: |
| 374 | + pass |
| 375 | + state["tip"] = None |
| 376 | + state["cell"] = None |
| 377 | + |
| 378 | + def _show(text: str, x_root: int, y_root: int) -> None: |
| 379 | + tip = tk.Toplevel(tree) |
| 380 | + tip.wm_overrideredirect(True) |
| 381 | + try: |
| 382 | + tip.attributes("-topmost", True) |
| 383 | + except tk.TclError: |
| 384 | + pass |
| 385 | + label = tk.Label( |
| 386 | + tip, |
| 387 | + text=text, |
| 388 | + justify="left", |
| 389 | + wraplength=460, |
| 390 | + background=get_color(BG_INPUT), |
| 391 | + foreground=get_color(TEXT), |
| 392 | + relief="solid", |
| 393 | + borderwidth=1, |
| 394 | + padx=8, |
| 395 | + pady=6, |
| 396 | + font=(FONT_FAMILY, 12), |
| 397 | + ) |
| 398 | + label.pack() |
| 399 | + tip.wm_geometry(f"+{x_root + 12}+{y_root + 16}") |
| 400 | + state["tip"] = tip |
| 401 | + |
| 402 | + def _motion(event) -> None: |
| 403 | + row_id = tree.identify_row(event.y) |
| 404 | + col_id = tree.identify_column(event.x) |
| 405 | + if not row_id or not col_id: |
| 406 | + _hide() |
| 407 | + return |
| 408 | + try: |
| 409 | + col_idx = int(col_id.lstrip("#")) - 1 |
| 410 | + except ValueError: |
| 411 | + _hide() |
| 412 | + return |
| 413 | + if col_idx < 0 or col_idx >= len(columns): |
| 414 | + _hide() |
| 415 | + return |
| 416 | + header = headers[col_idx] |
| 417 | + if header not in targets: |
| 418 | + _hide() |
| 419 | + return |
| 420 | + value = str(tree.set(row_id, columns[col_idx]) or "").strip() |
| 421 | + if not value or value in {"—", "-"}: |
| 422 | + _hide() |
| 423 | + return |
| 424 | + cell = (row_id, col_idx, value) |
| 425 | + if state.get("cell") == cell: |
| 426 | + tip = state.get("tip") |
| 427 | + if tip is not None: |
| 428 | + tip.wm_geometry(f"+{event.x_root + 12}+{event.y_root + 16}") |
| 429 | + return |
| 430 | + _hide() |
| 431 | + state["cell"] = cell |
| 432 | + state["after"] = tree.after( |
| 433 | + delay_ms, |
| 434 | + lambda text=value, x=event.x_root, y=event.y_root: _show(text, x, y), |
| 435 | + ) |
| 436 | + |
| 437 | + tree.bind("<Motion>", _motion, add="+") |
| 438 | + tree.bind("<Leave>", _hide, add="+") |
| 439 | + tree.bind("<ButtonPress>", _hide, add="+") |
| 440 | + tree.bind("<MouseWheel>", _hide, add="+") |
0 commit comments