ctkforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctkforge/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """CustomTkinter extension components and widgets."""
2
+
3
+ from ctkforge.debug import (
4
+ DEBUG_BANNER,
5
+ enable_debug_mode,
6
+ is_debug_mode,
7
+ print_window_telemetry,
8
+ )
9
+ from ctkforge.geometry import scale, unscale
10
+ from ctkforge.popup import CTkPopup
11
+ from ctkforge.resources import resource_path
12
+ from ctkforge.spinbox import CTkSpinbox
13
+ from ctkforge.table import CTkTable
14
+
15
+ __all__ = [
16
+ "DEBUG_BANNER",
17
+ "CTkPopup",
18
+ "CTkSpinbox",
19
+ "CTkTable",
20
+ "enable_debug_mode",
21
+ "is_debug_mode",
22
+ "print_window_telemetry",
23
+ "resource_path",
24
+ "scale",
25
+ "unscale",
26
+ ]
ctkforge/debug.py ADDED
@@ -0,0 +1,95 @@
1
+ """Runtime telemetry and debug tools for CustomTkinter windows."""
2
+
3
+ from typing import Any
4
+
5
+ from ctkforge.geometry import unscale
6
+
7
+ DEBUG_BANNER: str = " === DEBUG MODE IS ACTIVE ==="
8
+ _DEBUG_ACTIVE: bool = False
9
+
10
+ __all__ = [
11
+ "DEBUG_BANNER",
12
+ "enable_debug_mode",
13
+ "is_debug_mode",
14
+ "print_window_telemetry",
15
+ ]
16
+
17
+
18
+ def is_debug_mode() -> bool:
19
+ """Return whether debug mode is currently active across the application."""
20
+ return _DEBUG_ACTIVE
21
+
22
+
23
+ def print_window_telemetry(window: Any) -> None:
24
+ """Print size, DPI scaling, and baseline dimensions for the active window.
25
+
26
+ Args:
27
+ window: The active Tk or CustomTkinter window to inspect.
28
+ """
29
+ if not hasattr(window, "winfo_exists") or not window.winfo_exists():
30
+ return
31
+
32
+ window.update_idletasks()
33
+ width = window.winfo_width()
34
+ height = window.winfo_height()
35
+ base_w, base_h = unscale(window, width, height)
36
+ base_req_w, base_req_h = unscale(
37
+ window, window.winfo_reqwidth(), window.winfo_reqheight()
38
+ )
39
+ scaling = (
40
+ window._get_window_scaling()
41
+ if hasattr(window, "_get_window_scaling")
42
+ else getattr(window, "_get_widget_scaling", lambda: 1.0)()
43
+ )
44
+
45
+ # Resolve window title
46
+ title_text = (
47
+ window.title()
48
+ if callable(getattr(window, "title", None))
49
+ else getattr(window, "_title_text", "Untitled")
50
+ )
51
+
52
+ class_name = window.__class__.__name__
53
+
54
+ print(f"\n--- {class_name} Telemetry ---")
55
+ print(f"Title: {title_text}")
56
+ print(
57
+ f"Physical Size: {width}x{height} px @ {scaling:g} ({int(scaling * 100)}%) Scaling"
58
+ )
59
+ print(f"Baseline Size: {base_w}x{base_h} px (96 DPI)")
60
+ if hasattr(window, "_target_width"):
61
+ print(f"Target Width: {window._target_width} px")
62
+ print(
63
+ f"Req Size: {base_req_w}x{base_req_h} px (base) / {window.winfo_reqwidth()}x{window.winfo_reqheight()} px (phys)"
64
+ )
65
+ print("-" * (len(class_name) + 18) + "\n")
66
+
67
+
68
+ def enable_debug_mode(app: Any, banner: str = DEBUG_BANNER) -> None:
69
+ """Activate debug mode across the application, popups, and future windows.
70
+
71
+ This function:
72
+ 1. Sets the global debug state to active.
73
+ 2. Appends the debug banner to the root application window title.
74
+ 3. Binds <F12> globally so pressing F12 in any focused window prints its telemetry.
75
+
76
+ Args:
77
+ app: The root CTk or CTkToplevel application instance.
78
+ banner: Text appended to the window title to show debug is active.
79
+ """
80
+ global _DEBUG_ACTIVE
81
+ _DEBUG_ACTIVE = True
82
+
83
+ # Append banner to app title if not already present
84
+ if hasattr(app, "title") and callable(app.title):
85
+ current_title = app.title()
86
+ if banner not in current_title:
87
+ app.title(f"{current_title}{banner}")
88
+
89
+ def _handle_f12(event: Any) -> None:
90
+ target_window = event.widget.winfo_toplevel()
91
+ print_window_telemetry(target_window)
92
+
93
+ # Bind globally so whichever window has focus receives the F12 telemetry dump
94
+ if hasattr(app, "bind_all") and callable(app.bind_all):
95
+ app.bind_all("<F12>", _handle_f12)
ctkforge/geometry.py ADDED
@@ -0,0 +1,96 @@
1
+ """DPI-aware geometry, scaling, and coordinate conversion helpers for CustomTkinter."""
2
+
3
+ from typing import Any, overload
4
+
5
+ __all__ = ["scale", "unscale"]
6
+
7
+
8
+ def _get_scaling(target: Any) -> float:
9
+ """Safely extract active DPI scaling factor from a CTk widget or window.
10
+
11
+ CustomTkinter defines `_get_window_scaling()` on CTkBaseClass, so `hasattr`
12
+ returns True for all widgets. However, the underlying private `__window_scaling`
13
+ attribute only exists on root `CTk` windows. Calling it on child widgets raises
14
+ AttributeError. We inspect `_get_widget_scaling` first and safely catch
15
+ AttributeError if `_get_window_scaling` is called on a child widget.
16
+ """
17
+ for method_name in ("_get_widget_scaling", "_get_window_scaling"):
18
+ method = getattr(target, method_name, None)
19
+ if callable(method):
20
+ try:
21
+ val = method()
22
+ if isinstance(val, (int, float)) and val > 0:
23
+ return float(val)
24
+ except AttributeError:
25
+ continue
26
+ return 1.0
27
+
28
+
29
+ @overload
30
+ def unscale(target: Any, value: float, /) -> int: ...
31
+
32
+
33
+ @overload
34
+ def unscale(
35
+ target: Any,
36
+ value1: float,
37
+ value2: float,
38
+ /,
39
+ *values: float,
40
+ ) -> tuple[int, ...]: ...
41
+
42
+
43
+ def unscale(target: Any, *values: float) -> int | tuple[int, ...]:
44
+ """Convert physical (device hardware) pixel values to logical (96 DPI baseline) integers.
45
+
46
+ Args:
47
+ target: A CTk widget or window providing the active DPI scaling factor.
48
+ *values: One or more physical pixel values to convert to logical units.
49
+
50
+ Returns:
51
+ A single logical integer if one value was provided, or a tuple of logical integers if multiple.
52
+
53
+ Raises:
54
+ ValueError: If no values are passed.
55
+ """
56
+ if not values:
57
+ raise ValueError("unscale() requires at least one value to convert.")
58
+
59
+ scaling = _get_scaling(target)
60
+ result = tuple(round(v / scaling) if scaling else round(v) for v in values)
61
+ return result[0] if len(result) == 1 else result
62
+
63
+
64
+ @overload
65
+ def scale(target: Any, value: float, /) -> int: ...
66
+
67
+
68
+ @overload
69
+ def scale(
70
+ target: Any,
71
+ value1: float,
72
+ value2: float,
73
+ /,
74
+ *values: float,
75
+ ) -> tuple[int, ...]: ...
76
+
77
+
78
+ def scale(target: Any, *values: float) -> int | tuple[int, ...]:
79
+ """Convert logical (96 DPI baseline) pixel values to physical (device hardware) integers.
80
+
81
+ Args:
82
+ target: A CTk widget or window providing the active DPI scaling factor.
83
+ *values: One or more logical pixel values to convert to physical device units.
84
+
85
+ Returns:
86
+ A single physical integer if one value was provided, or a tuple of physical integers if multiple.
87
+
88
+ Raises:
89
+ ValueError: If no values are passed.
90
+ """
91
+ if not values:
92
+ raise ValueError("scale() requires at least one value to convert.")
93
+
94
+ scaling = _get_scaling(target)
95
+ result = tuple(round(v * scaling) if scaling else round(v) for v in values)
96
+ return result[0] if len(result) == 1 else result
@@ -0,0 +1,20 @@
1
+ """CTkPopup - A robust, modern, and DPI-aware dialog replacement for CTkMessagebox.
2
+
3
+ Features:
4
+ - 4 semantic button roles (primary, cancel, destructive, normal) with dedicated resting and hover colors
5
+ - Decoupled semantic return values (booleans, tokens)
6
+ - Hidden-staging window lifecycle preventing geometry jumps
7
+ - The Courier Rule: safe return values without widget destruction errors
8
+ - Automatic switching to copyable CTkTextbox for long logs and exceptions
9
+ - Option B Smart Fail-Safe icon resolution
10
+ """
11
+
12
+ from .constants import PRESETS, ROLE_COLORS
13
+ from .popup import CTkPopup
14
+
15
+ __version__ = "1.0.0"
16
+ __all__ = [
17
+ "PRESETS",
18
+ "ROLE_COLORS",
19
+ "CTkPopup",
20
+ ]
@@ -0,0 +1,145 @@
1
+ """Button normalization and styling helpers for CTkPopup.
2
+
3
+ Internal helper module. Not part of the public API.
4
+ """
5
+
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import customtkinter as ctk
10
+
11
+ from .constants import ROLE_COLORS
12
+
13
+ __all__: list[str] = []
14
+
15
+
16
+ def normalize_buttons(raw_buttons: Any) -> list[dict[str, Any]]:
17
+ """Convert various button input styles into a standardized list of button dicts.
18
+
19
+ Supported input formats:
20
+ - Full dictionary:
21
+ [{"label": "Delete", "value": "del", "role": "destructive"}]
22
+ - Tuples:
23
+ [("Save", True)] -> role defaults to "primary"
24
+ [("Delete", False, "destructive")] -> explicit role
25
+ - Plain strings:
26
+ ["OK"] -> 1 button: role="primary"
27
+ ["Yes", "No"] -> 2 buttons: first="primary", second="cancel"
28
+ ["Save", "Skip", "Cancel"] -> 3+ buttons: first="primary", last="cancel", middle="normal"
29
+
30
+ Returns:
31
+ List of dicts, each guaranteed to have 'label', 'value', and 'role' keys.
32
+ """
33
+ if not raw_buttons:
34
+ return [{"label": "OK", "value": "ok", "role": "primary"}]
35
+
36
+ # If developer accidentally passed a single string or dict instead of a list
37
+ if isinstance(raw_buttons, (str, dict, tuple)):
38
+ raw_buttons = [raw_buttons]
39
+
40
+ normalized: list[dict[str, Any]] = []
41
+ total = len(raw_buttons)
42
+
43
+ for index, item in enumerate(raw_buttons):
44
+ # 1. Full dictionary format
45
+ if isinstance(item, dict):
46
+ # Support both 'text' and 'label' for standard CustomTkinter syntax
47
+ label = str(item.get("text", item.get("label", "OK")))
48
+ value = item.get("value", label)
49
+ role = str(item.get("role", "normal"))
50
+ spec: dict[str, Any] = {"label": label, "value": value, "role": role}
51
+
52
+ # Optional custom color overrides per button
53
+ for color_key in ("fg_color", "hover_color", "text_color"):
54
+ if color_key in item:
55
+ spec[color_key] = item[color_key]
56
+ if "color" in item and "fg_color" not in spec:
57
+ spec["fg_color"] = item["color"]
58
+
59
+ normalized.append(spec)
60
+
61
+ # 2. Tuple format
62
+ elif isinstance(item, tuple):
63
+ if len(item) == 1:
64
+ label = str(item[0])
65
+ value = label
66
+ role = "primary" if index == 0 else "normal"
67
+ elif len(item) == 2:
68
+ label = str(item[0])
69
+ value = item[1]
70
+ role = "primary" if index == 0 else "normal"
71
+ else:
72
+ label = str(item[0])
73
+ value = item[1]
74
+ role = str(item[2])
75
+ normalized.append({"label": label, "value": value, "role": role})
76
+
77
+ # 3. Plain string format
78
+ elif isinstance(item, str):
79
+ label = item
80
+ value = item
81
+
82
+ if total == 1:
83
+ role = "primary"
84
+ elif total == 2:
85
+ role = "primary" if index == 0 else "cancel"
86
+ else:
87
+ if index == 0:
88
+ role = "primary"
89
+ elif index == total - 1:
90
+ role = "cancel"
91
+ else:
92
+ role = "normal"
93
+
94
+ normalized.append({"label": label, "value": value, "role": role})
95
+
96
+ # Fallback for unrecognized types
97
+ else:
98
+ text = str(item)
99
+ normalized.append({"label": text, "value": item, "role": "normal"})
100
+
101
+ return normalized
102
+
103
+
104
+ def create_button(
105
+ master: Any,
106
+ spec: dict[str, Any],
107
+ on_click: Callable[[Any], None],
108
+ role_colors: dict[str, dict[str, Any]] | None = None,
109
+ ) -> ctk.CTkButton:
110
+ """Create a styled CTkButton based on its semantic role.
111
+
112
+ Args:
113
+ master: The parent container widget.
114
+ spec: Button definition dictionary containing 'label', 'value', and 'role'.
115
+ on_click: Callback function invoked with button's semantic value when clicked.
116
+ role_colors: Optional role-to-color mapping overriding default ROLE_COLORS.
117
+
118
+ Returns:
119
+ Configured ctk.CTkButton instance.
120
+ """
121
+ colors_map = role_colors or ROLE_COLORS
122
+ role = spec.get("role", "normal")
123
+ styling = colors_map.get(role, colors_map.get("normal", {}))
124
+
125
+ kwargs: dict[str, Any] = {
126
+ "text": spec.get("label", "OK"),
127
+ "command": lambda: on_click(spec.get("value")),
128
+ "height": 36,
129
+ "corner_radius": 6,
130
+ }
131
+
132
+ # Only pass explicit colors if defined (None lets CTk inherit theme accent)
133
+ if styling.get("fg_color") is not None:
134
+ kwargs["fg_color"] = styling["fg_color"]
135
+ if styling.get("hover_color") is not None:
136
+ kwargs["hover_color"] = styling["hover_color"]
137
+ if styling.get("text_color") is not None:
138
+ kwargs["text_color"] = styling["text_color"]
139
+
140
+ # Explicit per-button overrides take precedence
141
+ for color_key in ("fg_color", "hover_color", "text_color"):
142
+ if spec.get(color_key) is not None:
143
+ kwargs[color_key] = spec[color_key]
144
+
145
+ return ctk.CTkButton(master, **kwargs)
@@ -0,0 +1,107 @@
1
+ """Configuration constants, presets, and role color palettes for CTkPopup.
2
+
3
+ All presets and role colors are defined at the module/class level so developers
4
+ can customize themes and localization in one central place.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ # fmt: off
10
+ # Layout dimensions and thresholds
11
+ MAX_LABEL_HEIGHT: int = 300
12
+ TEXTBOX_HEIGHT: int = 220
13
+ TEXTBOX_WIDTH_PADDING: int = 40
14
+ DEFAULT_POPUP_WIDTH: int = 478 # Baseline width (floor is ~355px for 2 buttons; ~478px fits full wrap 340 + icon 138)
15
+ DEFAULT_WRAPLENGTH: int = 340 # Maximum horizontal wrap boundary for text
16
+ ICON_DISPLAY_SIZE: tuple[int, int] = (64, 64)
17
+
18
+ # Styling and typography tokens
19
+ MESSAGE_FONT_SIZE: int = 13
20
+ POPUP_BORDER_COLOR: tuple[str, str] = ("gray80", "gray25")
21
+ POPUP_BORDER_WIDTH: int = 1
22
+ # fmt: on
23
+
24
+ # Color definitions for the 4 essential button roles.
25
+ # Each role contains light and dark mode hex tuples: (light_color, dark_color).
26
+ # "primary" uses None so it automatically inherits CustomTkinter's active accent color.
27
+ ROLE_COLORS: dict[str, dict[str, Any]] = {
28
+ "primary": {
29
+ "fg_color": None,
30
+ "hover_color": None,
31
+ "text_color": None,
32
+ },
33
+ "destructive": {
34
+ "fg_color": ("#DC2626", "#EF4444"),
35
+ "hover_color": ("#B91C1C", "#DC2626"),
36
+ "text_color": ("#FFFFFF", "#FFFFFF"),
37
+ },
38
+ "cancel": {
39
+ "fg_color": ("#E5E7EB", "#374151"),
40
+ "hover_color": ("#D1D5DB", "#4B5563"),
41
+ "text_color": ("#1F2937", "#F9FAFB"),
42
+ },
43
+ "normal": {
44
+ "fg_color": ("#F3F4F6", "#1F2937"),
45
+ "hover_color": ("#E5E7EB", "#374151"),
46
+ "text_color": ("#374151", "#E5E7EB"),
47
+ },
48
+ }
49
+
50
+ # The 5 standard preset configurations for dialogs.
51
+ # Exposes only title, icon, buttons, and keyboard action defaults.
52
+ # Button layout follows Windows Standard (Microsoft Guidelines):
53
+ # Primary action comes first (left), and Cancel is on the far right:
54
+ # [ Proceed ] [ Cancel ]
55
+ # [ Delete ] [ Cancel ]
56
+ # [ Yes ] [ No ]
57
+ # (Windows users read left-to-right and expect the affirmative action first).
58
+ PRESETS: dict[str, dict[str, Any]] = {
59
+ "info": {
60
+ "title": "Information",
61
+ "icon": "info.png",
62
+ "buttons": [{"label": "OK", "value": "ok", "role": "primary"}],
63
+ "default_button": "ok",
64
+ "cancel_button": "ok",
65
+ },
66
+ "warning": {
67
+ "title": "Warning",
68
+ "icon": "warning.png",
69
+ "buttons": [
70
+ {"label": "Proceed", "value": True, "role": "primary"},
71
+ {"label": "Cancel", "value": False, "role": "cancel"},
72
+ ],
73
+ "default_button": True,
74
+ "cancel_button": False,
75
+ },
76
+ "error": {
77
+ "title": "Error",
78
+ "icon": "error.png",
79
+ "buttons": [{"label": "Dismiss", "value": "dismiss", "role": "destructive"}],
80
+ "default_button": "dismiss",
81
+ "cancel_button": "dismiss",
82
+ },
83
+ "question": {
84
+ "title": "Confirm",
85
+ "icon": "question.png",
86
+ "buttons": [
87
+ {"label": "Yes", "value": True, "role": "primary"},
88
+ {"label": "No", "value": False, "role": "cancel"},
89
+ ],
90
+ "default_button": True,
91
+ "cancel_button": False,
92
+ },
93
+ "success": {
94
+ "title": "Success",
95
+ "icon": "success.png",
96
+ "buttons": [{"label": "OK", "value": "ok", "role": "primary"}],
97
+ "default_button": "ok",
98
+ "cancel_button": "ok",
99
+ },
100
+ }
101
+
102
+ # Public customization tables (safe for application-wide customization).
103
+ # All other constants in this file are internal layout defaults subject to change.
104
+ __all__ = [
105
+ "PRESETS",
106
+ "ROLE_COLORS",
107
+ ]
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,135 @@
1
+ """Icon resolution, loading, and high-DPI scaling for CTkPopup.
2
+
3
+ Internal helper module. Not part of the public API.
4
+ """
5
+
6
+ import importlib.resources
7
+ import sys
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import customtkinter as ctk
13
+ from PIL import Image
14
+
15
+ from ctkforge.resources import resource_path
16
+
17
+ from .constants import ICON_DISPLAY_SIZE, PRESETS
18
+
19
+ __all__: list[str] = []
20
+
21
+
22
+ def load_image_as_ctk(
23
+ image_path: Path,
24
+ display_size: tuple[int, int] = ICON_DISPLAY_SIZE,
25
+ ) -> ctk.CTkImage:
26
+ """Open an image file with Pillow and wrap it into a DPI-aware CTkImage.
27
+
28
+ Pillow handles the master file (e.g., 256x256), while CustomTkinter downscales
29
+ it using Lanczos filtering to the logical display size (e.g., 48x48).
30
+ """
31
+ with Image.open(image_path) as img:
32
+ pil_img = img.copy()
33
+ return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=display_size)
34
+
35
+
36
+ def load_stock_icon(
37
+ icon_name: str,
38
+ display_size: tuple[int, int] = ICON_DISPLAY_SIZE,
39
+ ) -> ctk.CTkImage | None:
40
+ """Load a bundled stock icon from the popup/icons/ package directory.
41
+
42
+ Args:
43
+ icon_name: Base name (e.g. 'warning' or 'warning.png').
44
+ display_size: Target logical dimensions on screen.
45
+
46
+ Returns:
47
+ Configured ctk.CTkImage, or None if not found.
48
+ """
49
+ filename = icon_name if icon_name.endswith(".png") else f"{icon_name}.png"
50
+
51
+ with suppress(OSError, TypeError, ValueError, ModuleNotFoundError):
52
+ # Modern standard library package resource locator
53
+ resource_ref = importlib.resources.files(__package__).joinpath("icons", filename)
54
+ # In Python 3.9+, as_file yields a context or path
55
+ with importlib.resources.as_file(resource_ref) as path:
56
+ if path.is_file():
57
+ return load_image_as_ctk(path, display_size)
58
+
59
+ # Direct filesystem fallback for development / unpacked trees
60
+ local_path = Path(__file__).resolve().parent / "icons" / filename
61
+ if local_path.is_file():
62
+ return load_image_as_ctk(local_path, display_size)
63
+
64
+ return None
65
+
66
+
67
+ def resolve_icon(
68
+ icon_input: Any,
69
+ preset_name: str | None = None,
70
+ display_size: tuple[int, int] = ICON_DISPLAY_SIZE,
71
+ ) -> ctk.CTkImage | None:
72
+ """Resolve an icon input through the fallback priority chain.
73
+
74
+ Priority Chain:
75
+ 1. Pre-configured ctk.CTkImage -> use as-is.
76
+ 2. Preset keyword or bundled stock icon filename.
77
+ 3. Unified resource locator via resource_path (CWD, PyInstaller _MEIPASS, sys.argv[0]).
78
+ 4. Option B Smart Fail-Safe: Log warning and fall back to stock preset icon.
79
+
80
+ Args:
81
+ icon_input: Image object, file path, stock preset name, or None.
82
+ preset_name: Optional active preset name (e.g., 'warning', 'error') for fallback.
83
+ display_size: Target logical display size.
84
+
85
+ Returns:
86
+ ctk.CTkImage if resolved, or None if no icon can be shown.
87
+ """
88
+ if icon_input is None:
89
+ if preset_name and preset_name in PRESETS:
90
+ stock_name = PRESETS[preset_name].get("icon", f"{preset_name}.png")
91
+ return load_stock_icon(stock_name, display_size)
92
+ return None
93
+
94
+ # Step 1: Already a ctk.CTkImage
95
+ if isinstance(icon_input, ctk.CTkImage):
96
+ return icon_input
97
+
98
+ # If it's a known stock preset keyword (e.g. "warning", "info")
99
+ if isinstance(icon_input, str) and icon_input in PRESETS:
100
+ stock_name = PRESETS[icon_input].get("icon", f"{icon_input}.png")
101
+ stock_img = load_stock_icon(stock_name, display_size)
102
+ if stock_img is not None:
103
+ return stock_img
104
+
105
+ if isinstance(icon_input, (str, Path)):
106
+ str_path = str(icon_input)
107
+
108
+ # Also check if it's a stock icon filename directly (e.g., "warning.png")
109
+ stock_img = load_stock_icon(str_path, display_size)
110
+ if stock_img is not None:
111
+ return stock_img
112
+
113
+ # Step 2: Unified resource locator (CWD, PyInstaller _MEIPASS, sys.argv[0])
114
+ resolved_file = resource_path(str_path)
115
+ if resolved_file is not None and resolved_file.is_file():
116
+ try:
117
+ return load_image_as_ctk(resolved_file, display_size)
118
+ except (OSError, ValueError) as e:
119
+ print(
120
+ f"[CTkPopup Warning]: Error reading icon file '{resolved_file}': {e}",
121
+ file=sys.stderr,
122
+ )
123
+
124
+ # Step 5: Option B Smart Fail-Safe
125
+ print(
126
+ f"[CTkPopup Warning]: Custom icon '{icon_input}' could not be resolved. "
127
+ f"Falling back to preset stock icon.",
128
+ file=sys.stderr,
129
+ )
130
+
131
+ if preset_name and preset_name in PRESETS:
132
+ stock_name = PRESETS[preset_name].get("icon", f"{preset_name}.png")
133
+ return load_stock_icon(stock_name, display_size)
134
+
135
+ return None