Checkpoint before more window sizing work

This commit is contained in:
2026-05-10 16:29:49 +02:00
parent 6ab42a2303
commit 8489cd7459
15 changed files with 2071 additions and 156 deletions

346
main.py
View File

@@ -9,20 +9,24 @@ import asyncio
import ctypes
import sys
import tkinter as tk
import time
import customtkinter as ctk
from tkinter import messagebox
from async_loop_singleton import get_global_loop
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
from audit_log import log_session_event
from gestione_layout import open_layout_window
from gestione_pickinglist import open_pickinglist_window
from login_window import prompt_login
from reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window
from audit_log import log_session_event
from view_celle_multi_udc import open_celle_multiple_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
from view_celle_multi_udc import open_celle_multiple_window
from window_placement import cascade_children_below_parent, place_window_fullsize_below_parent_later
# ---- Config ----
@@ -102,78 +106,320 @@ def _build_bypass_session() -> UserSession:
class Launcher(ctk.CTk):
"""Main launcher window that exposes the available warehouse tools."""
_WINDOW_ORDER = [
"reset_corsie",
"layout",
"multi_udc",
"search",
"pickinglist",
]
def __init__(self, session: UserSession):
"""Create the launcher toolbar and wire every button to a feature window."""
super().__init__()
self.session: UserSession = session
self._theme = theme_section("launcher", {})
self._tooltip_catalog = load_tooltip_catalog()
self._child_windows: list[tk.Misc] = []
self._child_windows_by_key: dict[str, tk.Misc] = {}
self._is_cascading = False
self._focus_restore_pending: set[str] = set()
self._restore_suppressed_until = 0.0
self.title(f"Warehouse 1.0.0 - {self.session.display_name}")
self.geometry("1280x96+0+0")
self._apply_dynamic_geometry()
outer_pady = int(theme_value(self._theme, "outer_pady", 10))
outer_padx = int(theme_value(self._theme, "outer_padx", 0))
info_padx = int(theme_value(self._theme, "info_padx", 6))
info_pady = theme_value(self._theme, "info_pady", [4, 2])
button_padx = int(theme_value(self._theme, "button_padx", 6))
button_pady = int(theme_value(self._theme, "button_pady", 6))
max_buttons_per_row = max(1, int(theme_value(self._theme, "max_buttons_per_row", 7)))
wrap = ctk.CTkFrame(self)
wrap.pack(pady=10, fill="x")
wrap.pack(padx=outer_padx, pady=outer_pady, fill="x")
actions = [
(
"reset_corsie",
"Gestione Corsie",
"launcher.open_reset_corsie",
lambda: self._open_child_window(
"reset_corsie",
open_reset_corsie_window(self, db_app, session=self.session),
),
),
(
"layout",
"Gestione Layout",
"launcher.open_layout",
lambda: self._open_child_window(
"layout",
open_layout_window(self, db_app, session=self.session),
),
),
(
"multi_udc",
"UDC Fantasma",
"launcher.open_multi_udc",
lambda: self._open_child_window(
"multi_udc",
open_celle_multiple_window(self, db_app, session=self.session),
),
),
(
"search",
"Ricerca UDC",
"launcher.open_search",
lambda: self._open_child_window(
"search",
open_search_window(self, db_app, session=self.session),
),
),
(
"pickinglist",
"Gestione Picking List",
"launcher.open_pickinglist",
lambda: self._open_child_window(
"pickinglist",
open_pickinglist_window(self, db_app, session=self.session),
),
),
(
"arrange",
"Ridisponi finestre",
"launcher.arrange_windows",
self._cascade_open_windows,
),
(
"exit",
"Esci",
"launcher.exit",
self._shutdown,
),
]
used_columns = max(1, min(len(actions), max_buttons_per_row))
info = ctk.CTkLabel(
wrap,
text=f"Operatore: {self.session.display_name} ({self.session.login})",
anchor="w",
font=("", 12, "bold"),
font=theme_font(self._theme, "info_font", default=("Segoe UI", 12, "bold")),
)
info.grid(row=0, column=0, columnspan=5, padx=6, pady=(4, 2), sticky="ew")
info.grid(row=0, column=0, columnspan=used_columns, padx=info_padx, pady=tuple(info_pady), sticky="ew")
ctk.CTkButton(
wrap,
text="Gestione Corsie",
state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled",
command=lambda: open_reset_corsie_window(self, db_app, session=self.session),
).grid(row=1, column=0, padx=6, pady=6, sticky="ew")
ctk.CTkButton(
wrap,
text="Gestione Layout",
state="normal" if self.session.can("launcher.open_layout") else "disabled",
command=lambda: open_layout_window(self, db_app, session=self.session),
).grid(row=1, column=1, padx=6, pady=6, sticky="ew")
ctk.CTkButton(
wrap,
text="UDC Fantasma",
state="normal" if self.session.can("launcher.open_multi_udc") else "disabled",
command=lambda: open_celle_multiple_window(self, db_app, session=self.session),
).grid(row=1, column=2, padx=6, pady=6, sticky="ew")
ctk.CTkButton(
wrap,
text="Ricerca UDC",
state="normal" if self.session.can("launcher.open_search") else "disabled",
command=lambda: open_search_window(self, db_app, session=self.session),
).grid(row=1, column=3, padx=6, pady=6, sticky="ew")
ctk.CTkButton(
wrap,
text="Gestione Picking List",
state="normal" if self.session.can("launcher.open_pickinglist") else "disabled",
command=lambda: open_pickinglist_window(self, db_app, session=self.session),
).grid(row=1, column=4, padx=6, pady=6, sticky="ew")
for idx, (_key, label, permission, callback) in enumerate(actions):
row = 1 + (idx // max_buttons_per_row)
column = idx % max_buttons_per_row
button = ctk.CTkButton(
wrap,
text=label,
font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")),
state="normal" if self.session.can(permission) else "disabled",
command=callback,
)
button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew")
tip = tooltip_text(permission, catalog=self._tooltip_catalog)
if tip:
WidgetToolTip(button, tip)
for i in range(5):
for i in range(max_buttons_per_row):
wrap.grid_columnconfigure(i, weight=1)
def _on_close():
"""Dispose shared resources before closing the launcher."""
try:
if self.session is not None:
log_session_event(self.session, action="logout", outcome="ok")
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
finally:
self.destroy()
self.update_idletasks()
self._apply_dynamic_geometry()
self.protocol("WM_DELETE_WINDOW", _on_close)
self.protocol("WM_DELETE_WINDOW", self._shutdown)
try:
self.lift()
self.focus_force()
except Exception:
pass
def _apply_dynamic_geometry(self) -> None:
"""Size the launcher around its current content and keep it docked at the top."""
top_x = int(theme_value(self._theme, "window_top_x", 0))
top_y = int(theme_value(self._theme, "window_top_y", 0))
min_width = int(theme_value(self._theme, "window_min_width", 960))
target_width = int(theme_value(self._theme, "window_width", 1280))
requested_width = max(min_width, self.winfo_reqwidth())
width = max(min_width, target_width, requested_width)
height = max(80, self.winfo_reqheight())
self.geometry(f"{width}x{height}+{top_x}+{top_y}")
def _open_child_window(self, key: str, window: tk.Misc | None) -> None:
"""Track child windows opened from the launcher."""
if window is None:
return
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
if window not in self._child_windows:
self._child_windows.append(window)
self._child_windows_by_key = {
child_key: child
for child_key, child in self._child_windows_by_key.items()
if getattr(child, "winfo_exists", lambda: False)()
}
self._child_windows_by_key[key] = window
try:
window.bind(
"<Destroy>",
lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<Activate>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<FocusIn>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<ButtonPress-1>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.lift()
except Exception:
pass
def _forget_child_window(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Remove stale references to child windows that have been closed."""
if event_widget is not None and event_widget is not window:
return
try:
tracked = self._child_windows_by_key.get(key)
if tracked is window:
self._child_windows_by_key.pop(key, None)
except Exception:
pass
try:
self._child_windows = [w for w in self._child_windows if w is not window and getattr(w, "winfo_exists", lambda: False)()]
except Exception:
pass
def _widget_belongs_to_window(self, window: tk.Misc, widget: tk.Misc | None) -> bool:
"""Return True when an event widget is the toplevel itself or one of its descendants."""
if widget is None:
return True
if widget is window:
return True
try:
current = widget
while current is not None:
if current is window:
return True
parent_name = current.winfo_parent()
if not parent_name:
break
current = current.nametowidget(parent_name)
except Exception:
pass
return False
def _on_child_activate(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Restore an activated child to the primary slot below the launcher."""
if self._is_cascading:
return
if time.monotonic() < self._restore_suppressed_until:
return
if not self._widget_belongs_to_window(window, event_widget):
return
tracked = self._child_windows_by_key.get(key)
if tracked is not window or not getattr(window, "winfo_exists", lambda: False)():
return
if key in self._focus_restore_pending:
return
self._focus_restore_pending.add(key)
def _restore() -> None:
try:
tracked_now = self._child_windows_by_key.get(key)
if tracked_now is window and getattr(window, "winfo_exists", lambda: False)():
place_window_fullsize_below_parent_later(self, window)
finally:
try:
self.after(250, lambda: self._focus_restore_pending.discard(key))
except Exception:
self._focus_restore_pending.discard(key)
try:
self.after(0, _restore)
except Exception:
self._focus_restore_pending.discard(key)
def _cascade_open_windows(self) -> None:
"""Cascade all open child windows following launcher button order."""
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
self._child_windows_by_key = {
key: window
for key, window in self._child_windows_by_key.items()
if getattr(window, "winfo_exists", lambda: False)()
}
ordered_windows = [
self._child_windows_by_key[key]
for key in self._WINDOW_ORDER
if key in self._child_windows_by_key
]
self._is_cascading = True
self._focus_restore_pending.clear()
self._restore_suppressed_until = time.monotonic() + 1.2
cascade_children_below_parent(
self,
ordered_windows,
x_offset_step=int(theme_value(self._theme, "cascade_x_offset", 28)),
y_offset_step=int(theme_value(self._theme, "cascade_y_offset", 28)),
margin_left=int(theme_value(self._theme, "cascade_margin_left", 0)),
margin_top=int(theme_value(self._theme, "cascade_margin_top", 0)),
)
def _finish_cascade() -> None:
self._is_cascading = False
try:
self.lift()
self.focus_force()
except Exception:
pass
try:
self.after(250, _finish_cascade)
except Exception:
self._is_cascading = False
def _shutdown(self) -> None:
"""Dispose session and shared DB resources before closing the launcher."""
try:
if self.session is not None:
log_session_event(self.session, action="logout", outcome="ok")
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
finally:
self.destroy()
if __name__ == "__main__":
ctk.set_appearance_mode("light")