from __future__ import annotations import tkinter as tk from tkinter import Menu, messagebox, filedialog import customtkinter as ctk from datetime import datetime from gestione_aree_frame_async import BusyOverlay, AsyncRunner # ---- Color palette ---- COLOR_EMPTY = "#B0B0B0" # grigio (vuota) COLOR_FULL = "#FFA500" # arancione (una UDC) COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC) FG_DARK = "#111111" FG_LIGHT = "#FFFFFF" def pct_text(p_full: float, p_double: float | None = None) -> str: p_full = max(0.0, min(1.0, p_full)) pf = round(p_full * 100, 1) pe = round(100 - pf, 1) if p_double and p_double > 0: pd = round(p_double * 100, 1) return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)" return f"Pieno {pf}% · Vuoto {pe}%" class LayoutWindow(ctk.CTkToplevel): """ Visualizzazione layout corsie con matrice di celle. - Ogni cella è un pulsante colorato (vuota/piena/doppia) - Etichetta su DUE righe: 1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo) 2) barcode UDC (primo, se presente) - Ricerca per barcode UDC con cambio automatico corsia + highlight cella - Statistiche: globale e corsia selezionata - Export XLSX """ def __init__(self, parent: tk.Widget, db_app): super().__init__(parent) self.title("Warehouse · Layout corsie") self.geometry("1200x740") self.minsize(980, 560) self.resizable(True, True) self.db = db_app self._busy = BusyOverlay(self) self._async = AsyncRunner(self) # layout principale 5% / 80% / 15% self.grid_rowconfigure(0, weight=5) self.grid_rowconfigure(1, weight=80) self.grid_rowconfigure(2, weight=15) self.grid_columnconfigure(0, weight=1) # stato runtime self.corsia_selezionata = tk.StringVar() self.buttons: list[list[ctk.CTkButton]] = [] self.btn_frames: list[list[ctk.CTkFrame]] = [] self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state self.fila_txt: list[list[str]] = [] self.col_txt: list[list[str]] = [] self.desc: list[list[str]] = [] self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "") # ricerca → focus differito (corsia, col, fila, barcode) self._pending_focus: tuple[str, str, str, str] | None = None self._highlighted: tuple[int, int] | None = None # anti-race: token per ignorare risposte vecchie self._req_counter = 0 self._last_req = 0 self._build_top() self._build_matrix_host() self._build_stats() self._load_corsie() self.bind("", lambda e: self.after_idle(self._refresh_stats)) # ---------------- TOP BAR ---------------- def _build_top(self): top = ctk.CTkFrame(self) top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6) for i in range(4): top.grid_columnconfigure(i, weight=0) top.grid_columnconfigure(1, weight=1) # lista corsie lf = ctk.CTkFrame(top) lf.grid(row=0, column=0, sticky="nsw") lf.grid_columnconfigure(0, weight=1) ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2)) self.lb = tk.Listbox(lf, height=6, exportselection=False) self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6)) self.lb.bind("<>", self._on_select) # search by barcode srch = ctk.CTkFrame(top) srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10)) self.search_var = tk.StringVar() self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260) self.search_entry.grid(row=0, column=0, sticky="w") ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0)) srch.grid_columnconfigure(0, weight=1) # toolbar tb = ctk.CTkFrame(top) tb.grid(row=0, column=3, sticky="ne") ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4) ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4) # ---------------- MATRIX HOST ---------------- def _build_matrix_host(self): center = ctk.CTkFrame(self) center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6)) center.grid_rowconfigure(0, weight=1) center.grid_columnconfigure(0, weight=1) self.host = ctk.CTkFrame(center) self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) def _apply_cell_style(self, btn: ctk.CTkButton, state: int): if state == 0: btn.configure( fg_color=COLOR_EMPTY, hover_color="#9A9A9A", text_color=FG_DARK, border_width=0 ) elif state == 1: btn.configure( fg_color=COLOR_FULL, hover_color="#E69500", text_color=FG_DARK, border_width=0 ) else: btn.configure( fg_color=COLOR_DOUBLE, hover_color="#B22222", text_color=FG_LIGHT, border_width=0 ) def _clear_highlight(self): if self._highlighted and self.buttons: r, c = self._highlighted try: if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]): btn = self.buttons[r][c] if getattr(btn, "winfo_exists", None) and btn.winfo_exists(): try: btn.configure(border_width=0) except Exception: pass # clear blue frame border try: fr = self.btn_frames[r][c] if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists(): fr.configure(border_width=0) # in CTkFrame non esiste highlightthickness come in tk; border_* è corretto except Exception: pass except Exception: pass self._highlighted = None def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia): # prima rimuovi highlight su vecchi bottoni self._clear_highlight() # ripulisci host for w in self.host.winfo_children(): w.destroy() self.buttons.clear() self.btn_frames.clear() # salva matrici self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1 # ridistribuisci pesi griglia for r in range(rows): self.host.grid_rowconfigure(r, weight=1) for c in range(cols): self.host.grid_columnconfigure(c, weight=1) # crea Frame+Button per cella (righe invertite: fila "a" in basso) for r in range(rows): row_btns = [] row_frames = [] for c in range(cols): st = state[r][c] code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea) udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC text = f"{code}\n{udc}" cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0) btn = ctk.CTkButton( cell, text=text, corner_radius=6) self._apply_cell_style(btn, st) rr = (rows - 1) - r # capovolgi cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew") btn.pack(fill="both", expand=True) btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc)) btn.bind("", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc)) row_btns.append(btn) row_frames.append(cell) self.buttons.append(row_btns) self.btn_frames.append(row_frames) # focus differito post-ricarica if self._pending_focus and self._pending_focus[0] == corsia: _, col, fila, _barcode = self._pending_focus self._pending_focus = None self._highlight_cell_by_labels(col, fila) # ---------------- CONTEXT MENU ---------------- def _open_menu(self, event, r, c): st = self.matrix_state[r][c] corsia = self.corsia_selezionata.get() label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}" m = Menu(self, tearoff=0) m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}")) if st == 0: m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1)) m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2)) elif st == 1: m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0)) m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2)) else: m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0)) m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1)) m.add_separator() m.add_command(label="Copia ubicazione", command=lambda: self._copy(label)) x = self.winfo_pointerx() if event is None else event.x_root y = self.winfo_pointery() if event is None else event.y_root m.tk_popup(x, y) def _set_cell(self, r, c, val): self.matrix_state[r][c] = val btn = self.buttons[r][c] self._apply_cell_style(btn, val) self._refresh_stats() # ---------------- STATS ---------------- def _build_stats(self): bottom = ctk.CTkFrame(self) bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6) bottom.grid_columnconfigure(0, weight=1) ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2)) self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0) self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260)) self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0)) self.tot_text.grid(row=1, column=0, sticky="e") ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2)) self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0) self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260)) self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0)) self.sel_text.grid(row=3, column=0, sticky="e") leg = ctk.CTkFrame(bottom) leg.grid(row=4, column=0, sticky="w", pady=(10, 0)) ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8)) self._legend(leg, 1, "Vuota", COLOR_EMPTY) self._legend(leg, 3, "Piena", COLOR_FULL) self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE) def _legend(self, parent, col, text, color): box = tk.Canvas(parent, width=18, height=12, highlightthickness=0) box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444") box.grid(row=0, column=col) ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12)) # ---------------- DATA LOADING ---------------- def _load_corsie(self): sql = """ WITH C AS ( SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia FROM dbo.Celle WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G' ) SELECT Corsia FROM C ORDER BY CASE WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0 WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1 ELSE 2 END, CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END, CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END, CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END, Corsia; """ def _ok(res): rows = res.get("rows", []) if isinstance(res, dict) else [] self.lb.delete(0, tk.END) corsie = [r[0] for r in rows] for c in corsie: self.lb.insert(tk.END, c) idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1) if idx >= 0: self.lb.selection_clear(0, tk.END) self.lb.selection_set(idx) self.lb.see(idx) self._on_select(None) else: self._toast("Nessuna corsia trovata.") self._busy.hide() def _err(ex): self._busy.hide() messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}") self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…") def _on_select(self, _): sel = self.lb.curselection() if not sel: return corsia = self.lb.get(sel[0]) self.corsia_selezionata.set(corsia) self._load_matrix(corsia) def _select_corsia_in_listbox(self, corsia: str): for i in range(self.lb.size()): if self.lb.get(i) == corsia: self.lb.selection_clear(0, tk.END) self.lb.selection_set(i) self.lb.see(i) break def _load_matrix(self, corsia: str): # nuovo token richiesta → evita che risposte vecchie spazzino la UI self._req_counter += 1 req_id = self._req_counter self._last_req = req_id sql = """ WITH C AS ( SELECT ID, LTRIM(RTRIM(Corsia)) AS Corsia, LTRIM(RTRIM(Fila)) AS Fila, LTRIM(RTRIM(Colonna)) AS Colonna, Descrizione FROM dbo.Celle WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia ), R AS ( SELECT Fila, DENSE_RANK() OVER ( ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END, TRY_CONVERT(int, Fila), Fila ) AS RowN FROM C GROUP BY Fila ), K AS ( SELECT Colonna, DENSE_RANK() OVER ( ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END, TRY_CONVERT(int, Colonna), Colonna ) AS ColN FROM C GROUP BY Colonna ), S AS ( SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID GROUP BY c.ID ), U AS ( SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC FROM C c LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID GROUP BY c.ID ) SELECT r.RowN, k.ColN, CASE WHEN s.n IS NULL OR s.n = 0 THEN 0 WHEN s.n = 1 THEN 1 ELSE 2 END AS Stato, c.Descrizione, LTRIM(RTRIM(c.Fila)) AS FilaTxt, LTRIM(RTRIM(c.Colonna)) AS ColTxt, U.FirstUDC FROM C c JOIN R r ON r.Fila = c.Fila JOIN K k ON k.Colonna = c.Colonna LEFT JOIN S s ON s.ID = c.ID LEFT JOIN U ON U.ID = c.ID ORDER BY r.RowN, k.ColN; """ def _ok(res): # ignora risposte superate if req_id < self._last_req: return rows = res.get("rows", []) if isinstance(res, dict) else [] if not rows: # mostra matrice vuota senza rimuovere il frame (evita "schermo bianco") self._rebuild_matrix(0, 0, [], [], [], [], [], corsia) self._refresh_stats() self._busy.hide() return max_r = max_c = 0 for row in rows: rown, coln = row[0], row[1] if rown and coln: max_r = max(max_r, int(rown)) max_c = max(max_c, int(coln)) mat = [[0] * max_c for _ in range(max_r)] fila = [[""] * max_c for _ in range(max_r)] col = [[""] * max_c for _ in range(max_r)] desc = [[""] * max_c for _ in range(max_r)] udc = [[""] * max_c for _ in range(max_r)] for row in rows: rown, coln, stato, descr, fila_txt, col_txt, first_udc = row r = int(rown) - 1 c = int(coln) - 1 mat[r][c] = int(stato) fila[r][c] = str(fila_txt or "") col[r][c] = str(col_txt or "") desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}") udc[r][c] = str(first_udc or "") self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia) self._refresh_stats() self._busy.hide() def _err(ex): if req_id < self._last_req: return self._busy.hide() messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}") self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}…") # ---------------- SEARCH ---------------- def _search_udc(self): barcode = (self.search_var.get() or "").strip() if not barcode: self._toast("Inserisci un barcode UDC da cercare.") return # bump token per impedire che una vecchia _load_matrix cancelli UI self._req_counter += 1 search_req_id = self._req_counter self._last_req = search_req_id sql = """ SELECT TOP (1) RTRIM(c.Corsia) AS Corsia, RTRIM(c.Colonna) AS Colonna, RTRIM(c.Fila) AS Fila, c.ID AS IDCella FROM dbo.XMag_GiacenzaPallet g JOIN dbo.Celle c ON c.ID = g.IDCella WHERE g.BarcodePallet = :barcode AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G' """ def _ok(res): if search_req_id < self._last_req: return rows = res.get("rows", []) if isinstance(res, dict) else [] if not rows: messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self) return corsia, col, fila, _idc = rows[0] corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip() self._pending_focus = (corsia, col, fila, barcode) # sincronizza listbox e carica SEMPRE la corsia della UDC self._select_corsia_in_listbox(corsia) self.corsia_selezionata.set(corsia) self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix def _err(ex): if search_req_id < self._last_req: return messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self) self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…") def _try_highlight(self, col_txt: str, fila_txt: str) -> bool: for r in range(len(self.col_txt)): for c in range(len(self.col_txt[r])): if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt: self._clear_highlight() btn = self.buttons[r][c] btn.configure(border_width=3, border_color="blue") try: fr = self.btn_frames[r][c] fr.configure(border_color="blue", border_width=2) except Exception: pass self._highlighted = (r, c) return True return False def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str): if not self._try_highlight(col_txt, fila_txt): self._toast("Cella trovata ma non mappabile a pulsante.") # ---------------- COMMANDS ---------------- def _refresh_current(self): if self.corsia_selezionata.get(): self._load_matrix(self.corsia_selezionata.get()) def _export_xlsx(self): if not self.matrix_state: messagebox.showinfo("Export", "Nessuna matrice da esportare.") return corsia = self.corsia_selezionata.get() or "NA" ts = datetime.now().strftime("%d_%m_%Y_%H-%M") default = f"layout_matrice_{corsia}_{ts}.xlsx" path = filedialog.asksaveasfilename( title="Esporta matrice", defaultextension=".xlsx", initialfile=default, filetypes=[("Excel", "*.xlsx")] ) if not path: return try: from openpyxl import Workbook from openpyxl.styles import PatternFill, Alignment, Font except Exception as ex: messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl") return rows = len(self.matrix_state) cols = len(self.matrix_state[0]) if self.matrix_state else 0 wb = Workbook() ws1 = wb.active ws1.title = f"Dettaglio {corsia}" ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"]) for r in range(rows): for c in range(cols): st = self.matrix_state[r][c] stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia") ws1.append([corsia, r + 1, c + 1, stato_lbl, self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]]) for cell in ws1[1]: cell.font = Font(bold=True) ws2 = wb.create_sheet(f"Matrice {corsia}") fills = { 0: PatternFill("solid", fgColor="B0B0B0"), 1: PatternFill("solid", fgColor="FFA500"), 2: PatternFill("solid", fgColor="D62728"), } center = Alignment(horizontal="center", vertical="center", wrap_text=True) for r in range(rows): for c in range(cols): value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}" cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso cell.fill = fills.get(self.matrix_state[r][c], fills[0]) cell.alignment = center try: wb.save(path) self._toast(f"Esportato: {path}") except Exception as ex: messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}") # ---------------- STATS ---------------- def _refresh_stats(self): # globale dal DB sql_tot = """ WITH C AS ( SELECT ID FROM dbo.Celle WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Fila)) IS NOT NULL AND LTRIM(RTRIM(Colonna)) IS NOT NULL ), S AS ( SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID GROUP BY c.ID ) SELECT CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno, CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie FROM C LEFT JOIN S s ON s.ID = C.ID; """ def _ok(res): rows = res.get("rows", []) if isinstance(res, dict) else [] p_full = float(rows[0][0] or 0.0) if rows else 0.0 p_dbl = float(rows[0][1] or 0.0) if rows else 0.0 self._draw_bar(self.tot_canvas, p_full) self.tot_text.configure(text=pct_text(p_full, p_dbl)) self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None) # selezionata dalla matrice in memoria if self.matrix_state: tot = sum(len(r) for r in self.matrix_state) full = sum(1 for row in self.matrix_state for v in row if v in (1, 2)) doubles = sum(1 for row in self.matrix_state for v in row if v == 2) p_full = (full / tot) if tot else 0.0 p_dbl = (doubles / tot) if tot else 0.0 else: p_full = p_dbl = 0.0 self._draw_bar(self.sel_canvas, p_full) self.sel_text.configure(text=pct_text(p_full, p_dbl)) def _draw_bar(self, cv: tk.Canvas, p_full: float): cv.delete("all") w = max(300, cv.winfo_width() or 600) h = 18 fw = int(w * max(0.0, min(1.0, p_full))) cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0) cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0) cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1) # ---------------- UTIL ---------------- def _toast(self, msg, ms=1400): if not hasattr(self, "_status"): self._status = ctk.CTkLabel(self, anchor="w") self._status.grid(row=3, column=0, sticky="ew") self._status.configure(text=msg) self.after(ms, lambda: self._status.configure(text="")) def _copy(self, txt: str): self.clipboard_clear() self.clipboard_append(txt) self._toast(f"Copiato: {txt}") def open_layout_window(parent, db_app): key = "_layout_window_singleton" ex = getattr(parent, key, None) if ex and ex.winfo_exists(): try: ex.lift() ex.focus_force() return ex except Exception: pass w = LayoutWindow(parent, db_app) setattr(parent, key, w) return w