thonny-ikode 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of thonny-ikode might be problematic. Click here for more details.

@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: thonny-ikode
3
+ Version: 1.0.2
4
+ Summary: Thonny plugin by IKODE: generate PyQt5 loadUi code and preview Qt Designer .ui forms
5
+ Author-email: Bejaoui Dhia <admin@ikode.tn>
6
+ Maintainer-email: IKODE <admin@ikode.tn>
7
+ License-Expression: LicenseRef-Proprietary
8
+ Project-URL: Homepage, https://ikode.tn
9
+ Keywords: thonny,thonny-plugin,PyQt5,Qt Designer,education,IKODE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: X11 Applications
12
+ Classifier: Environment :: Win32 (MS Windows)
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: Natural Language :: French
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Topic :: Education
21
+ Classifier: Topic :: Software Development :: User Interfaces
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: thonny>=4.0
26
+ Provides-Extra: preview
27
+ Requires-Dist: Pillow; extra == "preview"
28
+ Dynamic: license-file
29
+
30
+ # thonny-ikode
31
+
32
+ Thonny plugin by IKODE for working with PyQt5 and Qt Designer `.ui` files.
33
+
34
+ ## Features
35
+
36
+ - **PyQt** button (`Ctrl+Shift+B`): pick a `.ui` file, choose the form variable name, and generate the `loadUi` code with a `*_click` handler for every `QPushButton`.
37
+ - **Éléments UI** button: interactive live preview of a `.ui` form. Hover a widget to highlight it, click to copy `form.widgetName`.
38
+ - **Tools > Oublier le nom du formulaire**: resets the remembered form name.
39
+
40
+ ## Install
41
+
42
+ From Thonny: *Tools > Open system shell*, then:
43
+
44
+ ```
45
+ pip install thonny-ikode
46
+ ```
47
+
48
+ or from this folder / a wheel:
49
+
50
+ ```
51
+ pip install .
52
+ pip install thonny_ikode-1.0.2-py3-none-any.whl
53
+ ```
54
+
55
+ Optional (sharper image previews): `pip install Pillow`
56
+
57
+ Restart Thonny afterwards.
58
+
59
+ ## Notes
60
+
61
+ - Generated code needs `PyQt5` in the interpreter used to run scripts.
62
+ - On startup the plugin sets the working folder to `Desktop/IKODE` and does not reopen previous files.
63
+
64
+ ## Build
65
+
66
+ ```
67
+ python -m pip install build
68
+ python -m build
69
+ ```
@@ -0,0 +1,9 @@
1
+ thonny_ikode-1.0.2.dist-info/licenses/LICENSE,sha256=Tcxk4taP5vz_Yn3tjrYqSUN2cJxsNhqs_XMOquEpcfc,942
2
+ thonnycontrib/ikode/__init__.py,sha256=njPomNx-MG7d-SRfGeQJsTjQxj709CImwWWA4BCd3cA,48911
3
+ thonnycontrib/ikode/res/logo.png,sha256=cmVSlHt2Ak8VDQ6r6IMjsBBx3ZECUxDkYmJIoGlny-Y,106009
4
+ thonnycontrib/ikode/res/qt_16.png,sha256=zRGwNxrYXruCHJTbv85ki1RWwbU0jTO_rC_APqzUV2I,732
5
+ thonnycontrib/ikode/res/qt_32.png,sha256=SxhSrjvi9r8EeRt90HhJcsKzN9r2uaPEkHLNc_h3Bow,1272
6
+ thonny_ikode-1.0.2.dist-info/METADATA,sha256=mE_LDh-anUPO6bRbr8rbcT0hdn9U0vs7oU5gWgImAIk,2212
7
+ thonny_ikode-1.0.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ thonny_ikode-1.0.2.dist-info/top_level.txt,sha256=thF9WQtY_lcNKOOR4SQeeWLAVWb4i8XhdGC7pEMAOHY,14
9
+ thonny_ikode-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Bejaoui Dhia / IKODE (admin@ikode.tn). All rights reserved.
2
+
3
+ This software ("thonny-ikode") is provided free of charge for personal and
4
+ educational use with the Thonny IDE.
5
+
6
+ You MAY:
7
+ - install and use it on any number of computers, including school labs;
8
+ - share the unmodified package (as downloaded from PyPI or ikode.tn).
9
+
10
+ You MAY NOT, without prior written permission from IKODE (admin@ikode.tn):
11
+ - modify, decompile, or create derivative works from it;
12
+ - remove or alter the IKODE name, logo, credits, or copyright notices;
13
+ - sell, sublicense, or redistribute it as part of another product.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL IKODE
18
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF
19
+ THE SOFTWARE.
@@ -0,0 +1 @@
1
+ thonnycontrib
@@ -0,0 +1,1213 @@
1
+ __version__ = "1.0.2"
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import keyword
7
+ import tempfile
8
+ import tkinter as tk
9
+ import webbrowser
10
+ from tkinter import messagebox, ttk
11
+ from thonny import get_workbench
12
+ from thonny.languages import tr
13
+ from thonny.ui_utils import select_sequence,askopenfilename
14
+
15
+
16
+ from xml.dom import minidom
17
+ global qt_ui_file
18
+ qt_ui_file =""
19
+ qt_form_name = ""
20
+ FORM_NAME_OPTION = "tunisiaschools.form_name"
21
+ _toolbar_logo_path = None
22
+ _live_preview_dialog = None
23
+
24
+
25
+ def _make_toolbar_logo(logo_path):
26
+ global _toolbar_logo_path
27
+ if _toolbar_logo_path and os.path.isfile(_toolbar_logo_path):
28
+ return _toolbar_logo_path
29
+ try:
30
+ logo = tk.PhotoImage(file=logo_path)
31
+ factor = max(1, (logo.width() + 23) // 24, (logo.height() + 23) // 24)
32
+ if factor == 1:
33
+ return logo_path
34
+ small_logo = logo.subsample(factor, factor)
35
+ icon_path = os.path.join(tempfile.gettempdir(), "thonny_tunisiaschools_logo_24.png")
36
+ small_logo.write(icon_path, format="png")
37
+ _toolbar_logo_path = icon_path
38
+ return icon_path
39
+ except (tk.TclError, OSError):
40
+ return logo_path
41
+
42
+
43
+ def ask_form_name(parent):
44
+ workbench = get_workbench()
45
+ saved_form_name = workbench.get_option(FORM_NAME_OPTION, "")
46
+ if saved_form_name:
47
+ return saved_form_name
48
+
49
+ dialog = tk.Toplevel(parent)
50
+ dialog.title("Nom du formulaire")
51
+ dialog.resizable(False, False)
52
+ dialog.transient(parent)
53
+ dialog.configure(bg="#f4f6fb")
54
+
55
+ parent.update_idletasks()
56
+ screen_width = dialog.winfo_screenwidth()
57
+ screen_height = dialog.winfo_screenheight()
58
+ width = min(410, screen_width - 32)
59
+ height = screen_height - 64
60
+
61
+ card = tk.Frame(dialog, bg="white", padx=20, pady=16)
62
+ card.pack(fill="both", expand=True, padx=10, pady=10)
63
+ tk.Label(
64
+ card, text="Nom du formulaire", bg="white", fg="#172033",
65
+ font=("Segoe UI", 17, "bold")
66
+ ).pack(anchor="w")
67
+ tk.Label(
68
+ card, text="Choisissez le nom de la variable Python à générer.",
69
+ bg="white", fg="#667085", font=("Segoe UI", 10),
70
+ wraplength=width - 80, justify="left"
71
+ ).pack(anchor="w", pady=(4, 10))
72
+
73
+ name_var = tk.StringVar(value="win")
74
+ name_entry = ttk.Entry(card, textvariable=name_var, font=("Segoe UI", 11))
75
+ name_entry.pack(fill="x", ipady=7)
76
+ remember_var = tk.BooleanVar(value=False)
77
+ tk.Checkbutton(
78
+ card, text="Ne plus me demander (réutiliser ce nom)",
79
+ variable=remember_var, bg="white", fg="#344054",
80
+ activebackground="white", selectcolor="white",
81
+ font=("Segoe UI", 9), cursor="hand2"
82
+ ).pack(anchor="w", pady=(8, 0))
83
+ error_label = tk.Label(card, text="", bg="white", fg="#c0392b", font=("Segoe UI", 9))
84
+ error_label.pack(anchor="w", pady=(5, 0))
85
+
86
+ result = {"name": None}
87
+
88
+ def validate_form_name(show_success=False):
89
+ form_name = name_var.get().strip()
90
+ is_valid = form_name.isidentifier() and not keyword.iskeyword(form_name)
91
+ if is_valid:
92
+ error_label.config(
93
+ text="Nom valide." if show_success else "", fg="#27864a"
94
+ )
95
+ else:
96
+ error_label.config(
97
+ text="Entrez un nom de variable Python valide.", fg="#c0392b"
98
+ )
99
+ return is_valid
100
+
101
+ def submit(event=None):
102
+ form_name = name_var.get().strip()
103
+ if not validate_form_name():
104
+ name_entry.focus_set()
105
+ return
106
+ result["name"] = form_name
107
+ if remember_var.get():
108
+ workbench.set_option(FORM_NAME_OPTION, form_name)
109
+ dialog.destroy()
110
+
111
+ name_var.trace_add("write", lambda *_args: validate_form_name(show_success=True))
112
+
113
+ buttons = tk.Frame(card, bg="white")
114
+ buttons.pack(fill="x", pady=(10, 0))
115
+ tk.Button(
116
+ buttons, text="Annuler", command=dialog.destroy, relief="flat",
117
+ bg="#eef1f6", fg="#344054", activebackground="#e2e7ef",
118
+ font=("Segoe UI", 10), padx=15, pady=8, cursor="hand2"
119
+ ).pack(side="right")
120
+ tk.Button(
121
+ buttons, text="Confirmer", command=submit, relief="flat",
122
+ bg="#3659d9", fg="white", activebackground="#2948bc",
123
+ activeforeground="white", font=("Segoe UI", 10, "bold"),
124
+ padx=16, pady=8, cursor="hand2"
125
+ ).pack(side="right", padx=(0, 9))
126
+
127
+ dialog.update_idletasks()
128
+ actual_width = min(max(width, dialog.winfo_reqwidth()), screen_width - 32)
129
+ actual_height = min(max(270, dialog.winfo_reqheight()), height)
130
+ x = parent.winfo_rootx() + (parent.winfo_width() - actual_width) // 2
131
+ y = parent.winfo_rooty() + (parent.winfo_height() - actual_height) // 2
132
+ x = min(max(x, 0), screen_width - actual_width)
133
+ y = min(max(y, 0), screen_height - actual_height)
134
+ dialog.geometry(f"{actual_width}x{actual_height}+{x}+{y}")
135
+ dialog.minsize(min(actual_width, 340), min(actual_height, 240))
136
+
137
+ dialog.bind("<Return>", submit)
138
+ dialog.bind("<Escape>", lambda event: dialog.destroy())
139
+ dialog.protocol("WM_DELETE_WINDOW", dialog.destroy)
140
+ dialog.grab_set()
141
+ name_entry.focus_set()
142
+ parent.wait_window(dialog)
143
+ return result["name"]
144
+
145
+
146
+ def confirm_replace_editor(parent):
147
+ dialog = tk.Toplevel(parent)
148
+ dialog.title("Remplacer le contenu ?")
149
+ dialog.resizable(False, False)
150
+ dialog.transient(parent)
151
+ dialog.configure(bg="#f4f6fb")
152
+
153
+ card = tk.Frame(dialog, bg="white", padx=22, pady=18)
154
+ card.pack(fill="both", expand=True, padx=10, pady=10)
155
+ tk.Label(
156
+ card, text="L’éditeur n’est pas vide", bg="white", fg="#172033",
157
+ font=("Segoe UI", 15, "bold")
158
+ ).pack(anchor="w")
159
+ tk.Label(
160
+ card,
161
+ text="Effacer son contenu et insérer le code du formulaire ?",
162
+ bg="white", fg="#667085", font=("Segoe UI", 10),
163
+ wraplength=340, justify="left"
164
+ ).pack(anchor="w", pady=(7, 16))
165
+
166
+ result = {"replace": False}
167
+
168
+ def accept():
169
+ result["replace"] = True
170
+ dialog.destroy()
171
+
172
+ buttons = tk.Frame(card, bg="white")
173
+ buttons.pack(fill="x")
174
+ tk.Button(
175
+ buttons, text="Non, conserver", command=dialog.destroy,
176
+ relief="flat", bg="#eef1f6", fg="#344054",
177
+ activebackground="#e2e7ef", font=("Segoe UI", 10),
178
+ padx=13, pady=8, cursor="hand2"
179
+ ).pack(side="right")
180
+ tk.Button(
181
+ buttons, text="Effacer et insérer", command=accept,
182
+ relief="flat", bg="#3659d9", fg="white",
183
+ activebackground="#2948bc", activeforeground="white",
184
+ font=("Segoe UI", 10, "bold"), padx=14, pady=8,
185
+ cursor="hand2"
186
+ ).pack(side="right", padx=(0, 8))
187
+
188
+ dialog.update_idletasks()
189
+ width, height = dialog.winfo_reqwidth(), dialog.winfo_reqheight()
190
+ screen_width, screen_height = dialog.winfo_screenwidth(), dialog.winfo_screenheight()
191
+ x = parent.winfo_rootx() + (parent.winfo_width() - width) // 2
192
+ y = parent.winfo_rooty() + (parent.winfo_height() - height) // 2
193
+ x = min(max(x, 0), screen_width - width)
194
+ y = min(max(y, 0), screen_height - height)
195
+ dialog.geometry(f"{width}x{height}+{x}+{y}")
196
+ dialog.bind("<Escape>", lambda event: dialog.destroy())
197
+ dialog.protocol("WM_DELETE_WINDOW", dialog.destroy)
198
+ dialog.grab_set()
199
+ parent.wait_window(dialog)
200
+ return result["replace"]
201
+
202
+
203
+ def _widget_property(widget, property_name):
204
+ for prop in widget.getElementsByTagName("property"):
205
+ if prop.parentNode is widget and prop.getAttribute("name") == property_name:
206
+ for value_node in prop.childNodes:
207
+ if value_node.nodeType == value_node.ELEMENT_NODE:
208
+ return "".join(
209
+ child.data for child in value_node.childNodes
210
+ if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE)
211
+ ).strip()
212
+ return ""
213
+
214
+
215
+ def _widget_preview_colors(widget):
216
+ stylesheet = _widget_property(widget, "styleSheet")
217
+ colors = {}
218
+ for property_name, option in (("background-color", "background"), ("color", "foreground")):
219
+ match = re.search(
220
+ rf"(?:^|;)\s*{property_name}\s*:\s*(#[0-9a-fA-F]{{3,12}}|rgba?\([^)]*\)|[a-zA-Z]+)",
221
+ stylesheet,
222
+ re.IGNORECASE,
223
+ )
224
+ if match:
225
+ value = match.group(1).strip().lower()
226
+ if value.startswith("rgb"):
227
+ channels = re.findall(r"\d+(?:\.\d+)?", value)
228
+ if len(channels) >= 3:
229
+ rgb = [max(0, min(255, round(float(channel)))) for channel in channels[:3]]
230
+ colors[option] = "#%02x%02x%02x" % tuple(rgb)
231
+ elif value.startswith("#"):
232
+ digits = value[1:]
233
+ if len(digits) == 3:
234
+ digits = "".join(character * 2 for character in digits)
235
+ elif len(digits) == 4:
236
+ digits = "".join(character * 2 for character in digits[1:])
237
+ elif len(digits) == 8:
238
+ digits = digits[2:]
239
+ elif len(digits) == 12:
240
+ digits = digits[::4]
241
+ if len(digits) == 6:
242
+ colors[option] = "#" + digits
243
+ elif value in {
244
+ "black", "white", "red", "green", "blue", "yellow", "cyan",
245
+ "magenta", "gray", "grey", "orange", "purple", "pink",
246
+ "navy", "teal", "lime", "silver", "maroon", "olive",
247
+ }:
248
+ colors[option] = value
249
+ return colors
250
+
251
+
252
+ def _widget_font_size(widget, default_size=9):
253
+ font_spec = _widget_property(widget, "styleSheet")
254
+ match = re.search(r"font:\s*[^;]*?(\d+(?:\.\d+)?)pt", font_spec)
255
+ if match:
256
+ try:
257
+ return float(match.group(1))
258
+ except ValueError:
259
+ pass
260
+ return default_size
261
+
262
+
263
+ def _list_widget_items(widget):
264
+ values = []
265
+ for node in widget.childNodes:
266
+ if getattr(node, "tagName", None) != "item":
267
+ continue
268
+ for prop in node.getElementsByTagName("property"):
269
+ if prop.getAttribute("name") == "text":
270
+ strings = prop.getElementsByTagName("string")
271
+ if strings and strings[0].firstChild:
272
+ values.append(strings[0].firstChild.data)
273
+ return values
274
+
275
+
276
+ def _resolve_preview_image(widget, base_directory):
277
+ image_path = _widget_property(widget, "pixmap").strip()
278
+ if not image_path or image_path.startswith(":/"):
279
+ return None
280
+ if image_path.startswith("file://"):
281
+ image_path = image_path[7:]
282
+ if not os.path.isabs(image_path):
283
+ image_path = os.path.join(base_directory, image_path)
284
+ image_path = os.path.normpath(image_path)
285
+ return image_path if os.path.isfile(image_path) else None
286
+
287
+
288
+ def _loaded_ui_path(source_text, editor_path):
289
+ match = re.search(
290
+ r"\bloadUi\s*\(\s*(['\"])(.*?)\1",
291
+ source_text,
292
+ re.DOTALL,
293
+ )
294
+ if not match:
295
+ return None
296
+ referenced_path = match.group(2).strip()
297
+ if not referenced_path:
298
+ return None
299
+ if not os.path.isabs(referenced_path):
300
+ editor_directory = os.path.dirname(editor_path) if editor_path else ""
301
+ candidates = [
302
+ os.path.join(editor_directory, referenced_path) if editor_directory else "",
303
+ os.path.join(os.getcwd(), referenced_path),
304
+ ]
305
+ else:
306
+ candidates = [referenced_path]
307
+ for candidate in candidates:
308
+ if candidate and os.path.isfile(candidate):
309
+ return os.path.normpath(candidate)
310
+ return None
311
+
312
+
313
+ def _widget_geometry(widget):
314
+ for prop in widget.getElementsByTagName("property"):
315
+ if prop.parentNode is not widget or prop.getAttribute("name") != "geometry":
316
+ continue
317
+ rects = prop.getElementsByTagName("rect")
318
+ if not rects:
319
+ continue
320
+ rect = rects[0]
321
+ values = {}
322
+ for key in ("x", "y", "width", "height"):
323
+ nodes = rect.getElementsByTagName(key)
324
+ if nodes and nodes[0].firstChild:
325
+ try:
326
+ values[key] = int(nodes[0].firstChild.data)
327
+ except ValueError:
328
+ values[key] = 0
329
+ if "width" in values and "height" in values:
330
+ return values
331
+ return None
332
+
333
+
334
+ def _widget_parent(widget):
335
+ node = widget.parentNode
336
+ while node is not None:
337
+ if getattr(node, "tagName", None) == "widget":
338
+ return node
339
+ node = node.parentNode
340
+ return None
341
+
342
+
343
+ def _layout_items(layout):
344
+ items = []
345
+ for node in layout.childNodes:
346
+ if getattr(node, "tagName", None) != "item":
347
+ continue
348
+ child = next(
349
+ (entry for entry in node.childNodes
350
+ if getattr(entry, "tagName", None) in ("widget", "layout", "spacer")),
351
+ None,
352
+ )
353
+ if child is not None:
354
+ items.append((node, child))
355
+ return items
356
+
357
+
358
+ def _layout_margins(layout):
359
+ values = {}
360
+ for prop in layout.getElementsByTagName("property"):
361
+ if prop.parentNode is not layout:
362
+ continue
363
+ name = prop.getAttribute("name")
364
+ if name in ("leftMargin", "topMargin", "rightMargin", "bottomMargin", "spacing"):
365
+ numbers = prop.getElementsByTagName("number")
366
+ if numbers and numbers[0].firstChild:
367
+ try:
368
+ values[name] = int(numbers[0].firstChild.data)
369
+ except ValueError:
370
+ pass
371
+ return values
372
+
373
+
374
+ def _widget_size_hint(widget):
375
+ geometry = _widget_geometry(widget) or {}
376
+ width = geometry.get("width", 0)
377
+ height = geometry.get("height", 0)
378
+ minimum = widget.getElementsByTagName("minimumSize")
379
+ if minimum:
380
+ for key in ("width", "height"):
381
+ nodes = minimum[0].getElementsByTagName(key)
382
+ if nodes and nodes[0].firstChild:
383
+ try:
384
+ value = int(nodes[0].firstChild.data)
385
+ if key == "width":
386
+ width = max(width, value)
387
+ else:
388
+ height = max(height, value)
389
+ except ValueError:
390
+ pass
391
+ text = _widget_property(widget, "text")
392
+ style = _widget_property(widget, "styleSheet")
393
+ font_match = re.search(r"font:\s*[^;]*?(\d+(?:\.\d+)?)pt", style)
394
+ font_px = float(font_match.group(1)) * 4 / 3 if font_match else 14
395
+ text_width = max(24, int(len(text) * font_px * 0.56 + 18))
396
+ kind = widget.getAttribute("class")
397
+ defaults = {
398
+ "QLabel": (text_width, int(font_px + 8)),
399
+ "QLineEdit": (110, int(font_px + 14)),
400
+ "QComboBox": (110, int(font_px + 14)),
401
+ "QPushButton": (max(80, text_width + 16), int(font_px + 18)),
402
+ "QCheckBox": (text_width + 18, int(font_px + 8)),
403
+ "QRadioButton": (text_width + 18, int(font_px + 8)),
404
+ "QListWidget": (160, 120),
405
+ }
406
+ default_width, default_height = defaults.get(kind, (90, 28))
407
+ return max(width, default_width), max(height, default_height)
408
+
409
+
410
+ def _layout_widget_rects(container):
411
+ """Approximate Qt box-layout placement using item order and size hints."""
412
+ container_geometry = _widget_geometry(container) or {}
413
+ width = container_geometry.get("width", 320)
414
+ height = container_geometry.get("height", 240)
415
+ layouts = []
416
+ for layout in container.getElementsByTagName("layout"):
417
+ node = layout.parentNode
418
+ nested_layout = False
419
+ while node is not None and node is not container:
420
+ if getattr(node, "tagName", None) == "layout":
421
+ nested_layout = True
422
+ break
423
+ node = node.parentNode
424
+ if node is container and not nested_layout:
425
+ layouts.append(layout)
426
+ if not layouts:
427
+ return {}
428
+ rects = {}
429
+
430
+ def measure(entry):
431
+ if entry.tagName == "widget":
432
+ return _widget_size_hint(entry)
433
+ children = _layout_items(entry)
434
+ if not children:
435
+ return 20, 20
436
+ sizes = [measure(child) for _item, child in children]
437
+ vertical = "VBox" in entry.getAttribute("class")
438
+ spacing = _layout_margins(entry).get("spacing", 6)
439
+ layout_margin = 18 if entry is layouts[0] else 0
440
+ if vertical:
441
+ return max(size[0] for size in sizes) + layout_margin, sum(size[1] for size in sizes) + spacing * (len(sizes) - 1) + layout_margin
442
+ return sum(size[0] for size in sizes) + spacing * (len(sizes) - 1) + layout_margin, max(size[1] for size in sizes) + layout_margin
443
+
444
+ def place(layout, x, y, area_width, area_height):
445
+ children = _layout_items(layout)
446
+ if not children:
447
+ return
448
+ margins = _layout_margins(layout)
449
+ default_margin = 9 if layout is layouts[0] else 0
450
+ left_margin = margins.get("leftMargin", default_margin)
451
+ top_margin = margins.get("topMargin", default_margin)
452
+ right_margin = margins.get("rightMargin", default_margin)
453
+ bottom_margin = margins.get("bottomMargin", default_margin)
454
+ spacing = margins.get("spacing", 6)
455
+ inner_x = x + left_margin
456
+ inner_y = y + top_margin
457
+ inner_width = max(area_width - left_margin - right_margin, 1)
458
+ inner_height = max(area_height - top_margin - bottom_margin, 1)
459
+ vertical = "VBox" in layout.getAttribute("class")
460
+ hints = [measure(child) for _item, child in children]
461
+ available = (inner_height if vertical else inner_width) - spacing * (len(children) - 1)
462
+ preferred = [size[1] if vertical else size[0] for size in hints]
463
+ total_preferred = sum(preferred)
464
+ stretch_weights = []
465
+ for item, child in children:
466
+ stretch_node = item.getElementsByTagName("property")
467
+ stretch = 0
468
+ for prop in stretch_node:
469
+ if prop.parentNode is item and prop.getAttribute("name") == "stretch":
470
+ numbers = prop.getElementsByTagName("number")
471
+ if numbers and numbers[0].firstChild:
472
+ try:
473
+ stretch = int(numbers[0].firstChild.data)
474
+ except ValueError:
475
+ pass
476
+ expandable = (
477
+ child.tagName == "layout"
478
+ or child.getAttribute("class") in (
479
+ "QLineEdit", "QComboBox", "QListWidget", "QTextEdit",
480
+ "QPlainTextEdit", "QPushButton",
481
+ )
482
+ )
483
+ stretch_weights.append(stretch if stretch else (1 if expandable and not vertical else 0))
484
+ extra = max(available - total_preferred, 0)
485
+ weights_total = sum(stretch_weights)
486
+ sizes = [
487
+ max(1, preferred[index] + (
488
+ extra * stretch_weights[index] / weights_total if weights_total else 0
489
+ ))
490
+ for index in range(len(children))
491
+ ]
492
+ if total_preferred > available and total_preferred:
493
+ sizes = [max(1, available * value / total_preferred) for value in preferred]
494
+ cursor = inner_y if vertical else inner_x
495
+ for index, (_item, child) in enumerate(children):
496
+ child_size = sizes[index]
497
+ if vertical:
498
+ child_x, child_y = inner_x, cursor
499
+ child_width, child_height = inner_width, child_size
500
+ else:
501
+ child_x, child_y = cursor, inner_y
502
+ child_width, child_height = child_size, inner_height
503
+ if child.tagName == "widget":
504
+ rects[child] = {
505
+ "x": child_x, "y": child_y,
506
+ "width": child_width, "height": child_height,
507
+ }
508
+ else:
509
+ place(child, child_x, child_y, child_width, child_height)
510
+ cursor += child_size + spacing
511
+
512
+ place(layouts[0], 0, 0, width, height)
513
+ return rects
514
+
515
+
516
+ def _draw_form_preview(canvas, root_widget, preview_widgets, on_hover=None, on_click=None):
517
+ canvas.delete("all")
518
+ canvas._preview_images = []
519
+ canvas.update_idletasks()
520
+ available_width = max(canvas.winfo_width(), 240)
521
+ available_height = max(canvas.winfo_height(), 180)
522
+ root_geometry = _widget_geometry(root_widget) or {}
523
+ widget_right = max(
524
+ (item["geometry"].get("x", 0) + item["geometry"].get("width", 0)
525
+ for item in preview_widgets if item["geometry"]),
526
+ default=0,
527
+ )
528
+ widget_bottom = max(
529
+ (item["geometry"].get("y", 0) + item["geometry"].get("height", 0)
530
+ for item in preview_widgets if item["geometry"]),
531
+ default=0,
532
+ )
533
+ form_width = max(root_geometry.get("width", 480), widget_right + 12, 320)
534
+ form_height = max(root_geometry.get("height", 320), widget_bottom + 12, 220)
535
+ margin = min(10, max(3, int(min(available_width, available_height) * 0.015)))
536
+ titlebar_height = 26
537
+ form_area_width = max(available_width - margin * 2, 1)
538
+ form_area_height = max(available_height - margin * 2 - titlebar_height, 1)
539
+ scale = min(
540
+ form_area_width / form_width,
541
+ form_area_height / form_height,
542
+ )
543
+ detail_scale = max(0.5, scale)
544
+ text_size = max(6, min(24, int(round(9 * min(scale, 2.4)))))
545
+ rendered_width = form_width * scale
546
+ rendered_height = form_height * scale
547
+ root_colors = _widget_preview_colors(root_widget)
548
+ left = margin + max((form_area_width - rendered_width) / 2, 0)
549
+ top = margin + max((form_area_height - rendered_height) / 2, 0)
550
+
551
+ canvas.create_rectangle(
552
+ left + 3, top + 5, left + rendered_width + 3,
553
+ top + rendered_height + titlebar_height + 5,
554
+ fill="#cbd5e1", outline=""
555
+ )
556
+ canvas.create_rectangle(
557
+ left, top, left + rendered_width, top + rendered_height + titlebar_height,
558
+ fill=root_colors.get("background", "#ffffff"),
559
+ outline="#d7dee9", width=1
560
+ )
561
+ canvas.create_rectangle(
562
+ left, top, left + rendered_width, top + titlebar_height,
563
+ fill="#f8fafc", outline=""
564
+ )
565
+ canvas.create_rectangle(
566
+ left, top + titlebar_height - 3, left + rendered_width,
567
+ top + titlebar_height, fill="#5578e8", outline=""
568
+ )
569
+ canvas.create_text(
570
+ left + 13, top + titlebar_height / 2,
571
+ text=(_widget_property(root_widget, "windowTitle") or
572
+ root_widget.getAttribute("class") or "Form Preview"),
573
+ anchor="w", fill="#1e293b", font=("Segoe UI", 10, "bold")
574
+ )
575
+ for offset, color in enumerate(("#f28b82", "#fbbc04", "#34a853")):
576
+ canvas.create_oval(
577
+ left + rendered_width - 44 + offset * 13, top + 12,
578
+ left + rendered_width - 36 + offset * 13, top + 20,
579
+ fill=color, outline=""
580
+ )
581
+
582
+ if not preview_widgets:
583
+ canvas.create_text(
584
+ left + rendered_width / 2,
585
+ top + titlebar_height + rendered_height / 2,
586
+ text="Aucun widget à prévisualiser", fill="#98a2b3",
587
+ font=("Segoe UI", text_size)
588
+ )
589
+ return
590
+
591
+ content_top = top + titlebar_height
592
+ fallback_index = 0
593
+ for item in preview_widgets:
594
+ geometry = item["geometry"]
595
+ if geometry:
596
+ x = geometry.get("x", 0) * scale
597
+ y = geometry.get("y", 0) * scale
598
+ widget_width = max(geometry.get("width", 110) * scale, 1)
599
+ widget_height = max(geometry.get("height", 30) * scale, 1)
600
+ else:
601
+ fallback_index += 1
602
+ x = 14 * scale
603
+ y = (16 + (fallback_index - 1) * 44) * scale
604
+ widget_width = min(form_width - 28, 180) * scale
605
+ widget_height = 30 * scale
606
+
607
+ x1 = min(max(left + x, left + 4), left + rendered_width - 4)
608
+ y1 = min(max(content_top + y, content_top + 4),
609
+ content_top + rendered_height - 4)
610
+ x2 = min(x1 + widget_width, left + rendered_width - 5)
611
+ y2 = min(y1 + widget_height, content_top + rendered_height - 5)
612
+ if x2 - x1 < 2 or y2 - y1 < 2:
613
+ continue
614
+ widget_type = item["type"]
615
+ name = item["name"]
616
+ widget = item.get("widget")
617
+ widget_font_size = max(
618
+ 5,
619
+ min(
620
+ 28,
621
+ int(round(_widget_font_size(widget) * scale))
622
+ if widget is not None
623
+ else max(5, min(text_size, int(max(y2 - y1, 1) * 0.55))),
624
+ ),
625
+ )
626
+ caption = (item["text"] or item["placeholder"]).replace("&", "")
627
+ if widget_type in ("QListWidget", "Line"):
628
+ caption = item["text"].replace("&", "")
629
+ if widget_type == "QComboBox" and not caption:
630
+ combo_items = item.get("list_items", [])
631
+ caption = combo_items[0] if combo_items else ""
632
+ label = caption or name
633
+ if widget_type in ("QLineEdit", "QTextEdit", "QPlainTextEdit", "QComboBox", "QListWidget", "Line"):
634
+ label = caption
635
+ available_text_width = max(x2 - x1 - 14, 1)
636
+ max_label_chars = max(1, int(available_text_width / 5.5))
637
+ if len(label) > max_label_chars:
638
+ label = label[:max_label_chars - 1] + "…"
639
+ colors = _widget_preview_colors(widget) if widget is not None else {}
640
+ border = "#c5cedd"
641
+ fill = colors.get("background", "#ffffff")
642
+ foreground = colors.get("foreground", "#334155")
643
+ tag = f"widget-{name}"
644
+ hover_area = canvas.create_rectangle(
645
+ x1, y1, x2, y2, fill="#f8faff", outline="",
646
+ tags=(tag, f"hover-area-{name}")
647
+ )
648
+ hover_shapes = []
649
+
650
+ if widget_type == "QLabel":
651
+ image_path = item.get("image_path")
652
+ image_rendered = False
653
+ if image_path:
654
+ try:
655
+ from PIL import Image, ImageTk
656
+
657
+ with Image.open(image_path) as source_image:
658
+ source_image = source_image.convert("RGBA")
659
+ image_width = max(1, int(x2 - x1 - 4))
660
+ image_height = max(1, int(y2 - y1 - 4))
661
+ resampling = getattr(Image, "Resampling", Image).LANCZOS
662
+ source_image.thumbnail(
663
+ (image_width, image_height), resampling
664
+ )
665
+ preview_image = ImageTk.PhotoImage(
666
+ source_image, master=canvas
667
+ )
668
+ canvas._preview_images.append(preview_image)
669
+ canvas.create_image(
670
+ (x1 + x2) / 2, (y1 + y2) / 2,
671
+ image=preview_image, anchor="center", tags=(tag,)
672
+ )
673
+ image_rendered = True
674
+ except (ImportError, OSError, tk.TclError, ValueError):
675
+ pass
676
+ if not image_rendered:
677
+ canvas.create_text(
678
+ x1 + 4, (y1 + y2) / 2, text=label, anchor="w",
679
+ width=max(x2 - x1 - 8, 1), fill=foreground,
680
+ font=("Segoe UI", widget_font_size), tags=(tag,)
681
+ )
682
+ elif widget_type in ("QCheckBox", "QRadioButton"):
683
+ indicator_size = min(
684
+ 14 * detail_scale,
685
+ max(y2 - y1 - 4, 2),
686
+ max(x2 - x1 - 4, 2),
687
+ )
688
+ indicator_inset = max((y2 - y1 - indicator_size) / 2, 1)
689
+ label_space = max(x2 - x1 - indicator_size - 8, 1)
690
+ if widget_type == "QRadioButton":
691
+ checkbox_shape = canvas.create_oval(
692
+ x1 + 2, y1 + indicator_inset,
693
+ x1 + 2 + indicator_size, y1 + indicator_inset + indicator_size,
694
+ fill="#ffffff", outline="#94a3b8", width=1, tags=(tag,)
695
+ )
696
+ else:
697
+ checkbox_shape = canvas.create_rectangle(
698
+ x1 + 2, y1 + indicator_inset,
699
+ x1 + 2 + indicator_size, y1 + indicator_inset + indicator_size,
700
+ fill="#ffffff", outline="#94a3b8", width=1, tags=(tag,)
701
+ )
702
+ hover_shapes.append((checkbox_shape, "#ffffff", "#bfdbfe"))
703
+ label_x = x1 + indicator_size + 6
704
+ canvas.create_text(
705
+ label_x, (y1 + y2) / 2, text=label, anchor="w",
706
+ width=label_space, fill=foreground,
707
+ font=("Segoe UI", widget_font_size), tags=(tag,)
708
+ )
709
+ elif widget_type in ("QLineEdit", "QTextEdit", "QPlainTextEdit", "QComboBox"):
710
+ input_shape = canvas.create_rectangle(
711
+ x1, y1, x2, y2, fill=fill, outline=border,
712
+ width=1, tags=(tag,)
713
+ )
714
+ hover_shapes.append((input_shape, "#ffffff", "#bfdbfe"))
715
+ text_width = max(
716
+ x2 - x1 - (22 if widget_type == "QComboBox" else 14), 1
717
+ )
718
+ canvas.create_text(
719
+ x1 + 7, (y1 + y2) / 2, text=label, anchor="w",
720
+ width=text_width, fill=foreground if caption else "#94a3b8",
721
+ font=("Segoe UI", widget_font_size), tags=(tag,)
722
+ )
723
+ if widget_type == "QComboBox":
724
+ canvas.create_text(x2 - 10, (y1 + y2) / 2, text="⌄",
725
+ fill="#667085", font=("Segoe UI", 9), tags=(tag,))
726
+ elif widget_type == "QListWidget":
727
+ list_shape = canvas.create_rectangle(
728
+ x1, y1, x2, y2, fill=fill, outline=border,
729
+ width=1, tags=(tag,)
730
+ )
731
+ hover_shapes.append((list_shape, fill, "#f1f5ff"))
732
+ rows = item.get("list_items", [])
733
+ row_height = max(22 * scale, (y2 - y1) / max(len(rows), 1))
734
+ for row_index, row_text in enumerate(rows):
735
+ row_y = y1 + row_index * row_height
736
+ if row_y + row_height > y2:
737
+ break
738
+ if row_index:
739
+ canvas.create_line(
740
+ x1 + 1, row_y, x2 - 1, row_y,
741
+ fill="#edf0f5", width=1, tags=(tag,)
742
+ )
743
+ canvas.create_text(
744
+ x1 + 8, row_y + row_height / 2, text=row_text,
745
+ anchor="w", width=max(x2 - x1 - 16, 1),
746
+ fill=foreground, font=("Segoe UI", widget_font_size), tags=(tag,)
747
+ )
748
+ elif widget_type == "Line":
749
+ orientation = _widget_property(widget, "orientation") if widget is not None else ""
750
+ if "Vertical" in orientation:
751
+ center_x = (x1 + x2) / 2
752
+ line_shape = canvas.create_line(
753
+ center_x, y1, center_x, y2,
754
+ fill="#94a3b8", width=max(1, scale), tags=(tag,)
755
+ )
756
+ else:
757
+ center_y = (y1 + y2) / 2
758
+ line_shape = canvas.create_line(
759
+ x1, center_y, x2, center_y,
760
+ fill="#94a3b8", width=max(1, scale), tags=(tag,)
761
+ )
762
+ hover_shapes.append((line_shape, "#94a3b8", "#5578e8"))
763
+ elif widget_type == "QProgressBar":
764
+ progress_track = canvas.create_rectangle(
765
+ x1, y1, x2, y2, fill="#e2e8f0", outline="", tags=(tag,)
766
+ )
767
+ progress_fill = canvas.create_rectangle(
768
+ x1, y1, x1 + (x2 - x1) * .62, y2,
769
+ fill="#6481e8", outline="", tags=(tag,)
770
+ )
771
+ hover_shapes.extend((
772
+ (progress_track, "#e2e8f0", "#bfdbfe"),
773
+ (progress_fill, "#6481e8", "#93b4fb"),
774
+ ))
775
+ canvas.create_text(
776
+ (x1 + x2) / 2, (y1 + y2) / 2, text=label,
777
+ width=max(x2 - x1 - 8, 1), fill="#334155",
778
+ font=("Segoe UI", widget_font_size), tags=(tag,)
779
+ )
780
+ elif widget_type == "QPushButton":
781
+ button_fill = colors.get("background", "#eef2f7")
782
+ button_text = colors.get("foreground", "#334155")
783
+ button_shape = canvas.create_rectangle(
784
+ x1, y1, x2, y2, fill=button_fill, outline="#cbd5e1",
785
+ width=1, tags=(tag,)
786
+ )
787
+ hover_shapes.append((button_shape, button_fill, "#dce7ff"))
788
+ canvas.create_text(
789
+ (x1 + x2) / 2, (y1 + y2) / 2, text=label,
790
+ width=max(x2 - x1 - 12, 1), fill=button_text,
791
+ font=("Segoe UI", widget_font_size, "bold"), tags=(tag,)
792
+ )
793
+ else:
794
+ widget_shape = canvas.create_rectangle(
795
+ x1, y1, x2, y2, fill=fill, outline=border,
796
+ width=1, tags=(tag,)
797
+ )
798
+ hover_shapes.append((widget_shape, "#ffffff", "#bfdbfe"))
799
+ canvas.create_text(
800
+ (x1 + x2) / 2, (y1 + y2) / 2, text=label,
801
+ width=max(x2 - x1 - 12, 1), fill="#334155",
802
+ font=("Segoe UI", widget_font_size), tags=(tag,)
803
+ )
804
+
805
+ hover_outline = canvas.create_rectangle(
806
+ x1, y1, x2, y2, fill="", outline="", width=3,
807
+ tags=(tag, f"hover-outline-{name}")
808
+ )
809
+
810
+ def highlight_widget(
811
+ _event, active, background=hover_area,
812
+ outline=hover_outline, shapes=tuple(hover_shapes)
813
+ ):
814
+ canvas.itemconfigure(
815
+ background, fill="#dce8ff" if active else "#f8faff"
816
+ )
817
+ canvas.itemconfigure(
818
+ outline, outline="#2458e8" if active else ""
819
+ )
820
+ for shape, normal_fill, hover_fill in shapes:
821
+ canvas.itemconfigure(shape, fill=hover_fill if active else normal_fill)
822
+
823
+ canvas.tag_bind(
824
+ tag, "<Enter>",
825
+ lambda event, widget_name=name, highlight=highlight_widget: (
826
+ highlight(event, True), on_hover(widget_name)
827
+ ) if on_hover else highlight(event, True)
828
+ )
829
+ canvas.tag_bind(
830
+ tag, "<Button-1>",
831
+ lambda event, widget_name=name: on_click(widget_name)
832
+ if on_click else (on_hover(widget_name) if on_hover else "")
833
+ )
834
+ canvas.tag_bind(
835
+ tag, "<Leave>",
836
+ lambda event, highlight=highlight_widget: highlight(event, False)
837
+ )
838
+
839
+
840
+ def show_ui_elements():
841
+ global qt_ui_file, qt_form_name, _live_preview_dialog
842
+ parent = get_workbench()
843
+ if _live_preview_dialog is not None:
844
+ try:
845
+ if _live_preview_dialog.winfo_exists():
846
+ _live_preview_dialog.destroy()
847
+ except tk.TclError:
848
+ pass
849
+ _live_preview_dialog = None
850
+
851
+ try:
852
+ ui_document = None
853
+ display_name = ""
854
+ source_ui_path = None
855
+ editor = parent.get_editor_notebook().get_current_editor()
856
+ editor_path = None
857
+ editor_text = ""
858
+ if editor is not None:
859
+ for attr in ("get_filename", "get_file_path"):
860
+ get_path = getattr(editor, attr, None)
861
+ if callable(get_path):
862
+ editor_path = get_path()
863
+ if editor_path:
864
+ break
865
+
866
+ editor_text = editor.get_code_view().text.get("1.0", "end-1c")
867
+ if "<ui" in editor_text and "</ui>" in editor_text:
868
+ candidate = minidom.parseString(editor_text.encode("utf-8"))
869
+ if candidate.documentElement.tagName == "ui":
870
+ ui_document = candidate
871
+ if editor_path:
872
+ qt_ui_file = editor_path
873
+ source_ui_path = editor_path
874
+ display_name = os.path.basename(editor_path)
875
+ else:
876
+ display_name = "Interface ouverte dans l’éditeur"
877
+
878
+ # Some Thonny editor implementations don't expose the .ui buffer
879
+ # as XML text. If the active tab is a UI file, load that opened file.
880
+ if (
881
+ ui_document is None
882
+ and editor_path
883
+ and os.path.splitext(editor_path)[1].lower() == ".ui"
884
+ ):
885
+ qt_ui_file = editor_path
886
+ source_ui_path = editor_path
887
+ ui_document = minidom.parse(editor_path)
888
+ display_name = os.path.basename(editor_path)
889
+
890
+ if ui_document is None:
891
+ loaded_ui_path = _loaded_ui_path(editor_text, editor_path)
892
+ if loaded_ui_path:
893
+ qt_ui_file = loaded_ui_path
894
+ source_ui_path = loaded_ui_path
895
+ ui_document = minidom.parse(loaded_ui_path)
896
+ display_name = os.path.basename(loaded_ui_path)
897
+
898
+ # Prefer the exact interface selected in the plugin even when there
899
+ # is no current editor. Generated loadUi calls can use a relative path.
900
+ if ui_document is None and qt_ui_file:
901
+ remembered_path = os.path.abspath(qt_ui_file)
902
+ if os.path.isfile(remembered_path):
903
+ qt_ui_file = remembered_path
904
+ source_ui_path = remembered_path
905
+ ui_document = minidom.parse(remembered_path)
906
+ display_name = os.path.basename(remembered_path)
907
+
908
+ # If the active editor is not showing a .ui document, ask for a file
909
+ # on every preview launch. This picker choice is temporary; the UI
910
+ # selected by “Ajouter code” or found in loadUi remains remembered.
911
+ if ui_document is None:
912
+ source_ui_path = askopenfilename(
913
+ filetypes=[("Fichiers UI", ".ui"), (tr("Tous les fichiers"), ".*")],
914
+ parent=parent,
915
+ )
916
+ if not source_ui_path:
917
+ return
918
+ ui_document = minidom.parse(source_ui_path)
919
+ display_name = os.path.basename(source_ui_path)
920
+
921
+ ui_widgets = list(ui_document.getElementsByTagName("widget"))
922
+ if not ui_widgets:
923
+ raise ValueError("Le fichier ne contient aucun widget Qt.")
924
+ root_widget = ui_widgets[0]
925
+ preview_widgets = []
926
+ layout_rects = {
927
+ widget: _layout_widget_rects(widget) for widget in ui_widgets
928
+ }
929
+ for widget in ui_widgets:
930
+ widget_name = widget.getAttribute("name") or "(sans nom)"
931
+ widget_type = widget.getAttribute("class") or "Type inconnu"
932
+ geometry = _widget_geometry(widget)
933
+ parent_widget = _widget_parent(widget)
934
+ if geometry is None and parent_widget is not None:
935
+ geometry = layout_rects.get(parent_widget, {}).get(widget)
936
+ if widget is not root_widget and geometry:
937
+ geometry = dict(geometry)
938
+ parent_node = parent_widget
939
+ while parent_node is not None and parent_node is not root_widget:
940
+ parent_geometry = _widget_geometry(parent_node)
941
+ if parent_geometry is None:
942
+ ancestor = _widget_parent(parent_node)
943
+ parent_geometry = layout_rects.get(ancestor, {}).get(parent_node, {})
944
+ geometry["x"] += parent_geometry.get("x", 0)
945
+ geometry["y"] += parent_geometry.get("y", 0)
946
+ parent_node = _widget_parent(parent_node)
947
+ item = {
948
+ "name": widget_name,
949
+ "type": widget_type,
950
+ "geometry": geometry,
951
+ "text": _widget_property(widget, "text"),
952
+ "placeholder": _widget_property(widget, "placeholderText"),
953
+ "widget": widget,
954
+ "image_path": _resolve_preview_image(
955
+ widget,
956
+ os.path.dirname(source_ui_path) if source_ui_path else os.getcwd(),
957
+ ),
958
+ "list_items": _list_widget_items(widget),
959
+ }
960
+ has_layout = bool(widget.getElementsByTagName("layout"))
961
+ if widget is not root_widget and not (
962
+ widget_type == "QWidget" and has_layout
963
+ ):
964
+ preview_widgets.append(item)
965
+ except Exception as error:
966
+ messagebox.showerror(
967
+ "Formulaire UI illisible",
968
+ f"Impossible de lire le fichier :\n{error}",
969
+ parent=parent,
970
+ )
971
+ return
972
+
973
+ dialog = tk.Toplevel(parent)
974
+ _live_preview_dialog = dialog
975
+ dialog.title("Aperçu live du formulaire")
976
+ dialog.transient(parent)
977
+ dialog.configure(bg="#edf1f7")
978
+ dialog.update_idletasks()
979
+ screen_width = dialog.winfo_screenwidth()
980
+ screen_height = dialog.winfo_screenheight()
981
+ root_geometry = _widget_geometry(root_widget) or {}
982
+ ui_width = max(
983
+ root_geometry.get("width", 480),
984
+ max(
985
+ (item["geometry"].get("x", 0) + item["geometry"].get("width", 0)
986
+ for item in preview_widgets if item["geometry"]),
987
+ default=0,
988
+ ) + 12,
989
+ )
990
+ ui_height = max(
991
+ root_geometry.get("height", 320),
992
+ max(
993
+ (item["geometry"].get("y", 0) + item["geometry"].get("height", 0)
994
+ for item in preview_widgets if item["geometry"]),
995
+ default=0,
996
+ ) + 12,
997
+ )
998
+ max_width = max(480, min(1040, screen_width - 24))
999
+ max_height = max(360, screen_height - 36)
1000
+ width = min(max(760, ui_width + 40), max_width)
1001
+ height = min(max(620, ui_height + 120), max_height)
1002
+ x = parent.winfo_rootx() + (parent.winfo_width() - width) // 2
1003
+ y = parent.winfo_rooty() + (parent.winfo_height() - height) // 2
1004
+ x = min(max(x, 0), screen_width - width)
1005
+ y = min(max(y, 0), screen_height - height)
1006
+ dialog.geometry(f"{width}x{height}+{x}+{y}")
1007
+ dialog.minsize(min(720, width), min(480, height))
1008
+
1009
+ card = tk.Frame(dialog, bg="#f8fafc", padx=10, pady=8)
1010
+ card.pack(fill="both", expand=True, padx=5, pady=5)
1011
+ header = tk.Frame(card, bg="#f8fafc")
1012
+ header.pack(fill="x", pady=(0, 5))
1013
+ logo_path = os.path.join(os.path.dirname(__file__), "res", "logo.png")
1014
+ try:
1015
+ try:
1016
+ from PIL import Image, ImageTk
1017
+
1018
+ source_logo = Image.open(logo_path).convert("RGBA")
1019
+ source_logo.thumbnail((76, 76), Image.Resampling.LANCZOS)
1020
+ preview_logo = ImageTk.PhotoImage(source_logo, master=dialog)
1021
+ except ImportError:
1022
+ # Keep the original pixels rather than producing a jagged Tk
1023
+ # subsample when Pillow is unavailable.
1024
+ preview_logo = tk.PhotoImage(file=logo_path, master=dialog)
1025
+ logo_label = tk.Label(
1026
+ header, image=preview_logo, bg="#f8fafc", cursor="hand2"
1027
+ )
1028
+ logo_label.pack(side="right", padx=(12, 2), pady=2)
1029
+ logo_label.bind(
1030
+ "<Button-1>", lambda _event: webbrowser.open("https://ikode.tn", new=2)
1031
+ )
1032
+ dialog.preview_logo = preview_logo
1033
+ except tk.TclError:
1034
+ pass
1035
+ heading = tk.Frame(header, bg="#f8fafc")
1036
+ heading.pack(side="left", fill="x", expand=True)
1037
+ tk.Label(
1038
+ heading, text="APERÇU INTERACTIF", bg="#f8fafc", fg="#4565c7",
1039
+ font=("Segoe UI", 9, "bold")
1040
+ ).pack(anchor="w")
1041
+ tk.Label(
1042
+ heading, text=display_name, bg="#f8fafc",
1043
+ fg="#172033", font=("Segoe UI", 15, "bold")
1044
+ ).pack(anchor="w", pady=(3, 0))
1045
+ tk.Label(
1046
+ heading, text="Survolez un élément pour le repérer · Cliquez pour copier son nom",
1047
+ bg="#f8fafc", fg="#64748b", font=("Segoe UI", 9)
1048
+ ).pack(anchor="w", pady=(3, 0))
1049
+ preview_canvas = tk.Canvas(
1050
+ card, bg="#eef2f7", highlightthickness=1,
1051
+ highlightbackground="#dce3ed", bd=0
1052
+ )
1053
+ preview_canvas.pack(fill="both", expand=True)
1054
+ hovered_name = tk.StringVar(
1055
+ value="Prêt · Sélectionnez un composant dans l’aperçu"
1056
+ )
1057
+ tk.Label(
1058
+ card, textvariable=hovered_name, bg="#edf2ff", fg="#294cae",
1059
+ font=("Segoe UI", 9, "bold"), anchor="w", padx=12, pady=7,
1060
+ highlightthickness=1, highlightbackground="#dce5ff"
1061
+ ).pack(fill="x", pady=(4, 0))
1062
+
1063
+ def show_hovered_widget(widget_name):
1064
+ widget = next(
1065
+ (item for item in preview_widgets if item["name"] == widget_name),
1066
+ None,
1067
+ )
1068
+ widget_type = widget["type"] if widget else "Widget"
1069
+ hovered_name.set(f"{widget_name} · {widget_type} · Cliquez pour copier")
1070
+
1071
+ def copy_widget_name(widget_name):
1072
+ form_name = qt_form_name or parent.get_option(FORM_NAME_OPTION, "win")
1073
+ qualified_name = f"{form_name}.{widget_name}"
1074
+ try:
1075
+ dialog.clipboard_clear()
1076
+ dialog.clipboard_append(qualified_name)
1077
+ hovered_name.set(f"Nom copié : {qualified_name}")
1078
+ except tk.TclError:
1079
+ hovered_name.set(f"Élément sélectionné : {qualified_name}")
1080
+
1081
+ redraw_job = {"id": None}
1082
+
1083
+ def redraw_preview():
1084
+ redraw_job["id"] = None
1085
+ _draw_form_preview(
1086
+ preview_canvas, root_widget, preview_widgets,
1087
+ show_hovered_widget, copy_widget_name
1088
+ )
1089
+
1090
+ def schedule_preview_redraw(_event=None):
1091
+ if redraw_job["id"] is not None:
1092
+ try:
1093
+ dialog.after_cancel(redraw_job["id"])
1094
+ except tk.TclError:
1095
+ pass
1096
+ redraw_job["id"] = dialog.after(35, redraw_preview)
1097
+
1098
+ preview_canvas.bind("<Configure>", schedule_preview_redraw)
1099
+
1100
+ _draw_form_preview(
1101
+ preview_canvas, root_widget, preview_widgets,
1102
+ show_hovered_widget, copy_widget_name
1103
+ )
1104
+
1105
+ def close_preview(event=None):
1106
+ global _live_preview_dialog
1107
+ if _live_preview_dialog is dialog:
1108
+ _live_preview_dialog = None
1109
+ try:
1110
+ dialog.destroy()
1111
+ except tk.TclError:
1112
+ pass
1113
+
1114
+ dialog.bind("<Escape>", close_preview)
1115
+ dialog.protocol("WM_DELETE_WINDOW", close_preview)
1116
+ parent.wait_window(dialog)
1117
+
1118
+
1119
+ def add_pyqt_code():
1120
+ global qt_ui_file, qt_form_name
1121
+ btnstxt = ""
1122
+ mytxt = ""
1123
+ path = askopenfilename(
1124
+ filetypes=[("Fichiers UI", ".ui"), (tr("Tous les fichiers"), ".*")], parent=get_workbench()
1125
+ )
1126
+ if path:
1127
+ form_name = ask_form_name(get_workbench())
1128
+ if form_name is None:
1129
+ return
1130
+ qt_form_name = form_name
1131
+
1132
+ qt_ui_file = os.path.abspath(path)
1133
+ file = minidom.parse(qt_ui_file)
1134
+ widgets = file.getElementsByTagName('widget')
1135
+ for w in widgets:
1136
+ if w.attributes['class'].value == "QPushButton" : #Bouton
1137
+ btnstxt = btnstxt + form_name+"."+w.attributes['name'].value +".clicked.connect ( "+ w.attributes['name'].value +"_click )\n"
1138
+ mytxt = mytxt + "def "+ w.attributes['name'].value +"_click():\n pass\n"
1139
+
1140
+ generated_code = (
1141
+ '#Updated By IKODE\n#Credits : Mr Selmen Arous\n' + 'from PyQt5.uic import loadUi\n'+
1142
+ 'from PyQt5.QtWidgets import *\n'+
1143
+ '\n'+mytxt+'\n'+
1144
+ 'app = QApplication([])\n'+
1145
+ form_name+' = loadUi (".'+ path[path.rfind("/"):] +'")\n'+
1146
+ form_name+'.show()\n'+
1147
+ btnstxt+'\n'
1148
+ 'app.exec_()'
1149
+ )
1150
+ editor = get_workbench().get_editor_notebook().get_current_editor()
1151
+ if editor is None:
1152
+ return
1153
+ text = editor.get_code_view().text
1154
+ if text.get("1.0", "end-1c").strip() and not confirm_replace_editor(get_workbench()):
1155
+ return
1156
+ text.delete("1.0", "end")
1157
+ text.insert("1.0", generated_code)
1158
+
1159
+
1160
+ def forget_form_name():
1161
+ get_workbench().set_option(FORM_NAME_OPTION, "")
1162
+
1163
+
1164
+ def load_plugin():
1165
+
1166
+ image_path = os.path.join(os.path.dirname(__file__), "res", "qt_16.png")
1167
+ logo_image_path = os.path.join(os.path.dirname(__file__), "res", "logo.png")
1168
+ toolbar_logo_path = _make_toolbar_logo(logo_image_path)
1169
+
1170
+ get_workbench().add_command(
1171
+ "selmen_command",
1172
+ "tools",
1173
+ tr("Ajouter code PyQt5"),
1174
+ add_pyqt_code,
1175
+ default_sequence=select_sequence("<Control-Shift-B>", "<Command-Shift-B>"),
1176
+ include_in_toolbar=True,
1177
+ caption="PyQt",
1178
+ image=image_path
1179
+ )
1180
+
1181
+ get_workbench().add_command(
1182
+ "show_ui_elements_command",
1183
+ "tools",
1184
+ tr("Afficher les éléments du formulaire UI"),
1185
+ show_ui_elements,
1186
+ include_in_toolbar=True,
1187
+ caption="Éléments UI",
1188
+ image=toolbar_logo_path,
1189
+ )
1190
+
1191
+ get_workbench().add_command(
1192
+ "forget_form_name_command",
1193
+ "tools",
1194
+ tr("Oublier le nom du formulaire"),
1195
+ forget_form_name,
1196
+ )
1197
+
1198
+ # Dossier de sauvegarde : Desktop/IKODE
1199
+ desktop = os.path.join(os.path.expanduser("~"), "Desktop")
1200
+ cwd = os.path.join(desktop, "IKODE")
1201
+ if os.path.exists(cwd) and not os.path.isdir(cwd):
1202
+ cwd = os.path.join(desktop, "IKODE_QT")
1203
+ suffix = 2
1204
+ while os.path.exists(cwd) and not os.path.isdir(cwd):
1205
+ cwd = os.path.join(desktop, f"IKODE_QT_{suffix}")
1206
+ suffix += 1
1207
+ os.makedirs(cwd, exist_ok=True)
1208
+ get_workbench().set_local_cwd(cwd)
1209
+
1210
+ # Ne pas ouvrir les derniers fichiers
1211
+
1212
+ get_workbench().set_option("file.current_file", "")
1213
+ get_workbench().set_option("file.open_files", "")
Binary file
Binary file
Binary file