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!" }