synapseForge 0.1.24.dev2__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.
@@ -0,0 +1,305 @@
1
+ """tkinter GUI for synapseforge colors — replaces terminal color editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import re
8
+ import sys
9
+ import tkinter as tk
10
+ from tkinter import colorchooser, messagebox, ttk
11
+ from typing import Any, Dict, Optional
12
+ from pathlib import Path
13
+ import ctypes
14
+
15
+ _HERE = Path(__file__).resolve().parent
16
+ _ICO_PATH = _HERE / "logo.ico"
17
+ _LOGO_PNG_PATH = _HERE / "logo.png"
18
+
19
+ try:
20
+ from PIL import Image, ImageTk
21
+ except ImportError: # pragma: no cover
22
+ Image = None # type: ignore[assignment]
23
+ ImageTk = None
24
+
25
+ _HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
26
+
27
+ COLOR_FIELDS = [
28
+ ("primary", "Color principal: botón de enviar, burbuja y avatar del asistente, barra de actividad, opción seleccionada del menú, enlaces de las respuestas y puntos de “escribiendo…”"),
29
+ ("secondary", "Color de los detalles suaves: borde que se ilumina al hacer clic en un campo, anillo de la conversación seleccionada, bordes de las tarjetas y cursor de escritura"),
30
+ ("primary_text", "Color del texto e íconos que van sobre el color principal: flecha de enviar, texto de botones, ícono del avatar y texto de la burbuja del asistente"),
31
+ ("gradient_secondary", "Color final del degradé de los botones y del avatar del asistente (el inicio es el color principal)"),
32
+ ]
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────
36
+ # Fijar el AppUserModelID ANTES de crear cualquier ventana
37
+ # ──────────────────────────────────────────────────────────────
38
+ def _set_app_user_model_id() -> None:
39
+ if sys.platform != "win32":
40
+ return
41
+ try:
42
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
43
+ "synapseforge.colors.1"
44
+ )
45
+ except Exception:
46
+ pass
47
+
48
+ _set_app_user_model_id()
49
+
50
+
51
+ class ColorsApp:
52
+ """GUI for editing runtime colors.json."""
53
+
54
+ def __init__(self, colors_path: Path, current: Dict[str, str]) -> None:
55
+ self.colors_path = colors_path
56
+ self.current = current
57
+ self.result: Optional[Dict[str, str]] = None
58
+
59
+ self.root = tk.Tk()
60
+ self.root.title("synapseForge — Colors")
61
+ self.root.resizable(False, False)
62
+
63
+ # ── Configurar el ícono (con retraso para asegurar que la ventana esté lista) ──
64
+ self.root.after(100, self._set_icon)
65
+
66
+ # ── Logo header (desde archivo local) ────────────────────────
67
+ self._load_logo_local()
68
+
69
+ # ── Instruction ──────────────────────────────────────────────
70
+ ttk.Label(
71
+ self.root,
72
+ text="Colores del proyecto (dejá vacío para mantener el actual):",
73
+ font=("", 10, ""),
74
+ ).pack(pady=(5, 10))
75
+
76
+ # ── Color fields ─────────────────────────────────────────────
77
+ frame = ttk.Frame(self.root)
78
+ frame.pack(fill="both", expand=True, padx=15, pady=(0, 10))
79
+
80
+ self._entries: Dict[str, tk.Entry] = {}
81
+ self._previews: Dict[str, tk.Canvas] = {}
82
+
83
+ for i, (key, desc) in enumerate(COLOR_FIELDS):
84
+ row = i * 2
85
+
86
+ # Explanation (wraps so it doesn't push the selector out of view)
87
+ ttk.Label(frame, text=desc, wraplength=430, justify="left").grid(
88
+ row=row, column=0, columnspan=4, sticky="w", pady=(8, 0), padx=(0, 10)
89
+ )
90
+
91
+ # Preview square (18×18)
92
+ cv = tk.Canvas(frame, width=20, height=20, highlightthickness=1,
93
+ highlightbackground="#ccc")
94
+ cv.grid(row=row + 1, column=0, pady=(4, 8), padx=(0, 6))
95
+ self._previews[key] = cv
96
+
97
+ # Entry with current value
98
+ ent = ttk.Entry(frame, width=14)
99
+ ent.grid(row=row + 1, column=1, pady=(4, 8), padx=(0, 6))
100
+ cur_val = current.get(key, "")
101
+ if cur_val:
102
+ ent.insert(0, cur_val)
103
+ ent.bind("<KeyRelease>", lambda _e, k=key: self._update_preview(k))
104
+ self._entries[key] = ent
105
+
106
+ # Color picker button
107
+ ttk.Button(
108
+ frame, text="Seleccionar", command=lambda k=key: self._pick_color(k)
109
+ ).grid(row=row + 1, column=2, pady=(4, 8), padx=(0, 0), sticky="w")
110
+
111
+ # Initial preview
112
+ self._update_preview(key)
113
+
114
+ # ── Gradient toggle ──────────────────────────────────────────
115
+ gradient_frame = ttk.Frame(self.root)
116
+ gradient_frame.pack(fill="x", padx=15, pady=(0, 5))
117
+
118
+ grad_default = current.get("usar_gradiente", True)
119
+ if isinstance(grad_default, str):
120
+ grad_default = grad_default.lower() in ("true", "1", "yes")
121
+ self._usar_gradiente_var = tk.BooleanVar(value=bool(grad_default))
122
+ ttk.Checkbutton(
123
+ gradient_frame,
124
+ text="Usar degradé en botones y avatar",
125
+ variable=self._usar_gradiente_var,
126
+ command=self._toggle_gradient_fields,
127
+ ).pack(anchor="w")
128
+
129
+ self._toggle_gradient_fields()
130
+
131
+ # ── Bottom buttons ───────────────────────────────────────────
132
+ bottom = ttk.Frame(self.root)
133
+ bottom.pack(fill="x", padx=15, pady=(0, 12))
134
+
135
+ ttk.Button(bottom, text="Cancelar", command=self._on_cancel).pack(
136
+ side="right", padx=(5, 0)
137
+ )
138
+ ttk.Button(bottom, text="Guardar", command=self._on_save).pack(side="right")
139
+
140
+ # ── Center ───────────────────────────────────────────────────
141
+ self._center(660, 800)
142
+
143
+ # ──────────────────────────────────────────────────────────────────
144
+ # Configuración robusta del ícono usando ctypes
145
+ # ──────────────────────────────────────────────────────────────────
146
+ def _set_icon(self) -> None:
147
+ """Asigna el ícono de la ventana y de la barra de tareas usando ctypes."""
148
+ if not _ICO_PATH.is_file():
149
+ return
150
+
151
+ try:
152
+ # 1. iconbitmap (funciona para la ventana)
153
+ self.root.iconbitmap(str(_ICO_PATH.resolve()))
154
+
155
+ # 2. Forzar actualización de la barra de tareas con ctypes
156
+ hwnd = self.root.winfo_id()
157
+ user32 = ctypes.windll.user32
158
+ # Cargar el ícono desde el archivo
159
+ hicon = user32.LoadImageW(
160
+ 0,
161
+ str(_ICO_PATH.resolve()),
162
+ 1, # IMAGE_ICON
163
+ 0, 0,
164
+ 0x00000010 # LR_LOADFROMFILE
165
+ )
166
+ if hicon:
167
+ # GCL_HICON = -14, GCL_HICONSM = -34
168
+ user32.SetClassLongW(hwnd, -14, hicon)
169
+ user32.SetClassLongW(hwnd, -34, hicon)
170
+ # WM_SETICON = 0x0080, ICON_BIG = 0, ICON_SMALL = 1
171
+ user32.SendMessageW(hwnd, 0x0080, 0, hicon)
172
+ user32.SendMessageW(hwnd, 0x0080, 1, hicon)
173
+
174
+ except Exception:
175
+ pass # Silencioso si falla
176
+
177
+ # ------------------------------------------------------------------
178
+ # Logo (cargado desde archivo local)
179
+ # ------------------------------------------------------------------
180
+ def _load_logo_local(self) -> None:
181
+ """Carga el logo desde logo_transparente.png (local) y lo muestra en la ventana."""
182
+ if Image is None or ImageTk is None:
183
+ return
184
+ if not _LOGO_PNG_PATH.is_file():
185
+ return
186
+ try:
187
+ pil = Image.open(_LOGO_PNG_PATH)
188
+ pil.thumbnail((150, 150), Image.LANCZOS)
189
+ self._logo_img = ImageTk.PhotoImage(pil)
190
+ lbl = tk.Label(self.root, image=self._logo_img)
191
+ lbl.pack(pady=(10, 2))
192
+ except Exception:
193
+ pass # silencioso si falla
194
+
195
+ # ------------------------------------------------------------------
196
+ # Color helpers
197
+ # ------------------------------------------------------------------
198
+ def _pick_color(self, key: str) -> None:
199
+ result = colorchooser.askcolor(
200
+ title=key,
201
+ color=self._entries[key].get() or None,
202
+ parent=self.root,
203
+ )
204
+ if result and result[1]:
205
+ self._entries[key].delete(0, tk.END)
206
+ self._entries[key].insert(0, result[1])
207
+ self._update_preview(key)
208
+
209
+ def _update_preview(self, key: str) -> None:
210
+ cv = self._previews[key]
211
+ raw = self._entries[key].get().strip()
212
+ cv.delete("all")
213
+ if raw and _HEX_RE.match(raw):
214
+ cv.config(bg=raw)
215
+ else:
216
+ cv.config(bg="#ffffff")
217
+
218
+ def _toggle_gradient_fields(self) -> None:
219
+ """Enable/disable gradient_secondary picker based on checkbox."""
220
+ state = "normal" if self._usar_gradiente_var.get() else "disabled"
221
+ key = "gradient_secondary"
222
+ if key in self._entries:
223
+ self._entries[key].config(state=state)
224
+ if key in self._previews:
225
+ self._previews[key].config(highlightbackground="#ccc" if state == "normal" else "#eee")
226
+
227
+ # ------------------------------------------------------------------
228
+ # Save / Cancel
229
+ # ------------------------------------------------------------------
230
+ def _on_save(self) -> None:
231
+ updated: Dict[str, str] = {}
232
+ errors: list[str] = []
233
+ usar_gradiente = self._usar_gradiente_var.get()
234
+ updated["usar_gradiente"] = str(usar_gradiente).lower()
235
+ for key, _desc in COLOR_FIELDS:
236
+ raw = self._entries[key].get().strip()
237
+ if key == "gradient_secondary" and not usar_gradiente:
238
+ # If gradient off, set same as primary so gradient looks solid
239
+ updated[key] = updated.get("primary") or self.current.get("primary", "#000000")
240
+ continue
241
+ if not raw:
242
+ updated[key] = self.current.get(key, "")
243
+ elif _HEX_RE.match(raw):
244
+ updated[key] = raw
245
+ else:
246
+ errors.append(f"'{key}': '{raw}' no es un color hex válido (#RRGGBB).")
247
+
248
+ if errors:
249
+ messagebox.showerror("Errores de validación",
250
+ "\n".join(errors), parent=self.root)
251
+ return
252
+
253
+ # Write
254
+ try:
255
+ self.colors_path.write_text(
256
+ json.dumps(updated, indent=2, ensure_ascii=False), encoding="utf-8"
257
+ )
258
+ self.result = updated
259
+ self.root.destroy()
260
+ except Exception as exc:
261
+ messagebox.showerror("Error al guardar",
262
+ f"No se pudo escribir colors.json:\n{exc}",
263
+ parent=self.root)
264
+
265
+ def _on_cancel(self) -> None:
266
+ self.result = None
267
+ self.root.destroy()
268
+
269
+ # ------------------------------------------------------------------
270
+ # Window utils
271
+ # ------------------------------------------------------------------
272
+ def _center(self, w: int, h: int) -> None:
273
+ sw = self.root.winfo_screenwidth()
274
+ sh = self.root.winfo_screenheight()
275
+ x = (sw - w) // 2
276
+ y = (sh - h) // 2
277
+ self.root.geometry(f"{w}x{h}+{x}+{y}")
278
+
279
+ # ------------------------------------------------------------------
280
+ # Public entry point
281
+ # ------------------------------------------------------------------
282
+ @staticmethod
283
+ def launch(project_dir: str) -> Optional[Dict[str, str]]:
284
+ """Open the colors GUI and return the updated dict (or None if cancelled).
285
+
286
+ Args:
287
+ project_dir: Path to project root containing frontend/public/colors.json.
288
+
289
+ Returns:
290
+ Updated colors dict, or ``None`` if the user cancelled.
291
+ """
292
+ colors_path = Path(project_dir).resolve() / "frontend" / "public" / "colors.json"
293
+ if not colors_path.is_file():
294
+ print(f"ERROR: No se encontró {colors_path}", file=sys.stderr)
295
+ sys.exit(1)
296
+
297
+ try:
298
+ current = json.loads(colors_path.read_text(encoding="utf-8"))
299
+ except Exception as exc:
300
+ print(f"ERROR leyendo colors.json: {exc}", file=sys.stderr)
301
+ sys.exit(1)
302
+
303
+ app = ColorsApp(colors_path, current)
304
+ app.root.mainloop()
305
+ return app.result