From 2c66f50e87e81ec08b8db7a03bc29bce8a23dc04 Mon Sep 17 00:00:00 2001 From: Kr4t0$-X <30687184+Kr4t0S-X@users.noreply.github.com> Date: Fri, 8 May 2026 15:47:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20QoL=20pass=20=E2=80=94=20overlay=20bar,?= =?UTF-8?q?=20broadcast=20mode,=20auto-refresh,=20layout=20manager,=20and?= =?UTF-8?q?=2010+=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 – Core - Add overlay_bar.py: frameless always-on-top strip showing all active accounts with class icons, name labels, and green highlight on the focused account. Draggable, position persisted in config. Shows when main window is hidden, hides when it reopens. - Redesign system tray icon: programmatic navy/green "D" circle via Pillow; tooltip now shows live account count ("DOSOFT — N comptes actifs"). - Per-account icons: overlay uses skin/ class sprites; account_icons config key allows custom override per account. P2 – Gameplay - Broadcast mode (logic.py): broadcast_keypress() loops PostMessage(WM_KEYDOWN/UP) over all active windows. Disabled by default with orange ToU warning toggle. - Auto-refresh (main.py): _winevent_thread() uses SetWinEventHook EVENT_OBJECT_CREATE/DESTROY — debounced 2s to avoid spam on login screens. - Close with confirmation: both close-account and close-all now show yes/no dialog. - Minimize all / Restore all: two new buttons in the actions bar. P3 – Organisation - Window layout manager: save/restore named layouts via GetWindowRect/MoveWindow; three built-in grid presets (2×2, 1+3, 3+1). - Settings export/import: JSON file dialog for full config backup/restore. - Focus history: prev_idx tracking + focus_prev() + configurable back_key hotkey. - AFK timer: "Xm ago" label per account, updated every 60 s via root.after. - Custom team names: per-team display rename; internal keys unchanged. - More teams (up to 4): dynamic team count selector; 4 distinct team colors. - Per-account custom label: click subtitle area to inline-edit a short note. - Fix: remove leftover merge-conflict markers (<<<<<<< / >>>>>>>) from main.py. - i18n: add all new keys to fr/en/pt translation files. Co-Authored-By: Claude Sonnet 4.6 --- config_manager.py | 19 +++ gui.py | 389 ++++++++++++++++++++++++++++++++++++++---- logic.py | 97 +++++++++++ main.py | 150 +++++++++++++--- overlay_bar.py | 288 +++++++++++++++++++++++++++++++ resources/i18n/en.yml | 37 +++- resources/i18n/fr.yml | 37 +++- resources/i18n/pt.yml | 38 ++++- 8 files changed, 997 insertions(+), 58 deletions(-) create mode 100644 overlay_bar.py diff --git a/config_manager.py b/config_manager.py index a488cc6..c5cc964 100644 --- a/config_manager.py +++ b/config_manager.py @@ -32,6 +32,25 @@ def __init__(self, filename="settings.json"): "cycle_row_binds": ["ctrl+F1", "ctrl+F2", "ctrl+F3", "ctrl+F4", "ctrl+F5", "ctrl+F6", "ctrl+F7", "ctrl+F8"], "keyboard_layout": "azerty_fr", "language": "fr", + # --- Overlay bar --- + "overlay_enabled": True, + "overlay_x": None, + "overlay_y": None, + # --- Broadcast mode --- + "broadcast_mode": False, + "broadcast_key": "", + "broadcast_vk": 0, + # --- Per-account customisation --- + "account_icons": {}, + "account_labels": {}, + # --- Teams --- + "team_count": 2, + "team_names": {"Team 1": "Team 1", "Team 2": "Team 2", "Team 3": "Team 3", "Team 4": "Team 4"}, + # --- Layout manager --- + "layout_presets": {}, + # --- Focus history --- + "back_key": "", + # --- AFK timer (runtime-only, not persisted, but key kept for safety) --- } self.load() diff --git a/gui.py b/gui.py index b8be23f..5fb6695 100644 --- a/gui.py +++ b/gui.py @@ -71,9 +71,184 @@ def __init__(self, parent_gui): self.var_keyboard_layout = ctk.StringVar(value=self.app.config.data.get("keyboard_layout", "azerty_fr")) ctk.CTkOptionMenu(frame_keyboard, values=["azerty_fr", "qwerty_us"], variable=self.var_keyboard_layout, command=lambda _: self.save_settings()).pack(side="right", padx=8, pady=8) + # --- Overlay bar --- + frame_overlay = ctk.CTkFrame(self.scroll_container) + frame_overlay.pack(fill="x", padx=10, pady=5) + ctk.CTkLabel(frame_overlay, text=self.app.i18n.t("settings_overlay", "Barre overlay")).pack(side="left", padx=8, pady=8) + self.var_overlay = ctk.BooleanVar(value=self.app.config.data.get("overlay_enabled", True)) + ctk.CTkSwitch(frame_overlay, text="", variable=self.var_overlay, command=self._save_overlay).pack(side="right", padx=8) + + # --- Teams --- + lbl_teams = ctk.CTkLabel(self.scroll_container, text=self.app.i18n.t("settings_teams", "Équipes"), font=title_font) + lbl_teams.pack(pady=(15, 5)) + + frame_team_count = ctk.CTkFrame(self.scroll_container) + frame_team_count.pack(fill="x", padx=10, pady=5) + ctk.CTkLabel(frame_team_count, text=self.app.i18n.t("settings_team_count", "Nombre d'équipes")).pack(side="left", padx=8, pady=8) + self.var_team_count = ctk.IntVar(value=self.app.config.data.get("team_count", 2)) + ctk.CTkOptionMenu(frame_team_count, values=["2", "3", "4"], + variable=ctk.StringVar(value=str(self.var_team_count.get())), + command=self._on_team_count_change).pack(side="right", padx=8, pady=8) + + frame_team_names = ctk.CTkFrame(self.scroll_container) + frame_team_names.pack(fill="x", padx=10, pady=5) + ctk.CTkLabel(frame_team_names, text=self.app.i18n.t("settings_team_names", "Noms d'équipes")).pack(padx=8, pady=(8, 2)) + self._team_name_entries = {} + team_names_cfg = self.app.config.data.get("team_names", {}) + team_count = self.app.config.data.get("team_count", 2) + for i in range(1, team_count + 1): + key = f"Team {i}" + row = ctk.CTkFrame(frame_team_names, fg_color="transparent") + row.pack(fill="x", padx=8, pady=2) + ctk.CTkLabel(row, text=f"T{i}:", width=30).pack(side="left") + ent = ctk.CTkEntry(row, width=140) + ent.insert(0, team_names_cfg.get(key, key)) + ent.pack(side="left", padx=4) + ent.bind("", lambda e, k=key, en=ent: self._save_team_name(k, en)) + ent.bind("", lambda e, k=key, en=ent: self._save_team_name(k, en)) + self._team_name_entries[key] = ent + + # --- Layout Manager --- + lbl_layout = ctk.CTkLabel(self.scroll_container, text=self.app.i18n.t("settings_layout", "Disposition des fenêtres"), font=title_font) + lbl_layout.pack(pady=(15, 5)) + + frame_layout = ctk.CTkFrame(self.scroll_container) + frame_layout.pack(fill="x", padx=10, pady=5) + + frame_layout_btns = ctk.CTkFrame(frame_layout, fg_color="transparent") + frame_layout_btns.pack(fill="x", padx=8, pady=5) + ctk.CTkButton(frame_layout_btns, text=self.app.i18n.t("btn_save_layout", "Sauvegarder"), width=120, + command=self._save_layout).pack(side="left", padx=4) + ctk.CTkButton(frame_layout_btns, text=self.app.i18n.t("btn_restore_layout", "Restaurer"), width=120, + command=self._restore_layout).pack(side="left", padx=4) + + frame_grid = ctk.CTkFrame(frame_layout, fg_color="transparent") + frame_grid.pack(fill="x", padx=8, pady=(0, 5)) + ctk.CTkLabel(frame_grid, text=self.app.i18n.t("label_grid_preset", "Grille:")).pack(side="left", padx=4) + for grid_name in ["2x2", "1+3", "3+1"]: + ctk.CTkButton(frame_grid, text=grid_name, width=55, height=26, + fg_color="#34495e", hover_color="#2c3e50", + command=lambda g=grid_name: self._apply_grid(g)).pack(side="left", padx=2) + + # --- Export / Import --- + lbl_export = ctk.CTkLabel(self.scroll_container, text=self.app.i18n.t("settings_export", "Sauvegarde Configuration"), font=title_font) + lbl_export.pack(pady=(15, 5)) + + frame_export = ctk.CTkFrame(self.scroll_container) + frame_export.pack(fill="x", padx=10, pady=5) + frame_export_btns = ctk.CTkFrame(frame_export, fg_color="transparent") + frame_export_btns.pack(pady=8) + ctk.CTkButton(frame_export_btns, text=self.app.i18n.t("btn_export", "📤 Exporter"), width=120, + command=self._export_config).pack(side="left", padx=8) + ctk.CTkButton(frame_export_btns, text=self.app.i18n.t("btn_import", "📥 Importer"), width=120, + fg_color="#27ae60", hover_color="#2ecc71", + command=self._import_config).pack(side="left", padx=8) + self.btn_close = ctk.CTkButton(self.scroll_container, text=self.app.i18n.t("settings_close", "Fermer"), fg_color="#7f8c8d", command=self.destroy) self.btn_close.pack(pady=(20, 10)) + def _save_overlay(self): + self.app.config.data["overlay_enabled"] = self.var_overlay.get() + self.app.config.save() + + def _on_team_count_change(self, val): + self.app.config.data["team_count"] = int(val) + self.app.config.save() + # Rebuild the main mode combo + team_count = int(val) + team_values = ["ALL"] + [f"Team {i+1}" for i in range(team_count)] + self.parent.combo_mode.configure(values=team_values) + + def _save_team_name(self, key, entry_widget): + val = entry_widget.get().strip() or key + self.app.config.data.setdefault("team_names", {})[key] = val + self.app.config.save() + + def _save_layout(self): + import tkinter.simpledialog as sd + name = sd.askstring( + self.app.i18n.t("dialog_layout_name_title", "Nom de la disposition"), + self.app.i18n.t("dialog_layout_name_prompt", "Entrez un nom pour cette disposition :"), + parent=self, + ) + if name: + self.app.logic.save_layout(name.strip()) + self.parent.show_temporary_message( + self.app.i18n.t("msg_layout_saved", "✅ Disposition '{n}' sauvegardée !").format(n=name), "#2ecc71" + ) + + def _restore_layout(self): + presets = self.app.config.data.get("layout_presets", {}) + if not presets: + messagebox.showinfo( + self.app.i18n.t("dialog_no_layout_title", "Aucune disposition"), + self.app.i18n.t("dialog_no_layout_text", "Aucune disposition sauvegardée."), + parent=self, + ) + return + import tkinter.simpledialog as sd + names = list(presets.keys()) + name = sd.askstring( + self.app.i18n.t("dialog_restore_layout_title", "Restaurer disposition"), + self.app.i18n.t("dialog_restore_layout_prompt", "Dispositions disponibles: {list}\n\nNom à restaurer:").format(list=", ".join(names)), + parent=self, + ) + if name and name.strip() in presets: + ok = self.app.logic.restore_layout(name.strip()) + if ok: + self.parent.show_temporary_message( + self.app.i18n.t("msg_layout_restored", "✅ Disposition '{n}' restaurée !").format(n=name), "#2ecc71" + ) + + def _apply_grid(self, grid_name): + self.app.logic.apply_grid_layout(grid_name) + self.parent.show_temporary_message( + self.app.i18n.t("msg_grid_applied", "✅ Grille {g} appliquée !").format(g=grid_name), "#2ecc71" + ) + + def _export_config(self): + import json + from tkinter import filedialog + path = filedialog.asksaveasfilename( + parent=self, + defaultextension=".json", + filetypes=[("JSON", "*.json")], + initialfile="dosoft_config.json", + title=self.app.i18n.t("dialog_export_title", "Exporter la configuration"), + ) + if path: + try: + with open(path, "w", encoding="utf-8") as f: + import json + json.dump(self.app.config.data, f, indent=4, ensure_ascii=False) + self.parent.show_temporary_message( + self.app.i18n.t("msg_export_ok", "✅ Configuration exportée !"), "#2ecc71" + ) + except Exception as e: + messagebox.showerror("Export", str(e), parent=self) + + def _import_config(self): + import json + from tkinter import filedialog + path = filedialog.askopenfilename( + parent=self, + filetypes=[("JSON", "*.json")], + title=self.app.i18n.t("dialog_import_title", "Importer la configuration"), + ) + if path: + try: + with open(path, "r", encoding="utf-8") as f: + loaded = json.load(f) + self.app.config.data.update(loaded) + self.app.config.save() + self.app.setup_hotkeys() + self.app.refresh() + self.parent.show_temporary_message( + self.app.i18n.t("msg_import_ok", "✅ Configuration importée !"), "#2ecc71" + ) + except Exception as e: + messagebox.showerror("Import", str(e), parent=self) + def save_settings(self): previous_language = self.app.config.data.get("language", "fr") self.app.config.data["radial_menu_active"] = self.var_radial.get() @@ -165,7 +340,9 @@ def __init__(self, app_controller): self.lbl_controls = ctk.CTkLabel(self.frame_mode, text=self.app.i18n.t("label_controls", "Contrôler :")) self.lbl_controls.pack(side="left", padx=10, pady=5) - self.combo_mode = ctk.CTkOptionMenu(self.frame_mode, values=["ALL", "Team 1", "Team 2"], command=self.on_mode_change) + team_count = cfg.get("team_count", 2) + team_values = ["ALL"] + [f"Team {i+1}" for i in range(team_count)] + self.combo_mode = ctk.CTkOptionMenu(self.frame_mode, values=team_values, command=self.on_mode_change) self.combo_mode.set(cfg.get("current_mode", "ALL")) self.combo_mode.pack(side="left", padx=5, pady=5) @@ -195,6 +372,48 @@ def __init__(self, app_controller): self.create_hotkey_row(self.frame_keys, "hotkey_toggle_ui", "toggle_app_key", 2, 3, "tooltip_toggle_ui") self.create_hotkey_row(self.frame_keys, "hotkey_refresh", "refresh_key", 1, 6, "tooltip_refresh") self.create_hotkey_row(self.frame_keys, "hotkey_quit", "quit_key", 2, 6, "tooltip_quit") + self.create_hotkey_row(self.frame_keys, "hotkey_back", "back_key", 3, 0, "tooltip_back") + + # --- Broadcast mode row --- + frame_broadcast = ctk.CTkFrame(self.root) + frame_broadcast.pack(fill="x", padx=15, pady=(0, 5)) + + lbl_bc = ctk.CTkLabel(frame_broadcast, text=self.app.i18n.t("label_broadcast", "📡 Broadcast :"), + font=ctk.CTkFont(weight="bold")) + lbl_bc.pack(side="left", padx=10, pady=5) + + self.var_broadcast = ctk.BooleanVar(value=self.app.config.data.get("broadcast_mode", False)) + self.sw_broadcast = ctk.CTkSwitch( + frame_broadcast, text="", variable=self.var_broadcast, + onvalue=True, offvalue=False, + progress_color="#e67e22", + command=self._on_broadcast_toggle, + ) + self.sw_broadcast.pack(side="left", padx=5) + + self.lbl_bc_warn = ctk.CTkLabel( + frame_broadcast, + text=self.app.i18n.t("label_broadcast_warn", "⚠️ Risque CGU — désactivé par défaut"), + font=ctk.CTkFont(size=10), + text_color="#e67e22", + ) + self.lbl_bc_warn.pack(side="left", padx=5) + + # Broadcast VK key picker (shows when enabled) + self.frame_bc_key = ctk.CTkFrame(frame_broadcast, fg_color="transparent") + self.frame_bc_key.pack(side="left", padx=10) + ctk.CTkLabel(self.frame_bc_key, text=self.app.i18n.t("label_broadcast_key", "Touche:"), font=ctk.CTkFont(size=11)).pack(side="left") + bc_key_val = self.app.config.data.get("broadcast_key", "") + self.btn_bc_key = ctk.CTkButton( + self.frame_bc_key, + text=bc_key_val if bc_key_val else self.app.i18n.t("none", "Aucun"), + width=80, + command=lambda: self.catch_key("broadcast_key", self.btn_bc_key, allow_mouse=False), + ) + self.btn_bc_key.pack(side="left", padx=4) + self.hotkey_btns["broadcast_key"] = self.btn_bc_key + if not self.var_broadcast.get(): + self.frame_bc_key.pack_forget() self.frame_actions = ctk.CTkFrame(self.root) self.frame_actions.pack(fill="x", padx=15, pady=5) @@ -207,7 +426,13 @@ def __init__(self, app_controller): self.btn_close_all = ctk.CTkButton(self.frame_actions, text=self.app.i18n.t("btn_close_team", "Fermer Team"), fg_color="#c0392b", hover_color="#e74c3c", command=self.close_all_and_refresh, width=120) self.btn_close_all.pack(side="right", padx=10) - + + self.btn_restore_all = ctk.CTkButton(self.frame_actions, text=self.app.i18n.t("btn_restore_all", "▲ Restaurer"), fg_color="#27ae60", hover_color="#2ecc71", command=lambda: self.app.logic.restore_all(), width=100) + self.btn_restore_all.pack(side="right", padx=5) + + self.btn_minimize_all = ctk.CTkButton(self.frame_actions, text=self.app.i18n.t("btn_minimize_all", "▼ Minimiser"), fg_color="#34495e", hover_color="#2c3e50", command=lambda: self.app.logic.minimize_all(), width=100) + self.btn_minimize_all.pack(side="right", padx=5) + self.btn_reset = ctk.CTkButton(self.frame_actions, text=self.app.i18n.t("btn_reset_settings", "Reset Settings"), fg_color="#7f8c8d", hover_color="#95a5a6", command=self.reset_all, width=120) self.btn_reset.pack(side="right", padx=10) @@ -321,11 +546,21 @@ def trigger_sort_taskbar(self): self.show_temporary_message(self.app.i18n.t("msg_sorted", "🚀 Les pages ont été rangées avec succès !"), "#9b59b6") def close_and_refresh(self, name): + if not messagebox.askyesno( + self.app.i18n.t("dialog_confirm_title", "Confirmation"), + self.app.i18n.t("dialog_close_account", "Fermer le compte {name} ?").format(name=name), + ): + return self.app.logic.close_account_window(name) - time.sleep(0.5) + time.sleep(0.5) self.original_app_refresh() - + def close_all_and_refresh(self): + if not messagebox.askyesno( + self.app.i18n.t("dialog_confirm_title", "Confirmation"), + self.app.i18n.t("dialog_close_team", "Fermer tous les comptes actifs ?"), + ): + return self.app.logic.close_all_active_accounts() time.sleep(0.5) self.original_app_refresh() @@ -363,82 +598,165 @@ def bind_i18n_tooltip(self, widget, key, default_text): self.tooltip_i18n_map[widget] = (key, default_text) self.bind_tooltip(widget, self.app.i18n.t(key, default_text)) + _TEAM_COLORS = ["#2980b9", "#c0392b", "#27ae60", "#8e44ad"] + + def _team_display(self, team_key): + names = self.app.config.data.get("team_names", {}) + label = names.get(team_key, team_key) + idx = int(team_key.split()[-1]) - 1 if team_key.startswith("Team ") else 0 + short = f"T{idx + 1}" + color = self._TEAM_COLORS[idx % len(self._TEAM_COLORS)] + return short, label, color + def toggle_team_ui(self, name, btn): + team_count = self.app.config.data.get("team_count", 2) current_team = self.app.config.data.get("accounts_team", {}).get(name, "Team 1") - new_team = "Team 2" if current_team == "Team 1" else "Team 1" + try: + current_n = int(current_team.split()[-1]) + except (ValueError, IndexError): + current_n = 1 + next_n = (current_n % team_count) + 1 + new_team = f"Team {next_n}" self.app.logic.change_team(name, new_team) - team_color = "#2980b9" if new_team == "Team 1" else "#c0392b" - btn.configure(text="T1" if new_team == "Team 1" else "T2", fg_color=team_color) + short, _, color = self._team_display(new_team) + btn.configure(text=short, fg_color=color) + + def _afk_text(self, name): + ts = self.app.last_focused.get(name) + if ts is None: + return "" + elapsed = int((time.time() - ts) / 60) + if elapsed < 1: + return "< 1m" + return f"{elapsed}m" def refresh_list(self, accounts): for widget in self.scroll_frame.winfo_children(): widget.destroy() leader_name = self.app.config.data.get("leader_name", "") - + is_retro = self.app.config.data.get("game_version", "Unity") == "Rétro" retro_classes = ["Inconnu", "Feca", "Osamodas", "Enutrof", "Sram", "Xelor", "Ecaflip", "Eniripsa", "Iop", "Cra", "Sadida", "Sacrieur", "Pandawa"] - + for idx, acc in enumerate(accounts): + name = acc['name'] row_frame = ctk.CTkFrame(self.scroll_frame, fg_color="transparent") row_frame.pack(fill="x", pady=2) - + img = self.get_class_image(acc.get('classe', 'Inconnu'), is_retro) if img: ctk.CTkLabel(row_frame, image=img, text="").pack(side="left", padx=5) - else: ctk.CTkLabel(row_frame, text="👤").pack(side="left", padx=5) - + else: ctk.CTkLabel(row_frame, text="👤").pack(side="left", padx=5) + var = tk.BooleanVar(value=acc['active']) - - chk_width = 110 if is_retro else 160 - chk = ctk.CTkCheckBox(row_frame, text=acc['name'][:15], variable=var, width=chk_width, command=lambda n=acc['name'], v=var: self.app.logic.toggle_account(n, v.get())) - chk.pack(side="left", padx=(5, 0)) + + # Name + optional sublabel in a small column + name_col = ctk.CTkFrame(row_frame, fg_color="transparent") + name_col.pack(side="left", padx=(5, 0)) + + chk_width = 110 if is_retro else 150 + chk = ctk.CTkCheckBox(name_col, text=name[:15], variable=var, width=chk_width, + command=lambda n=name, v=var: self.app.logic.toggle_account(n, v.get())) + chk.pack(anchor="w") + + # Custom label subtitle (click to edit) + user_label = self.app.config.data.get("account_labels", {}).get(name, "") + lbl_sub = ctk.CTkLabel(name_col, text=user_label if user_label else "", font=ctk.CTkFont(size=9), + text_color="#95a5a6", width=chk_width, anchor="w") + lbl_sub.pack(anchor="w") + lbl_sub.bind("", lambda e, n=name, l=lbl_sub: self._edit_account_label(n, l)) + + # AFK timer label (right side of name col) + afk_lbl = ctk.CTkLabel(name_col, text=self._afk_text(name), + font=ctk.CTkFont(size=9), text_color="#7f8c8d", width=30, anchor="e") + afk_lbl.pack(anchor="e") + self._schedule_afk_refresh(afk_lbl, name) if is_retro: combo_classe = ctk.CTkOptionMenu( - row_frame, - values=retro_classes, + row_frame, + values=retro_classes, width=90, height=24, fg_color="#34495e", button_color="#2c3e50", button_hover_color="#1a252f", - command=lambda val, n=acc['name']: self.change_retro_class(n, val) + command=lambda val, n=name: self.change_retro_class(n, val) ) combo_classe.set(acc.get('classe', 'Inconnu')) combo_classe.pack(side="left", padx=5) btn_close = ctk.CTkButton(row_frame, text="✖", width=25, fg_color="#c0392b", hover_color="#e74c3c") - btn_close.configure(command=lambda n=acc['name']: self.close_and_refresh(n)) + btn_close.configure(command=lambda n=name: self.close_and_refresh(n)) btn_close.pack(side="right", padx=(2, 5)) self.bind_i18n_tooltip(btn_close, "tooltip_close_game", "Fermer instantanément le jeu") - - is_leader = (acc['name'] == leader_name) + + is_leader = (name == leader_name) leader_txt = "🌟" if is_leader else "☆" leader_color = "#f39c12" if is_leader else "transparent" - btn_lead = ctk.CTkButton(row_frame, text=leader_txt, width=35, fg_color=leader_color, border_width=1, command=lambda n=acc['name']: self.set_leader(n)) + btn_lead = ctk.CTkButton(row_frame, text=leader_txt, width=35, fg_color=leader_color, border_width=1, + command=lambda n=name: self.set_leader(n)) btn_lead.pack(side="right", padx=2) self.bind_i18n_tooltip(btn_lead, "tooltip_set_leader", "Définir comme Chef") team_val = acc.get('team', "Team 1") - team_color = "#2980b9" if team_val == "Team 1" else "#c0392b" - btn_team = ctk.CTkButton(row_frame, text="T1" if team_val == "Team 1" else "T2", width=35, fg_color=team_color) - btn_team.configure(command=lambda n=acc['name'], b=btn_team: self.toggle_team_ui(n, b)) + short, _, color = self._team_display(team_val) + btn_team = ctk.CTkButton(row_frame, text=short, width=35, fg_color=color) + btn_team.configure(command=lambda n=name, b=btn_team: self.toggle_team_ui(n, b)) btn_team.pack(side="right", padx=5) - self.bind_i18n_tooltip(btn_team, "tooltip_change_team", "Changer d'équipe (T1/T2)") + self.bind_i18n_tooltip(btn_team, "tooltip_change_team", "Changer d'équipe") btn_down = ctk.CTkButton(row_frame, text="▼", width=25, fg_color="#34495e", hover_color="#2c3e50") - btn_down.configure(command=lambda n=acc['name']: self.move_row(n, 1)) + btn_down.configure(command=lambda n=name: self.move_row(n, 1)) btn_down.pack(side="right", padx=(2, 10)) self.bind_i18n_tooltip(btn_down, "tooltip_move_down", "Descendre dans l'initiative") - + btn_up = ctk.CTkButton(row_frame, text="▲", width=25, fg_color="#34495e", hover_color="#2c3e50") - btn_up.configure(command=lambda n=acc['name']: self.move_row(n, -1)) + btn_up.configure(command=lambda n=name: self.move_row(n, -1)) btn_up.pack(side="right", padx=2) self.bind_i18n_tooltip(btn_up, "tooltip_move_up", "Monter dans l'initiative") pos_values = [str(i+1) for i in range(len(accounts))] current_pos = str(idx + 1) - combo_pos = ctk.CTkOptionMenu(row_frame, values=pos_values, width=50, height=24, fg_color="#34495e", button_color="#2c3e50", button_hover_color="#1a252f") + combo_pos = ctk.CTkOptionMenu(row_frame, values=pos_values, width=50, height=24, + fg_color="#34495e", button_color="#2c3e50", button_hover_color="#1a252f") combo_pos.set(current_pos) - combo_pos.configure(command=lambda val, n=acc['name']: self.change_position(n, val)) + combo_pos.configure(command=lambda val, n=name: self.change_position(n, val)) combo_pos.pack(side="right", padx=(2, 5)) self.bind_i18n_tooltip(combo_pos, "tooltip_exact_position", "Choisir la position exacte") + def _schedule_afk_refresh(self, label_widget, name): + def _update(): + try: + if label_widget.winfo_exists(): + label_widget.configure(text=self._afk_text(name)) + self.root.after(60000, _update) + except Exception: + pass + self.root.after(60000, _update) + + def _edit_account_label(self, name, lbl_widget): + current = self.app.config.data.get("account_labels", {}).get(name, "") + entry = ctk.CTkEntry(lbl_widget.master, width=120, height=18, font=ctk.CTkFont(size=9)) + entry.insert(0, current) + entry.place(in_=lbl_widget, relx=0, rely=0, anchor="nw") + entry.focus_set() + + def save(event=None): + val = entry.get().strip() + self.app.config.data.setdefault("account_labels", {})[name] = val + self.app.config.save() + lbl_widget.configure(text=val) + entry.destroy() + + entry.bind("", save) + entry.bind("", save) + + def _on_broadcast_toggle(self): + enabled = self.var_broadcast.get() + self.app.config.data["broadcast_mode"] = enabled + self.app.config.save() + self.app.setup_hotkeys() + if enabled: + self.frame_bc_key.pack(side="left", padx=10) + else: + self.frame_bc_key.pack_forget() + def toggle_autofocus(self): self.app.config.data["auto_focus_retro"] = self.var_autofocus.get() self.app.config.save() @@ -621,19 +939,20 @@ def reset_all(self): self.original_app_refresh() def hide_to_tray(self): - """ Cache la fenêtre quand on clique sur la croix, sans la détruire """ self.root.withdraw() self.is_visible = False + if self.app.config.data.get("overlay_enabled", True): + self.app.overlay.show() def toggle_visibility(self): - """ Alterne entre l'affichage et la mise en veille dans la barre des tâches """ if self.is_visible: self.hide_to_tray() else: self.root.deiconify() self.root.lift() - self.root.focus_force() # Force la fenêtre à passer par-dessus les autres ! + self.root.focus_force() self.is_visible = True + self.app.overlay.hide() def run(self): self.root.mainloop() diff --git a/logic.py b/logic.py index b7592e5..bc183ce 100644 --- a/logic.py +++ b/logic.py @@ -1,8 +1,21 @@ import win32gui import win32con +import win32api import ctypes import win32process import time +import os + +from overlay_bar import CLASSE_TO_SKIN + + +def get_account_icon_path(name, classe, config): + custom = config.data.get("account_icons", {}).get(name) + if custom and os.path.exists(custom): + return custom + key = classe.lower().replace(" ", "_") if classe else "" + path = CLASSE_TO_SKIN.get(key, "skin/character.png") + return path if os.path.exists(path) else "skin/character.png" class DofusLogic: def __init__(self, config): @@ -203,6 +216,90 @@ def sort_taskbar(self): self.focus_window(self.leader_hwnd) except Exception: pass + def broadcast_keypress(self, vk_code: int): + for acc in self.get_cycle_list(): + hwnd = acc["hwnd"] + try: + win32api.PostMessage(hwnd, win32con.WM_KEYDOWN, vk_code, 0) + win32api.PostMessage(hwnd, win32con.WM_KEYUP, vk_code, 0xC0000001) + except Exception: + pass + + def minimize_all(self): + for acc in self.get_cycle_list(): + try: + win32gui.ShowWindow(acc["hwnd"], win32con.SW_MINIMIZE) + except Exception: + pass + + def restore_all(self): + for acc in self.get_cycle_list(): + try: + win32gui.ShowWindow(acc["hwnd"], win32con.SW_RESTORE) + except Exception: + pass + + def save_layout(self, preset_name: str): + preset = [] + for acc in self.get_cycle_list(): + try: + rect = win32gui.GetWindowRect(acc["hwnd"]) + x, y, x2, y2 = rect + preset.append({"name": acc["name"], "x": x, "y": y, "w": x2 - x, "h": y2 - y}) + except Exception: + pass + self.config.data.setdefault("layout_presets", {})[preset_name] = preset + self.config.save() + return preset + + def restore_layout(self, preset_name: str): + preset = self.config.data.get("layout_presets", {}).get(preset_name) + if not preset: + return False + name_to_hwnd = {acc["name"]: acc["hwnd"] for acc in self.all_accounts} + for entry in preset: + hwnd = name_to_hwnd.get(entry["name"]) + if hwnd: + try: + if win32gui.IsIconic(hwnd): + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + win32gui.MoveWindow(hwnd, entry["x"], entry["y"], entry["w"], entry["h"], True) + except Exception: + pass + return True + + def apply_grid_layout(self, grid_name: str): + accounts = self.get_cycle_list() + if not accounts: + return + sw = ctypes.windll.user32.GetSystemMetrics(0) + sh = ctypes.windll.user32.GetSystemMetrics(1) + n = len(accounts) + + if grid_name == "2x2": + cols, rows = 2, 2 + elif grid_name == "1+3": + cols, rows = 2, 2 + elif grid_name == "3+1": + cols, rows = 2, 2 + else: + cols = max(1, round(n ** 0.5)) + rows = (n + cols - 1) // cols + + cell_w = sw // cols + cell_h = sh // rows + + for i, acc in enumerate(accounts): + col = i % cols + row = i // cols + hwnd = acc["hwnd"] + try: + if win32gui.IsIconic(hwnd): + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + win32gui.MoveWindow(hwnd, col * cell_w, row * cell_h, cell_w, cell_h, True) + except Exception: + pass + def execute_advanced_bind(self, source, identifier): """ Gère le focus dynamique et retourne le nouvel index pour la boucle. """ active_list = self.get_cycle_list() diff --git a/main.py b/main.py index a031374..ff35f25 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ from logic import DofusLogic from gui import OrganizerGUI from radial_menu import RadialMenu +from overlay_bar import OverlayBar from i18n_manager import I18nManager from keyboard_layout_manager import KeyboardLayoutManager @@ -67,26 +68,59 @@ def __init__(self): self.mouse_states = {} self.radial_focus = RadialMenu(self.gui.root, self.on_radial_focus_select, accent_color="#2ecc71", hover_color="#27ae60", center_icon_path="skin/character.png") - + saved_vol = self.config.data.get("volume_level", 50) / 100.0 self.radial_focus.set_base_volume(saved_vol) - + + self.prev_idx = None + self.last_focused: dict = {} + + self.overlay = OverlayBar(self.gui.root, self) + threading.Thread(target=self.background_listener, daemon=True).start() - + threading.Thread(target=self._winevent_thread, daemon=True).start() + self.setup_hotkeys() self.refresh() self.start_notification_listener() self.setup_system_tray() - + self.gui.root.after(1000, self.check_conflicting_software) if not self.config.data.get("tutorial_done", False): self.gui.root.after(800, self.gui.launch_tutorial) + def _make_tray_image(self): + from PIL import ImageDraw, ImageFont + size = 64 + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + # Navy circle background + draw.ellipse([2, 2, size - 2, size - 2], fill=(26, 42, 78, 255)) + # White "D" letter + try: + font = ImageFont.truetype("C:/Windows/Fonts/arialbd.ttf", 38) + except Exception: + font = ImageFont.load_default() + bbox = draw.textbbox((0, 0), "D", font=font) + tx = (size - (bbox[2] - bbox[0])) // 2 - bbox[0] + ty = (size - (bbox[3] - bbox[1])) // 2 - bbox[1] + draw.text((tx, ty), "D", fill=(46, 204, 113, 255), font=font) + # Thin green ring + draw.ellipse([2, 2, size - 2, size - 2], outline=(46, 204, 113, 200), width=2) + return img + def setup_system_tray(self): - icon_path = "logo.ico" - try: image = Image.open(icon_path) - except: image = Image.new('RGB', (64, 64), color=(44, 62, 80)) + try: + image = self._make_tray_image() + except Exception: + try: + image = Image.open("logo.ico").convert("RGBA") + except Exception: + image = Image.new("RGBA", (64, 64), (26, 42, 78, 255)) + + n_active = len([a for a in self.logic.all_accounts if a.get("active")]) + tooltip = f"DOSOFT — {n_active} compte{'s' if n_active != 1 else ''} actif{'s' if n_active != 1 else ''}" menu = pystray.Menu( item(self.i18n.t("tray_toggle", "Afficher/Cacher"), self.toggle_from_tray, default=True), @@ -94,17 +128,28 @@ def setup_system_tray(self): item(self.i18n.t("tray_refresh", "Rafraîchir"), self.refresh_from_tray), item(self.i18n.t("tray_quit", "Quitter"), self.quit_from_tray) ) - self.tray_icon = pystray.Icon("dosoft_tray", image, "DOSOFT", menu) + self.tray_icon = pystray.Icon("dosoft_tray", image, tooltip, menu) self.tray_icon.run_detached() + def update_tray_tooltip(self): + try: + n_active = len([a for a in self.logic.all_accounts if a.get("active")]) + self.tray_icon.title = f"DOSOFT — {n_active} compte{'s' if n_active != 1 else ''} actif{'s' if n_active != 1 else ''}" + except Exception: + pass + def toggle_from_tray(self, icon, item): def safe_toggle(): if self.gui.root.state() == 'withdrawn': self.gui.root.deiconify() self.gui.root.lift() self.gui.root.focus_force() + if self.config.data.get("overlay_enabled", True): + self.overlay.hide() else: self.gui.root.withdraw() + if self.config.data.get("overlay_enabled", True): + self.overlay.show() self.gui.root.after(0, safe_toggle) def refresh_from_tray(self, icon, item): @@ -282,7 +327,7 @@ def safe_mouse_execute(f=func): radial_was_open = False self.gui.root.after(0, self.radial_focus.hide) - # Synchronisation de l'index au clic manuel + # Synchronisation de l'index au clic manuel + AFK timer + overlay highlight try: fg_hwnd = win32gui.GetForegroundWindow() cycle_list = self.logic.get_cycle_list() @@ -290,7 +335,10 @@ def safe_mouse_execute(f=func): for index, acc in enumerate(cycle_list): if acc['hwnd'] == fg_hwnd: if self.current_idx != index: + self.prev_idx = self.current_idx self.current_idx = index + self.last_focused[acc['name']] = time.time() + self.gui.root.after(0, self.overlay.set_active, acc['name']) break except Exception: pass time.sleep(0.01) @@ -390,13 +438,21 @@ def setup_hotkeys(self): if cfg.get("leader_key"): self.register_action(cfg["leader_key"], self.focus_leader) if cfg.get("toggle_app_key"): self.register_action(cfg["toggle_app_key"], lambda: self.gui.root.after(0, self.gui.toggle_visibility)) if cfg.get('refresh_key'): self.register_action(cfg['refresh_key'], self.refresh) - if cfg.get('quit_key'): self.register_action(cfg['quit_key'], self.quit_app) + if cfg.get('quit_key'): self.register_action(cfg['quit_key'], self.quit_app) + if cfg.get("back_key"): self.register_action(cfg["back_key"], self.focus_prev) + if cfg.get("broadcast_mode") and cfg.get("broadcast_key"): + self.register_action(cfg["broadcast_key"], self.trigger_broadcast) keyboard.hook(self.global_hook_listener) except Exception: pass - def refresh(self): + def refresh(self): slots = self.logic.scan_slots() self.gui.root.after(0, self.gui.refresh_list, slots) + self.gui.root.after(0, self._post_refresh, slots) + + def _post_refresh(self, slots): + self.overlay.rebuild(slots) + self.update_tray_tooltip() def focus_leader(self): if self.logic.leader_hwnd: @@ -411,15 +467,79 @@ def focus_leader(self): def next_char(self): cycle_list = self.logic.get_cycle_list() if not cycle_list: return + self.prev_idx = self.current_idx self.current_idx = (self.current_idx + 1) % len(cycle_list) self.logic.focus_window(cycle_list[self.current_idx]['hwnd']) def prev_char(self): cycle_list = self.logic.get_cycle_list() if not cycle_list: return + self.prev_idx = self.current_idx self.current_idx = (self.current_idx - 1) % len(cycle_list) self.logic.focus_window(cycle_list[self.current_idx]['hwnd']) + def focus_prev(self): + if self.prev_idx is None: return + cycle_list = self.logic.get_cycle_list() + if not cycle_list or self.prev_idx >= len(cycle_list): return + old = self.current_idx + self.current_idx = self.prev_idx + self.prev_idx = old + self.logic.focus_window(cycle_list[self.current_idx]['hwnd']) + + def trigger_broadcast(self): + vk = self.config.data.get("broadcast_vk", 0) + if vk: + self.logic.broadcast_keypress(vk) + + def _winevent_thread(self): + """Background thread that auto-refreshes when a Dofus window is created/destroyed.""" + import ctypes.wintypes + EVENT_OBJECT_CREATE = 0x8000 + EVENT_OBJECT_DESTROY = 0x8001 + WINEVENT_OUTOFCONTEXT = 0x0000 + + last_refresh = [0.0] + DEBOUNCE = 2.0 + + WinEventProc = ctypes.WINFUNCTYPE( + None, + ctypes.wintypes.HANDLE, + ctypes.wintypes.DWORD, + ctypes.wintypes.HWND, + ctypes.wintypes.LONG, + ctypes.wintypes.LONG, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ) + + def callback(hWinEventHook, event, hwnd, idObject, idChild, dwEventThread, dwmsEventTime): + try: + if hwnd and idObject == 0: + cls = win32gui.GetClassName(hwnd) if hwnd else "" + title = win32gui.GetWindowText(hwnd) if hwnd else "" + is_dofus = (cls == "UnityWndClass") or ("Dofus Retro" in title) + if is_dofus: + now = time.time() + if now - last_refresh[0] > DEBOUNCE: + last_refresh[0] = now + self.gui.root.after(600, self.refresh) + except Exception: + pass + + proc = WinEventProc(callback) + hook = ctypes.windll.user32.SetWinEventHook( + EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, + 0, proc, 0, 0, WINEVENT_OUTOFCONTEXT + ) + if not hook: + return + msg = ctypes.wintypes.MSG() + while ctypes.windll.user32.GetMessageW(ctypes.byref(msg), 0, 0, 0): + ctypes.windll.user32.TranslateMessage(ctypes.byref(msg)) + ctypes.windll.user32.DispatchMessageW(ctypes.byref(msg)) + ctypes.windll.user32.UnhookWinEvent(hook) + def quit_app(self): my_pid = os.getpid() os.system(f"taskkill /F /PID {my_pid} /T") @@ -514,10 +634,6 @@ async def remove_notif_delayed(notif_id): await asyncio.sleep(0.5) # --- SYSTÈME DE VÉRIFICATION DE VERSION --- -<<<<<<< fix/crash-notifrétro -CURRENT_VERSION = "1.2.1" -======= ->>>>>>> dev VERSION_URL = "https://raw.githubusercontent.com/LuframeCode/Dosoft/main/version.json" def check_version(i18n=None): @@ -570,11 +686,7 @@ def handle_multiple_instances(): root.attributes("-topmost", True) rep = messagebox.askyesno(i18n.t("header_instace_off", "Instance détectée"),i18n.t("popup_conflict_instance_text","Une instance de DOSOFT est déjà en cours d'exécution !\n\nVoulez-vous fermer l'ancienne instance pour ouvrir celle-ci ?")) if rep: -<<<<<<< fix/crash-notifrétro - hwnd = win32gui.FindWindow(None, "DOSOFT v1.2.1") -======= hwnd = win32gui.FindWindow(None, APP_TITLE) ->>>>>>> dev if hwnd: _, pid = win32process.GetWindowThreadProcessId(hwnd) try: diff --git a/overlay_bar.py b/overlay_bar.py new file mode 100644 index 0000000..b1ef69d --- /dev/null +++ b/overlay_bar.py @@ -0,0 +1,288 @@ +import tkinter as tk +import os +from PIL import Image, ImageTk + +CLASSE_TO_SKIN = { + "cra": "skin/cra.png", + "ecaflip": "skin/ecaflip.png", + "eliotrope": "skin/eliotrope.png", + "eniripsa": "skin/eniripsa.png", + "enutrof": "skin/enutrof.png", + "feca": "skin/feca.png", + "forgelance": "skin/forgelance.png", + "huppermage": "skin/huppermage.png", + "iop": "skin/iop.png", + "osamodas": "skin/osamodas.png", + "ouginak": "skin/ouginak.png", + "pandawa": "skin/pandawa.png", + "roublard": "skin/roublard.png", + "sacrieur": "skin/sacrieur.png", + "sadida": "skin/sadida.png", + "sram": "skin/sram.png", + "steamer": "skin/steamer.png", + "xelor": "skin/xelor.png", + "zobal": "skin/zobal.png", + # Rétro variants + "cra_retro": "skin/cra_retro.png", + "ecaflip_retro": "skin/ecaflip_retro.png", + "eniripsa_retro": "skin/eniripsa_retro.png", + "enutrof_retro": "skin/enutrof_retro.png", + "feca_retro": "skin/feca_retro.png", + "iop_retro": "skin/iop_retro.png", + "osamodas_retro": "skin/osamodas_retro.png", + "pandawa_retro": "skin/pandawa_retro.png", + "sacrieur_retro": "skin/sacrieur_retro.png", + "sadida_retro": "skin/sadida_retro.png", + "sram_retro": "skin/sram_retro.png", + "xelor_retro": "skin/xelor_retro.png", +} + +BG_COLOR = "#1a1a2e" +ACTIVE_COLOR = "#2ecc71" +INACTIVE_COLOR = "#2c3e50" +HOVER_COLOR = "#34495e" +TEXT_COLOR = "#ecf0f1" +SUBTEXT_COLOR = "#95a5a6" +BTN_SIZE = 64 # width per account button +BAR_HEIGHT = 72 # total bar height +ICON_SIZE = 36 + + +def get_account_icon_path(name, classe, config): + custom = config.data.get("account_icons", {}).get(name) + if custom and os.path.exists(custom): + return custom + key = classe.lower().replace(" ", "_") if classe else "" + path = CLASSE_TO_SKIN.get(key, "skin/character.png") + return path if os.path.exists(path) else "skin/character.png" + + +class OverlayBar: + def __init__(self, parent_root, app): + self.app = app + self.win = tk.Toplevel(parent_root) + self.win.overrideredirect(True) + self.win.attributes("-topmost", True) + self.win.configure(bg=BG_COLOR) + self.win.withdraw() + + self._drag_x = 0 + self._drag_y = 0 + self._accounts = [] + self._active_name = None + self._image_cache = {} + self._btns = {} # name -> canvas item ids dict + self._canvas = None + self._visible = False + + self.win.bind("", self._on_drag_start) + self.win.bind("", self._on_drag_move) + + # ── Visibility ────────────────────────────────────────────────────────── + + def show(self): + if not self._accounts: + return + self._visible = True + self._position() + self.win.deiconify() + self.win.lift() + + def hide(self): + self._visible = False + self.win.withdraw() + + def is_visible(self): + return self._visible + + # ── Rebuild (called after refresh) ────────────────────────────────────── + + def rebuild(self, accounts): + self._accounts = [a for a in accounts if a.get("active", True)] + self._image_cache.clear() + self._btns.clear() + if self._canvas: + self._canvas.destroy() + self._build_canvas() + if self._visible: + self._position() + self.win.deiconify() + self.win.lift() + + def _build_canvas(self): + n = len(self._accounts) + bar_width = max(BTN_SIZE * n + 40, 80) # +40 for the expand button + + canvas = tk.Canvas( + self.win, + width=bar_width, + height=BAR_HEIGHT, + bg=BG_COLOR, + highlightthickness=0, + ) + canvas.pack(fill="both", expand=True) + self._canvas = canvas + self.win.geometry(f"{bar_width}x{BAR_HEIGHT}") + + # Draw rounded-ish background + canvas.create_rectangle(0, 0, bar_width, BAR_HEIGHT, fill=BG_COLOR, outline="#2c3e50", width=1) + + for i, acc in enumerate(self._accounts): + x = i * BTN_SIZE + self._draw_account_btn(canvas, acc, x, i) + + # Expand / restore UI button on the right + ex = n * BTN_SIZE + expand_id = canvas.create_text( + ex + 20, BAR_HEIGHT // 2, + text="⊞", + fill=TEXT_COLOR, + font=("Segoe UI", 14), + ) + canvas.tag_bind(expand_id, "", lambda e: self._on_expand()) + canvas.tag_bind(expand_id, "", lambda e: canvas.itemconfig(expand_id, fill=ACTIVE_COLOR)) + canvas.tag_bind(expand_id, "", lambda e: canvas.itemconfig(expand_id, fill=TEXT_COLOR)) + + # Drag bindings on canvas background + canvas.bind("", self._on_drag_start) + canvas.bind("", self._on_drag_move) + + def _draw_account_btn(self, canvas, acc, x, idx): + name = acc["name"] + classe = acc.get("classe", "Inconnu") + label = self.app.config.data.get("account_labels", {}).get(name, "") + display = (label if label else name)[:9] + + is_active = (name == self._active_name) + border_color = ACTIVE_COLOR if is_active else INACTIVE_COLOR + + # Background rect for this button + bg_id = canvas.create_rectangle( + x + 2, 2, x + BTN_SIZE - 2, BAR_HEIGHT - 2, + fill=INACTIVE_COLOR if not is_active else "#1a3a2a", + outline=border_color, + width=2, + tags=(f"btn_{name}",), + ) + + # Icon + icon_path = get_account_icon_path(name, classe, self.app.config) + photo = self._load_icon(icon_path) + icon_y = 10 + if photo: + icon_id = canvas.create_image(x + BTN_SIZE // 2, icon_y + ICON_SIZE // 2, image=photo, tags=(f"btn_{name}",)) + else: + icon_id = None + + # Name label + text_id = canvas.create_text( + x + BTN_SIZE // 2, icon_y + ICON_SIZE + 4, + text=display, + fill=ACTIVE_COLOR if is_active else TEXT_COLOR, + font=("Segoe UI", 8, "bold" if is_active else "normal"), + tags=(f"btn_{name}",), + ) + + self._btns[name] = {"bg": bg_id, "text": text_id, "x": x} + + # Hover + click bindings + for tag_item in [bg_id, text_id] + ([icon_id] if icon_id else []): + canvas.tag_bind(tag_item, "", + lambda e, n=name: self._on_btn_hover(n, True)) + canvas.tag_bind(tag_item, "", + lambda e, n=name: self._on_btn_hover(n, False)) + canvas.tag_bind(tag_item, "", + lambda e, a=acc: self._on_btn_click(a)) + + def _load_icon(self, path): + if path in self._image_cache: + return self._image_cache[path] + try: + img = Image.open(path).convert("RGBA").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS) + photo = ImageTk.PhotoImage(img) + self._image_cache[path] = photo + return photo + except Exception: + self._image_cache[path] = None + return None + + # ── Active account highlight ───────────────────────────────────────────── + + def set_active(self, name): + if self._active_name == name: + return + self._active_name = name + if not self._canvas: + return + for acc_name, ids in self._btns.items(): + is_active = (acc_name == name) + self._canvas.itemconfig( + ids["bg"], + fill="#1a3a2a" if is_active else INACTIVE_COLOR, + outline=ACTIVE_COLOR if is_active else INACTIVE_COLOR, + ) + self._canvas.itemconfig( + ids["text"], + fill=ACTIVE_COLOR if is_active else TEXT_COLOR, + font=("Segoe UI", 8, "bold" if is_active else "normal"), + ) + + # ── Interactions ───────────────────────────────────────────────────────── + + def _on_btn_hover(self, name, entering): + if not self._canvas or name not in self._btns: + return + ids = self._btns[name] + is_active = (name == self._active_name) + if entering: + self._canvas.itemconfig(ids["bg"], fill=HOVER_COLOR if not is_active else "#22502e") + else: + self._canvas.itemconfig(ids["bg"], fill="#1a3a2a" if is_active else INACTIVE_COLOR) + + def _on_btn_click(self, acc): + self.app.logic.focus_window(acc["hwnd"]) + # Update current_idx in app + cycle = self.app.logic.get_cycle_list() + for i, a in enumerate(cycle): + if a["name"] == acc["name"]: + self.app.current_idx = i + break + self.set_active(acc["name"]) + + def _on_expand(self): + self.hide() + self.app.gui.root.after(0, lambda: ( + self.app.gui.root.deiconify(), + self.app.gui.root.lift(), + self.app.gui.root.focus_force(), + )) + + # ── Drag ──────────────────────────────────────────────────────────────── + + def _on_drag_start(self, event): + self._drag_x = event.x_root - self.win.winfo_x() + self._drag_y = event.y_root - self.win.winfo_y() + + def _on_drag_move(self, event): + new_x = event.x_root - self._drag_x + new_y = event.y_root - self._drag_y + self.win.geometry(f"+{new_x}+{new_y}") + self.app.config.data["overlay_x"] = new_x + self.app.config.data["overlay_y"] = new_y + self.app.config.save() + + # ── Positioning ────────────────────────────────────────────────────────── + + def _position(self): + saved_x = self.app.config.data.get("overlay_x") + saved_y = self.app.config.data.get("overlay_y") + if saved_x is not None and saved_y is not None: + self.win.geometry(f"+{saved_x}+{saved_y}") + else: + # Default: bottom-center of screen + sw = self.win.winfo_screenwidth() + n = max(len(self._accounts), 1) + bar_w = BTN_SIZE * n + 40 + x = (sw - bar_w) // 2 + y = self.win.winfo_screenheight() - BAR_HEIGHT - 60 + self.win.geometry(f"+{x}+{y}") diff --git a/resources/i18n/en.yml b/resources/i18n/en.yml index f77144c..f5d270b 100644 --- a/resources/i18n/en.yml +++ b/resources/i18n/en.yml @@ -77,5 +77,40 @@ "header_instance_off": "Instance detected", "button_yes": "Yes", "button_no": "No", - "popup_conflict_instance_text": "An instance of DOSOFT is already running!\n\nDo you want to close the old instance to open this one?" + "popup_conflict_instance_text": "An instance of DOSOFT is already running!\n\nDo you want to close the old instance to open this one?", + "hotkey_back": "Back", + "tooltip_back": "Focus previously active account", + "label_broadcast": "📡 Broadcast:", + "label_broadcast_warn": "⚠️ ToU risk — disabled by default", + "label_broadcast_key": "Key:", + "btn_minimize_all": "▼ Minimize all", + "btn_restore_all": "▲ Restore all", + "tooltip_change_team": "Switch team", + "settings_overlay": "Overlay bar", + "settings_teams": "Teams", + "settings_team_count": "Number of teams", + "settings_team_names": "Team names", + "settings_layout": "Window layout", + "btn_save_layout": "Save layout", + "btn_restore_layout": "Restore layout", + "label_grid_preset": "Grid:", + "settings_export": "Config Backup", + "btn_export": "📤 Export", + "btn_import": "📥 Import", + "dialog_confirm_title": "Confirmation", + "dialog_close_account": "Close account {name}?", + "dialog_close_team": "Close all active accounts?", + "dialog_layout_name_title": "Layout name", + "dialog_layout_name_prompt": "Enter a name for this layout:", + "dialog_restore_layout_title": "Restore layout", + "dialog_restore_layout_prompt": "Available layouts: {list}\n\nName to restore:", + "dialog_no_layout_title": "No layout", + "dialog_no_layout_text": "No saved layout found.", + "dialog_export_title": "Export configuration", + "dialog_import_title": "Import configuration", + "msg_layout_saved": "✅ Layout '{n}' saved!", + "msg_layout_restored": "✅ Layout '{n}' restored!", + "msg_grid_applied": "✅ Grid {g} applied!", + "msg_export_ok": "✅ Configuration exported!", + "msg_import_ok": "✅ Configuration imported!" } diff --git a/resources/i18n/fr.yml b/resources/i18n/fr.yml index c74ff64..4663896 100644 --- a/resources/i18n/fr.yml +++ b/resources/i18n/fr.yml @@ -77,5 +77,40 @@ "header_instace_off": "Instance détectée", "button_yes": "Oui", "button_no": "Non", - "popup_conflict_instance_text": "Une instance de DOSOFT est déjà en cours d'exécution !\n\nVoulez-vous fermer l'ancienne instance pour ouvrir celle-ci ?" + "popup_conflict_instance_text": "Une instance de DOSOFT est déjà en cours d'exécution !\n\nVoulez-vous fermer l'ancienne instance pour ouvrir celle-ci ?", + "hotkey_back": "Retour", + "tooltip_back": "Focus sur le compte précédemment actif", + "label_broadcast": "📡 Broadcast :", + "label_broadcast_warn": "⚠️ Risque CGU — désactivé par défaut", + "label_broadcast_key": "Touche:", + "btn_minimize_all": "▼ Minimiser", + "btn_restore_all": "▲ Restaurer", + "tooltip_change_team": "Changer d'équipe", + "settings_overlay": "Barre overlay", + "settings_teams": "Équipes", + "settings_team_count": "Nombre d'équipes", + "settings_team_names": "Noms d'équipes", + "settings_layout": "Disposition des fenêtres", + "btn_save_layout": "Sauvegarder", + "btn_restore_layout": "Restaurer", + "label_grid_preset": "Grille:", + "settings_export": "Sauvegarde Configuration", + "btn_export": "📤 Exporter", + "btn_import": "📥 Importer", + "dialog_confirm_title": "Confirmation", + "dialog_close_account": "Fermer le compte {name} ?", + "dialog_close_team": "Fermer tous les comptes actifs ?", + "dialog_layout_name_title": "Nom de la disposition", + "dialog_layout_name_prompt": "Entrez un nom pour cette disposition :", + "dialog_restore_layout_title": "Restaurer disposition", + "dialog_restore_layout_prompt": "Dispositions disponibles: {list}\n\nNom à restaurer:", + "dialog_no_layout_title": "Aucune disposition", + "dialog_no_layout_text": "Aucune disposition sauvegardée.", + "dialog_export_title": "Exporter la configuration", + "dialog_import_title": "Importer la configuration", + "msg_layout_saved": "✅ Disposition '{n}' sauvegardée !", + "msg_layout_restored": "✅ Disposition '{n}' restaurée !", + "msg_grid_applied": "✅ Grille {g} appliquée !", + "msg_export_ok": "✅ Configuration exportée !", + "msg_import_ok": "✅ Configuration importée !" } diff --git a/resources/i18n/pt.yml b/resources/i18n/pt.yml index 4439ba3..b01fa5d 100644 --- a/resources/i18n/pt.yml +++ b/resources/i18n/pt.yml @@ -77,6 +77,40 @@ "header_instace_off": "Instância detectada", "button_yes": "Sim", "button_no": "Não", - "popup_conflict_instance_text": "Uma instância de DOSOFT já está em execução!\n\nVocê deseja fechar a instância antiga para abrir esta?" - + "popup_conflict_instance_text": "Uma instância de DOSOFT já está em execução!\n\nVocê deseja fechar a instância antiga para abrir esta?", + "hotkey_back": "Voltar", + "tooltip_back": "Focar conta anteriormente ativa", + "label_broadcast": "📡 Broadcast:", + "label_broadcast_warn": "⚠️ Risco CGU — desativado por padrão", + "label_broadcast_key": "Tecla:", + "btn_minimize_all": "▼ Minimizar tudo", + "btn_restore_all": "▲ Restaurar tudo", + "tooltip_change_team": "Mudar de equipe", + "settings_overlay": "Barra overlay", + "settings_teams": "Equipes", + "settings_team_count": "Número de equipes", + "settings_team_names": "Nomes das equipes", + "settings_layout": "Layout de janelas", + "btn_save_layout": "Salvar layout", + "btn_restore_layout": "Restaurar layout", + "label_grid_preset": "Grade:", + "settings_export": "Backup de Configuração", + "btn_export": "📤 Exportar", + "btn_import": "📥 Importar", + "dialog_confirm_title": "Confirmação", + "dialog_close_account": "Fechar conta {name}?", + "dialog_close_team": "Fechar todas as contas ativas?", + "dialog_layout_name_title": "Nome do layout", + "dialog_layout_name_prompt": "Digite um nome para este layout:", + "dialog_restore_layout_title": "Restaurar layout", + "dialog_restore_layout_prompt": "Layouts disponíveis: {list}\n\nNome a restaurar:", + "dialog_no_layout_title": "Nenhum layout", + "dialog_no_layout_text": "Nenhum layout salvo encontrado.", + "dialog_export_title": "Exportar configuração", + "dialog_import_title": "Importar configuração", + "msg_layout_saved": "✅ Layout '{n}' salvo!", + "msg_layout_restored": "✅ Layout '{n}' restaurado!", + "msg_grid_applied": "✅ Grade {g} aplicada!", + "msg_export_ok": "✅ Configuração exportada!", + "msg_import_ok": "✅ Configuração importada!" }