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 +26 -0
- ctkforge/debug.py +95 -0
- ctkforge/geometry.py +96 -0
- ctkforge/popup/__init__.py +20 -0
- ctkforge/popup/buttons.py +145 -0
- ctkforge/popup/constants.py +107 -0
- ctkforge/popup/icons/error.png +0 -0
- ctkforge/popup/icons/info.png +0 -0
- ctkforge/popup/icons/question.png +0 -0
- ctkforge/popup/icons/success.png +0 -0
- ctkforge/popup/icons/warning.png +0 -0
- ctkforge/popup/icons.py +135 -0
- ctkforge/popup/popup.py +389 -0
- ctkforge/resources.py +84 -0
- ctkforge/spinbox/__init__.py +5 -0
- ctkforge/spinbox/spinbox.py +322 -0
- ctkforge/table/__init__.py +5 -0
- ctkforge/table/table.py +2117 -0
- ctkforge-0.1.0.dist-info/METADATA +149 -0
- ctkforge-0.1.0.dist-info/RECORD +22 -0
- ctkforge-0.1.0.dist-info/WHEEL +5 -0
- ctkforge-0.1.0.dist-info/top_level.txt +1 -0
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
|
ctkforge/popup/icons.py
ADDED
|
@@ -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
|