253 lines
8.1 KiB
Python
253 lines
8.1 KiB
Python
"""Centralized icon helpers backed by qtawesome.
|
||
|
||
qtawesome renders icons from bundled fonts (Material Design Icons, etc.) into
|
||
real ``QIcon``/``QPixmap`` objects, so they look identical on Windows and Linux
|
||
and avoid the emoji "tofu" problem on Linux font stacks.
|
||
|
||
Everything degrades gracefully: if qtawesome is unavailable the helpers fall
|
||
back to the legacy ``platform_glyph`` text so the UI still renders.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from PyQt5.QtCore import QSize, Qt
|
||
from PyQt5.QtGui import QIcon, QPixmap
|
||
from PyQt5.QtWidgets import QHBoxLayout, QLabel, QWidget
|
||
|
||
try:
|
||
import qtawesome as qta
|
||
|
||
HAS_QTA = True
|
||
except Exception: # pragma: no cover - only when the optional dep is missing
|
||
qta = None # type: ignore[assignment]
|
||
HAS_QTA = False
|
||
|
||
# Neutral light gray that matches the app's default text on dark surfaces.
|
||
DEFAULT_COLOR = "#cccccc"
|
||
ACCENT_COLOR = "#007acc"
|
||
|
||
# Map the emoji/symbols used across the GUI to Material Design Icon names.
|
||
# Multi-character keys (e.g. "►►", "⚙️" with variation selector) are matched
|
||
# before single characters, so order-independent longest-match lookup works.
|
||
EMOJI_ICON: dict[str, str] = {
|
||
"►►": "mdi6.fast-forward",
|
||
"⚙️": "mdi6.cog",
|
||
"🖥️": "mdi6.monitor",
|
||
"🖼️": "mdi6.image-multiple-outline",
|
||
"⚔️": "mdi6.sword-cross",
|
||
"⚠️": "mdi6.alert-outline",
|
||
"🌐": "mdi6.web",
|
||
"⚡": "mdi6.lightning-bolt",
|
||
"⚙": "mdi6.cog",
|
||
"🔧": "mdi6.wrench-outline",
|
||
"🔑": "mdi6.key-variant",
|
||
"📝": "mdi6.text-box-outline",
|
||
"💰": "mdi6.currency-usd",
|
||
"🖥": "mdi6.monitor",
|
||
"📁": "mdi6.folder-open-outline",
|
||
"📂": "mdi6.folder-open-outline",
|
||
"📄": "mdi6.file-document-outline",
|
||
"🗺": "mdi6.map-outline",
|
||
"❓": "mdi6.help-circle-outline",
|
||
"💬": "mdi6.message-text-outline",
|
||
"🖼": "mdi6.image-multiple-outline",
|
||
"🎮": "mdi6.gamepad-variant-outline",
|
||
"📚": "mdi6.bookshelf",
|
||
"👤": "mdi6.account",
|
||
"📋": "mdi6.clipboard-text-outline",
|
||
"📊": "mdi6.table-column",
|
||
"📤": "mdi6.export",
|
||
"📥": "mdi6.import",
|
||
"🔄": "mdi6.refresh",
|
||
"↺": "mdi6.refresh",
|
||
"↻": "mdi6.refresh",
|
||
"🔍": "mdi6.magnify",
|
||
"💾": "mdi6.content-save",
|
||
"🗑️": "mdi6.delete-outline",
|
||
"🗑": "mdi6.delete-outline",
|
||
"➕": "mdi6.plus",
|
||
"🛑": "mdi6.stop-circle-outline",
|
||
"⚔": "mdi6.sword-cross",
|
||
"⬇": "mdi6.download",
|
||
"⬆": "mdi6.upload",
|
||
"↓": "mdi6.import",
|
||
"↑": "mdi6.export",
|
||
"►": "mdi6.play",
|
||
"▶": "mdi6.play",
|
||
"◆": "mdi6.diamond-stone",
|
||
"☑": "mdi6.checkbox-marked-outline",
|
||
"☐": "mdi6.checkbox-blank-outline",
|
||
"▼": "mdi6.chevron-down",
|
||
"◄": "mdi6.chevron-left",
|
||
"←": "mdi6.arrow-left",
|
||
"→": "mdi6.arrow-right",
|
||
"✓": "mdi6.check-bold",
|
||
"✔": "mdi6.check-bold",
|
||
"✅": "mdi6.check-circle-outline",
|
||
"✗": "mdi6.close-thick",
|
||
"✕": "mdi6.close-thick",
|
||
"❌": "mdi6.close-circle-outline",
|
||
"⚠": "mdi6.alert-outline",
|
||
}
|
||
|
||
# Leading symbol/emoji cluster at the start of a button/header label.
|
||
_LEADING_SYMBOLS = re.compile(
|
||
r"^[\s]*("
|
||
r"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U00002190-\U000021FF"
|
||
r"\U00002300-\U000023FF\U000025A0-\U000025FF\U00002B00-\U00002BFF"
|
||
r"\uFE0F\u2028\u2029]+"
|
||
r")\s*"
|
||
)
|
||
|
||
|
||
# File-list categories -> (icon name, tint color).
|
||
_FILE_CATEGORY_ICONS: dict[str, tuple[str, str]] = {
|
||
"core": ("mdi6.file-document-outline", "#9cdcfe"),
|
||
"map": ("mdi6.map-outline", "#c5c5c0"),
|
||
"other": ("mdi6.help-circle-outline", "#888888"),
|
||
}
|
||
|
||
|
||
def file_category_icon(category: str) -> QIcon:
|
||
"""Return the list icon for a file category (``core`` / ``map`` / other)."""
|
||
name, color = _FILE_CATEGORY_ICONS.get(category, _FILE_CATEGORY_ICONS["other"])
|
||
return get_icon(name, color=color)
|
||
|
||
|
||
def get_icon(name: str, color: str = DEFAULT_COLOR, **kwargs) -> QIcon:
|
||
"""Return a qtawesome ``QIcon`` (empty ``QIcon`` if unavailable)."""
|
||
if not HAS_QTA:
|
||
return QIcon()
|
||
try:
|
||
return qta.icon(name, color=color, **kwargs)
|
||
except Exception:
|
||
return QIcon()
|
||
|
||
|
||
def get_pixmap(name: str, size: int, color: str = DEFAULT_COLOR) -> QPixmap:
|
||
"""Render a qtawesome icon to a square ``QPixmap`` (empty if unavailable)."""
|
||
if not HAS_QTA:
|
||
return QPixmap()
|
||
try:
|
||
return qta.icon(name, color=color).pixmap(QSize(size, size))
|
||
except Exception:
|
||
return QPixmap()
|
||
|
||
|
||
def two_state_icon(
|
||
name: str,
|
||
size: int,
|
||
*,
|
||
off_color: str,
|
||
on_color: str,
|
||
disabled_color: str | None = None,
|
||
) -> QIcon:
|
||
"""Build a nav ``QIcon`` whose On (checked) pixmap uses a brighter color."""
|
||
icon = QIcon()
|
||
if not HAS_QTA:
|
||
return icon
|
||
off_pix = get_pixmap(name, size, off_color)
|
||
on_pix = get_pixmap(name, size, on_color)
|
||
dis_pix = get_pixmap(name, size, disabled_color or off_color)
|
||
icon.addPixmap(off_pix, QIcon.Normal, QIcon.Off)
|
||
icon.addPixmap(on_pix, QIcon.Active, QIcon.Off)
|
||
icon.addPixmap(on_pix, QIcon.Normal, QIcon.On)
|
||
icon.addPixmap(on_pix, QIcon.Active, QIcon.On)
|
||
icon.addPixmap(on_pix, QIcon.Selected, QIcon.On)
|
||
icon.addPixmap(dis_pix, QIcon.Disabled, QIcon.Off)
|
||
return icon
|
||
|
||
|
||
def split_leading_symbol(text: str) -> tuple[str | None, str]:
|
||
"""Split a leading emoji/symbol cluster from *text*.
|
||
|
||
Returns ``(icon_name_or_None, remaining_text)``. The icon name is resolved
|
||
from :data:`EMOJI_ICON` using a longest-match on the leading cluster, then a
|
||
first-character fallback.
|
||
"""
|
||
match = _LEADING_SYMBOLS.match(text)
|
||
if not match:
|
||
return None, text
|
||
cluster = match.group(1).strip()
|
||
rest = text[match.end():].strip()
|
||
|
||
if cluster in EMOJI_ICON:
|
||
return EMOJI_ICON[cluster], rest
|
||
# Try progressively shorter prefixes, then the first character.
|
||
for length in range(len(cluster), 0, -1):
|
||
prefix = cluster[:length]
|
||
if prefix in EMOJI_ICON:
|
||
return EMOJI_ICON[prefix], rest
|
||
if cluster and cluster[0] in EMOJI_ICON:
|
||
return EMOJI_ICON[cluster[0]], rest
|
||
return None, rest
|
||
|
||
|
||
def icon_for_emoji(emoji: str) -> str | None:
|
||
"""Return the MDI icon name mapped to *emoji*, or ``None``."""
|
||
name, _ = split_leading_symbol(emoji)
|
||
return name
|
||
|
||
|
||
def apply_button_icon(btn, raw_text: str, color: str = DEFAULT_COLOR) -> None:
|
||
"""Set a labeled button's icon from its leading emoji and strip the emoji.
|
||
|
||
Falls back to the legacy ``platform_glyph`` text when qtawesome is missing or
|
||
no icon mapping exists.
|
||
"""
|
||
from gui.platform_glyph import platform_glyph
|
||
|
||
icon_name, rest = split_leading_symbol(raw_text)
|
||
if HAS_QTA and icon_name and rest:
|
||
btn.setIcon(get_icon(icon_name, color=color))
|
||
btn.setText(rest)
|
||
return
|
||
if HAS_QTA and icon_name and not rest:
|
||
# Icon-only button.
|
||
btn.setIcon(get_icon(icon_name, color=color))
|
||
btn.setText("")
|
||
return
|
||
btn.setText(platform_glyph(raw_text))
|
||
|
||
|
||
def make_section_header(
|
||
title: str,
|
||
text_stylesheet: str,
|
||
*,
|
||
icon_color: str = ACCENT_COLOR,
|
||
icon_px: int = 15,
|
||
) -> QWidget:
|
||
"""Build a section header row: a qtawesome icon (from the leading emoji) plus
|
||
the styled title text.
|
||
|
||
When qtawesome is unavailable, falls back to a plain ``QLabel`` using the
|
||
legacy ``platform_glyph`` text so behavior is unchanged.
|
||
"""
|
||
from gui.platform_glyph import platform_glyph
|
||
|
||
icon_name, rest = split_leading_symbol(title)
|
||
|
||
if not (HAS_QTA and icon_name):
|
||
label = QLabel(platform_glyph(title))
|
||
label.setStyleSheet(text_stylesheet)
|
||
return label
|
||
|
||
container = QWidget()
|
||
layout = QHBoxLayout(container)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(6)
|
||
|
||
icon_label = QLabel()
|
||
icon_label.setPixmap(get_pixmap(icon_name, icon_px, icon_color))
|
||
icon_label.setStyleSheet("background: transparent; padding: 0;")
|
||
icon_label.setAlignment(Qt.AlignVCenter)
|
||
layout.addWidget(icon_label, 0, Qt.AlignVCenter)
|
||
|
||
text_label = QLabel(rest)
|
||
text_label.setStyleSheet(text_stylesheet)
|
||
layout.addWidget(text_label, 0, Qt.AlignVCenter)
|
||
layout.addStretch()
|
||
|
||
return container
|