flexgui 1.0.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.
flexgui/designer.py ADDED
@@ -0,0 +1,1974 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ FlexGUI Studio — trình thiết kế trực quan, đồ họa nền tối hiện đại.
4
+
5
+ Bố cục (kiểu Figma/VS Code):
6
+
7
+ ┌──────────────────────────────────────────────────────────────┐
8
+ │ ◈ FlexGUI Studio · myapp [Mới][Mở][Lưu] [↶ ↷] [▦ Lưới] │
9
+ │ [Triggers] [Xuất .py] [▲ Build] [▶│
10
+ ├──────┬───────────────────────────────────────────┬────────────┤
11
+ │ │ ╔════════════════════════════════╗ │ CỬA SỔ / │
12
+ │ PAL │ ║ ⬜ myapp ─ □ ✕ ║ │ THÀNH PHẦN │
13
+ │ ETTE │ ║────────────────────────────────║ │ (thuộc tính)│
14
+ │ TE │ ║ vùng thiết kế (kéo thả) ║ │ │
15
+ │ │ ╚════════════════════════════════╝ │ [Trigger] │
16
+ ├──────┴───────────────────────────────────────────┴────────────┤
17
+ │ ● Sẵn sàng · X: 12 Y: 34 · Chọn: Button #2 · Lưới 10px │
18
+ └──────────────────────────────────────────────────────────────┘
19
+
20
+ Thiết kế vẽ bằng CANVAS ITEM (không phải widget thật) — kéo thả
21
+ mượt tuyệt đối, KHÔNG TÀN ẢNH, resize 8 núm, multi-select quét vùng,
22
+ snap lưới, undo/redo, mini VS Code cho trigger, xem trước, build .exe.
23
+ """
24
+
25
+ import copy
26
+ import heapq
27
+ import json
28
+ import os
29
+ import re
30
+ import traceback
31
+ from pathlib import Path
32
+
33
+ import tkinter as tk
34
+ import tkinter.font as tkfont
35
+ from tkinter import ttk, messagebox, filedialog, colorchooser, simpledialog
36
+
37
+ from . import registry as _reg
38
+ from .theme import MAU, MAU_APP, font_ui, font_mono, vien_bo, mau_luoi
39
+ from .editor import TrinhSoanThao
40
+ from .widgets import _tao_tu_du_lieu
41
+ from .app import App
42
+ from .triggers import nap_trigger, SUKIEN_CHINH
43
+ from . import builder
44
+
45
+ # ==============================================================================
46
+ # HẰNG SỐ BỐ CỤC
47
+ # ==============================================================================
48
+ LE = 36 # khoảng trống quanh cửa sổ thiết kế (canvas)
49
+ H_TIEU_DE = 36 # chiều cao thanh tiêu đề giả của cửa sổ thiết kế
50
+ KICH_THUOC_MAC_DINH = { # kích thước px mặc định khi thả mới
51
+ "label": (None, 26),
52
+ "button": (140, 40),
53
+ "input": (220, 34),
54
+ "checkbox": (170, 28),
55
+ "text": (300, 150),
56
+ }
57
+ CHU_MAC_DINH = {
58
+ "label": "Nhãn mới",
59
+ "button": "Nút bấm",
60
+ "input": "",
61
+ "checkbox": "Lựa chọn",
62
+ "text": "",
63
+ }
64
+ TEN_HIEN_THI = {
65
+ "label": "Label", "button": "Button", "input": "Input",
66
+ "checkbox": "Checkbox", "text": "Text",
67
+ }
68
+ CAC_NUT_BAM = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] # 8 núm resize
69
+
70
+ MUC_CHEN = [ # snippet cho drawer trigger
71
+ ("Đếm số lần bấm nút",
72
+ "so_lan = 0\n\n"
73
+ "def on_button_1_click():\n"
74
+ " global so_lan\n"
75
+ " so_lan += 1\n"
76
+ " label[1].set_text(f\"Đã bấm {so_lan} lần\")\n"),
77
+ ("Đổi màu + cỡ chữ",
78
+ "def on_button_1_click():\n"
79
+ " label[1].color = '#d63384'\n"
80
+ " label[1].font_size = 16\n"
81
+ " label[1].refresh()\n"),
82
+ ("Hiện / ẩn widget",
83
+ "def on_checkbox_2_toggle():\n"
84
+ " if checkbox[2].checked:\n"
85
+ " text[3].show()\n"
86
+ " else:\n"
87
+ " text[3].hide()\n"),
88
+ ("Nhập rồi Enter",
89
+ "def on_input_1_enter():\n"
90
+ " ten = input[1].text.strip()\n"
91
+ " if ten:\n"
92
+ " label[1].set_text(\"Xin chào \" + ten)\n"
93
+ " input[1].clear()\n"),
94
+ ("Đồng hồ mỗi giây",
95
+ "from datetime import datetime\n\n"
96
+ "@every(1000)\n"
97
+ "def dong_ho():\n"
98
+ " label[1].set_text(datetime.now().strftime(\"%H:%M:%S\"))\n"),
99
+ ("Trigger điều kiện",
100
+ "@when(lambda: checkbox[2].checked and input[1].text == \"admin\")\n"
101
+ "def khi_du():\n"
102
+ " alert(\"Đăng nhập hợp lệ!\")\n"),
103
+ ("import thư viện ngoài",
104
+ "import random\n\n"
105
+ "def on_button_1_click():\n"
106
+ " label[1].set_text(\"Số may mắn: \" + str(random.randint(1, 99)))\n"),
107
+ ("Lệnh app (refresh…)",
108
+ "def on_button_1_click():\n"
109
+ " set_title(\"Tiêu đề mới\")\n"
110
+ " set_bg(\"#fff8e1\")\n"
111
+ " refresh()\n"),
112
+ ]
113
+
114
+
115
+ # ==============================================================================
116
+ # Nút phẳng (toolbar) — hover, kiểu accent, kiểu toggle
117
+ # ==============================================================================
118
+ class NutPhang(tk.Label):
119
+ """Nút phẳng kiểu VS Code — Label + hiệu ứng hover + click."""
120
+
121
+ def __init__(self, cha, chu, lenh=None, accent=False, toggle=False,
122
+ rong=None, co=9, dam=False):
123
+ self._trang_thai_bat = False
124
+ self._lenh = lenh
125
+ self._toggle = toggle
126
+ self._accent = accent
127
+ try:
128
+ nen_cha = cha.cget("bg")
129
+ except Exception:
130
+ nen_cha = MAU["nen"]
131
+ kw = dict(
132
+ bg=MAU["gt"] if accent else nen_cha,
133
+ fg=MAU["chu_sang"] if accent else MAU["chu"],
134
+ font=font_ui(co, dam), padx=12, pady=6, cursor="hand2")
135
+ if rong:
136
+ kw["width"] = rong
137
+ super().__init__(cha, **kw)
138
+ self.bind("<Enter>", self._hover)
139
+ self.bind("<Leave>", self._roi)
140
+ self.bind("<Button-1>", self._bam)
141
+
142
+ # ---------------------------------------------------------------- trạng thái
143
+ def dat_bat(self, bat):
144
+ """Bật/tắt trạng thái toggle (nút đang được kích hoạt)."""
145
+ self._trang_thai_bat = bool(bat)
146
+ self._ve()
147
+
148
+ def dat_chu(self, chu):
149
+ self.config(text=chu)
150
+
151
+ def dat_lenh(self, lenh):
152
+ self._lenh = lenh
153
+
154
+ # ---------------------------------------------------------------- hiệu ứng
155
+ def _hover(self, _e=None):
156
+ if self._accent:
157
+ self.config(bg=MAU["gt_nghi"])
158
+ elif self._trang_thai_bat:
159
+ self.config(bg="#094771")
160
+ else:
161
+ self.config(bg=MAU["nen_hover"])
162
+
163
+ def _roi(self, _e=None):
164
+ self._ve()
165
+
166
+ def _ve(self):
167
+ if self._accent:
168
+ self.config(bg=MAU["gt"])
169
+ elif self._trang_thai_bat:
170
+ self.config(bg="#094771", fg=MAU["chu_sang"])
171
+ else:
172
+ self.config(bg=self.master.cget("bg"), fg=MAU["chu"])
173
+
174
+ def _bam(self, _e=None):
175
+ if self._toggle:
176
+ self._trang_thai_bat = not self._trang_thai_bat
177
+ self._ve()
178
+ if self._lenh:
179
+ self._lenh()
180
+
181
+
182
+ # ==============================================================================
183
+ # STUDIO
184
+ # ==============================================================================
185
+ class Studio:
186
+ """Trình thiết kế trực quan FlexGUI (mọi thứ vẽ trên canvas — mượt)."""
187
+
188
+ def __init__(self, duong_dan=None, thiet_ke_ban_dau=None):
189
+ self.duong_dan = Path(duong_dan) if duong_dan else None
190
+ self.duong_json = None
191
+ self.duong_triggers = None
192
+ if self.duong_dan is not None:
193
+ from . import runtime as _rt
194
+ self.duong_json = _rt.duong_dan_json(self.duong_dan)
195
+ self.duong_triggers = _rt.duong_dan_triggers(self.duong_dan)
196
+
197
+ # ---- dữ liệu thiết kế ----
198
+ self.design = thiet_ke_ban_dau or self._doc_json() or self._thiet_ke_moi()
199
+ self.chon = [] # các id đang chọn (cuối = chính)
200
+ self.undo_ds = [] # chồng undo (ảnh chụp)
201
+ self.redo_ds = [] # chồng redo
202
+ self.ban_dau_undo = None # ảnh chụp trước khi sửa liên tục
203
+ self.dirty = False
204
+
205
+ # ---- quản lý ID (tái sử dụng như runtime) ----
206
+ ids = [c["id"] for c in self.design["components"]]
207
+ self._id_tiep_theo = (max(ids) + 1) if ids else 1
208
+ self._id_tu_do = []
209
+
210
+ # ---- cấu hình lưới ----
211
+ self.luoi_bat = True
212
+ self.buoc_luoi = 10
213
+
214
+ # ---- trạng thái chuột ----
215
+ self._trang_thai = None # None/keo/bam/quet
216
+ self._nhan_x = self._nhan_y = 0
217
+ self._goc = {} # id -> (x, y) lúc bắt đầu kéo
218
+ self._goc_kich_thuoc = None # (x, y, w, h) lúc bắt đầu resize
219
+ self._dda_x = self._dda_y = 0 # tổng dịch đã áp cho canvas.move
220
+ self._quet_hcn = None # item vùng quét
221
+ self._chon_truoc_quet = ()
222
+ self._hover_id = None
223
+ self._them_dem = 0 # so le khi thêm liên tiếp
224
+
225
+ self._xay_dung_giao_dien()
226
+ self._gan_phim()
227
+
228
+ # ================================================================== KHỞI TẠO
229
+ def _xay_dung_giao_dien(self):
230
+ self.root = tk.Tk()
231
+ self.root.title("FlexGUI Studio")
232
+ self.root.geometry("1280x780")
233
+ self.root.minsize(1024, 640)
234
+ self.root.configure(bg=MAU["nen"])
235
+ self._dinh_vi_giua_man_hinh()
236
+
237
+ self._tao_thanh_cong_cu()
238
+ self._tao_than()
239
+ self._tao_thanh_trang_thai()
240
+ self._tao_drawer_trigger(an=True)
241
+
242
+ self.root.protocol("WM_DELETE_WINDOW", self._khi_dong)
243
+ self._ve_lai()
244
+ self._lam_moi_bang_thuoc_tinh()
245
+ # Focus canvas để phím tắt hoạt động ngay
246
+ self.ban_ve.focus_set()
247
+
248
+ def _dinh_vi_giua_man_hinh(self):
249
+ """Căn Studio ra giữa màn hình."""
250
+ x = (self.root.winfo_screenwidth() - 1280) // 2
251
+ y = (self.root.winfo_screenheight() - 780) // 2
252
+ self.root.geometry(f"+{max(x, 0)}+{max(y, 0)}")
253
+
254
+ # ------------------------------------------------------------- thanh công cụ
255
+ def _tao_thanh_cong_cu(self):
256
+ bar = tk.Frame(self.root, bg=MAU["nen"], pady=6, padx=8)
257
+ bar.pack(fill="x", side="top")
258
+
259
+ tk.Label(bar, text="◈", fg=MAU["gt"], bg=MAU["nen"],
260
+ font=font_ui(15)).pack(side="left")
261
+ tk.Label(bar, text="FlexGUI Studio", fg=MAU["chu_sang"],
262
+ bg=MAU["nen"], font=font_ui(10, True)).pack(side="left",
263
+ padx=(4, 2))
264
+ ten = self.duong_dan.name if self.duong_dan else "chưa lưu"
265
+ self.nhan_ten_file = tk.Label(bar, text="· " + ten, fg=MAU["chu_mo"],
266
+ bg=MAU["nen"], font=font_ui(9))
267
+ self.nhan_ten_file.pack(side="left")
268
+
269
+ tk.Frame(bar, bg=MAU["vien"], width=1).pack(side="left", fill="y",
270
+ padx=8, pady=2)
271
+ NutPhang(bar, "Mới", self._moi, rong=4).pack(side="left", padx=1)
272
+ NutPhang(bar, "Mở…", self._mo_file, rong=4).pack(side="left", padx=1)
273
+ NutPhang(bar, "Lưu", lambda: self._luu_toan_bo(True), rong=4,
274
+ dam=True).pack(side="left", padx=1)
275
+ NutPhang(bar, "↶", self._hoan_tac, rong=2).pack(side="left", padx=1)
276
+ NutPhang(bar, "↷", self._lam_lai, rong=2).pack(side="left", padx=1)
277
+
278
+ tk.Frame(bar, bg=MAU["vien"], width=1).pack(side="left", fill="y",
279
+ padx=8, pady=2)
280
+ self.nut_luoi = NutPhang(bar, "▦ Lưới 10px", None, toggle=True,
281
+ rong=9)
282
+ self.nut_luoi.dat_lenh(self._doi_luoi)
283
+ self.nut_luoi.pack(side="left", padx=1)
284
+ self.nut_luoi.dat_bat(True)
285
+
286
+ # ---- bên phải ----
287
+ self.nut_drawer = NutPhang(bar, "⌨ Triggers", self._dao_drawer,
288
+ toggle=True, rong=10)
289
+ self.nut_drawer.pack(side="right", padx=1)
290
+ NutPhang(bar, "▲ Xuất .py", self._xuat_py, rong=9).pack(
291
+ side="right", padx=1)
292
+ self.nut_build = NutPhang(bar, "📦 Build .exe", self._build_exe,
293
+ rong=9)
294
+ self.nut_build.pack(side="right", padx=1)
295
+ NutPhang(bar, "▶ Xem trước", self._xem_truoc, accent=True,
296
+ rong=10).pack(side="right", padx=(1, 8))
297
+ NutPhang(bar, "?", self._mo_tro_giup, rong=2).pack(side="right",
298
+ padx=1)
299
+
300
+ # ------------------------------------------------------------- thân chính
301
+ def _tao_than(self):
302
+ self.than = tk.Frame(self.root, bg=MAU["nen"])
303
+ self.than.pack(fill="both", expand=True)
304
+ self._tao_palette()
305
+ self._tao_vung_ve()
306
+ self._tao_bang_thuoc_tinh()
307
+
308
+ def _tao_palette(self):
309
+ """Bảng thành phần bên trái — gợi ý + nhấp để thêm vào giữa."""
310
+ pl = tk.Frame(self.than, bg=MAU["nen_panel"], width=86, padx=6,
311
+ pady=8)
312
+ pl.pack(side="left", fill="y")
313
+ pl.pack_propagate(False)
314
+ tk.Label(pl, text="THÀNH PHẦN", fg=MAU["chu_mo"], bg=MAU["nen_panel"],
315
+ font=font_ui(7, True)).pack(pady=(2, 8))
316
+ for loai in ("label", "button", "input", "checkbox", "text"):
317
+ self._o_palette(pl, loai)
318
+ tk.Label(pl, text="Nhấp để thêm\nvào giữa", fg="#6e6e6e",
319
+ bg=MAU["nen_panel"], font=font_ui(7),
320
+ justify="center").pack(side="bottom", pady=8)
321
+
322
+ def _o_palette(self, cha, loai):
323
+ """Một ô palette: vẽ mock nhỏ + tên — hover sáng, click thêm."""
324
+ khung = tk.Frame(cha, bg=MAU["nen_panel"], cursor="hand2")
325
+ khung.pack(pady=3, ipadx=2, ipady=2)
326
+ cv = tk.Canvas(khung, width=64, height=40, bg="#ffffff",
327
+ highlightthickness=1, highlightbackground=MAU["vien"])
328
+ cv.pack()
329
+ self._ve_icon(cv, loai)
330
+ tk.Label(khung, text=TEN_HIEN_THI[loai], fg=MAU["chu_mo"],
331
+ bg=MAU["nen_panel"], font=font_ui(7)).pack(pady=(1, 0))
332
+ cv.bind("<Button-1>", lambda e, l=loai: self._them(l))
333
+ cv.bind("<Enter>", lambda e: cv.config(
334
+ highlightbackground=MAU["gt"]))
335
+ cv.bind("<Leave>", lambda e: cv.config(
336
+ highlightbackground=MAU["vien"]))
337
+
338
+ def _ve_icon(self, cv, loai):
339
+ """Vẽ icon mô phỏng cho ô palette."""
340
+ f = font_ui(8)
341
+ if loai == "label":
342
+ cv.create_text(32, 20, text="Aa", fill="#1f2430", font=f)
343
+ elif loai == "button":
344
+ vien_bo(cv, 10, 11, 54, 29, 6, fill=MAU_APP["gt_mac_dinh"],
345
+ outline="")
346
+ cv.create_text(32, 20, text="OK", fill="#fff", font=f)
347
+ elif loai == "input":
348
+ vien_bo(cv, 8, 13, 56, 27, 5, fill="#ffffff",
349
+ outline=MAU_APP["khung"])
350
+ cv.create_text(14, 20, anchor="w", text="…", fill="#9aa1ab",
351
+ font=f)
352
+ elif loai == "checkbox":
353
+ cv.create_rectangle(11, 12, 23, 24, fill="#ffffff",
354
+ outline="#8a919c")
355
+ cv.create_line(14, 19, 17, 22, 20, 13, fill=MAU_APP["gt_mac_dinh"],
356
+ width=2, capstyle="round", joinstyle="round")
357
+ cv.create_text(29, 18, anchor="w", text="✓", fill="#1f2430",
358
+ font=f)
359
+ else: # text
360
+ cv.create_rectangle(9, 8, 55, 32, fill="#ffffff",
361
+ outline=MAU_APP["khung"])
362
+ for i, y in enumerate((14, 19, 24)):
363
+ cv.create_rectangle(14, y, 14 + (34 if i < 2 else 22), y + 2,
364
+ fill="#e4e8ee", outline="")
365
+
366
+ def _tao_vung_ve(self):
367
+ """Vùng canvas trung tâm (cuộn được khi cửa sổ thiết kế to)."""
368
+ vung = tk.Frame(self.than, bg=MAU["nen_den"])
369
+ vung.pack(side="left", fill="both", expand=True)
370
+
371
+ self.ban_ve = tk.Canvas(vung, bg=MAU["nen_den"], highlightthickness=0,
372
+ cursor="arrow")
373
+ self.ban_ve.pack(side="left", fill="both", expand=True)
374
+
375
+ # Thanh cuộn tối màu
376
+ self._dinh_dang_ttk()
377
+ sb_y = ttk.Scrollbar(vung, orient="vertical",
378
+ command=self.ban_ve.yview)
379
+ sb_y.pack(side="right", fill="y")
380
+ sb_x = ttk.Scrollbar(vung, orient="horizontal",
381
+ command=self.ban_ve.xview)
382
+ sb_x.pack(side="bottom", fill="x")
383
+ self.ban_ve.configure(xscrollcommand=sb_x.set,
384
+ yscrollcommand=sb_y.set)
385
+
386
+ # ---- các item THƯỜNG TRỰC (không tạo/hủy khi kéo → hết tàn ảnh) ----
387
+ self._vien_chon = self.ban_ve.create_rectangle(
388
+ 0, 0, 0, 0, outline=MAU["vien_chon"], width=2, state="hidden")
389
+ self._ds_bam = {}
390
+ for ten in CAC_NUT_BAM:
391
+ self._ds_bam[ten] = self.ban_ve.create_rectangle(
392
+ 0, 0, 0, 0, fill=MAU["nen_handle"],
393
+ outline=MAU["vien_chon"], width=1, state="hidden")
394
+ self._vien_hover = self.ban_ve.create_rectangle(
395
+ 0, 0, 0, 0, outline=MAU["vien_hover"], state="hidden")
396
+ self._quet_hcn = self.ban_ve.create_rectangle(
397
+ 0, 0, 0, 0, fill=MAU["gt"], stipple="gray50",
398
+ outline=MAU["gt"], state="hidden")
399
+ # viên thuốc tọa độ khi kéo
400
+ self._toa_do_nen = self.ban_ve.create_rectangle(
401
+ 0, 0, 0, 0, fill="#111418", outline=MAU["gt"], state="hidden")
402
+ self._toa_do_chu = self.ban_ve.create_text(
403
+ 0, 0, text="", fill=MAU["chu_sang"], font=font_ui(8),
404
+ state="hidden")
405
+
406
+ # ---- gắn sự kiện chuột ----
407
+ cv = self.ban_ve
408
+ cv.bind("<Button-1>", self._nhan)
409
+ cv.bind("<B1-Motion>", self._keo)
410
+ cv.bind("<ButtonRelease-1>", self._tha)
411
+ cv.bind("<Double-Button-1>", self._nhan_kp)
412
+ cv.bind("<Button-3>", self._menu_phai)
413
+ cv.bind("<Motion>", self._di_chuot)
414
+ cv.bind("<Leave>", lambda e: self._an_toa_do_chuot())
415
+ cv.bind("<Button-1>", lambda e: cv.focus_set(), add="+")
416
+ # cuộn bằng con lăn (bỏ qua khi đang gõ trong editor ô nhập)
417
+ def _lan_doc(e):
418
+ w = self.root.focus_get()
419
+ if w is not None and w.winfo_class() in ("Text", "Entry",
420
+ "TSpinbox", "TEntry"):
421
+ return
422
+ self.ban_ve.yview_scroll(-int(e.delta / 120) * 3, "units")
423
+
424
+ def _lan_ngang(e):
425
+ w = self.root.focus_get()
426
+ if w is not None and w.winfo_class() in ("Text", "Entry",
427
+ "TSpinbox", "TEntry"):
428
+ return
429
+ self.ban_ve.xview_scroll(-int(e.delta / 120) * 3, "units")
430
+
431
+ cv.bind_all("<MouseWheel>", _lan_doc)
432
+ cv.bind_all("<Shift-MouseWheel>", _lan_ngang)
433
+
434
+ def _dinh_dang_ttk(self):
435
+ """Áp style tối cho widget ttk (scrollbar, progressbar...)."""
436
+ self._phong = ttk.Style()
437
+ try:
438
+ self._phong.theme_use("clam")
439
+ except Exception:
440
+ pass
441
+ self._phong.configure("Vertical.TScrollbar",
442
+ background=MAU["vien"],
443
+ troughcolor=MAU["nen_den"],
444
+ bordercolor=MAU["nen_den"],
445
+ arrowcolor=MAU["chu_mo"],
446
+ arrowsize=11)
447
+ self._phong.configure("Horizontal.TScrollbar",
448
+ background=MAU["vien"],
449
+ troughcolor=MAU["nen_den"],
450
+ bordercolor=MAU["nen_den"],
451
+ arrowcolor=MAU["chu_mo"],
452
+ arrowsize=11)
453
+
454
+ # ------------------------------------------------------------- bảng thuộc tính
455
+ def _tao_bang_thuoc_tinh(self):
456
+ """Panel phải: thuộc tính cửa sổ HOẶC thành phần đang chọn."""
457
+ self.bang = tk.Frame(self.than, bg=MAU["nen_panel"], width=272)
458
+ self.bang.pack(side="right", fill="y")
459
+ self.bang.pack_propagate(False)
460
+ self.noi_bang = tk.Frame(self.bang, bg=MAU["nen_panel"])
461
+ self.noi_bang.pack(fill="both", expand=True, padx=10, pady=8)
462
+
463
+ def _tao_thanh_trang_thai(self):
464
+ sb = tk.Frame(self.root, bg="#007acc", pady=3, padx=8)
465
+ sb.pack(fill="x", side="bottom")
466
+ f = font_ui(8)
467
+ self.nhan_trang_thai = tk.Label(sb, text="● Sẵn sàng", bg="#007acc",
468
+ fg="#ffffff", font=f)
469
+ self.nhan_trang_thai.pack(side="left")
470
+ self.nhan_toa_do = tk.Label(sb, text="", bg="#007acc", fg="#e8f2fa",
471
+ font=f)
472
+ self.nhan_toa_do.pack(side="left", padx=16)
473
+ self.nhan_chon = tk.Label(sb, text="", bg="#007acc", fg="#e8f2fa",
474
+ font=f)
475
+ self.nhan_chon.pack(side="left")
476
+ self.nhan_thong_tin = tk.Label(
477
+ sb, text="Ctrl+S Lưu · F5 Xem trước · Del Xóa · Ctrl+Z Hoàn tác",
478
+ bg="#007acc", fg="#cfe3f5", font=f)
479
+ self.nhan_thong_tin.pack(side="right")
480
+
481
+ # ------------------------------------------------------------- drawer trigger
482
+ def _tao_drawer_trigger(self, an=True):
483
+ """Ngăn kéo dưới cùng: mini VS Code cho myapp.triggers.py."""
484
+ self.drawer = tk.Frame(self.root, bg=MAU["nen_panel"], height=320)
485
+ self.drawer.pack_propagate(False)
486
+
487
+ dau = tk.Frame(self.drawer, bg="#2d2d2d", padx=8, pady=3)
488
+ dau.pack(fill="x")
489
+ self.nhan_tab = tk.Label(
490
+ dau, text=" ⌨ " + self._ten_triggers() + " • Python • UTF-8",
491
+ bg="#2d2d2d", fg=MAU["chu_sang"], font=font_ui(8),
492
+ anchor="w")
493
+ self.nhan_tab.pack(side="left")
494
+ NutPhang(dau, "✕", self._an_drawer, rong=2, co=8).pack(
495
+ side="right")
496
+ NutPhang(dau, "Chèn mẫu ▾", self._chen_mau, rong=9, co=8).pack(
497
+ side="right", padx=4)
498
+ NutPhang(dau, "💾 Áp dụng & Lưu", lambda: self._ghi_triggers(True),
499
+ rong=14, co=8).pack(side="right", padx=4)
500
+
501
+ self.editor = TrinhSoanThao(self.drawer)
502
+ self.editor.pack(fill="both", expand=True, padx=8, pady=(0, 8))
503
+ self._doc_triggers()
504
+
505
+ # Ctrl+S trong editor cũng lưu
506
+ self.editor.o.bind("<Control-s>",
507
+ lambda e: (self._luu_toan_bo(True), "break")[1])
508
+
509
+ def _ten_triggers(self):
510
+ if self.duong_triggers:
511
+ return self.duong_triggers.name
512
+ return "triggers.py"
513
+
514
+ # ================================================================== VÒNG ĐỜI
515
+ def chay(self):
516
+ """Vào mainloop của Studio."""
517
+ self.root.mainloop()
518
+
519
+ def _khi_dong(self):
520
+ """Đóng Studio — nhắc lưu nếu còn thay đổi."""
521
+ if self.dirty:
522
+ dap = messagebox.askyesnocancel(
523
+ "Thoát FlexGUI Studio",
524
+ "Thiết kế chưa lưu. Lưu trước khi thoát?")
525
+ if dap is None:
526
+ return
527
+ if dap and not self._luu_toan_bo(im_lang=True):
528
+ return
529
+ try:
530
+ self.root.destroy()
531
+ except Exception:
532
+ pass
533
+
534
+ # ==================================================================
535
+ # DỮ LIỆU THIẾT KẾ
536
+ # ==================================================================
537
+ def _thiet_ke_moi(self):
538
+ """Thiết kế trống — giao diện trắng mặc định 860×560."""
539
+ return {
540
+ "window": {"title": "Ứng dụng FlexGUI", "width": 860,
541
+ "height": 560, "bg": MAU_APP["nen_mac_dinh"]},
542
+ "components": [],
543
+ }
544
+
545
+ def _doc_json(self):
546
+ """Nạp myapp.flexgui.json (nếu có)."""
547
+ if self.duong_json and Path(self.duong_json).exists():
548
+ try:
549
+ with open(self.duong_json, "r", encoding="utf-8") as f:
550
+ return json.load(f)
551
+ except Exception:
552
+ messagebox.showwarning(
553
+ "Không đọc được file thiết kế",
554
+ f"{self.duong_json}\n\nMở một thiết kế trống mới.")
555
+ return None
556
+
557
+ def _danh_sach(self):
558
+ return self.design["components"]
559
+
560
+ def _comp(self, cid):
561
+ for d in self._danh_sach():
562
+ if d["id"] == cid:
563
+ return d
564
+ return None
565
+
566
+ def _xin_id(self):
567
+ """ID mới — tái sử dụng ID nhỏ nhất vừa bị xóa (như runtime)."""
568
+ if self._id_tu_do:
569
+ return heapq.heappop(self._id_tu_do)
570
+ self._id_tiep_theo += 1
571
+ return self._id_tiep_theo - 1
572
+
573
+ def _moi(self):
574
+ """Thiết kế mới (hỏi trước khi xóa)."""
575
+ if self.dirty:
576
+ if not messagebox.askyesno(
577
+ "Thiết kế mới", "Bỏ thay đổi chưa lưu và tạo mới?"):
578
+ return
579
+ self.design = self._thiet_ke_moi()
580
+ self._id_tiep_theo, self._id_tu_do = 1, []
581
+ self.chon = []
582
+ self.undo_ds.clear()
583
+ self.redo_ds.clear()
584
+ self.dirty = False
585
+ self._ve_lai()
586
+ self._lam_moi_bang_thuoc_tinh()
587
+
588
+ def _mo_file(self):
589
+ """Mở file .flexgui.json khác."""
590
+ duong = filedialog.askopenfilename(
591
+ title="Mở thiết kế FlexGUI",
592
+ filetypes=[("Thiết kế FlexGUI", "*.flexgui.json"),
593
+ ("Tất cả", "*.*")])
594
+ if not duong:
595
+ return
596
+ try:
597
+ with open(duong, "r", encoding="utf-8") as f:
598
+ self.design = json.load(f)
599
+ except Exception:
600
+ messagebox.showerror("Lỗi", "Không đọc được file:\n"
601
+ + traceback.format_exc())
602
+ return
603
+ self.duong_json = Path(duong)
604
+ # file trigger đi kèm: cùng tên, đuôi .triggers.py
605
+ self.duong_triggers = self.duong_json.parent / (
606
+ self.duong_json.name[:-len(".flexgui.json")] + ".triggers.py")
607
+ ids = [c["id"] for c in self._danh_sach()]
608
+ self._id_tiep_theo = (max(ids) + 1) if ids else 1
609
+ self._id_tu_do = []
610
+ self.chon = []
611
+ self.undo_ds.clear()
612
+ self.redo_ds.clear()
613
+ self.dirty = False
614
+ self.nhan_ten_file.config(text="· " + self.duong_json.name)
615
+ self.nhan_tab.config(text=" ⌨ " + self._ten_triggers()
616
+ + " • Python • UTF-8")
617
+ self._doc_triggers()
618
+ self._ve_lai()
619
+ self._lam_moi_bang_thuoc_tinh()
620
+
621
+ # ==================================================================
622
+ # HỆ TOẠ ĐỘ: canvas <-> thiết kế
623
+ # ==================================================================
624
+ def _khung(self):
625
+ """Kích thước vùng client của cửa sổ thiết kế."""
626
+ return self.design["window"].get("width", 860), \
627
+ self.design["window"].get("height", 560)
628
+
629
+ def _goc_client(self):
630
+ """Góc trái-trên vùng client TRÊN CANVAS."""
631
+ return LE, LE + H_TIEU_DE
632
+
633
+ def _sang_canvas(self, x, y):
634
+ gx, gy = self._goc_client()
635
+ return gx + x, gy + y
636
+
637
+ def _sang_thiet_ke(self, cx, cy):
638
+ gx, gy = self._goc_client()
639
+ return cx - gx, cy - gy
640
+
641
+ def _snap(self, v):
642
+ if self.luoi_bat:
643
+ return int(round(v / self.buoc_luoi) * self.buoc_luoi)
644
+ return int(v)
645
+
646
+ # ==================================================================
647
+ # VẼ
648
+ # ==================================================================
649
+ def _ve_lai(self):
650
+ """Vẽ lại toàn bộ (khung cửa sổ + lưới + mọi thành phần)."""
651
+ cv = self.ban_ve
652
+ cv.delete("cua_so", "luoi", "comp", "goi_y")
653
+ self._ve_cua_so()
654
+ self._ve_luoi()
655
+ for d in self._danh_sach():
656
+ self._ve_comp(d)
657
+ if not self._danh_sach():
658
+ self._ve_goi_y()
659
+ self._cap_nhat_vien_chon()
660
+ self._cap_nhat_trang_thai()
661
+
662
+ def _ve_cua_so(self):
663
+ """Khung cửa sổ giả: bóng đổ + thanh tiêu đề + nút cửa sổ."""
664
+ cv = self.ban_ve
665
+ w, h = self._khung()
666
+ nen = self.design["window"].get("bg") or MAU_APP["nen_mac_dinh"]
667
+ tieu_de = self.design["window"].get("title", "FlexGUI")
668
+ x0, y0 = LE, LE
669
+ x1, y1 = LE + w, LE + H_TIEU_DE + h
670
+ # bóng đổ 2 lớp
671
+ cv.create_rectangle(x0 + 7, y0 + 7, x1 + 7, y1 + 7,
672
+ fill=MAU["soi_bong_1"], outline="", tags="cua_so")
673
+ cv.create_rectangle(x0 + 3, y0 + 3, x1 + 3, y1 + 3,
674
+ fill=MAU["soi_bong_2"], outline="", tags="cua_so")
675
+ # thân
676
+ cv.create_rectangle(x0, y0, x1, y1, fill=nen,
677
+ outline=MAU["khung_so"], tags="cua_so")
678
+ # thanh tiêu đề
679
+ cv.create_rectangle(x0, y0, x1, y0 + H_TIEU_DE,
680
+ fill=MAU["thanh_tieu_de"], outline="",
681
+ tags="cua_so")
682
+ cv.create_line(x0, y0 + H_TIEU_DE, x1, y0 + H_TIEU_DE,
683
+ fill="#e3e6ea", tags="cua_so")
684
+ vien_bo(cv, x0 + 12, y0 + 9, x0 + 24, y0 + 21, 4,
685
+ fill=MAU_APP["gt_mac_dinh"], outline="", tags="cua_so")
686
+ cv.create_text(x0 + 18, y0 + 15, text="◈", fill="#ffffff",
687
+ font=font_ui(8, True), tags="cua_so")
688
+ cv.create_text(x0 + 34, y0 + H_TIEU_DE / 2, text=tieu_de,
689
+ anchor="w", fill="#3b4048", font=font_ui(9, True),
690
+ tags="cua_so")
691
+ # nút cửa sổ (Windows style)
692
+ for i, chu in enumerate(("—", "☐", "✕")):
693
+ nx = x1 - 96 + i * 32
694
+ cv.create_rectangle(nx, y0, nx + 32, y0 + H_TIEU_DE,
695
+ fill=MAU["thanh_tieu_de"], outline="",
696
+ tags="cua_so")
697
+ cv.create_text(nx + 16, y0 + H_TIEU_DE / 2, text=chu,
698
+ fill="#8a919c", font=font_ui(8), tags="cua_so")
699
+ # vùng cuộn theo kích thước cửa sổ
700
+ cv.configure(scrollregion=(0, 0, x1 + LE, y1 + LE))
701
+ # cuộn về gốc
702
+ cv.xview_moveto(0)
703
+ cv.yview_moveto(0)
704
+
705
+ def _ve_luoi(self):
706
+ """Lưới điểm gián đoạn trong vùng client (màu tương phản nền)."""
707
+ cv = self.ban_ve
708
+ w, h = self._khung()
709
+ gx, gy = self._goc_client()
710
+ mau = mau_luoi(self.design["window"].get("bg")
711
+ or MAU_APP["nen_mac_dinh"])
712
+ buoc = self.buoc_luoi
713
+ x = gx + buoc
714
+ while x < gx + w:
715
+ cv.create_line(x, gy, x, gy + h, fill=mau, tags="luoi")
716
+ x += buoc * 2 # giãn đôi → nhìn nhẹ hơn
717
+ y = gy + buoc
718
+ while y < gy + h:
719
+ cv.create_line(gx, y, gx + w, y, fill=mau, tags="luoi")
720
+ y += buoc * 2
721
+
722
+ def _ve_goi_y(self):
723
+ """Gợi ý giữa vùng trống khi chưa có thành phần nào."""
724
+ cv = self.ban_ve
725
+ w, h = self._khung()
726
+ gx, gy = self._goc_client()
727
+ cv.create_text(
728
+ gx + w / 2, gy + h / 2 - 12,
729
+ text="Giao diện trống — nhấp một thành phần ở bảng bên trái\n"
730
+ "để thêm vào đây (kéo thả, snap lưới, resize 8 núm)",
731
+ fill="#aeb4bf", font=font_ui(11), justify="center",
732
+ tags="goi_y")
733
+
734
+ def _doi_luoi(self):
735
+ """Bật/tắt lưới + snap (nút ▦ trên toolbar)."""
736
+ self.luoi_bat = not self.luoi_bat
737
+ self.nut_luoi.dat_bat(self.luoi_bat)
738
+ if self.luoi_bat:
739
+ self._ve_lai()
740
+ else:
741
+ self.ban_ve.delete("luoi")
742
+ self._cap_nhat_trang_thai(
743
+ "● Tắt lưới — kéo thả hoàn toàn tự do")
744
+
745
+ def _ve_comp(self, d):
746
+ """Vẽ MỘT thành phần theo đúng loại (đồ họa mô phỏng hiện đại)."""
747
+ cv = self.ban_ve
748
+ X, Y = self._sang_canvas(d["x"], d["y"])
749
+ w, h = d.get("w") or 100, d.get("h") or 30
750
+ tag = ("comp", f"c{d['id']}")
751
+ nen_cua_so = self.design["window"].get("bg") or \
752
+ MAU_APP["nen_mac_dinh"]
753
+ co = d.get("font_size") or 10
754
+ mau_chu = d.get("color") or "#1f2430"
755
+
756
+ # hit-rect trong suốt (đón chuột) — luôn nằm dưới cùng của comp
757
+ cv.create_rectangle(X, Y, X + w, Y + h, fill=nen_cua_so,
758
+ outline="", tags=tag)
759
+
760
+ if d["loai"] == "label":
761
+ cv.create_text(X, Y + h / 2, anchor="w", text=d.get("text") or "",
762
+ fill=mau_chu, font=font_ui(co), tags=tag)
763
+
764
+ elif d["loai"] == "button":
765
+ mau_nen = d.get("bg") or MAU_APP["gt_mac_dinh"]
766
+ vien_bo(cv, X, Y, X + w, Y + h, 8, fill=mau_nen, outline="",
767
+ tags=tag)
768
+ cv.create_text(X + w / 2, Y + h / 2, text=d.get("text") or "",
769
+ fill="#ffffff" if not d.get("color") else mau_chu,
770
+ font=font_ui(co), tags=tag)
771
+
772
+ elif d["loai"] == "input":
773
+ vien_bo(cv, X, Y, X + w, Y + h, 6, fill=d.get("bg") or "#ffffff",
774
+ outline=d.get("bg") or MAU_APP["khung"], tags=tag)
775
+ chu = (d.get("text") or "").strip()
776
+ cv.create_text(X + 8, Y + h / 2, anchor="w",
777
+ text=chu or "Ô nhập…",
778
+ fill=mau_chu if chu else "#b8bec8",
779
+ font=font_ui(co), tags=tag)
780
+
781
+ elif d["loai"] == "checkbox":
782
+ c = 16
783
+ cy = Y + (h - c) / 2
784
+ cv.create_rectangle(X, cy, X + c, cy + c, fill="#ffffff",
785
+ outline="#8a919c", tags=tag)
786
+ if d.get("checked"):
787
+ cv.create_rectangle(X + 2, cy + 2, X + c - 2, cy + c - 2,
788
+ fill=MAU_APP["gt_mac_dinh"], outline="",
789
+ tags=tag)
790
+ cv.create_line(X + 4, cy + 8, X + 7, cy + 11, X + 12,
791
+ cy + 4, fill="#ffffff", width=2,
792
+ capstyle="round", joinstyle="round",
793
+ tags=tag)
794
+ cv.create_text(X + c + 8, Y + h / 2, anchor="w",
795
+ text=d.get("text") or "", fill=mau_chu,
796
+ font=font_ui(co), tags=tag)
797
+
798
+ elif d["loai"] == "text":
799
+ cv.create_rectangle(X, Y, X + w, Y + h,
800
+ fill=d.get("bg") or "#ffffff",
801
+ outline=MAU_APP["khung"], tags=tag)
802
+ # thanh cuộn giả bên phải
803
+ cv.create_rectangle(X + w - 8, Y + 3, X + w - 3, Y + h - 3,
804
+ fill="#eef1f5", outline="", tags=tag)
805
+ noi_dung = (d.get("text") or "").strip()
806
+ if noi_dung:
807
+ for i, dong in enumerate(noi_dung.split("\n")[:3]):
808
+ cv.create_text(X + 8, Y + 8 + i * 16, anchor="w",
809
+ text=dong[:40], fill=mau_chu,
810
+ font=font_ui(co), tags=tag)
811
+ else:
812
+ for i in range(3):
813
+ cv.create_rectangle(X + 8, Y + 10 + i * 16,
814
+ X + 8 + (w - 34 if i < 2 else 20),
815
+ Y + 14 + i * 16,
816
+ fill="#e4e8ee", outline="", tags=tag)
817
+
818
+ # huy hiệu trạng thái đặc biệt
819
+ if not d.get("visible", True):
820
+ cv.create_rectangle(X, Y, X + w, Y + h, fill="#5b21b6",
821
+ stipple="gray25", outline="#8b5cf6",
822
+ dash=(4, 3), tags=tag)
823
+ vien_bo(cv, X + w - 44, Y + 4, X + w - 4, Y + 20, 5,
824
+ fill="#8b5cf6", outline="", tags=tag)
825
+ cv.create_text(X + w - 24, Y + 12, text="ẨN", fill="#ffffff",
826
+ font=font_ui(7, True), tags=tag)
827
+ if not d.get("enabled", True):
828
+ vien_bo(cv, X + 4, Y + h - 20, X + 40, Y + h - 4, 5,
829
+ fill="#6b7280", outline="", tags=tag)
830
+ cv.create_text(X + 22, Y + h - 12, text="KHOÁ", fill="#ffffff",
831
+ font=font_ui(7, True), tags=tag)
832
+
833
+ def _ve_comp_tu_dong(self, cid):
834
+ """Vẽ lại 1 comp, giữ đúng thứ tự chồng lớp."""
835
+ d = self._comp(cid)
836
+ if not d:
837
+ return
838
+ self.ban_ve.delete(f"c{cid}")
839
+ self._ve_comp(d)
840
+ # nâng lại các comp nằm TRÊN nó trong z-order
841
+ ds = self._danh_sach()
842
+ vi_tri = next(i for i, c in enumerate(ds) if c["id"] == cid)
843
+ for c in ds[vi_tri + 1:]:
844
+ self.ban_ve.tag_raise(f"c{c['id']}")
845
+
846
+ # ----------------------------------------------------- viền chọn + núm
847
+ def _cap_nhat_vien_chon(self):
848
+ """Cập nhật viền chọn + 8 núm resize (chỉ coords — không tạo mới)."""
849
+ cv = self.ban_ve
850
+ cv.delete("vien_phu")
851
+ if not self.chon:
852
+ cv.itemconfigure(self._vien_chon, state="hidden")
853
+ for it in self._ds_bam.values():
854
+ cv.itemconfigure(it, state="hidden")
855
+ return
856
+ chinh = self._comp(self.chon[-1])
857
+ if chinh is None:
858
+ cv.itemconfigure(self._vien_chon, state="hidden")
859
+ return
860
+ # viền phụ cho các comp chọn thêm (trừ comp chính)
861
+ for cid in self.chon[:-1]:
862
+ d = self._comp(cid)
863
+ if d:
864
+ a, b = self._sang_canvas(d["x"], d["y"])
865
+ r = cv.create_rectangle(
866
+ a - 1, b - 1, a + (d.get("w") or 100) + 1,
867
+ b + (d.get("h") or 30) + 1,
868
+ outline=MAU["vien_chon"], width=1, tags="vien_phu")
869
+ cv.tag_lower(r, self._vien_chon)
870
+ X, Y = self._sang_canvas(chinh["x"], chinh["y"])
871
+ w, h = chinh.get("w") or 100, chinh.get("h") or 30
872
+ cv.coords(self._vien_chon, X - 1, Y - 1, X + w + 1, Y + h + 1)
873
+ cv.itemconfigure(self._vien_chon, state="normal")
874
+ cv.tag_raise(self._vien_chon)
875
+ self._dat_bam(X, Y, w, h)
876
+ self._cap_nhat_chon_trang_thai()
877
+
878
+ def _dat_bam(self, X, Y, w, h):
879
+ """Đặt 8 núm quanh hình chữ nhật (canvas coords)."""
880
+ cv = self.ban_ve
881
+ r = 4 # nửa kích thước núm
882
+ vi_tri = {
883
+ "nw": (X, Y), "n": (X + w / 2, Y), "ne": (X + w, Y),
884
+ "e": (X + w, Y + h / 2), "se": (X + w, Y + h),
885
+ "s": (X + w / 2, Y + h), "sw": (X, Y + h), "w": (X, Y + h / 2),
886
+ }
887
+ for ten, (x, y) in vi_tri.items():
888
+ it = self._ds_bam[ten]
889
+ cv.coords(it, x - r, y - r, x + r, y + r)
890
+ cv.itemconfigure(it, state="normal")
891
+ cv.tag_raise(it)
892
+
893
+ def _an_bam(self):
894
+ for it in self._ds_bam.values():
895
+ self.ban_ve.itemconfigure(it, state="hidden")
896
+
897
+ # ----------------------------------------------------- trạng thái / toast
898
+ def _cap_nhat_trang_thai(self, chu=None):
899
+ if chu is not None:
900
+ self.nhan_trang_thai.config(text=chu)
901
+ self.nhan_thong_tin.config(
902
+ text=f"{len(self._danh_sach())} thành phần · Lưới "
903
+ f"{'Bật' if self.luoi_bat else 'Tắt'} {self.buoc_luoi}px")
904
+
905
+ def _cap_nhat_chon_trang_thai(self):
906
+ if not self.chon:
907
+ self.nhan_chon.config(text="")
908
+ return
909
+ if len(self.chon) == 1:
910
+ d = self._comp(self.chon[0])
911
+ self.nhan_chon.config(
912
+ text=f"Đang chọn: {TEN_HIEN_THI[d['loai']]} #{d['id']}")
913
+ else:
914
+ self.nhan_chon.config(text=f"Đang chọn: {len(self.chon)} thành phần")
915
+
916
+ def _toast(self, chu, mau="#2d2d30"):
917
+ """Thông báo nổi dưới đáy canvas (tự ẩn sau 2 giây)."""
918
+ if getattr(self, "_toast_phu", None) is not None:
919
+ try:
920
+ self._toast_phu.destroy()
921
+ except Exception:
922
+ pass
923
+ phu = tk.Frame(self.ban_ve, bg=mau, highlightthickness=1,
924
+ highlightbackground=MAU["vien"])
925
+ tk.Label(phu, text=chu, bg=mau, fg=MAU["chu_sang"],
926
+ font=font_ui(9), padx=14, pady=6).pack()
927
+ phu.place(relx=0.5, rely=1.0, y=-46, anchor="s")
928
+ self._toast_phu = phu
929
+ self.root.after(2000, lambda: phu.destroy()
930
+ if phu.winfo_exists() else None)
931
+
932
+ # ==================================================================
933
+ # TƯƠNG TÁC CHUỘT
934
+ # ==================================================================
935
+ def _tim_comp_tai(self, x, y):
936
+ """Comp chứa điểm canvas (x, y) — duyệt từ trên xuống."""
937
+ for d in reversed(self._danh_sach()):
938
+ a, b = self._sang_canvas(d["x"], d["y"])
939
+ if a <= x <= a + (d.get("w") or 100) and \
940
+ b <= y <= b + (d.get("h") or 30):
941
+ return d
942
+ return None
943
+
944
+ def _bam_tai(self, x, y):
945
+ """Tên núm resize tại điểm (nếu có) — đếm cả vùng đệm 6px."""
946
+ if not self.chon:
947
+ return None
948
+ chinh = self._comp(self.chon[-1])
949
+ if not chinh:
950
+ return None
951
+ X, Y = self._sang_canvas(chinh["x"], chinh["y"])
952
+ w, h = chinh.get("w") or 100, chinh.get("h") or 30
953
+ tam = {"nw": (X, Y), "n": (X + w / 2, Y), "ne": (X + w, Y),
954
+ "e": (X + w, Y + h / 2), "se": (X + w, Y + h),
955
+ "s": (X + w / 2, Y + h), "sw": (X, Y + h), "w": (X, Y + h / 2)}
956
+ for ten, (cx, cy) in tam.items():
957
+ if abs(x - cx) <= 7 and abs(y - cy) <= 7:
958
+ return ten
959
+ return None
960
+
961
+ def _nhan(self, e):
962
+ """Nhấn chuột trái: chọn / bắt đầu kéo / resize / quét vùng."""
963
+ cv = self.ban_ve
964
+ cv.focus_set()
965
+ self._nhan_x, self._nhan_y = e.x, e.y
966
+ self._dda_x = self._dda_y = 0
967
+
968
+ bam = self._bam_tai(e.x, e.y)
969
+ if bam:
970
+ self._trang_thai = "bam"
971
+ chinh = self._comp(self.chon[-1])
972
+ self._goc_kich_thuoc = (chinh["x"], chinh["y"],
973
+ chinh.get("w") or 100,
974
+ chinh.get("h") or 30)
975
+ self._bam_ten = bam
976
+ self._chup_truoc_khi_sua() # undo resize = hình gốc
977
+ cv.configure(cursor="fleur")
978
+ return
979
+
980
+ d = self._tim_comp_tai(e.x, e.y)
981
+ if d is not None:
982
+ them = e.state & 0x0004 # Ctrl
983
+ them_hoac = e.state & 0x0001 # Shift
984
+ if them or them_hoac:
985
+ if d["id"] in self.chon:
986
+ self.chon.remove(d["id"])
987
+ else:
988
+ self.chon.append(d["id"])
989
+ else:
990
+ if d["id"] in self.chon:
991
+ self.chon.remove(d["id"])
992
+ self.chon.append(d["id"])
993
+ self._cap_nhat_vien_chon()
994
+ # bắt đầu kéo cả cụm
995
+ self._trang_thai = "keo"
996
+ self._goc = {cid: (self._comp(cid)["x"], self._comp(cid)["y"])
997
+ for cid in self.chon}
998
+ self._chup_truoc_khi_sua()
999
+ cv.configure(cursor="fleur")
1000
+ return
1001
+
1002
+ # vùng trống → quét vùng chọn
1003
+ if not (e.state & (0x0004 | 0x0001)):
1004
+ self.chon = []
1005
+ self._cap_nhat_vien_chon()
1006
+ self._chon_truoc_quet = tuple(self.chon)
1007
+ self._trang_thai = "quet"
1008
+ cv.itemconfigure(self._quet_hcn, state="normal")
1009
+ cv.coords(self._quet_hcn, e.x, e.y, e.x, e.y)
1010
+ cv.configure(cursor="crosshair")
1011
+
1012
+ def _keo(self, e):
1013
+ """Kéo chuột: di chuyển cụm / resize / quét vùng."""
1014
+ cv = self.ban_ve
1015
+ self._chuot_x, self._chuot_y = e.x, e.y
1016
+ if self._trang_thai == "keo":
1017
+ dx = e.x - self._nhan_x
1018
+ dy = e.y - self._nhan_y
1019
+ if not self.chon:
1020
+ return
1021
+ chinh = self._comp(self.chon[-1])
1022
+ gx, gy = self._goc.get(chinh["id"], (chinh["x"], chinh["y"]))
1023
+ nx = self._snap(gx + dx)
1024
+ ny = self._snap(gy + dy)
1025
+ ddx, ddy = nx - gx, ny - gy
1026
+ for cid in self.chon:
1027
+ d = self._comp(cid)
1028
+ ox, oy = self._goc.get(cid, (d["x"], d["y"]))
1029
+ d["x"], d["y"] = ox + ddx, oy + ddy
1030
+ # di chuyển item canvas MƯỢT (không vẽ lại → hết tàn ảnh)
1031
+ cv.move(f"c{cid}", ddx - self._dda_x, ddy - self._dda_y)
1032
+ self._dda_x, self._dda_y = ddx, ddy
1033
+ self._cap_nhat_vien_chon()
1034
+ self._hien_toa_do(nx, ny)
1035
+ self.dirty = True
1036
+
1037
+ elif self._trang_thai == "bam":
1038
+ self._keo_bam(e)
1039
+
1040
+ elif self._trang_thai == "quet":
1041
+ cv.coords(self._quet_hcn, self._nhan_x, self._nhan_y,
1042
+ e.x, e.y)
1043
+ x0, x1 = sorted((self._nhan_x, e.x))
1044
+ y0, y1 = sorted((self._nhan_y, e.y))
1045
+ self.chon = list(self._chon_truoc_quet)
1046
+ for d in self._danh_sach():
1047
+ a, b = self._sang_canvas(d["x"], d["y"])
1048
+ if a <= x1 and a + (d.get("w") or 100) >= x0 and \
1049
+ b <= y1 and b + (d.get("h") or 30) >= y0:
1050
+ if d["id"] not in self.chon:
1051
+ self.chon.append(d["id"])
1052
+ self._cap_nhat_vien_chon()
1053
+
1054
+ def _keo_bam(self, e):
1055
+ """Kéo núm resize — tính từ hình gốc, snap biên, cỡ tối thiểu."""
1056
+ cv = self.ban_ve
1057
+ self._chuot_x, self._chuot_y = e.x, e.y
1058
+ cid = self.chon[-1]
1059
+ d = self._comp(cid)
1060
+ gx, gy, gw, gh = self._goc_kich_thuoc
1061
+ dx = e.x - self._nhan_x
1062
+ dy = e.y - self._nhan_y
1063
+ ten = self._bam_ten
1064
+ x, y, w, h = gx, gy, gw, gh
1065
+
1066
+ if "e" in ten:
1067
+ w = self._snap(gx + gw + dx) - gx
1068
+ if "s" in ten:
1069
+ h = self._snap(gy + gh + dy) - gy
1070
+ if "w" in ten:
1071
+ x_moi = self._snap(gx + dx)
1072
+ w = gx + gw - x_moi
1073
+ x = x_moi
1074
+ if "n" in ten:
1075
+ y_moi = self._snap(gy + dy)
1076
+ h = gy + gh - y_moi
1077
+ y = y_moi
1078
+ w = max(16, w)
1079
+ h = max(14, h)
1080
+ d.update(x=x, y=y, w=w, h=h)
1081
+ self._ve_comp_tu_dong(cid)
1082
+ self._cap_nhat_vien_chon()
1083
+ self._hien_toa_do(x, y, w, h)
1084
+ self.dirty = True
1085
+
1086
+ def _tha(self, e):
1087
+ """Nhả chuột: kết thúc kéo/resize/quét."""
1088
+ cv = self.ban_ve
1089
+ if self._trang_thai in ("keo", "bam"):
1090
+ self._day_undo()
1091
+ self._lam_moi_bang_thuoc_tinh()
1092
+ elif self._trang_thai == "quet":
1093
+ self._cap_nhat_vien_chon()
1094
+ self._trang_thai = None
1095
+ self._an_toa_do()
1096
+ cv.itemconfigure(self._quet_hcn, state="hidden")
1097
+ cv.configure(cursor="arrow")
1098
+
1099
+ def _di_chuot(self, e):
1100
+ """Rê chuột: viền hover + tọa độ trên thanh trạng thái."""
1101
+ dx, dy = self._sang_thiet_ke(e.x, e.y)
1102
+ w, h = self._khung()
1103
+ self.nhan_toa_do.config(
1104
+ text=f"X: {min(max(dx, 0), w)} Y: {min(max(dy, 0), h)}")
1105
+ if self._trang_thai:
1106
+ return
1107
+ cv = self.ban_ve
1108
+ bam = self._bam_tai(e.x, e.y)
1109
+ if bam:
1110
+ cv.configure(cursor="fleur")
1111
+ cv.itemconfigure(self._vien_hover, state="hidden")
1112
+ return
1113
+ d = self._tim_comp_tai(e.x, e.y)
1114
+ if d is not None and d["id"] not in self.chon:
1115
+ X, Y = self._sang_canvas(d["x"], d["y"])
1116
+ cv.coords(self._vien_hover, X - 1, Y - 1,
1117
+ X + (d.get("w") or 100) + 1,
1118
+ Y + (d.get("h") or 30) + 1)
1119
+ cv.itemconfigure(self._vien_hover, state="normal")
1120
+ cv.configure(cursor="hand2")
1121
+ self._hover_id = d["id"]
1122
+ else:
1123
+ cv.itemconfigure(self._vien_hover, state="hidden")
1124
+ cv.configure(cursor="arrow")
1125
+ self._hover_id = None
1126
+
1127
+ def _an_toa_do_chuot(self):
1128
+ self.nhan_toa_do.config(text="")
1129
+
1130
+ # ----------------------------------------------------- viên thuốc tọa độ
1131
+ def _hien_toa_do(self, x, y, w=None, h=None):
1132
+ """Viên thuốc tọa độ bay theo con trỏ khi kéo/resize."""
1133
+ cv = self.ban_ve
1134
+ chu = (f"x: {x} y: {y}" if w is None
1135
+ else f"x: {x} y: {y} w: {w} h: {h}")
1136
+ try:
1137
+ f = tkfont.Font(font=font_ui(8))
1138
+ rong = f.measure(chu)
1139
+ except Exception:
1140
+ rong = 9 * len(chu)
1141
+ mx = getattr(self, "_chuot_x", 0)
1142
+ my = getattr(self, "_chuot_y", 0)
1143
+ cx = min(max(mx + 18, LE + 4), max(LE + 4, cv.winfo_width() - rong - 40))
1144
+ cy = max(6, my - 44)
1145
+ cv.coords(self._toa_do_nen, cx, cy, cx + rong + 18, cy + 22)
1146
+ cv.coords(self._toa_do_chu, cx + 9, cy + 11)
1147
+ cv.itemconfigure(self._toa_do_chu, text=chu, state="normal")
1148
+ cv.itemconfigure(self._toa_do_nen, state="normal")
1149
+ cv.tag_raise(self._toa_do_nen)
1150
+ cv.tag_raise(self._toa_do_chu)
1151
+
1152
+ def _an_toa_do(self):
1153
+ self.ban_ve.itemconfigure(self._toa_do_chu, state="hidden")
1154
+ self.ban_ve.itemconfigure(self._toa_do_nen, state="hidden")
1155
+
1156
+ # ==================================================================
1157
+ # THAO TÁC: THÊM / XÓA / NHÂN BẢN / Z-ORDER
1158
+ # ==================================================================
1159
+ def _them(self, loai, x=None, y=None):
1160
+ """Thêm thành phần mới (nhấp palette → giữa, so le mỗi lần)."""
1161
+ anh_truoc = self._chup() # ảnh TRƯỚC khi thêm (undo)
1162
+ w, h = KICH_THUOC_MAC_DINH[loai]
1163
+ chu = CHU_MAC_DINH[loai]
1164
+ if loai == "label" and w is None: # đo rộng theo chữ
1165
+ try:
1166
+ f = tkfont.Font(font=font_ui(10))
1167
+ w = f.measure(chu) + 8
1168
+ except Exception:
1169
+ w = 80
1170
+ if x is None or y is None:
1171
+ kw, kh = self._khung()
1172
+ x = (kw - (w or 100)) // 2 + (self._them_dem % 6) * 14
1173
+ y = (kh - (h or 26)) // 2 + (self._them_dem % 6) * 14
1174
+ self._them_dem += 1
1175
+ d = {"loai": loai, "id": self._xin_id(),
1176
+ "x": self._snap(x), "y": self._snap(y),
1177
+ "w": w or 100, "h": h or 26, "text": chu,
1178
+ "color": None, "bg": None, "font_size": None,
1179
+ "visible": True, "enabled": True,
1180
+ "checked": False if loai == "checkbox" else None}
1181
+ self._danh_sach().append(d)
1182
+ self.chon = [d["id"]]
1183
+ self._ve_lai()
1184
+ self._lam_moi_bang_thuoc_tinh()
1185
+ self._day_undo(anh_truoc)
1186
+ self.dirty = True
1187
+ self._cap_nhat_trang_thai(
1188
+ f"● Đã thêm {TEN_HIEN_THI[loai]} #{d['id']}")
1189
+
1190
+ def _xoa_dang_chon(self):
1191
+ """Xóa các thành phần đang chọn — ID trả về pool TÁI SỬ DỤNG."""
1192
+ if not self.chon:
1193
+ return
1194
+ anh_truoc = self._chup() # ảnh TRƯỚC khi xóa (undo)
1195
+ ds = self._danh_sach()
1196
+ da_xoa = [d for d in ds if d["id"] in self.chon]
1197
+ self.design["components"] = [d for d in ds
1198
+ if d["id"] not in self.chon]
1199
+ for d in da_xoa:
1200
+ heapq.heappush(self._id_tu_do, d["id"]) # trả ID về pool
1201
+ ten = ", ".join(f"{TEN_HIEN_THI[d['loai']]}#{d['id']}"
1202
+ for d in da_xoa)
1203
+ self.chon = []
1204
+ self._ve_lai()
1205
+ self._lam_moi_bang_thuoc_tinh()
1206
+ self._day_undo(anh_truoc)
1207
+ self.dirty = True
1208
+ self._cap_nhat_trang_thai(f"● Đã xóa: {ten}")
1209
+ if da_xoa:
1210
+ self._toast(f"Đã xóa {len(da_xoa)} thành phần — "
1211
+ f"thành phần mới sẽ nhận lại ID vừa xóa")
1212
+
1213
+ def _nhan_ban(self):
1214
+ """Nhân bản các thành phần đang chọn (ID mới, lệch +14px)."""
1215
+ if not self.chon:
1216
+ return
1217
+ anh_truoc = self._chup()
1218
+ ds = self._danh_sach()
1219
+ moi = []
1220
+ for cid in self.chon:
1221
+ goc = self._comp(cid)
1222
+ if not goc:
1223
+ continue
1224
+ d = copy.deepcopy(goc)
1225
+ d["id"] = self._xin_id()
1226
+ d["x"] += 14
1227
+ d["y"] += 14
1228
+ ds.append(d)
1229
+ moi.append(d["id"])
1230
+ self.chon = moi
1231
+ self._ve_lai()
1232
+ self._lam_moi_bang_thuoc_tinh()
1233
+ self._day_undo(anh_truoc)
1234
+ self.dirty = True
1235
+
1236
+ def _doi_z(self, lenh):
1237
+ """Đổi thứ tự chồng lớp: len / xuong / tren / duoi."""
1238
+ if not self.chon:
1239
+ return
1240
+ anh_truoc = self._chup()
1241
+ ds = self._danh_sach()
1242
+ chinh = self._comp(self.chon[-1])
1243
+ if not chinh:
1244
+ return
1245
+ i = ds.index(chinh)
1246
+ ds.pop(i)
1247
+ if lenh == "len":
1248
+ ds.insert(min(i + 1, len(ds)), chinh)
1249
+ elif lenh == "xuong":
1250
+ ds.insert(max(i - 1, 0), chinh)
1251
+ elif lenh == "tren":
1252
+ ds.append(chinh)
1253
+ else:
1254
+ ds.insert(0, chinh)
1255
+ self._ve_lai()
1256
+ self._day_undo(anh_truoc)
1257
+ self.dirty = True
1258
+
1259
+ # ==================================================================
1260
+ # UNDO / REDO (ảnh chụp thiết kế)
1261
+ # ==================================================================
1262
+ def _chup(self):
1263
+ return copy.deepcopy(
1264
+ {"window": self.design["window"],
1265
+ "components": self.design["components"]})
1266
+
1267
+ def _chup_truoc_khi_sua(self):
1268
+ """Lưu ảnh trước khi sửa liên tục (kéo nhiều bước = 1 undo)."""
1269
+ if self.ban_dau_undo is None:
1270
+ self.ban_dau_undo = self._chup()
1271
+
1272
+ def _day_undo(self, anh=None):
1273
+ """Đưa ảnh TRƯỚC KHI SỬA vào chồng undo — gọi khi thao tác xong.
1274
+
1275
+ Ưu tiên ảnh truyền vào, rồi đến ban_dau_undo (kéo/sửa liên tục),
1276
+ cuối cùng là ảnh hiện tại (fallback an toàn).
1277
+ """
1278
+ if anh is None and self.ban_dau_undo is not None:
1279
+ anh, self.ban_dau_undo = self.ban_dau_undo, None
1280
+ if anh is None:
1281
+ anh = self._chup()
1282
+ self.undo_ds.append(anh)
1283
+ if len(self.undo_ds) > 80:
1284
+ self.undo_ds.pop(0)
1285
+ self.redo_ds.clear()
1286
+
1287
+ def _phuc_hoi(self, anh):
1288
+ self.design["window"] = copy.deepcopy(anh["window"])
1289
+ self.design["components"] = copy.deepcopy(anh["components"])
1290
+ ids = [c["id"] for c in self.design["components"]]
1291
+ self._id_tiep_theo = (max(ids) + 1) if ids else 1
1292
+ self._id_tu_do = []
1293
+ self.chon = [cid for cid in self.chon if cid in ids]
1294
+ self._ve_lai()
1295
+ self._lam_moi_bang_thuoc_tinh()
1296
+ self.dirty = True
1297
+
1298
+ def _hoan_tac(self):
1299
+ if not self.undo_ds:
1300
+ self._toast("Không còn gì để hoàn tác")
1301
+ return
1302
+ self.redo_ds.append(self._chup())
1303
+ self._phuc_hoi(self.undo_ds.pop())
1304
+ self._cap_nhat_trang_thai("● Đã hoàn tác")
1305
+
1306
+ def _lam_lai(self):
1307
+ if not self.redo_ds:
1308
+ self._toast("Không còn gì để làm lại")
1309
+ return
1310
+ self.undo_ds.append(self._chup())
1311
+ self._phuc_hoi(self.redo_ds.pop())
1312
+ self._cap_nhat_trang_thai("● Đã làm lại")
1313
+
1314
+ # ==================================================================
1315
+ # BẢNG THUỘC TÍNH (phải)
1316
+ # ==================================================================
1317
+ def _lam_moi_bang_thuoc_tinh(self):
1318
+ """Vẽ lại panel phải theo trạng thái chọn."""
1319
+ for w in self.noi_bang.winfo_children():
1320
+ w.destroy()
1321
+ if self.chon:
1322
+ self._bang_thanh_phan()
1323
+ else:
1324
+ self._bang_cua_so()
1325
+
1326
+ def _tieu_de_bang(self, chu):
1327
+ tk.Label(self.noi_bang, text=chu, fg=MAU["chu_mo"],
1328
+ bg=MAU["nen_panel"], font=font_ui(7, True),
1329
+ anchor="w").pack(fill="x", pady=(10, 4))
1330
+
1331
+ def _hang(self, nhan, widget):
1332
+ """Một hàng: nhãn trái + control phải."""
1333
+ f = tk.Frame(self.noi_bang, bg=MAU["nen_panel"])
1334
+ f.pack(fill="x", pady=2)
1335
+ tk.Label(f, text=nhan, fg=MAU["chu"], bg=MAU["nen_panel"],
1336
+ font=font_ui(8), width=9, anchor="w").pack(side="left")
1337
+ widget.pack(side="left", fill="x", expand=True)
1338
+ return f
1339
+
1340
+ def _o_nhap(self, gia_tri, lenh, chi_so=False, rong=8):
1341
+ """Entry tối màu — commit khi rời ô hoặc Enter."""
1342
+ o = tk.Entry(self.noi_bang, bg="#3c3c3c", fg=MAU["chu_sang"],
1343
+ insertbackground=MAU["chu_sang"], relief="flat",
1344
+ highlightthickness=1, font=font_ui(8),
1345
+ highlightbackground=MAU["vien"],
1346
+ highlightcolor=MAU["gt"])
1347
+ o.insert(0, str(gia_tri if gia_tri is not None else ""))
1348
+ o.pack_propagate(False)
1349
+
1350
+ def _cam_ket(_e=None):
1351
+ v = o.get().strip()
1352
+ if chi_so:
1353
+ try:
1354
+ v = int(float(v))
1355
+ except ValueError:
1356
+ return
1357
+ lenh(v)
1358
+ o.bind("<Return>", _cam_ket)
1359
+ o.bind("<FocusOut>", _cam_ket)
1360
+ return o
1361
+
1362
+ def _o_mau(self, mau_hien_tai, lenh):
1363
+ """Swatch màu + click chọn + chuột phải đặt lại mặc định."""
1364
+ f = tk.Frame(self.noi_bang, bg=MAU["nen_panel"])
1365
+ sw = tk.Label(f, width=3, height=1, bd=0, cursor="hand2",
1366
+ bg=mau_hien_tai or "#3c3c3c",
1367
+ highlightthickness=1,
1368
+ highlightbackground=MAU["vien"])
1369
+
1370
+ def _chon():
1371
+ m = colorchooser.askcolor(parent=self.root)[1]
1372
+ if m:
1373
+ sw.config(bg=m)
1374
+ lenh(m)
1375
+
1376
+ def _dat_lai(_e=None):
1377
+ sw.config(bg="#3c3c3c")
1378
+ lenh(None)
1379
+ sw.bind("<Button-1>", lambda e: _chon())
1380
+ sw.bind("<Button-3>", _dat_lai)
1381
+ sw.pack(side="left")
1382
+ tk.Label(f, text="chuột phải = mặc định", fg=MAU["chu_mo"],
1383
+ bg=MAU["nen_panel"], font=font_ui(7)).pack(side="left",
1384
+ padx=6)
1385
+ return f
1386
+
1387
+ # ----------------------------------------------------- bảng cửa sổ
1388
+ def _bang_cua_so(self):
1389
+ win = self.design["window"]
1390
+ self._tieu_de_bang("CỬA SỔ ỨNG DỤNG")
1391
+
1392
+ def _dat(k, ep_kieu=None):
1393
+ def f(v):
1394
+ self._chup_truoc_khi_sua()
1395
+ if ep_kieu:
1396
+ try:
1397
+ v = ep_kieu(v)
1398
+ except (TypeError, ValueError):
1399
+ return
1400
+ win[k] = v
1401
+ self.dirty = True
1402
+ if k in ("width", "height", "bg"):
1403
+ self._ve_lai()
1404
+ elif k == "title":
1405
+ self._ve_lai()
1406
+ return f
1407
+
1408
+ self._hang("Tiêu đề",
1409
+ self._o_nhap(win.get("title", ""), _dat("title")))
1410
+ self._hang("Rộng (px)",
1411
+ self._o_nhap(win.get("width", 860), _dat("width", int),
1412
+ chi_so=True))
1413
+ self._hang("Cao (px)",
1414
+ self._o_nhap(win.get("height", 560), _dat("height", int),
1415
+ chi_so=True))
1416
+ self._hang("Màu nền",
1417
+ self._o_mau(win.get("bg"), _dat("bg")))
1418
+ self._tieu_de_bang("GỢI Ý")
1419
+ tk.Label(self.noi_bang,
1420
+ text="Chọn một thành phần để sửa thuộc tính.\n"
1421
+ "Vùng trống: quét chuột để chọn nhiều.\n"
1422
+ "Double-click thành phần: sửa chữ nhanh.",
1423
+ fg=MAU["chu_mo"], bg=MAU["nen_panel"], font=font_ui(8),
1424
+ justify="left", anchor="w", wraplength=236).pack(
1425
+ fill="x", pady=(2, 0))
1426
+
1427
+ # ----------------------------------------------------- bảng thành phần
1428
+ def _bang_thanh_phan(self):
1429
+ chinh = self._comp(self.chon[-1])
1430
+ if chinh is None:
1431
+ return
1432
+ loai = chinh["loai"]
1433
+ self._tieu_de_bang(
1434
+ f"{TEN_HIEN_THI[loai].upper()} #{chinh['id']}"
1435
+ + (f" + {len(self.chon) - 1} khác" if len(self.chon) > 1 else ""))
1436
+
1437
+ def _dat(k, ep_kieu=None, ve_lai=True):
1438
+ def f(v):
1439
+ self._chup_truoc_khi_sua()
1440
+ if ep_kieu:
1441
+ try:
1442
+ v = ep_kieu(v)
1443
+ except (TypeError, ValueError):
1444
+ return
1445
+ if k == "text" and v == "":
1446
+ v = ""
1447
+ chinh[k] = v
1448
+ self.dirty = True
1449
+ if ve_lai:
1450
+ self._ve_comp_tu_dong(chinh["id"])
1451
+ self._cap_nhat_vien_chon()
1452
+ self._chup_ve_cuoi_hang()
1453
+ return f
1454
+
1455
+ # các trường theo loại
1456
+ if loai in ("label", "button", "checkbox"):
1457
+ self._hang("Chữ", self._o_nhap(chinh.get("text") or "",
1458
+ _dat("text")))
1459
+ elif loai == "input":
1460
+ self._hang("Giá trị đầu", self._o_nhap(chinh.get("text") or "",
1461
+ _dat("text")))
1462
+ elif loai == "text":
1463
+ o = tk.Text(self.noi_bang, height=3, bd=0, bg="#3c3c3c",
1464
+ fg=MAU["chu_sang"], insertbackground=MAU["chu_sang"],
1465
+ font=font_ui(8), relief="flat", padx=6, pady=4,
1466
+ highlightthickness=1,
1467
+ highlightbackground=MAU["vien"])
1468
+ o.insert("1.0", chinh.get("text") or "")
1469
+
1470
+ def _cam(_e=None):
1471
+ _dat("text")(o.get("1.0", "end-1c"))
1472
+ o.bind("<FocusOut>", _cam)
1473
+ self._hang("Nội dung", o)
1474
+
1475
+ self._hang("X", self._o_nhap(chinh["x"], _dat("x", int),
1476
+ chi_so=True))
1477
+ self._hang("Y", self._o_nhap(chinh["y"], _dat("y", int),
1478
+ chi_so=True))
1479
+ self._hang("Rộng", self._o_nhap(chinh.get("w") or 100,
1480
+ _dat("w", int), chi_so=True))
1481
+ self._hang("Cao", self._o_nhap(chinh.get("h") or 30,
1482
+ _dat("h", int), chi_so=True))
1483
+ self._hang("Màu chữ", self._o_mau(chinh.get("color"),
1484
+ _dat("color")))
1485
+ self._hang("Màu nền", self._o_mau(chinh.get("bg"), _dat("bg")))
1486
+ self._hang("Cỡ chữ", self._o_nhap(chinh.get("font_size") or 10,
1487
+ _dat("font_size", int),
1488
+ chi_so=True))
1489
+
1490
+ # ô kiểm
1491
+ if loai == "checkbox":
1492
+ self._o_kiem(
1493
+ "Đang chọn (checked)", chinh.get("checked"),
1494
+ lambda: _dat("checked", bool)(not chinh.get("checked")))
1495
+ self._o_kiem(
1496
+ "Hiện khi chạy (visible)", chinh.get("visible", True),
1497
+ lambda: _dat("visible", bool)(
1498
+ not chinh.get("visible", True)))
1499
+ self._o_kiem(
1500
+ "Kích hoạt (enabled)", chinh.get("enabled", True),
1501
+ lambda: _dat("enabled", bool)(
1502
+ not chinh.get("enabled", True)))
1503
+
1504
+ # nút tiện dụng
1505
+ duoi = tk.Frame(self.noi_bang, bg=MAU["nen_panel"])
1506
+ duoi.pack(fill="x", pady=(10, 0))
1507
+ NutPhang(duoi, "⌨ Sửa trigger", lambda: self._sua_trigger(),
1508
+ co=8).pack(side="left")
1509
+ NutPhang(duoi, "⧉ Nhân bản", self._nhan_ban, co=8).pack(
1510
+ side="left", padx=4)
1511
+ NutPhang(duoi, "🗑 Xóa", self._xoa_dang_chon, co=8).pack(
1512
+ side="left", padx=4)
1513
+
1514
+ def _o_kiem(self, nhan, gia_tri, lenh):
1515
+ """Một ô kiểm (Checkbutton) tối màu trong bảng thuộc tính."""
1516
+ f = tk.Frame(self.noi_bang, bg=MAU["nen_panel"])
1517
+ f.pack(fill="x", pady=2)
1518
+ ck = tk.Checkbutton(f, text=nhan,
1519
+ variable=tk.BooleanVar(value=bool(gia_tri)),
1520
+ bg=MAU["nen_panel"], fg=MAU["chu"],
1521
+ activebackground=MAU["nen_panel"],
1522
+ selectcolor="#2d2d30", font=font_ui(8),
1523
+ anchor="w", command=lenh)
1524
+ ck.pack(side="left")
1525
+
1526
+ def _chup_ve_cuoi_hang(self):
1527
+ """Hẹn đẩy undo 700ms sau lần sửa cuối (gộp thao tác liên tục)."""
1528
+ if getattr(self, "_hen_undo_cuoi", None):
1529
+ try:
1530
+ self.root.after_cancel(self._hen_undo_cuoi)
1531
+ except Exception:
1532
+ pass
1533
+ self._hen_undo_cuoi = self.root.after(700, self._ket_thuc_sua)
1534
+
1535
+ def _ket_thuc_sua(self):
1536
+ self._hen_undo_cuoi = None
1537
+ self._day_undo()
1538
+
1539
+ # ==================================================================
1540
+ # TRIGGER — file .triggers.py dùng chung (Studio + VS Code)
1541
+ # ==================================================================
1542
+ def _mau_triggers(self):
1543
+ """Nội dung mẫu khi file trigger chưa tồn tại."""
1544
+ ten = (self.duong_triggers.name if self.duong_triggers
1545
+ else "triggers.py")
1546
+ return (
1547
+ "# " + "=" * 70 + "\n"
1548
+ f"# {ten} — trigger cho ứng dụng\n"
1549
+ "# File này DÙNG CHUNG: sửa ở FlexGUI Studio HOẶC VS Code.\n"
1550
+ "# (label/button/input/checkbox/text đã có sẵn — không cần "
1551
+ "import)\n"
1552
+ "# " + "=" * 70 + "\n"
1553
+ "\n"
1554
+ "def on_start():\n"
1555
+ ' """Chạy 1 lần khi app mở."""\n'
1556
+ " pass\n"
1557
+ "\n"
1558
+ )
1559
+
1560
+ def _doc_triggers(self):
1561
+ """Nạp file trigger vào editor (file cũ hoặc mẫu mới)."""
1562
+ if self.duong_triggers and Path(self.duong_triggers).exists():
1563
+ try:
1564
+ self.editor.dat(
1565
+ Path(self.duong_triggers).read_text(encoding="utf-8"))
1566
+ return
1567
+ except Exception:
1568
+ pass
1569
+ self.editor.dat(self._mau_triggers())
1570
+
1571
+ def _bo_sung_skeleton(self):
1572
+ """Thêm sườn hàm trigger cho thành phần chưa có — giữ code cũ."""
1573
+ nguon = self.editor.lay()
1574
+ cac_dong = [nguon]
1575
+ for d in self._danh_sach():
1576
+ su_kien = SUKIEN_CHINH.get(d["loai"], "click")
1577
+ ten = f"on_{d['loai']}_{d['id']}_{su_kien}"
1578
+ if re.search(rf"^\s*def\s+{ten}\s*\(", nguon, re.M):
1579
+ continue
1580
+ if su_kien == "toggle":
1581
+ mo_ta = f"{TEN_HIEN_THI[d['loai']]} #{d['id']} được tick/bỏ tick"
1582
+ elif su_kien == "enter":
1583
+ mo_ta = f"nhấn Enter trong Input #{d['id']}"
1584
+ elif su_kien == "change":
1585
+ mo_ta = f"gõ chữ trong Text #{d['id']}"
1586
+ else:
1587
+ mo_ta = f"{TEN_HIEN_THI[d['loai']]} #{d['id']} được nhấn"
1588
+ cac_dong.append(
1589
+ f"\n\ndef {ten}():\n"
1590
+ f' """Chạy khi {mo_ta}."""\n'
1591
+ f" pass\n")
1592
+ if len(cac_dong) > 1:
1593
+ self.editor.dat("".join(cac_dong), dat_con_tro=True)
1594
+
1595
+ def _ghi_triggers(self, bao=False):
1596
+ """Ghi buffer editor ra file .triggers.py (kèm skeleton)."""
1597
+ if not self.duong_triggers:
1598
+ # chưa gắn file app — hỏi nơi lưu chung với JSON
1599
+ if not self._hoi_duong_luu():
1600
+ return False
1601
+ self._bo_sung_skeleton()
1602
+ try:
1603
+ Path(self.duong_triggers).write_text(self.editor.lay(),
1604
+ encoding="utf-8")
1605
+ if bao:
1606
+ self._toast("✓ Đã lưu " + self._ten_triggers(),
1607
+ "#173f2f")
1608
+ return True
1609
+ except Exception:
1610
+ messagebox.showerror("Không lưu được trigger",
1611
+ traceback.format_exc())
1612
+ return False
1613
+
1614
+ def _sua_trigger(self, cid=None):
1615
+ """Mở drawer trigger, nhảy tới hàm của thành phần đang chọn."""
1616
+ self._mo_drawer()
1617
+ d = self._comp(cid if cid is not None
1618
+ else (self.chon[-1] if self.chon else None))
1619
+ if not d:
1620
+ return
1621
+ su_kien = SUKIEN_CHINH.get(d["loai"], "click")
1622
+ ten = f"on_{d['loai']}_{d['id']}_{su_kien}"
1623
+ if self.editor.tim_ham(ten) is None:
1624
+ self._bo_sung_skeleton()
1625
+ dong = self.editor.tim_ham(ten)
1626
+ if dong:
1627
+ self.editor.den_dong(dong)
1628
+
1629
+ def _dao_drawer(self):
1630
+ """Bật/tắt ngăn kéo trigger dưới cùng."""
1631
+ if self.drawer.winfo_ismapped():
1632
+ self._an_drawer()
1633
+ else:
1634
+ self._mo_drawer()
1635
+
1636
+ def _mo_drawer(self):
1637
+ if not self.drawer.winfo_ismapped():
1638
+ self.drawer.pack(fill="x", side="bottom")
1639
+ self.nut_drawer.dat_bat(True)
1640
+ self.editor.o.focus_set()
1641
+ try:
1642
+ self.editor.o.mark_set("insert", "insert")
1643
+ except Exception:
1644
+ pass
1645
+
1646
+ def _an_drawer(self):
1647
+ self.drawer.pack_forget()
1648
+ self.nut_drawer.dat_bat(False)
1649
+ self.ban_ve.focus_set()
1650
+
1651
+ def _chen_mau(self):
1652
+ """Menu chèn snippet code mẫu tại vị trí con trỏ editor."""
1653
+ menu = tk.Menu(self.root, tearoff=0, bg=MAU["nen_nut"],
1654
+ fg=MAU["chu"], activebackground=MAU["gt"],
1655
+ activeforeground="#ffffff", bd=0)
1656
+ for ten, code in MUC_CHEN:
1657
+ menu.add_command(
1658
+ label=ten,
1659
+ command=lambda c=code: self._chen_code(c))
1660
+ menu.tk_popup(self.root.winfo_pointerx(), self.root.winfo_pointery())
1661
+
1662
+ def _chen_code(self, code):
1663
+ self._mo_drawer()
1664
+ try:
1665
+ self.editor.o.insert("insert", code)
1666
+ self.editor._lam_moi_sau()
1667
+ except Exception:
1668
+ pass
1669
+
1670
+ # ==================================================================
1671
+ # LƯU / XEM TRƯỚC / XUẤT / BUILD
1672
+ # ==================================================================
1673
+ def _hoi_duong_luu(self):
1674
+ """Hỏi nơi lưu khi chưa có file (chưa gắn với file .py nào)."""
1675
+ duong = filedialog.asksaveasfilename(
1676
+ title="Lưu thiết kế FlexGUI",
1677
+ defaultextension=".flexgui.json",
1678
+ filetypes=[("Thiết kế FlexGUI", "*.flexgui.json")])
1679
+ if not duong:
1680
+ return False
1681
+ self.duong_json = Path(duong)
1682
+ self.duong_triggers = self.duong_json.parent / (
1683
+ self.duong_json.name[:-len(".flexgui.json")] + ".triggers.py")
1684
+ self.nhan_ten_file.config(text="· " + self.duong_json.name)
1685
+ self.nhan_tab.config(text=" ⌨ " + self._ten_triggers()
1686
+ + " • Python • UTF-8")
1687
+ return True
1688
+
1689
+ def _luu_toan_bo(self, bao=False, im_lang=False):
1690
+ """Ctrl+S — lưu thiết kế (.json) + trigger (.triggers.py)."""
1691
+ if not self.duong_json and not self._hoi_duong_luu():
1692
+ return False
1693
+ self._bo_sung_skeleton()
1694
+ try:
1695
+ Path(self.duong_json).write_text(
1696
+ json.dumps(self.design, ensure_ascii=False, indent=1),
1697
+ encoding="utf-8")
1698
+ Path(self.duong_triggers).write_text(self.editor.lay(),
1699
+ encoding="utf-8")
1700
+ except Exception:
1701
+ if not im_lang:
1702
+ messagebox.showerror("Không lưu được",
1703
+ traceback.format_exc())
1704
+ return False
1705
+ self.dirty = False
1706
+ if bao:
1707
+ self._toast(f"✓ Đã lưu {self.duong_json.name}", "#173f2f")
1708
+ self._cap_nhat_trang_thai("● Đã lưu")
1709
+ return True
1710
+
1711
+ def _xem_truoc(self):
1712
+ """F5 — chạy thử THẬT thiết kế hiện tại (không cần lưu file)."""
1713
+ if _reg._app_hien_tai() is not None:
1714
+ messagebox.showinfo("Đang mở",
1715
+ "Hãy đóng cửa sổ xem trước hiện tại trước.")
1716
+ return
1717
+ thiet_ke = copy.deepcopy(self.design)
1718
+ win = thiet_ke["window"]
1719
+ try:
1720
+ app = App(win.get("title", "Xem trước"), win.get("width", 860),
1721
+ win.get("height", 560), bg=win.get("bg"),
1722
+ goc=self.root)
1723
+ for d in thiet_ke["components"]:
1724
+ _tao_tu_du_lieu(app, d)
1725
+ loi = nap_trigger(app, self.editor.lay(),
1726
+ ten_file=str(self.duong_triggers or "<triggers>"))
1727
+ if loi:
1728
+ chi_tiet = "\n\n".join(f"— {b} —\n{t}" for b, t in loi)
1729
+ messagebox.showwarning(
1730
+ "Trigger có lỗi (vẫn xem trước được)",
1731
+ "Một số đoạn trigger không chạy được:\n\n" + chi_tiet,
1732
+ parent=self.root)
1733
+ app.run(chay_mainloop=False)
1734
+ self._cap_nhat_trang_thai("● Đang xem trước…")
1735
+ except Exception:
1736
+ _reg._dat_app(None)
1737
+ messagebox.showerror("Lỗi xem trước", traceback.format_exc(),
1738
+ parent=self.root)
1739
+
1740
+ def _ten_sach(self):
1741
+ """Tên an toàn cho file build (ASCII)."""
1742
+ ten = (self.duong_json.stem if self.duong_json
1743
+ else (self.duong_dan.stem if self.duong_dan else "ung_dung"))
1744
+ ten = ten[:-len(".flexgui")] if ten.endswith(".flexgui") else ten
1745
+ re.sub(r"[^A-Za-z0-9_-]+", "_", ten)
1746
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", ten) or "ung_dung"
1747
+
1748
+ def _duong_file_dung(self):
1749
+ """Thư mục + file .py gộp dùng chung cho xuất/build."""
1750
+ if not self.duong_json and not self._hoi_duong_luu():
1751
+ return None
1752
+ thu_muc = self.duong_json.parent / "flexgui_build"
1753
+ thu_muc.mkdir(exist_ok=True)
1754
+ return thu_muc / (self._ten_sach() + ".py")
1755
+
1756
+ def _xuat_py(self):
1757
+ """Xuất MỘT file .py tự chạy (nhúng thiết kế + trigger)."""
1758
+ if not self._luu_toan_bo(im_lang=True):
1759
+ return
1760
+ duong = self._duong_file_dung()
1761
+ if not duong:
1762
+ return
1763
+ try:
1764
+ builder.xuat_file_dung(duong, self.design, self.editor.lay())
1765
+ self._toast(f"✓ Đã xuất {duong.name} (thư mục flexgui_build)")
1766
+ messagebox.showinfo(
1767
+ "Đã xuất file chạy đơn",
1768
+ f"Đã tạo:\n{duong}\n\nChạy bằng: python {duong.name}\n"
1769
+ f"Cần đã cài: pip install flexgui", parent=self.root)
1770
+ except Exception:
1771
+ messagebox.showerror("Lỗi xuất file", traceback.format_exc(),
1772
+ parent=self.root)
1773
+
1774
+ def _build_exe(self):
1775
+ """Build .exe bằng PyInstaller (chỉ khi user bấm nút)."""
1776
+ if not self._luu_toan_bo(im_lang=True):
1777
+ return
1778
+ duong = self._duong_file_dung()
1779
+ if not duong:
1780
+ return
1781
+ try:
1782
+ builder.xuat_file_dung(duong, self.design, self.editor.lay())
1783
+ self._dong_goi = builder.TrinhDongGoi(self.root,
1784
+ self._ten_sach())
1785
+ self._dong_goi.chay(duong)
1786
+ except Exception:
1787
+ messagebox.showerror("Lỗi build", traceback.format_exc(),
1788
+ parent=self.root)
1789
+
1790
+ # ==================================================================
1791
+ # MENU CHUỘT PHẢI / DOUBLE-CLICK / TRỢ GIÚP
1792
+ # ==================================================================
1793
+ def _nhan_kp(self, e):
1794
+ """Double-click: sửa chữ nhanh thành phần."""
1795
+ d = self._tim_comp_tai(e.x, e.y)
1796
+ if not d:
1797
+ return
1798
+ self.chon = [d["id"]]
1799
+ self._cap_nhat_vien_chon()
1800
+ self._lam_moi_bang_thuoc_tinh()
1801
+ if d["loai"] in ("label", "button", "checkbox", "input", "text"):
1802
+ moi = simpledialog.askstring(
1803
+ "Sửa nhanh nội dung",
1804
+ "Nội dung mới cho "
1805
+ f"{TEN_HIEN_THI[d['loai']]} #{d['id']}:",
1806
+ initialvalue=d.get("text") or "", parent=self.root)
1807
+ if moi is not None:
1808
+ self._chup_truoc_khi_sua()
1809
+ d["text"] = moi
1810
+ self._ve_comp_tu_dong(d["id"])
1811
+ self._ket_thuc_sua()
1812
+ self.dirty = True
1813
+
1814
+ def _menu_phai(self, e):
1815
+ menu = tk.Menu(self.root, tearoff=0, bg=MAU["nen_nut"],
1816
+ fg=MAU["chu"], activebackground=MAU["gt"],
1817
+ activeforeground="#ffffff", bd=0,
1818
+ font=font_ui(9))
1819
+ d = self._tim_comp_tai(e.x, e.y)
1820
+ if d is not None:
1821
+ if d["id"] not in self.chon:
1822
+ self.chon = [d["id"]]
1823
+ self._cap_nhat_vien_chon()
1824
+ menu.add_command(label=f"Sửa chữ… (double-click)",
1825
+ command=lambda: self._nhan_kp(e))
1826
+ menu.add_command(label="Sửa trigger…", command=self._sua_trigger)
1827
+ menu.add_command(label="Nhân bản (Ctrl+D)",
1828
+ command=self._nhan_ban)
1829
+ menu.add_separator()
1830
+ menu.add_command(label="Đưa lên một lớp",
1831
+ command=lambda: self._doi_z("len"))
1832
+ menu.add_command(label="Đưa xuống một lớp",
1833
+ command=lambda: self._doi_z("xuong"))
1834
+ menu.add_command(label="Nằm trên cùng",
1835
+ command=lambda: self._doi_z("tren"))
1836
+ menu.add_command(label="Nằm dưới cùng",
1837
+ command=lambda: self._doi_z("duoi"))
1838
+ menu.add_separator()
1839
+ menu.add_command(label="Xóa (Delete)",
1840
+ command=self._xoa_dang_chon)
1841
+ else:
1842
+ menu.add_command(label="Chọn hết (Ctrl+A)",
1843
+ command=lambda: self._chon_het())
1844
+ try:
1845
+ menu.tk_popup(e.x_root, e.y_root)
1846
+ finally:
1847
+ menu.grab_release()
1848
+
1849
+ def _chon_het(self):
1850
+ self.chon = [d["id"] for d in self._danh_sach()]
1851
+ self._cap_nhat_vien_chon()
1852
+
1853
+ def _mo_tro_giup(self):
1854
+ """F1 — bảng phím tắt + hướng dẫn nhanh."""
1855
+ hop = tk.Toplevel(self.root)
1856
+ hop.title("FlexGUI Studio — Trợ giúp")
1857
+ hop.configure(bg=MAU["nen_panel"])
1858
+ hop.geometry("640x560")
1859
+ hop.transient(self.root)
1860
+ noi_dung = (
1861
+ ("PHÍM TẮT", ""),
1862
+ ("Ctrl+S", "Lưu thiết kế (.flexgui.json + .triggers.py)"),
1863
+ ("F5", "Xem trước — chạy thử app thật (kéo được, trigger chạy)"),
1864
+ ("Delete / Backspace", "Xóa các thành phần đang chọn"),
1865
+ ("Ctrl+Z / Ctrl+Y", "Hoàn tác / Làm lại"),
1866
+ ("Ctrl+D", "Nhân bản thành phần đang chọn"),
1867
+ ("Ctrl+A", "Chọn hết"),
1868
+ ("Mũi tên (+Shift)", "Dịch 1px (Shift = 10px, tự snap lưới)"),
1869
+ ("Escape", "Bỏ chọn hết"),
1870
+ ("", ""),
1871
+ ("TƯƠNG TÁC", ""),
1872
+ ("Kéo", "Di chuyển — giữ Ctrl/Shift + click để chọn nhiều, "
1873
+ "quét vùng chuột trên nền trống"),
1874
+ ("8 núm xanh", "Resize — viền + góc, tự snap lưới 10px"),
1875
+ ("Double-click", "Sửa chữ nhanh"),
1876
+ ("Chuột phải", "Menu: z-order, nhân bản, xóa, trigger"),
1877
+ ("", ""),
1878
+ ("QUY TRÌNH XUẤT BẢN", ""),
1879
+ ("1.", "flexgui.setup() trong file .py → chạy → Studio mở"),
1880
+ ("2.", "Kéo thả, chỉnh thuộc tính, viết trigger (nút Triggers)"),
1881
+ ("3.", "Xuất .py (1 file tự chạy) hoặc Build .exe (PyInstaller)"),
1882
+ ("4.", "Chạy thật: flexgui.run(__file__) thay cho setup()"),
1883
+ )
1884
+ f = font_mono(9)
1885
+ for i, (a, b) in enumerate(noi_dung):
1886
+ mau = MAU["chu_sang"] if b == "" else MAU["chu"]
1887
+ trong = MAU["chu"] if b == "" else MAU["chu_mo"]
1888
+ tk.Label(hop, text=a, fg=mau, bg=MAU["nen_panel"], font=f,
1889
+ width=20, anchor="w").grid(row=i, column=0,
1890
+ sticky="nw", padx=(16, 0),
1891
+ pady=1)
1892
+ tk.Label(hop, text=b, fg=trong, bg=MAU["nen_panel"], font=f,
1893
+ anchor="w", justify="left", wraplength=420).grid(
1894
+ row=i, column=1, sticky="nw", padx=(0, 16), pady=1)
1895
+ NutPhang(hop, "Đã hiểu", hop.destroy, rong=10).grid(
1896
+ row=len(noi_dung) + 1, column=0, columnspan=2, pady=14)
1897
+
1898
+ # ==================================================================
1899
+ # PHÍM TẮT (an toàn bộ gõ — bỏ qua khi đang gõ trong ô nhập)
1900
+ # ==================================================================
1901
+ def _gan_phim(self):
1902
+ self.root.bind("<KeyPress>", self._nhan_phim)
1903
+ self.root.bind("<Control-s>", self._phim_luu)
1904
+ self.root.bind("<F5>", self._phim_xem_truoc)
1905
+ self.editor.o.bind("<Control-s>", self._phim_luu)
1906
+
1907
+ def _phim_luu(self, _e=None):
1908
+ self._luu_toan_bo(True)
1909
+ return "break"
1910
+
1911
+ def _phim_xem_truoc(self, _e=None):
1912
+ self._xem_truoc()
1913
+ return "break"
1914
+
1915
+ @staticmethod
1916
+ def _dang_go_van_ban(w):
1917
+ """True khi focus đang trong ô nhập/editor — không chặn phím của IME."""
1918
+ return w is not None and w.winfo_class() in (
1919
+ "Text", "Entry", "TSpinbox", "TEntry", "TCombobox")
1920
+
1921
+ def _nhan_phim(self, e):
1922
+ # Đang gõ trong ô nhập / editor → KHÔNG can thiệp (bảo vệ IME)
1923
+ if self._dang_go_van_ban(self.root.focus_get()):
1924
+ return
1925
+ ks = e.keysym.lower()
1926
+
1927
+ if ks in ("delete", "backspace"):
1928
+ self._xoa_dang_chon()
1929
+ return "break"
1930
+ if ks == "escape":
1931
+ self.chon = []
1932
+ self._cap_nhat_vien_chon()
1933
+ self._lam_moi_bang_thuoc_tinh()
1934
+ return
1935
+ if ks in ("left", "right", "up", "down"):
1936
+ self._di_chuyen_bang_phim(ks, e)
1937
+ return "break"
1938
+ if not (e.state & 0x0004):
1939
+ return
1940
+ # ---- tổ hợp Ctrl+... ----
1941
+ if ks == "a":
1942
+ self._chon_het()
1943
+ return "break"
1944
+ if ks == "z":
1945
+ self._hoan_tac()
1946
+ return "break"
1947
+ if ks == "y":
1948
+ self._lam_lai()
1949
+ return "break"
1950
+ if ks == "d":
1951
+ self._nhan_ban()
1952
+ return "break"
1953
+ if ks == "s":
1954
+ self._luu_toan_bo(True)
1955
+ return "break"
1956
+
1957
+ def _di_chuyen_bang_phim(self, ks, e):
1958
+ """Mũi tên: 1px, Shift = 10px — KHÔNG snap (giữ nguyên độ chính xác)."""
1959
+ if not self.chon:
1960
+ return
1961
+ buoc = 10 if (e.state & 0x0001) else 1
1962
+ d = {"left": (-buoc, 0), "right": (buoc, 0),
1963
+ "up": (0, -buoc), "down": (0, buoc)}[ks]
1964
+ self._chup_truoc_khi_sua()
1965
+ for cid in self.chon:
1966
+ c = self._comp(cid)
1967
+ if c:
1968
+ c["x"] += d[0]
1969
+ c["y"] += d[1]
1970
+ self._ve_comp_tu_dong(cid) # vẽ lại đúng vị trí mới
1971
+ self._cap_nhat_vien_chon()
1972
+ self.dirty = True
1973
+ self._chup_ve_cuoi_hang()
1974
+