abstractcode 0.3.0__py3-none-any.whl → 0.3.2__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.
abstractcode/theme.py ADDED
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ import re
6
+ from typing import Dict, Optional, Tuple
7
+
8
+
9
+ _HEX_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
10
+
11
+
12
+ def _clamp_u8(x: int) -> int:
13
+ return 0 if x < 0 else 255 if x > 255 else int(x)
14
+
15
+
16
+ def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
17
+ s = str(hex_color or "").strip()
18
+ if not s:
19
+ return (0, 0, 0)
20
+ if not s.startswith("#"):
21
+ s = "#" + s
22
+ if not _HEX_RE.match(s):
23
+ return (0, 0, 0)
24
+ r = int(s[1:3], 16)
25
+ g = int(s[3:5], 16)
26
+ b = int(s[5:7], 16)
27
+ return (r, g, b)
28
+
29
+
30
+ def _rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
31
+ r, g, b = rgb
32
+ return f"#{_clamp_u8(r):02x}{_clamp_u8(g):02x}{_clamp_u8(b):02x}"
33
+
34
+
35
+ def ansi_fg(hex_color: str) -> str:
36
+ """Return a truecolor ANSI foreground escape for a hex color (or "" if invalid)."""
37
+ s = str(hex_color or "").strip()
38
+ if not s:
39
+ return ""
40
+ if not s.startswith("#"):
41
+ s = "#" + s
42
+ if not _HEX_RE.match(s):
43
+ return ""
44
+ r, g, b = _hex_to_rgb(s)
45
+ return f"\033[38;2;{r};{g};{b}m"
46
+
47
+
48
+ def ansi_bg(hex_color: str) -> str:
49
+ """Return a truecolor ANSI background escape for a hex color (or "" if invalid)."""
50
+ s = str(hex_color or "").strip()
51
+ if not s:
52
+ return ""
53
+ if not s.startswith("#"):
54
+ s = "#" + s
55
+ if not _HEX_RE.match(s):
56
+ return ""
57
+ r, g, b = _hex_to_rgb(s)
58
+ return f"\033[48;2;{r};{g};{b}m"
59
+
60
+
61
+ def blend_hex(a: str, b: str, t: float) -> str:
62
+ """Blend two hex colors (t=0 -> a, t=1 -> b)."""
63
+ t = 0.0 if t < 0 else 1.0 if t > 1 else float(t)
64
+ ar, ag, ab = _hex_to_rgb(a)
65
+ br, bg, bb = _hex_to_rgb(b)
66
+ r = int(ar + (br - ar) * t)
67
+ g = int(ag + (bg - ag) * t)
68
+ b2 = int(ab + (bb - ab) * t)
69
+ return _rgb_to_hex((r, g, b2))
70
+
71
+
72
+ def relative_luminance(hex_color: str) -> float:
73
+ """Return relative luminance (0..1) for an sRGB hex color."""
74
+ r, g, b = _hex_to_rgb(hex_color)
75
+
76
+ def _to_linear(u8: int) -> float:
77
+ x = max(0.0, min(1.0, float(u8) / 255.0))
78
+ return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
79
+
80
+ rl = _to_linear(r)
81
+ gl = _to_linear(g)
82
+ bl = _to_linear(b)
83
+ return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
84
+
85
+
86
+ def is_dark(hex_color: str, *, threshold: float = 0.40) -> bool:
87
+ """Heuristic for whether a color should be treated as a dark surface."""
88
+ try:
89
+ return relative_luminance(hex_color) < float(threshold)
90
+ except Exception:
91
+ return True
92
+
93
+
94
+ def normalize_hex(hex_color: str, *, fallback: str) -> str:
95
+ s = str(hex_color or "").strip()
96
+ if not s:
97
+ return fallback
98
+ if not s.startswith("#"):
99
+ s = "#" + s
100
+ if not _HEX_RE.match(s):
101
+ return fallback
102
+ return s.lower()
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Theme:
107
+ """A small set of design tokens for the TUI.
108
+
109
+ We keep this intentionally small (4 base colors) and derive everything else.
110
+ """
111
+
112
+ name: str
113
+ primary: str
114
+ secondary: str
115
+ surface: str
116
+ muted: str
117
+
118
+ def normalized(self) -> "Theme":
119
+ return Theme(
120
+ name=str(self.name or "theme").strip() or "theme",
121
+ primary=normalize_hex(self.primary, fallback="#00aa00"),
122
+ secondary=normalize_hex(self.secondary, fallback="#00aaff"),
123
+ surface=normalize_hex(self.surface, fallback="#1a1a2e"),
124
+ muted=normalize_hex(self.muted, fallback="#888888"),
125
+ )
126
+
127
+
128
+ BUILTIN_THEMES: Dict[str, Theme] = {
129
+ # Close to the existing palette; good default for dark terminals.
130
+ "midnight": Theme(
131
+ name="midnight",
132
+ primary="#00aa00",
133
+ secondary="#00aaff",
134
+ surface="#1a1a2e",
135
+ muted="#888888",
136
+ ),
137
+ # Tokyo Night base, but with an orange secondary accent for footer/help.
138
+ "tokyo": Theme(
139
+ name="tokyo",
140
+ primary="#7aa2f7",
141
+ secondary="#ff9e64",
142
+ surface="#1a1b26",
143
+ muted="#565f89",
144
+ ),
145
+ "tokyo-night": Theme(
146
+ name="tokyo-night",
147
+ primary="#7aa2f7",
148
+ secondary="#bb9af7",
149
+ surface="#1a1b26",
150
+ muted="#565f89",
151
+ ),
152
+ "dracula": Theme(
153
+ name="dracula",
154
+ primary="#50fa7b",
155
+ secondary="#bd93f9",
156
+ surface="#282a36",
157
+ muted="#6272a4",
158
+ ),
159
+ "nord": Theme(
160
+ name="nord",
161
+ primary="#88c0d0",
162
+ secondary="#81a1c1",
163
+ surface="#2e3440",
164
+ muted="#8fbcbb",
165
+ ),
166
+ "gruvbox-dark": Theme(
167
+ name="gruvbox-dark",
168
+ primary="#b8bb26",
169
+ secondary="#fabd2f",
170
+ surface="#282828",
171
+ muted="#a89984",
172
+ ),
173
+ # Additional one-word themes (more visually distinct).
174
+ "aurora": Theme(
175
+ name="aurora",
176
+ primary="#22c55e",
177
+ secondary="#a78bfa",
178
+ surface="#0b1021",
179
+ muted="#64748b",
180
+ ),
181
+ "ember": Theme(
182
+ name="ember",
183
+ primary="#f97316",
184
+ secondary="#fb7185",
185
+ surface="#160b10",
186
+ muted="#9ca3af",
187
+ ),
188
+ "ocean": Theme(
189
+ name="ocean",
190
+ primary="#38bdf8",
191
+ secondary="#34d399",
192
+ surface="#071a2b",
193
+ muted="#64748b",
194
+ ),
195
+ "coral": Theme(
196
+ name="coral",
197
+ primary="#fb923c",
198
+ secondary="#34d399",
199
+ surface="#071a2b",
200
+ muted="#64748b",
201
+ ),
202
+ "paper": Theme(
203
+ name="paper",
204
+ primary="#2563eb",
205
+ secondary="#7c3aed",
206
+ surface="#f8fafc",
207
+ muted="#334155",
208
+ ),
209
+ }
210
+
211
+
212
+ def _env(name: str) -> str:
213
+ return str(os.getenv(name, "") or "").strip()
214
+
215
+
216
+ def theme_from_env(*, default: str = "tokyo") -> Theme:
217
+ name = _env("ABSTRACTCODE_THEME") or default
218
+ base = BUILTIN_THEMES.get(name.lower(), BUILTIN_THEMES.get(default, next(iter(BUILTIN_THEMES.values()))))
219
+ t = base.normalized()
220
+
221
+ # Optional overrides (lets users define up to 4 base colors without code changes).
222
+ primary = _env("ABSTRACTCODE_THEME_PRIMARY")
223
+ secondary = _env("ABSTRACTCODE_THEME_SECONDARY")
224
+ surface = _env("ABSTRACTCODE_THEME_SURFACE")
225
+ muted = _env("ABSTRACTCODE_THEME_MUTED")
226
+ if any([primary, secondary, surface, muted]):
227
+ t = Theme(
228
+ name="custom",
229
+ primary=normalize_hex(primary, fallback=t.primary),
230
+ secondary=normalize_hex(secondary, fallback=t.secondary),
231
+ surface=normalize_hex(surface, fallback=t.surface),
232
+ muted=normalize_hex(muted, fallback=t.muted),
233
+ ).normalized()
234
+
235
+ return t
236
+
237
+
238
+ def get_theme(name: str, *, default: str = "tokyo") -> Optional[Theme]:
239
+ n = str(name or "").strip().lower()
240
+ if not n:
241
+ return None
242
+ if n == "custom":
243
+ return theme_from_env(default=default)
244
+ return BUILTIN_THEMES.get(n)