hexproxy 0.2.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.
- hexproxy/__init__.py +7 -0
- hexproxy/__main__.py +5 -0
- hexproxy/app.py +192 -0
- hexproxy/bodyview.py +435 -0
- hexproxy/certs.py +222 -0
- hexproxy/clipboard.py +89 -0
- hexproxy/extensions.py +739 -0
- hexproxy/mcp.py +2114 -0
- hexproxy/models.py +72 -0
- hexproxy/preferences.py +131 -0
- hexproxy/proxy.py +1178 -0
- hexproxy/store.py +1001 -0
- hexproxy/themes.py +274 -0
- hexproxy/tui.py +8796 -0
- hexproxy-0.2.2.dist-info/METADATA +556 -0
- hexproxy-0.2.2.dist-info/RECORD +20 -0
- hexproxy-0.2.2.dist-info/WHEEL +5 -0
- hexproxy-0.2.2.dist-info/entry_points.txt +2 -0
- hexproxy-0.2.2.dist-info/licenses/LICENSE +37 -0
- hexproxy-0.2.2.dist-info/top_level.txt +1 -0
hexproxy/themes.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .preferences import default_config_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
THEME_ROLES = (
|
|
12
|
+
"chrome",
|
|
13
|
+
"selection",
|
|
14
|
+
"success",
|
|
15
|
+
"error",
|
|
16
|
+
"warning",
|
|
17
|
+
"accent",
|
|
18
|
+
"keyword",
|
|
19
|
+
"info",
|
|
20
|
+
)
|
|
21
|
+
COLOR_NAMES = {
|
|
22
|
+
"default",
|
|
23
|
+
"black",
|
|
24
|
+
"red",
|
|
25
|
+
"green",
|
|
26
|
+
"yellow",
|
|
27
|
+
"blue",
|
|
28
|
+
"magenta",
|
|
29
|
+
"cyan",
|
|
30
|
+
"white",
|
|
31
|
+
}
|
|
32
|
+
HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
33
|
+
|
|
34
|
+
BUILTIN_THEME_DEFINITIONS: dict[str, dict[str, object]] = {
|
|
35
|
+
"default": {
|
|
36
|
+
"description": "Balanced cyan/blue terminal palette.",
|
|
37
|
+
"colors": {
|
|
38
|
+
"chrome": {"fg": "black", "bg": "blue"},
|
|
39
|
+
"selection": {"fg": "black", "bg": "cyan"},
|
|
40
|
+
"success": {"fg": "green", "bg": "default"},
|
|
41
|
+
"error": {"fg": "red", "bg": "default"},
|
|
42
|
+
"warning": {"fg": "yellow", "bg": "default"},
|
|
43
|
+
"accent": {"fg": "cyan", "bg": "default"},
|
|
44
|
+
"keyword": {"fg": "magenta", "bg": "default"},
|
|
45
|
+
"info": {"fg": "blue", "bg": "default"},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
"amber": {
|
|
49
|
+
"description": "Warm amber terminal look with bright highlights.",
|
|
50
|
+
"colors": {
|
|
51
|
+
"chrome": {"fg": "black", "bg": "yellow"},
|
|
52
|
+
"selection": {"fg": "black", "bg": "yellow"},
|
|
53
|
+
"success": {"fg": "yellow", "bg": "default"},
|
|
54
|
+
"error": {"fg": "red", "bg": "default"},
|
|
55
|
+
"warning": {"fg": "magenta", "bg": "default"},
|
|
56
|
+
"accent": {"fg": "yellow", "bg": "default"},
|
|
57
|
+
"keyword": {"fg": "red", "bg": "default"},
|
|
58
|
+
"info": {"fg": "white", "bg": "default"},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
"ocean": {
|
|
62
|
+
"description": "Cool blues and teals for a calmer workspace.",
|
|
63
|
+
"colors": {
|
|
64
|
+
"chrome": {"fg": "black", "bg": "cyan"},
|
|
65
|
+
"selection": {"fg": "black", "bg": "blue"},
|
|
66
|
+
"success": {"fg": "green", "bg": "default"},
|
|
67
|
+
"error": {"fg": "magenta", "bg": "default"},
|
|
68
|
+
"warning": {"fg": "yellow", "bg": "default"},
|
|
69
|
+
"accent": {"fg": "cyan", "bg": "default"},
|
|
70
|
+
"keyword": {"fg": "blue", "bg": "default"},
|
|
71
|
+
"info": {"fg": "white", "bg": "default"},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"forest": {
|
|
75
|
+
"description": "Green-heavy palette with earthy accents.",
|
|
76
|
+
"colors": {
|
|
77
|
+
"chrome": {"fg": "black", "bg": "green"},
|
|
78
|
+
"selection": {"fg": "black", "bg": "green"},
|
|
79
|
+
"success": {"fg": "green", "bg": "default"},
|
|
80
|
+
"error": {"fg": "red", "bg": "default"},
|
|
81
|
+
"warning": {"fg": "yellow", "bg": "default"},
|
|
82
|
+
"accent": {"fg": "green", "bg": "default"},
|
|
83
|
+
"keyword": {"fg": "cyan", "bg": "default"},
|
|
84
|
+
"info": {"fg": "blue", "bg": "default"},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"mono": {
|
|
88
|
+
"description": "High-contrast monochrome theme.",
|
|
89
|
+
"colors": {
|
|
90
|
+
"chrome": {"fg": "black", "bg": "white"},
|
|
91
|
+
"selection": {"fg": "black", "bg": "white"},
|
|
92
|
+
"success": {"fg": "white", "bg": "default"},
|
|
93
|
+
"error": {"fg": "white", "bg": "default"},
|
|
94
|
+
"warning": {"fg": "white", "bg": "default"},
|
|
95
|
+
"accent": {"fg": "white", "bg": "default"},
|
|
96
|
+
"keyword": {"fg": "white", "bg": "default"},
|
|
97
|
+
"info": {"fg": "white", "bg": "default"},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True)
|
|
104
|
+
class ThemeDefinition:
|
|
105
|
+
name: str
|
|
106
|
+
description: str
|
|
107
|
+
colors: dict[str, tuple[str, str]]
|
|
108
|
+
source: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ThemeManager:
|
|
112
|
+
def __init__(self, theme_dirs: list[Path] | None = None) -> None:
|
|
113
|
+
configured = theme_dirs if theme_dirs is not None else [self.default_user_dir()]
|
|
114
|
+
self._theme_dirs = [Path(path).expanduser() for path in configured]
|
|
115
|
+
self._themes: dict[str, ThemeDefinition] = {}
|
|
116
|
+
self._load_errors: list[str] = []
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def default_user_dir() -> Path:
|
|
120
|
+
return default_config_dir() / "themes"
|
|
121
|
+
|
|
122
|
+
def load(self) -> None:
|
|
123
|
+
self._themes = {}
|
|
124
|
+
self._load_errors = []
|
|
125
|
+
for name, payload in BUILTIN_THEME_DEFINITIONS.items():
|
|
126
|
+
self._themes[name] = self._build_theme_definition(
|
|
127
|
+
name=name,
|
|
128
|
+
description=str(payload.get("description", "")),
|
|
129
|
+
colors=dict(payload.get("colors", {})),
|
|
130
|
+
source="builtin",
|
|
131
|
+
base_theme=None,
|
|
132
|
+
)
|
|
133
|
+
for directory in self._theme_dirs:
|
|
134
|
+
if not directory.exists():
|
|
135
|
+
continue
|
|
136
|
+
for path in sorted(directory.glob("*.json")):
|
|
137
|
+
try:
|
|
138
|
+
theme = self._load_theme_file(path)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
self._load_errors.append(f"{path}: {exc}")
|
|
141
|
+
continue
|
|
142
|
+
self._themes[theme.name] = theme
|
|
143
|
+
|
|
144
|
+
def theme_dir(self) -> Path:
|
|
145
|
+
return self._theme_dirs[0]
|
|
146
|
+
|
|
147
|
+
def available_themes(self) -> list[ThemeDefinition]:
|
|
148
|
+
return sorted(self._themes.values(), key=lambda theme: theme.name)
|
|
149
|
+
|
|
150
|
+
def theme_names(self) -> list[str]:
|
|
151
|
+
return [theme.name for theme in self.available_themes()]
|
|
152
|
+
|
|
153
|
+
def get(self, name: str) -> ThemeDefinition | None:
|
|
154
|
+
return self._themes.get(name)
|
|
155
|
+
|
|
156
|
+
def default_theme(self) -> ThemeDefinition:
|
|
157
|
+
return self._themes["default"]
|
|
158
|
+
|
|
159
|
+
def load_errors(self) -> list[str]:
|
|
160
|
+
return list(self._load_errors)
|
|
161
|
+
|
|
162
|
+
def save_theme(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
name: str,
|
|
166
|
+
description: str,
|
|
167
|
+
extends: str,
|
|
168
|
+
colors: dict[str, tuple[str, str]],
|
|
169
|
+
) -> Path:
|
|
170
|
+
if not self._themes:
|
|
171
|
+
self.load()
|
|
172
|
+
name = name.strip()
|
|
173
|
+
if not name:
|
|
174
|
+
raise ValueError("theme name must not be empty")
|
|
175
|
+
extends = extends.strip() or "default"
|
|
176
|
+
base_theme = self._themes.get(extends)
|
|
177
|
+
if base_theme is None:
|
|
178
|
+
raise ValueError(f"unknown base theme {extends!r}")
|
|
179
|
+
if name in BUILTIN_THEME_DEFINITIONS:
|
|
180
|
+
raise ValueError("built-in theme names cannot be overwritten")
|
|
181
|
+
payload_colors = {
|
|
182
|
+
role: {"fg": fg, "bg": bg}
|
|
183
|
+
for role, (fg, bg) in colors.items()
|
|
184
|
+
}
|
|
185
|
+
self._build_theme_definition(
|
|
186
|
+
name=name,
|
|
187
|
+
description=description,
|
|
188
|
+
colors=payload_colors,
|
|
189
|
+
source="preview",
|
|
190
|
+
base_theme=base_theme,
|
|
191
|
+
)
|
|
192
|
+
target_path = self._theme_path_for_name(name)
|
|
193
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
payload = {
|
|
195
|
+
"name": name,
|
|
196
|
+
"description": description,
|
|
197
|
+
"extends": extends,
|
|
198
|
+
"colors": payload_colors,
|
|
199
|
+
}
|
|
200
|
+
target_path.write_text(
|
|
201
|
+
json.dumps(payload, indent=2, ensure_ascii=True) + "\n",
|
|
202
|
+
encoding="utf-8",
|
|
203
|
+
)
|
|
204
|
+
return target_path
|
|
205
|
+
|
|
206
|
+
def _load_theme_file(self, path: Path) -> ThemeDefinition:
|
|
207
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
208
|
+
if not isinstance(payload, dict):
|
|
209
|
+
raise ValueError("theme file must be a JSON object")
|
|
210
|
+
name = str(payload.get("name", path.stem)).strip()
|
|
211
|
+
if not name:
|
|
212
|
+
raise ValueError("theme name must not be empty")
|
|
213
|
+
description = str(payload.get("description", "")).strip()
|
|
214
|
+
extends = str(payload.get("extends", "default")).strip() or "default"
|
|
215
|
+
base_theme = self._themes.get(extends)
|
|
216
|
+
if base_theme is None:
|
|
217
|
+
raise ValueError(f"unknown base theme {extends!r}")
|
|
218
|
+
colors = payload.get("colors", {})
|
|
219
|
+
if not isinstance(colors, dict):
|
|
220
|
+
raise ValueError("colors must be a JSON object")
|
|
221
|
+
return self._build_theme_definition(
|
|
222
|
+
name=name,
|
|
223
|
+
description=description,
|
|
224
|
+
colors=colors,
|
|
225
|
+
source=str(path),
|
|
226
|
+
base_theme=base_theme,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _build_theme_definition(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
name: str,
|
|
233
|
+
description: str,
|
|
234
|
+
colors: dict[str, object],
|
|
235
|
+
source: str,
|
|
236
|
+
base_theme: ThemeDefinition | None,
|
|
237
|
+
) -> ThemeDefinition:
|
|
238
|
+
merged: dict[str, tuple[str, str]] = {}
|
|
239
|
+
if base_theme is not None:
|
|
240
|
+
merged.update(base_theme.colors)
|
|
241
|
+
for role in THEME_ROLES:
|
|
242
|
+
if role in colors:
|
|
243
|
+
color_spec = colors[role]
|
|
244
|
+
if not isinstance(color_spec, dict):
|
|
245
|
+
raise ValueError(f"{role}: color entry must be an object")
|
|
246
|
+
fg = str(color_spec.get("fg", merged.get(role, ("default", "default"))[0])).strip().lower()
|
|
247
|
+
bg = str(color_spec.get("bg", merged.get(role, ("default", "default"))[1])).strip().lower()
|
|
248
|
+
if not self._is_supported_color(fg):
|
|
249
|
+
raise ValueError(f"{role}: unsupported fg color {fg!r}")
|
|
250
|
+
if not self._is_supported_color(bg):
|
|
251
|
+
raise ValueError(f"{role}: unsupported bg color {bg!r}")
|
|
252
|
+
merged[role] = (fg, bg)
|
|
253
|
+
elif role not in merged:
|
|
254
|
+
raise ValueError(f"missing required theme role {role!r}")
|
|
255
|
+
for role in colors:
|
|
256
|
+
if role not in THEME_ROLES:
|
|
257
|
+
raise ValueError(f"unknown theme role {role!r}")
|
|
258
|
+
return ThemeDefinition(
|
|
259
|
+
name=name,
|
|
260
|
+
description=description,
|
|
261
|
+
colors=merged,
|
|
262
|
+
source=source,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _theme_path_for_name(self, name: str) -> Path:
|
|
266
|
+
existing = self._themes.get(name)
|
|
267
|
+
if existing is not None and existing.source != "builtin":
|
|
268
|
+
return Path(existing.source)
|
|
269
|
+
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-._") or "theme"
|
|
270
|
+
return self.theme_dir() / f"{safe_name}.json"
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _is_supported_color(value: str) -> bool:
|
|
274
|
+
return value in COLOR_NAMES or bool(HEX_COLOR_RE.fullmatch(value))
|