askakalsky commited on
Commit
8e3b2eb
Β·
verified Β·
1 Parent(s): 5aafe09

Upload train/font_editor.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train/font_editor.py +665 -0
train/font_editor.py ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Universal dot-matrix font editor.
3
+
4
+ Features:
5
+ - Add / remove any character (any Unicode)
6
+ - Any grid size per character: cols Γ— rows set independently
7
+ - Each character can have a different size
8
+ - Save to font_custom.json after every action (auto-save)
9
+ - Export to font_custom.py (ready for generate_sequences.py)
10
+
11
+ Usage:
12
+ python font_editor.py
13
+ python font_editor.py --font my_font.json # open a specific file
14
+
15
+ Keyboard shortcuts:
16
+ β†’ / Enter save current char and go to next
17
+ ← save current char and go to previous
18
+ C clear the grid
19
+ Delete remove current character (with confirmation)
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import tkinter as tk
25
+ from tkinter import messagebox, simpledialog
26
+ from pathlib import Path
27
+
28
+
29
+ # ── Defaults ────────────────────────────────────────────────────────────────
30
+
31
+ DEFAULT_COLS = 4
32
+ DEFAULT_ROWS = 6
33
+ MIN_COLS = 1
34
+ MAX_COLS = 20
35
+ MIN_ROWS = 1
36
+ MAX_ROWS = 20
37
+
38
+ MAX_CANVAS_W = 720 # px β€” canvas will not exceed this
39
+ MAX_CANVAS_H = 560
40
+ MIN_CELL = 10 # px β€” minimum dot size
41
+ MAX_CELL = 64 # px β€” maximum dot size
42
+ GAP = 4 # px β€” gap between cells
43
+
44
+ DOT_ON = "#1c1c1e"
45
+ DOT_OFF = "#ececec"
46
+ BORDER = "#c0c0c0"
47
+
48
+ SAVE_FILE = Path("font_custom.json")
49
+ EXPORT_FILE = Path("font_custom.py")
50
+
51
+
52
+ # ── Persistence ──────────────────────────────────────────────────────────────
53
+
54
+ def load_font(path: Path) -> tuple[dict, list]:
55
+ """
56
+ Load font from JSON file.
57
+ Returns (font_data, chars_list) where:
58
+ font_data = {char: [[0,1,...], ...]}
59
+ chars_list = ordered list of all characters in the font
60
+ """
61
+ if path.exists():
62
+ raw = json.loads(path.read_text(encoding="utf-8"))
63
+ font_data = {k: [list(r) for r in v] for k, v in raw.items()}
64
+ chars_list = list(raw.keys())
65
+ return font_data, chars_list
66
+
67
+ # Seed from font5x7_cyrillic if available
68
+ try:
69
+ from font5x7_cyrillic import FONT
70
+ font_data = {}
71
+ chars_list = []
72
+ for ch, grid in FONT.items():
73
+ rows_list = [list(map(int, row)) for row in grid.tolist()]
74
+ font_data[ch] = rows_list
75
+ chars_list.append(ch)
76
+ return font_data, chars_list
77
+ except Exception:
78
+ return {}, []
79
+
80
+
81
+ def save_font(font_data: dict, chars_list: list, path: Path):
82
+ ordered = {ch: font_data[ch] for ch in chars_list if ch in font_data}
83
+ path.write_text(
84
+ json.dumps(ordered, ensure_ascii=False, indent=2),
85
+ encoding="utf-8",
86
+ )
87
+
88
+
89
+ # ── Cell-size helper ─────────────────────────────────────────────────────────
90
+
91
+ def compute_cell(cols: int, rows: int) -> int:
92
+ cell_w = (MAX_CANVAS_W - (cols + 1) * GAP) // cols
93
+ cell_h = (MAX_CANVAS_H - (rows + 1) * GAP) // rows
94
+ return max(MIN_CELL, min(MAX_CELL, cell_w, cell_h))
95
+
96
+
97
+ # ── Add-character dialog ─────────────────────────────────────────────────────
98
+
99
+ class AddCharDialog(simpledialog.Dialog):
100
+ def __init__(self, parent, existing: set):
101
+ self.existing = existing
102
+ self.result_char = None
103
+ self.result_cols = DEFAULT_COLS
104
+ self.result_rows = DEFAULT_ROWS
105
+ super().__init__(parent, title="Add character")
106
+
107
+ def body(self, master):
108
+ master.config(bg="#f5f5f7")
109
+
110
+ tk.Label(master, text="Character:", bg="#f5f5f7",
111
+ font=("Segoe UI", 11)).grid(row=0, column=0, sticky="w", padx=8, pady=4)
112
+ self.ent_char = tk.Entry(master, width=6, font=("Segoe UI", 16))
113
+ self.ent_char.grid(row=0, column=1, sticky="w", padx=8)
114
+ self.ent_char.focus_set()
115
+
116
+ tk.Label(master, text="Columns:", bg="#f5f5f7",
117
+ font=("Segoe UI", 11)).grid(row=1, column=0, sticky="w", padx=8, pady=4)
118
+ self.spn_cols = tk.Spinbox(master, from_=MIN_COLS, to=MAX_COLS,
119
+ width=5, font=("Segoe UI", 11))
120
+ self.spn_cols.delete(0, "end")
121
+ self.spn_cols.insert(0, str(DEFAULT_COLS))
122
+ self.spn_cols.grid(row=1, column=1, sticky="w", padx=8)
123
+
124
+ tk.Label(master, text="Rows:", bg="#f5f5f7",
125
+ font=("Segoe UI", 11)).grid(row=2, column=0, sticky="w", padx=8, pady=4)
126
+ self.spn_rows = tk.Spinbox(master, from_=MIN_ROWS, to=MAX_ROWS,
127
+ width=5, font=("Segoe UI", 11))
128
+ self.spn_rows.delete(0, "end")
129
+ self.spn_rows.insert(0, str(DEFAULT_ROWS))
130
+ self.spn_rows.grid(row=2, column=1, sticky="w", padx=8)
131
+
132
+ return self.ent_char
133
+
134
+ def validate(self):
135
+ ch = self.ent_char.get().strip()
136
+ if not ch:
137
+ messagebox.showwarning("Invalid", "Enter a character.", parent=self)
138
+ return False
139
+ if len(ch) > 1:
140
+ messagebox.showwarning("Invalid",
141
+ "Enter exactly one character.", parent=self)
142
+ return False
143
+ if ch in self.existing:
144
+ messagebox.showwarning("Duplicate",
145
+ f"'{ch}' is already in the font.", parent=self)
146
+ return False
147
+ try:
148
+ c = int(self.spn_cols.get())
149
+ r = int(self.spn_rows.get())
150
+ except ValueError:
151
+ messagebox.showwarning("Invalid", "Cols/rows must be integers.", parent=self)
152
+ return False
153
+ if not (MIN_COLS <= c <= MAX_COLS) or not (MIN_ROWS <= r <= MAX_ROWS):
154
+ messagebox.showwarning(
155
+ "Invalid",
156
+ f"Cols: {MIN_COLS}–{MAX_COLS}, Rows: {MIN_ROWS}–{MAX_ROWS}.",
157
+ parent=self,
158
+ )
159
+ return False
160
+ self.result_char = ch
161
+ self.result_cols = c
162
+ self.result_rows = r
163
+ return True
164
+
165
+ def apply(self):
166
+ pass # result_* already set in validate
167
+
168
+
169
+ # ── Main editor ──────────────────────────────────────────────────────────────
170
+
171
+ class FontEditor:
172
+
173
+ def __init__(self, root: tk.Tk, save_path: Path):
174
+ self.root = root
175
+ self.save_path = save_path
176
+
177
+ self.root.title("Dot-Matrix Font Editor")
178
+ self.root.configure(bg="#f5f5f7")
179
+ self.root.resizable(True, True)
180
+
181
+ self.font_data: dict = {}
182
+ self.chars_list: list[str] = []
183
+ self.idx: int = 0
184
+ self.grid: list[list[int]] = []
185
+ self._cell_ids: list[list[int]] = []
186
+
187
+ self.font_data, self.chars_list = load_font(save_path)
188
+
189
+ self._build_ui()
190
+ if self.chars_list:
191
+ self._load_char(0)
192
+ else:
193
+ self._update_empty_state()
194
+
195
+ # ── Build UI ─────────────────────────────────────────────────────────
196
+
197
+ def _build_ui(self):
198
+ # Header
199
+ hdr = tk.Frame(self.root, bg="#1c1c1e", padx=18, pady=10)
200
+ hdr.pack(fill="x")
201
+
202
+ self.lbl_char = tk.Label(hdr, text="β€”",
203
+ font=("Segoe UI", 36, "bold"),
204
+ fg="white", bg="#1c1c1e", width=4, anchor="w")
205
+ self.lbl_char.pack(side="left")
206
+
207
+ info = tk.Frame(hdr, bg="#1c1c1e")
208
+ info.pack(side="left", padx=16)
209
+
210
+ self.lbl_dim = tk.Label(info, text="",
211
+ font=("Segoe UI", 12), fg="#98c5e9", bg="#1c1c1e")
212
+ self.lbl_dim.pack(anchor="w")
213
+
214
+ self.lbl_progress = tk.Label(hdr, text="",
215
+ font=("Segoe UI", 11), fg="#ccc", bg="#1c1c1e")
216
+ self.lbl_progress.pack(side="right", padx=8)
217
+
218
+ # Grid-size controls
219
+ ctrl = tk.Frame(self.root, bg="#f5f5f7", pady=8)
220
+ ctrl.pack()
221
+
222
+ def _spinbox(parent, var, label, lo, hi, cmd):
223
+ f = tk.Frame(parent, bg="#f5f5f7")
224
+ f.pack(side="left", padx=10)
225
+ tk.Label(f, text=label, font=("Segoe UI", 11),
226
+ bg="#f5f5f7").pack(side="left")
227
+ spn = tk.Spinbox(f, textvariable=var,
228
+ from_=lo, to=hi, width=4,
229
+ font=("Segoe UI", 12), command=cmd)
230
+ spn.pack(side="left", padx=4)
231
+ spn.bind("<FocusOut>", lambda _: cmd())
232
+ spn.bind("<Return>", lambda _: cmd())
233
+ return spn
234
+
235
+ self.var_cols = tk.IntVar(value=DEFAULT_COLS)
236
+ self.var_rows = tk.IntVar(value=DEFAULT_ROWS)
237
+
238
+ _spinbox(ctrl, self.var_cols, "Columns:", MIN_COLS, MAX_COLS, self._on_cols_change)
239
+ _spinbox(ctrl, self.var_rows, "Rows:", MIN_ROWS, MAX_ROWS, self._on_rows_change)
240
+
241
+ # Canvas (resizable)
242
+ cvs_outer = tk.Frame(self.root, bg="#f5f5f7", pady=4)
243
+ cvs_outer.pack()
244
+
245
+ self.canvas = tk.Canvas(cvs_outer,
246
+ width=MAX_CANVAS_W, height=MAX_CANVAS_H,
247
+ bg="#f5f5f7", highlightthickness=0)
248
+ self.canvas.pack()
249
+ self.canvas.tag_bind("cell", "<Button-1>", self._on_click)
250
+
251
+ # Navigation
252
+ nav = tk.Frame(self.root, bg="#f5f5f7", pady=6)
253
+ nav.pack()
254
+
255
+ _b = dict(font=("Segoe UI", 12), relief="flat",
256
+ padx=14, pady=6, cursor="hand2", bd=0)
257
+
258
+ self.btn_prev = tk.Button(nav, text="← Prev", command=self.prev_char,
259
+ bg="#8e8e93", fg="white", **_b)
260
+ self.btn_prev.pack(side="left", padx=5)
261
+
262
+ tk.Button(nav, text="Clear", command=self.clear_grid,
263
+ bg="#ff3b30", fg="white", **_b).pack(side="left", padx=5)
264
+
265
+ self.btn_next = tk.Button(nav, text="Save & Next β†’",
266
+ command=self.save_and_next,
267
+ bg="#34c759", fg="white", **_b)
268
+ self.btn_next.pack(side="left", padx=5)
269
+
270
+ # Add / Remove
271
+ mgmt = tk.Frame(self.root, bg="#f5f5f7", pady=4)
272
+ mgmt.pack()
273
+
274
+ tk.Button(mgmt, text="+ Add character", command=self._add_char,
275
+ bg="#007aff", fg="white", **_b).pack(side="left", padx=5)
276
+
277
+ tk.Button(mgmt, text="βˆ’ Remove current", command=self._remove_char,
278
+ bg="#ff9500", fg="white", **_b).pack(side="left", padx=5)
279
+
280
+ # Overview strip (scrollable canvas)
281
+ sep = tk.Frame(self.root, bg="#d0d0d0", height=1)
282
+ sep.pack(fill="x", pady=4)
283
+
284
+ ov_outer = tk.Frame(self.root, bg="#f5f5f7", padx=10, pady=4)
285
+ ov_outer.pack(fill="x")
286
+
287
+ tk.Label(ov_outer, text="Characters (● = defined, click to jump):",
288
+ font=("Segoe UI", 9), fg="#888", bg="#f5f5f7").pack(anchor="w")
289
+
290
+ # Use a Frame with wraplength for the overview buttons
291
+ self.ov_frame = tk.Frame(ov_outer, bg="#f5f5f7")
292
+ self.ov_frame.pack(fill="x")
293
+ self.ov_btns: dict[str, tk.Label] = {}
294
+ self._build_overview()
295
+
296
+ # Bottom bar
297
+ sep2 = tk.Frame(self.root, bg="#d0d0d0", height=1)
298
+ sep2.pack(fill="x")
299
+
300
+ bot = tk.Frame(self.root, bg="#f5f5f7", padx=12, pady=8)
301
+ bot.pack(fill="x")
302
+
303
+ tk.Button(bot, text="Export β†’ font_custom.py",
304
+ command=self.export_font,
305
+ font=("Segoe UI", 11), relief="flat",
306
+ bg="#5856d6", fg="white",
307
+ padx=12, pady=5, cursor="hand2").pack(side="left")
308
+
309
+ self.lbl_status = tk.Label(bot, text="", font=("Segoe UI", 10),
310
+ fg="#555", bg="#f5f5f7")
311
+ self.lbl_status.pack(side="right")
312
+
313
+ # Keyboard shortcuts
314
+ self.root.bind("<Right>", lambda _: self.save_and_next())
315
+ self.root.bind("<Return>", lambda _: self.save_and_next())
316
+ self.root.bind("<Left>", lambda _: self.prev_char())
317
+ self.root.bind("c", lambda _: self.clear_grid())
318
+ self.root.bind("C", lambda _: self.clear_grid())
319
+ self.root.bind("<Delete>", lambda _: self._remove_char())
320
+
321
+ # ── Overview ─────────────────────────────────────────────────────────
322
+
323
+ def _build_overview(self):
324
+ for w in self.ov_frame.winfo_children():
325
+ w.destroy()
326
+ self.ov_btns.clear()
327
+
328
+ PER_ROW = 20
329
+ row_frame = None
330
+ for i, ch in enumerate(self.chars_list):
331
+ if i % PER_ROW == 0:
332
+ row_frame = tk.Frame(self.ov_frame, bg="#f5f5f7")
333
+ row_frame.pack(anchor="w")
334
+ lbl = tk.Label(row_frame, text=ch,
335
+ font=("Segoe UI", 10), padx=3, pady=1,
336
+ bg="#f5f5f7", cursor="hand2")
337
+ lbl.pack(side="left")
338
+ lbl.bind("<Button-1>", lambda e, idx=i: self._jump_to(idx))
339
+ self.ov_btns[ch] = lbl
340
+ self._update_overview_colors()
341
+
342
+ def _update_overview_colors(self):
343
+ for i, ch in enumerate(self.chars_list):
344
+ lbl = self.ov_btns.get(ch)
345
+ if not lbl:
346
+ continue
347
+ current = (i == self.idx)
348
+ defined = ch in self.font_data
349
+ if current:
350
+ lbl.config(fg="#ff9500", font=("Segoe UI", 10, "bold underline"))
351
+ elif defined:
352
+ lbl.config(fg="#34c759", font=("Segoe UI", 10, "bold"))
353
+ else:
354
+ lbl.config(fg="#aaaaaa", font=("Segoe UI", 10))
355
+
356
+ # ── Canvas drawing ────────────────────────────────────────────────────
357
+
358
+ def _draw_grid(self):
359
+ cols = len(self.grid[0]) if self.grid else self.var_cols.get()
360
+ rows = len(self.grid) if self.grid else self.var_rows.get()
361
+
362
+ cell = compute_cell(cols, rows)
363
+ cw = cols * cell + (cols + 1) * GAP
364
+ ch = rows * cell + (rows + 1) * GAP
365
+
366
+ self.canvas.config(width=cw, height=ch)
367
+ self.canvas.delete("all")
368
+ self._cell_ids = []
369
+
370
+ for r in range(rows):
371
+ row_ids = []
372
+ for c in range(cols):
373
+ x1 = GAP + c * (cell + GAP)
374
+ y1 = GAP + r * (cell + GAP)
375
+ x2 = x1 + cell
376
+ y2 = y1 + cell
377
+ val = self.grid[r][c] if (r < len(self.grid) and
378
+ c < len(self.grid[r])) else 0
379
+ cid = self.canvas.create_rectangle(
380
+ x1, y1, x2, y2,
381
+ fill=DOT_ON if val else DOT_OFF,
382
+ outline=BORDER, width=1,
383
+ tags=("cell", f"r{r}c{c}"),
384
+ )
385
+ row_ids.append(cid)
386
+ self._cell_ids.append(row_ids)
387
+
388
+ self.root.update_idletasks()
389
+
390
+ def _on_click(self, event):
391
+ items = self.canvas.find_closest(event.x, event.y)
392
+ if not items:
393
+ return
394
+ for tag in self.canvas.gettags(items[0]):
395
+ if tag.startswith("r") and "c" in tag:
396
+ try:
397
+ r_s, c_s = tag[1:].split("c")
398
+ r, c = int(r_s), int(c_s)
399
+ self.grid[r][c] ^= 1
400
+ self.canvas.itemconfig(
401
+ items[0],
402
+ fill=DOT_ON if self.grid[r][c] else DOT_OFF,
403
+ )
404
+ except ValueError:
405
+ pass
406
+ break
407
+
408
+ # ── Grid resize helpers ───────────────────────────────────────────────
409
+
410
+ def _resize_grid(self, new_cols: int, new_rows: int):
411
+ old_rows = len(self.grid)
412
+ old_cols = len(self.grid[0]) if self.grid else 0
413
+
414
+ # Adjust columns
415
+ new_grid = []
416
+ for r in range(old_rows):
417
+ row = self.grid[r]
418
+ if new_cols > old_cols:
419
+ row = row + [0] * (new_cols - old_cols)
420
+ else:
421
+ row = row[:new_cols]
422
+ new_grid.append(row)
423
+
424
+ # Adjust rows
425
+ if new_rows > old_rows:
426
+ for _ in range(new_rows - old_rows):
427
+ new_grid.append([0] * new_cols)
428
+ else:
429
+ new_grid = new_grid[:new_rows]
430
+
431
+ self.grid = new_grid
432
+
433
+ def _on_cols_change(self):
434
+ if not self.chars_list:
435
+ return
436
+ try:
437
+ new_cols = int(self.var_cols.get())
438
+ except (ValueError, tk.TclError):
439
+ return
440
+ new_cols = max(MIN_COLS, min(MAX_COLS, new_cols))
441
+ self.var_cols.set(new_cols)
442
+ new_rows = len(self.grid)
443
+ self._resize_grid(new_cols, new_rows)
444
+ self._draw_grid()
445
+ self._update_header()
446
+
447
+ def _on_rows_change(self):
448
+ if not self.chars_list:
449
+ return
450
+ try:
451
+ new_rows = int(self.var_rows.get())
452
+ except (ValueError, tk.TclError):
453
+ return
454
+ new_rows = max(MIN_ROWS, min(MAX_ROWS, new_rows))
455
+ self.var_rows.set(new_rows)
456
+ new_cols = len(self.grid[0]) if self.grid else self.var_cols.get()
457
+ self._resize_grid(new_cols, new_rows)
458
+ self._draw_grid()
459
+ self._update_header()
460
+
461
+ # ── Navigation ────────────────────────────────────────────────────────
462
+
463
+ def _load_char(self, idx: int):
464
+ if not self.chars_list:
465
+ return
466
+ self.idx = idx
467
+ ch = self.chars_list[idx]
468
+
469
+ if ch in self.font_data:
470
+ saved = self.font_data[ch]
471
+ saved_rows = len(saved)
472
+ saved_cols = len(saved[0]) if saved else DEFAULT_COLS
473
+ self.var_cols.set(saved_cols)
474
+ self.var_rows.set(saved_rows)
475
+ self.grid = [list(row) for row in saved]
476
+ else:
477
+ cols = self.var_cols.get()
478
+ rows = self.var_rows.get()
479
+ self.grid = [[0] * cols for _ in range(rows)]
480
+
481
+ self._draw_grid()
482
+ self._update_header()
483
+ self._update_overview_colors()
484
+
485
+ def _update_header(self):
486
+ if not self.chars_list:
487
+ self.lbl_char.config(text="β€”")
488
+ self.lbl_dim.config(text="No characters")
489
+ self.lbl_progress.config(text="0 / 0")
490
+ return
491
+
492
+ ch = self.chars_list[self.idx]
493
+ cols = len(self.grid[0]) if self.grid else self.var_cols.get()
494
+ rows = len(self.grid) if self.grid else self.var_rows.get()
495
+ done = sum(1 for c in self.chars_list if c in self.font_data)
496
+
497
+ self.lbl_char.config(text=ch)
498
+ self.lbl_dim.config(text=f"{cols} cols Γ— {rows} rows")
499
+ self.lbl_progress.config(
500
+ text=f"{self.idx + 1} / {len(self.chars_list)} | {done} defined")
501
+
502
+ self.btn_prev.config(state="normal" if self.idx > 0 else "disabled")
503
+ last = (self.idx == len(self.chars_list) - 1)
504
+ self.btn_next.config(text="Save βœ“" if last else "Save & Next β†’")
505
+
506
+ def _update_empty_state(self):
507
+ self.lbl_char.config(text="β€”")
508
+ self.lbl_dim.config(text="Font is empty β€” click '+ Add character'")
509
+ self.lbl_progress.config(text="0 / 0")
510
+ self.canvas.delete("all")
511
+
512
+ def _save_current(self):
513
+ if not self.chars_list:
514
+ return
515
+ ch = self.chars_list[self.idx]
516
+ self.font_data[ch] = [list(row) for row in self.grid]
517
+ save_font(self.font_data, self.chars_list, self.save_path)
518
+
519
+ def save_and_next(self):
520
+ if not self.chars_list:
521
+ return
522
+ self._save_current()
523
+ if self.idx < len(self.chars_list) - 1:
524
+ prev_ch = self.chars_list[self.idx]
525
+ self._load_char(self.idx + 1)
526
+ self.lbl_status.config(text=f"Saved {prev_ch}")
527
+ else:
528
+ self._update_overview_colors()
529
+ self._update_header()
530
+ self.lbl_status.config(text="All characters saved!")
531
+ messagebox.showinfo("Done",
532
+ "All characters defined!\n"
533
+ "Click 'Export' to generate font_custom.py.")
534
+
535
+ def prev_char(self):
536
+ if self.chars_list and self.idx > 0:
537
+ self._save_current()
538
+ self._load_char(self.idx - 1)
539
+
540
+ def _jump_to(self, idx: int):
541
+ if self.chars_list:
542
+ self._save_current()
543
+ self._load_char(idx)
544
+
545
+ def clear_grid(self):
546
+ if not self.grid:
547
+ return
548
+ cols = len(self.grid[0])
549
+ rows = len(self.grid)
550
+ self.grid = [[0] * cols for _ in range(rows)]
551
+ self._draw_grid()
552
+
553
+ # ── Add / Remove ──────────────────────────────────────────────────────
554
+
555
+ def _add_char(self):
556
+ dlg = AddCharDialog(self.root, existing=set(self.chars_list))
557
+ if dlg.result_char is None:
558
+ return
559
+
560
+ ch = dlg.result_char
561
+ cols = dlg.result_cols
562
+ rows = dlg.result_rows
563
+
564
+ # Save current before switching
565
+ if self.chars_list:
566
+ self._save_current()
567
+
568
+ self.chars_list.append(ch)
569
+ self.font_data[ch] = [[0] * cols for _ in range(rows)]
570
+ save_font(self.font_data, self.chars_list, self.save_path)
571
+
572
+ self._build_overview()
573
+ self.var_cols.set(cols)
574
+ self.var_rows.set(rows)
575
+ self._load_char(len(self.chars_list) - 1)
576
+ self.lbl_status.config(text=f"Added '{ch}' ({cols}Γ—{rows})")
577
+
578
+ def _remove_char(self):
579
+ if not self.chars_list:
580
+ return
581
+ ch = self.chars_list[self.idx]
582
+ if not messagebox.askyesno(
583
+ "Remove character",
584
+ f"Remove '{ch}' from the font?\nThis cannot be undone."):
585
+ return
586
+
587
+ self.chars_list.pop(self.idx)
588
+ self.font_data.pop(ch, None)
589
+ save_font(self.font_data, self.chars_list, self.save_path)
590
+
591
+ self._build_overview()
592
+
593
+ if not self.chars_list:
594
+ self.idx = 0
595
+ self._update_empty_state()
596
+ else:
597
+ new_idx = min(self.idx, len(self.chars_list) - 1)
598
+ self._load_char(new_idx)
599
+
600
+ self.lbl_status.config(text=f"Removed '{ch}'")
601
+
602
+ # ── Export ────────────────────────────────────────────────────────────
603
+
604
+ def export_font(self):
605
+ if not self.font_data:
606
+ messagebox.showwarning("No data", "No characters defined yet.")
607
+ return
608
+
609
+ # Save current char first
610
+ if self.chars_list:
611
+ self._save_current()
612
+
613
+ lines = [
614
+ '"""',
615
+ "Custom dot-matrix font.",
616
+ "Generated by font_editor.py β€” edit with font_editor.py",
617
+ "",
618
+ "Each entry: FONT[char] = numpy array of shape (rows, cols).",
619
+ "Characters can have different sizes.",
620
+ '"""',
621
+ "import numpy as np",
622
+ "",
623
+ "",
624
+ "FONT = {",
625
+ ]
626
+
627
+ for ch in self.chars_list:
628
+ if ch not in self.font_data:
629
+ continue
630
+ grid = self.font_data[ch]
631
+ rows = len(grid)
632
+ cols = len(grid[0]) if grid else 0
633
+ rows_s = ", ".join(str(list(r)) for r in grid)
634
+ lines.append(
635
+ f" {repr(ch)}: np.array([{rows_s}], dtype=np.uint8),"
636
+ f" # {cols}Γ—{rows}"
637
+ )
638
+
639
+ lines += [
640
+ "}",
641
+ "",
642
+ "FONT_5x7 = FONT # backward-compat alias",
643
+ "",
644
+ ]
645
+
646
+ EXPORT_FILE.write_text("\n".join(lines), encoding="utf-8")
647
+ n = len([c for c in self.chars_list if c in self.font_data])
648
+ self.lbl_status.config(text=f"Exported {n} chars β†’ {EXPORT_FILE}")
649
+ messagebox.showinfo(
650
+ "Exported",
651
+ f"Saved to {EXPORT_FILE}\n{n} characters exported.",
652
+ )
653
+
654
+
655
+ # ── Entry point ──────────────────────────────────────────────────────────────
656
+
657
+ if __name__ == "__main__":
658
+ ap = argparse.ArgumentParser(description="Dot-matrix font editor")
659
+ ap.add_argument("--font", default=str(SAVE_FILE),
660
+ help=f"JSON font file to edit (default: {SAVE_FILE})")
661
+ args = ap.parse_args()
662
+
663
+ root = tk.Tk()
664
+ FontEditor(root, save_path=Path(args.font))
665
+ root.mainloop()