host.controller_config.binding_dialog

Modal dialog for assigning/removing actions to a controller input.

Opened when clicking a binding box on the controller canvas. Shows the input name, currently assigned actions, and allows adding/removing actions. Double-click transfers items between lists.

  1"""Modal dialog for assigning/removing actions to a controller input.
  2
  3Opened when clicking a binding box on the controller canvas.
  4Shows the input name, currently assigned actions, and allows
  5adding/removing actions.  Double-click transfers items between lists.
  6"""
  7
  8import tkinter as tk
  9from tkinter import ttk
 10
 11from .layout_coords import XBOX_INPUT_MAP
 12
 13
 14class BindingDialog(tk.Toplevel):
 15    """Dialog for editing the action bindings of a single controller input."""
 16
 17    def __init__(self, parent, input_name: str, current_actions: list[str],
 18                 available_actions: list[str],
 19                 action_descriptions: dict[str, str] | None = None):
 20        """
 21        Args:
 22            parent: parent window
 23            input_name: canonical input name (e.g., "left_stick_x")
 24            current_actions: list of currently bound action names
 25            available_actions: all action names available to assign
 26            action_descriptions: optional mapping of qname -> description
 27        """
 28        super().__init__(parent)
 29        self.transient(parent)
 30        self.grab_set()
 31
 32        inp = XBOX_INPUT_MAP.get(input_name)
 33        display = inp.display_name if inp else input_name
 34        self.title(f"Bindings: {display}")
 35
 36        self._input_name = input_name
 37        self._result: list[str] | None = None
 38        self._assigned = list(current_actions)
 39        self._available = [a for a in available_actions if a not in current_actions]
 40        self._descriptions = action_descriptions or {}
 41
 42        self._build_ui()
 43
 44        # Center dialog on parent
 45        self.update_idletasks()
 46        pw = parent.winfo_width()
 47        ph = parent.winfo_height()
 48        px = parent.winfo_rootx()
 49        py = parent.winfo_rooty()
 50        w = self.winfo_width()
 51        h = self.winfo_height()
 52        x = px + (pw - w) // 2
 53        y = py + (ph - h) // 2
 54        self.geometry(f"+{x}+{y}")
 55
 56        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
 57        self.bind("<Escape>", lambda e: self._on_cancel())
 58
 59    def _build_ui(self):
 60        self.minsize(400, 350)
 61
 62        main = ttk.Frame(self, padding=10)
 63        main.pack(fill=tk.BOTH, expand=True)
 64
 65        # --- Description status bar ---
 66        self._desc_var = tk.StringVar(value="")
 67        self._desc_label = ttk.Label(
 68            main, textvariable=self._desc_var,
 69            relief=tk.SUNKEN, anchor=tk.W, foreground="grey40")
 70        self._desc_label.pack(fill=tk.X, pady=(0, 8))
 71
 72        # --- Currently assigned actions ---
 73        ttk.Label(main, text="Assigned Actions:", font=("Arial", 9, "bold")).pack(anchor=tk.W)
 74
 75        assigned_frame = tk.Frame(main)
 76        assigned_frame.pack(fill=tk.BOTH, expand=True, pady=(2, 5))
 77
 78        self._assigned_listbox = tk.Listbox(assigned_frame, height=5, exportselection=False)
 79        self._assigned_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
 80
 81        assigned_scroll = ttk.Scrollbar(assigned_frame, orient=tk.VERTICAL,
 82                                        command=self._assigned_listbox.yview)
 83        assigned_scroll.pack(side=tk.RIGHT, fill=tk.Y)
 84        self._assigned_listbox.config(yscrollcommand=assigned_scroll.set)
 85
 86        for action in self._assigned:
 87            self._assigned_listbox.insert(tk.END, action)
 88
 89        # Double-click to remove, select to show description
 90        self._assigned_listbox.bind("<Double-1>", lambda e: self._remove_action())
 91        self._assigned_listbox.bind("<<ListboxSelect>>",
 92                                    lambda e: self._show_description(self._assigned_listbox))
 93
 94        # Remove button
 95        ttk.Button(main, text="Remove Selected",
 96                   command=self._remove_action).pack(anchor=tk.W, pady=(0, 10))
 97
 98        # --- Available actions to add ---
 99        ttk.Label(main, text="Available Actions:", font=("Arial", 9, "bold")).pack(anchor=tk.W)
100
101        avail_frame = tk.Frame(main)
102        avail_frame.pack(fill=tk.BOTH, expand=True, pady=(2, 5))
103
104        self._avail_listbox = tk.Listbox(avail_frame, height=6, exportselection=False)
105        self._avail_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
106
107        avail_scroll = ttk.Scrollbar(avail_frame, orient=tk.VERTICAL,
108                                     command=self._avail_listbox.yview)
109        avail_scroll.pack(side=tk.RIGHT, fill=tk.Y)
110        self._avail_listbox.config(yscrollcommand=avail_scroll.set)
111
112        for action in sorted(self._available):
113            self._avail_listbox.insert(tk.END, action)
114
115        # Double-click to add, select to show description
116        self._avail_listbox.bind("<Double-1>", lambda e: self._add_action())
117        self._avail_listbox.bind("<<ListboxSelect>>",
118                                 lambda e: self._show_description(self._avail_listbox))
119
120        # Add button
121        ttk.Button(main, text="Add Selected",
122                   command=self._add_action).pack(anchor=tk.W, pady=(0, 10))
123
124        # --- OK / Cancel ---
125        btn_frame = tk.Frame(main)
126        btn_frame.pack(fill=tk.X)
127        ttk.Button(btn_frame, text="OK", command=self._on_ok, width=10).pack(side=tk.RIGHT, padx=5)
128        ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10).pack(side=tk.RIGHT)
129
130    def _show_description(self, listbox):
131        """Show the description of the selected action."""
132        sel = listbox.curselection()
133        if not sel:
134            self._desc_var.set("")
135            return
136        action = listbox.get(sel[0])
137        desc = self._descriptions.get(action, "")
138        self._desc_var.set(desc if desc else "No description")
139
140    def _remove_action(self):
141        """Move selected action from assigned back to available."""
142        sel = self._assigned_listbox.curselection()
143        if not sel:
144            return
145        action = self._assigned_listbox.get(sel[0])
146        self._assigned_listbox.delete(sel[0])
147        self._assigned.remove(action)
148
149        # Add back to available list in sorted position
150        self._available.append(action)
151        self._available.sort()
152        self._avail_listbox.delete(0, tk.END)
153        for a in self._available:
154            self._avail_listbox.insert(tk.END, a)
155        self._desc_var.set("")
156
157    def _add_action(self):
158        """Move selected action from available to assigned."""
159        sel = self._avail_listbox.curselection()
160        if not sel:
161            return
162        action = self._avail_listbox.get(sel[0])
163        self._avail_listbox.delete(sel[0])
164        self._available.remove(action)
165
166        self._assigned.append(action)
167        self._assigned_listbox.insert(tk.END, action)
168        self._desc_var.set("")
169
170    def _on_ok(self):
171        self._result = list(self._assigned)
172        self.destroy()
173
174    def _on_cancel(self):
175        self._result = None
176        self.destroy()
177
178    def get_result(self) -> list[str] | None:
179        """Return the updated action list, or None if canceled."""
180        self.wait_window()
181        return self._result
class BindingDialog(tkinter.Toplevel):
 15class BindingDialog(tk.Toplevel):
 16    """Dialog for editing the action bindings of a single controller input."""
 17
 18    def __init__(self, parent, input_name: str, current_actions: list[str],
 19                 available_actions: list[str],
 20                 action_descriptions: dict[str, str] | None = None):
 21        """
 22        Args:
 23            parent: parent window
 24            input_name: canonical input name (e.g., "left_stick_x")
 25            current_actions: list of currently bound action names
 26            available_actions: all action names available to assign
 27            action_descriptions: optional mapping of qname -> description
 28        """
 29        super().__init__(parent)
 30        self.transient(parent)
 31        self.grab_set()
 32
 33        inp = XBOX_INPUT_MAP.get(input_name)
 34        display = inp.display_name if inp else input_name
 35        self.title(f"Bindings: {display}")
 36
 37        self._input_name = input_name
 38        self._result: list[str] | None = None
 39        self._assigned = list(current_actions)
 40        self._available = [a for a in available_actions if a not in current_actions]
 41        self._descriptions = action_descriptions or {}
 42
 43        self._build_ui()
 44
 45        # Center dialog on parent
 46        self.update_idletasks()
 47        pw = parent.winfo_width()
 48        ph = parent.winfo_height()
 49        px = parent.winfo_rootx()
 50        py = parent.winfo_rooty()
 51        w = self.winfo_width()
 52        h = self.winfo_height()
 53        x = px + (pw - w) // 2
 54        y = py + (ph - h) // 2
 55        self.geometry(f"+{x}+{y}")
 56
 57        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
 58        self.bind("<Escape>", lambda e: self._on_cancel())
 59
 60    def _build_ui(self):
 61        self.minsize(400, 350)
 62
 63        main = ttk.Frame(self, padding=10)
 64        main.pack(fill=tk.BOTH, expand=True)
 65
 66        # --- Description status bar ---
 67        self._desc_var = tk.StringVar(value="")
 68        self._desc_label = ttk.Label(
 69            main, textvariable=self._desc_var,
 70            relief=tk.SUNKEN, anchor=tk.W, foreground="grey40")
 71        self._desc_label.pack(fill=tk.X, pady=(0, 8))
 72
 73        # --- Currently assigned actions ---
 74        ttk.Label(main, text="Assigned Actions:", font=("Arial", 9, "bold")).pack(anchor=tk.W)
 75
 76        assigned_frame = tk.Frame(main)
 77        assigned_frame.pack(fill=tk.BOTH, expand=True, pady=(2, 5))
 78
 79        self._assigned_listbox = tk.Listbox(assigned_frame, height=5, exportselection=False)
 80        self._assigned_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
 81
 82        assigned_scroll = ttk.Scrollbar(assigned_frame, orient=tk.VERTICAL,
 83                                        command=self._assigned_listbox.yview)
 84        assigned_scroll.pack(side=tk.RIGHT, fill=tk.Y)
 85        self._assigned_listbox.config(yscrollcommand=assigned_scroll.set)
 86
 87        for action in self._assigned:
 88            self._assigned_listbox.insert(tk.END, action)
 89
 90        # Double-click to remove, select to show description
 91        self._assigned_listbox.bind("<Double-1>", lambda e: self._remove_action())
 92        self._assigned_listbox.bind("<<ListboxSelect>>",
 93                                    lambda e: self._show_description(self._assigned_listbox))
 94
 95        # Remove button
 96        ttk.Button(main, text="Remove Selected",
 97                   command=self._remove_action).pack(anchor=tk.W, pady=(0, 10))
 98
 99        # --- Available actions to add ---
100        ttk.Label(main, text="Available Actions:", font=("Arial", 9, "bold")).pack(anchor=tk.W)
101
102        avail_frame = tk.Frame(main)
103        avail_frame.pack(fill=tk.BOTH, expand=True, pady=(2, 5))
104
105        self._avail_listbox = tk.Listbox(avail_frame, height=6, exportselection=False)
106        self._avail_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
107
108        avail_scroll = ttk.Scrollbar(avail_frame, orient=tk.VERTICAL,
109                                     command=self._avail_listbox.yview)
110        avail_scroll.pack(side=tk.RIGHT, fill=tk.Y)
111        self._avail_listbox.config(yscrollcommand=avail_scroll.set)
112
113        for action in sorted(self._available):
114            self._avail_listbox.insert(tk.END, action)
115
116        # Double-click to add, select to show description
117        self._avail_listbox.bind("<Double-1>", lambda e: self._add_action())
118        self._avail_listbox.bind("<<ListboxSelect>>",
119                                 lambda e: self._show_description(self._avail_listbox))
120
121        # Add button
122        ttk.Button(main, text="Add Selected",
123                   command=self._add_action).pack(anchor=tk.W, pady=(0, 10))
124
125        # --- OK / Cancel ---
126        btn_frame = tk.Frame(main)
127        btn_frame.pack(fill=tk.X)
128        ttk.Button(btn_frame, text="OK", command=self._on_ok, width=10).pack(side=tk.RIGHT, padx=5)
129        ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10).pack(side=tk.RIGHT)
130
131    def _show_description(self, listbox):
132        """Show the description of the selected action."""
133        sel = listbox.curselection()
134        if not sel:
135            self._desc_var.set("")
136            return
137        action = listbox.get(sel[0])
138        desc = self._descriptions.get(action, "")
139        self._desc_var.set(desc if desc else "No description")
140
141    def _remove_action(self):
142        """Move selected action from assigned back to available."""
143        sel = self._assigned_listbox.curselection()
144        if not sel:
145            return
146        action = self._assigned_listbox.get(sel[0])
147        self._assigned_listbox.delete(sel[0])
148        self._assigned.remove(action)
149
150        # Add back to available list in sorted position
151        self._available.append(action)
152        self._available.sort()
153        self._avail_listbox.delete(0, tk.END)
154        for a in self._available:
155            self._avail_listbox.insert(tk.END, a)
156        self._desc_var.set("")
157
158    def _add_action(self):
159        """Move selected action from available to assigned."""
160        sel = self._avail_listbox.curselection()
161        if not sel:
162            return
163        action = self._avail_listbox.get(sel[0])
164        self._avail_listbox.delete(sel[0])
165        self._available.remove(action)
166
167        self._assigned.append(action)
168        self._assigned_listbox.insert(tk.END, action)
169        self._desc_var.set("")
170
171    def _on_ok(self):
172        self._result = list(self._assigned)
173        self.destroy()
174
175    def _on_cancel(self):
176        self._result = None
177        self.destroy()
178
179    def get_result(self) -> list[str] | None:
180        """Return the updated action list, or None if canceled."""
181        self.wait_window()
182        return self._result

Dialog for editing the action bindings of a single controller input.

BindingDialog( parent, input_name: str, current_actions: list[str], available_actions: list[str], action_descriptions: dict[str, str] | None = None)
18    def __init__(self, parent, input_name: str, current_actions: list[str],
19                 available_actions: list[str],
20                 action_descriptions: dict[str, str] | None = None):
21        """
22        Args:
23            parent: parent window
24            input_name: canonical input name (e.g., "left_stick_x")
25            current_actions: list of currently bound action names
26            available_actions: all action names available to assign
27            action_descriptions: optional mapping of qname -> description
28        """
29        super().__init__(parent)
30        self.transient(parent)
31        self.grab_set()
32
33        inp = XBOX_INPUT_MAP.get(input_name)
34        display = inp.display_name if inp else input_name
35        self.title(f"Bindings: {display}")
36
37        self._input_name = input_name
38        self._result: list[str] | None = None
39        self._assigned = list(current_actions)
40        self._available = [a for a in available_actions if a not in current_actions]
41        self._descriptions = action_descriptions or {}
42
43        self._build_ui()
44
45        # Center dialog on parent
46        self.update_idletasks()
47        pw = parent.winfo_width()
48        ph = parent.winfo_height()
49        px = parent.winfo_rootx()
50        py = parent.winfo_rooty()
51        w = self.winfo_width()
52        h = self.winfo_height()
53        x = px + (pw - w) // 2
54        y = py + (ph - h) // 2
55        self.geometry(f"+{x}+{y}")
56
57        self.protocol("WM_DELETE_WINDOW", self._on_cancel)
58        self.bind("<Escape>", lambda e: self._on_cancel())

Args: parent: parent window input_name: canonical input name (e.g., "left_stick_x") current_actions: list of currently bound action names available_actions: all action names available to assign action_descriptions: optional mapping of qname -> description

def get_result(self) -> list[str] | None:
179    def get_result(self) -> list[str] | None:
180        """Return the updated action list, or None if canceled."""
181        self.wait_window()
182        return self._result

Return the updated action list, or None if canceled.