# Released under the MIT License. See LICENSE for details.
#
# pylint: disable=too-many-lines
"""Settings UI functionality related to gamepads."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, override
from bauiv1lib.popup import PopupMenuWindow
import bascenev1 as bs
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable
from bauiv1lib.popup import PopupWindow
[docs]
class GamepadSettingsWindow(bui.MainWindow):
"""Window for configuring a gamepad."""
# pylint: disable=too-many-public-methods
def __init__(
self,
inputdevice: bs.InputDevice,
*,
modal: bool = False,
transition: str | None = 'in_right',
transition_out: str = 'out_right',
origin_widget: bui.Widget | None = None,
settings: dict | None = None,
):
self._inputdevice = inputdevice
# If our input-device went away, just return an empty zombie.
if not self._inputdevice:
return
self._name = self._inputdevice.name
self._r = 'configGamepadWindow'
self._transition_out = transition_out
# We're a secondary gamepad if supplied with settings.
self._is_secondary = settings is not None
self._ext = '_B' if self._is_secondary else ''
self._modal = modal
self._displayname = self._name
self._width = 700 if self._is_secondary else 730
self._height = 440 if self._is_secondary else 450
self._spacing = 40
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height),
scale=(
1.4
if uiscale is bui.UIScale.SMALL
else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, -10) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
self._settings: dict[str, int] = {}
if not self._is_secondary:
self._get_config_mapping()
# Don't ask to config joysticks while we're in here.
self._rebuild_ui()
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
# Pull stuff out of self here; if we do it in the lambda we keep
# self alive which we don't want.
assert not self._is_secondary
assert not self._modal
inputdevice = self._inputdevice
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
inputdevice=inputdevice,
transition=transition,
origin_widget=origin_widget,
)
)
def _get_config_mapping(self, default: bool = False) -> None:
for button in [
'buttonJump',
'buttonJump_B',
'buttonPunch',
'buttonPunch_B',
'buttonBomb',
'buttonBomb_B',
'buttonPickUp',
'buttonPickUp_B',
'buttonStart',
'buttonStart_B',
'buttonStart2',
'buttonStart2_B',
'buttonUp',
'buttonUp_B',
'buttonDown',
'buttonDown_B',
'buttonLeft',
'buttonLeft_B',
'buttonRight',
'buttonRight_B',
'buttonRun1',
'buttonRun1_B',
'buttonRun2',
'buttonRun2_B',
'triggerRun1',
'triggerRun1_B',
'triggerRun2',
'triggerRun2_B',
'buttonIgnored',
'buttonIgnored_B',
'buttonIgnored2',
'buttonIgnored2_B',
'buttonIgnored3',
'buttonIgnored3_B',
'buttonIgnored4',
'buttonIgnored4_B',
'buttonVRReorient',
'buttonVRReorient_B',
'analogStickDeadZone',
'analogStickDeadZone_B',
'dpad',
'dpad_B',
'unassignedButtonsRun',
'unassignedButtonsRun_B',
'startButtonActivatesDefaultWidget',
'startButtonActivatesDefaultWidget_B',
'uiOnly',
'uiOnly_B',
'ignoreCompletely',
'ignoreCompletely_B',
'autoRecalibrateAnalogStick',
'autoRecalibrateAnalogStick_B',
'analogStickLR',
'analogStickLR_B',
'analogStickUD',
'analogStickUD_B',
'enableSecondary',
]:
assert bui.app.classic is not None
val = bui.app.classic.get_input_device_mapped_value(
self._inputdevice, button, default
)
if val != -1:
self._settings[button] = val
def _rebuild_ui(self, is_reset: bool = False) -> None:
# pylint: disable=too-many-statements
assert bui.app.classic is not None
# Clear existing UI.
for widget in self._root_widget.get_children():
widget.delete()
self._textwidgets: dict[str, bui.Widget] = {}
back_button: bui.Widget | None
if self._is_secondary:
back_button = bui.buttonwidget(
parent=self._root_widget,
position=(self._width - 180, self._height - 65),
autoselect=True,
size=(160, 60),
label=bui.Lstr(resource='doneText'),
scale=0.9,
on_activate_call=self._save,
)
bui.containerwidget(
edit=self._root_widget,
start_button=back_button,
on_cancel_call=back_button.activate,
)
cancel_button = None
else:
cancel_button = bui.buttonwidget(
parent=self._root_widget,
position=(51, self._height - 65),
autoselect=True,
size=(160, 60),
label=bui.Lstr(resource='cancelText'),
scale=0.9,
on_activate_call=self._cancel,
)
bui.containerwidget(
edit=self._root_widget, cancel_button=cancel_button
)
save_button: bui.Widget | None
if not self._is_secondary:
save_button = bui.buttonwidget(
parent=self._root_widget,
position=(self._width - 195, self._height - 65),
size=(180, 60),
autoselect=True,
label=bui.Lstr(resource='saveText'),
scale=0.9,
on_activate_call=self._save,
)
bui.containerwidget(
edit=self._root_widget, start_button=save_button
)
else:
save_button = None
if not self._is_secondary:
v = self._height - 59
bui.textwidget(
parent=self._root_widget,
position=(0, v + 5),
size=(self._width, 25),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
maxwidth=310,
h_align='center',
v_align='center',
)
v -= 48
bui.textwidget(
parent=self._root_widget,
position=(0, v + 3),
size=(self._width, 25),
text=self._name,
color=bui.app.ui_v1.infotextcolor,
maxwidth=self._width * 0.9,
h_align='center',
v_align='center',
)
v -= self._spacing * 1
bui.textwidget(
parent=self._root_widget,
position=(50, v + 10),
size=(self._width - 100, 30),
text=bui.Lstr(resource=f'{self._r}.appliesToAllText'),
maxwidth=330,
scale=0.65,
color=(0.5, 0.6, 0.5, 1.0),
h_align='center',
v_align='center',
)
v -= 70
self._enable_check_box = None
else:
v = self._height - 49
bui.textwidget(
parent=self._root_widget,
position=(0, v + 5),
size=(self._width, 25),
text=bui.Lstr(resource=f'{self._r}.secondaryText'),
color=bui.app.ui_v1.title_color,
maxwidth=300,
h_align='center',
v_align='center',
)
v -= self._spacing * 1
bui.textwidget(
parent=self._root_widget,
position=(50, v + 10),
size=(self._width - 100, 30),
text=bui.Lstr(resource=f'{self._r}.secondHalfText'),
maxwidth=300,
scale=0.65,
color=(0.6, 0.8, 0.6, 1.0),
h_align='center',
)
self._enable_check_box = bui.checkboxwidget(
parent=self._root_widget,
position=(self._width * 0.5 - 80, v - 73),
value=self.get_enable_secondary_value(),
autoselect=True,
on_value_change_call=self._enable_check_box_changed,
size=(200, 30),
text=bui.Lstr(resource=f'{self._r}.secondaryEnableText'),
scale=1.2,
)
v = self._height - 205
h_offs = 160
dist = 70
d_color = (0.4, 0.4, 0.8)
sclx = 1.2
scly = 0.98
dpm = bui.Lstr(resource=f'{self._r}.pressAnyButtonOrDpadText')
dpm2 = bui.Lstr(resource=f'{self._r}.ifNothingHappensTryAnalogText')
self._capture_button(
pos=(h_offs, v + scly * dist),
color=d_color,
button='buttonUp' + self._ext,
texture=bui.gettexture('upButton'),
scale=1.0,
message=dpm,
message2=dpm2,
)
self._capture_button(
pos=(h_offs - sclx * dist, v),
color=d_color,
button='buttonLeft' + self._ext,
texture=bui.gettexture('leftButton'),
scale=1.0,
message=dpm,
message2=dpm2,
)
self._capture_button(
pos=(h_offs + sclx * dist, v),
color=d_color,
button='buttonRight' + self._ext,
texture=bui.gettexture('rightButton'),
scale=1.0,
message=dpm,
message2=dpm2,
)
self._capture_button(
pos=(h_offs, v - scly * dist),
color=d_color,
button='buttonDown' + self._ext,
texture=bui.gettexture('downButton'),
scale=1.0,
message=dpm,
message2=dpm2,
)
dpm3 = bui.Lstr(resource=f'{self._r}.ifNothingHappensTryDpadText')
self._capture_button(
pos=(h_offs + 130, v - 125),
color=(0.4, 0.4, 0.6),
button='analogStickLR' + self._ext,
maxwidth=140,
texture=bui.gettexture('analogStick'),
scale=1.2,
message=bui.Lstr(resource=f'{self._r}.pressLeftRightText'),
message2=dpm3,
)
self._capture_button(
pos=(self._width * 0.5, v),
color=(0.4, 0.4, 0.6),
button='buttonStart' + self._ext,
texture=bui.gettexture('startButton'),
scale=0.7,
)
h_offs = self._width - 160
self._capture_button(
pos=(h_offs, v + scly * dist),
color=(0.6, 0.4, 0.8),
button='buttonPickUp' + self._ext,
texture=bui.gettexture('buttonPickUp'),
scale=1.0,
)
self._capture_button(
pos=(h_offs - sclx * dist, v),
color=(0.7, 0.5, 0.1),
button='buttonPunch' + self._ext,
texture=bui.gettexture('buttonPunch'),
scale=1.0,
)
self._capture_button(
pos=(h_offs + sclx * dist, v),
color=(0.5, 0.2, 0.1),
button='buttonBomb' + self._ext,
texture=bui.gettexture('buttonBomb'),
scale=1.0,
)
self._capture_button(
pos=(h_offs, v - scly * dist),
color=(0.2, 0.5, 0.2),
button='buttonJump' + self._ext,
texture=bui.gettexture('buttonJump'),
scale=1.0,
)
self._more_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
label='...',
text_scale=0.9,
color=(0.45, 0.4, 0.5),
textcolor=(0.65, 0.6, 0.7),
position=(self._width - 300, 30),
size=(130, 40),
on_activate_call=self._do_more,
)
try:
if cancel_button is not None and save_button is not None:
bui.widget(edit=cancel_button, right_widget=save_button)
bui.widget(edit=save_button, left_widget=cancel_button)
if is_reset:
bui.containerwidget(
edit=self._root_widget,
selected_child=self._more_button,
)
except Exception:
logging.exception('Error wiring up gamepad config window.')
[docs]
def get_r(self) -> str:
"""(internal)"""
return self._r
[docs]
def get_is_secondary(self) -> bool:
"""(internal)"""
return self._is_secondary
[docs]
def get_settings(self) -> dict[str, Any]:
"""(internal)"""
assert self._settings is not None
return self._settings
[docs]
def get_ext(self) -> str:
"""(internal)"""
return self._ext
def _do_advanced(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.settings import gamepadadvanced
gamepadadvanced.GamepadAdvancedSettingsWindow(self)
def _enable_check_box_changed(self, value: bool) -> None:
assert self._settings is not None
if value:
self._settings['enableSecondary'] = 1
else:
# Just clear since this is default.
if 'enableSecondary' in self._settings:
del self._settings['enableSecondary']
[docs]
def get_ui_only_value(self) -> bool:
"""(internal)"""
assert self._settings is not None
val = self._settings.get('uiOnly', False)
assert isinstance(val, bool)
return val
[docs]
def set_ui_only_value(self, value: bool) -> None:
"""(internal)"""
assert self._settings is not None
if not value:
if 'uiOnly' in self._settings:
# Clear since this is default.
del self._settings['uiOnly']
return
self._settings['uiOnly'] = True
[docs]
def get_ignore_completely_value(self) -> bool:
"""(internal)"""
assert self._settings is not None
val = self._settings.get('ignoreCompletely', False)
assert isinstance(val, bool)
return val
[docs]
def set_ignore_completely_value(self, value: bool) -> None:
"""(internal)"""
assert self._settings is not None
if not value:
if 'ignoreCompletely' in self._settings:
# Clear since this is default.
del self._settings['ignoreCompletely']
return
self._settings['ignoreCompletely'] = True
[docs]
def get_auto_recalibrate_analog_stick_value(self) -> bool:
"""(internal)"""
assert self._settings is not None
val = self._settings.get('autoRecalibrateAnalogStick', False)
assert isinstance(val, bool)
return val
[docs]
def set_auto_recalibrate_analog_stick_value(self, value: bool) -> None:
"""(internal)"""
assert self._settings is not None
if not value:
if 'autoRecalibrateAnalogStick' in self._settings:
# Clear since this is default.
del self._settings['autoRecalibrateAnalogStick']
else:
self._settings['autoRecalibrateAnalogStick'] = True
[docs]
def get_enable_secondary_value(self) -> bool:
"""(internal)"""
assert self._settings is not None
if not self._is_secondary:
raise RuntimeError('Enable value only applies to secondary editor.')
val = self._settings.get('enableSecondary', False)
assert isinstance(val, bool)
return val
[docs]
def show_secondary_editor(self) -> None:
"""(internal)"""
GamepadSettingsWindow(
self._inputdevice,
modal=True,
settings=self._settings,
transition='in_scale',
transition_out='out_scale',
)
[docs]
def get_control_value_name(self, control: str) -> str | bui.Lstr:
"""(internal)"""
# pylint: disable=too-many-return-statements
assert self._settings is not None
if control == 'analogStickLR' + self._ext:
# This actually shows both LR and UD.
sval1 = (
self._settings['analogStickLR' + self._ext]
if 'analogStickLR' + self._ext in self._settings
else 5 if self._is_secondary else None
)
sval2 = (
self._settings['analogStickUD' + self._ext]
if 'analogStickUD' + self._ext in self._settings
else 6 if self._is_secondary else None
)
assert isinstance(sval1, (int, type(None)))
assert isinstance(sval2, (int, type(None)))
if sval1 is not None and sval2 is not None:
return (
self._inputdevice.get_axis_name(sval1)
+ ' / '
+ self._inputdevice.get_axis_name(sval2)
)
return bui.Lstr(resource=f'{self._r}.unsetText')
# If they're looking for triggers.
if control in ['triggerRun1' + self._ext, 'triggerRun2' + self._ext]:
if control in self._settings:
return self._inputdevice.get_axis_name(self._settings[control])
return bui.Lstr(resource=f'{self._r}.unsetText')
# Dead-zone.
if control == 'analogStickDeadZone' + self._ext:
if control in self._settings:
return str(self._settings[control])
return str(1.0)
# For dpad buttons: show individual buttons if any are set.
# Otherwise show whichever dpad is set.
dpad_buttons = [
'buttonLeft' + self._ext,
'buttonRight' + self._ext,
'buttonUp' + self._ext,
'buttonDown' + self._ext,
]
if control in dpad_buttons:
# If *any* dpad buttons are assigned, show only button assignments.
if any(b in self._settings for b in dpad_buttons):
if control in self._settings:
return self._inputdevice.get_button_name(
self._settings[control]
)
return bui.Lstr(resource=f'{self._r}.unsetText')
# No dpad buttons - show the dpad number for all 4.
dpadnum = (
self._settings['dpad' + self._ext]
if 'dpad' + self._ext in self._settings
else 2 if self._is_secondary else None
)
assert isinstance(dpadnum, (int, type(None)))
if dpadnum is not None:
return bui.Lstr(
value='${A} ${B}',
subs=[
('${A}', bui.Lstr(resource=f'{self._r}.dpadText')),
(
'${B}',
str(dpadnum),
),
],
)
return bui.Lstr(resource=f'{self._r}.unsetText')
# Other buttons.
if control in self._settings:
return self._inputdevice.get_button_name(self._settings[control])
return bui.Lstr(resource=f'{self._r}.unsetText')
def _gamepad_event(
self,
control: str,
event: dict[str, Any],
dialog: AwaitGamepadInputWindow,
) -> None:
# pylint: disable=too-many-branches
assert self._settings is not None
ext = self._ext
# For our dpad-buttons we're looking for either a button-press or a
# hat-switch press.
if control in [
'buttonUp' + ext,
'buttonLeft' + ext,
'buttonDown' + ext,
'buttonRight' + ext,
]:
if event['type'] in ['BUTTONDOWN', 'HATMOTION']:
# If its a button-down.
if event['type'] == 'BUTTONDOWN':
value = event['button']
self._settings[control] = value
# If its a dpad.
elif event['type'] == 'HATMOTION':
# clear out any set dir-buttons
for btn in [
'buttonUp' + ext,
'buttonLeft' + ext,
'buttonRight' + ext,
'buttonDown' + ext,
]:
if btn in self._settings:
del self._settings[btn]
if event['hat'] == (2 if self._is_secondary else 1):
self._settings['dpad' + ext] = event['hat']
# Update the 4 dpad button txt widgets.
bui.textwidget(
edit=self._textwidgets['buttonUp' + ext],
text=self.get_control_value_name('buttonUp' + ext),
)
bui.textwidget(
edit=self._textwidgets['buttonLeft' + ext],
text=self.get_control_value_name('buttonLeft' + ext),
)
bui.textwidget(
edit=self._textwidgets['buttonRight' + ext],
text=self.get_control_value_name('buttonRight' + ext),
)
bui.textwidget(
edit=self._textwidgets['buttonDown' + ext],
text=self.get_control_value_name('buttonDown' + ext),
)
bui.getsound('gunCocking').play()
dialog.die()
elif control == 'analogStickLR' + ext:
if event['type'] == 'AXISMOTION':
# Ignore small values or else we might get triggered by noise.
if abs(event['value']) > 0.5:
axis = event['axis']
if axis == (5 if self._is_secondary else 1):
self._settings['analogStickLR' + ext] = axis
bui.textwidget(
edit=self._textwidgets['analogStickLR' + ext],
text=self.get_control_value_name('analogStickLR' + ext),
)
bui.getsound('gunCocking').play()
dialog.die()
# Now launch the up/down listener.
AwaitGamepadInputWindow(
self._inputdevice,
'analogStickUD' + ext,
self._gamepad_event,
bui.Lstr(resource=f'{self._r}.pressUpDownText'),
)
elif control == 'analogStickUD' + ext:
if event['type'] == 'AXISMOTION':
# Ignore small values or else we might get triggered by noise.
if abs(event['value']) > 0.5:
axis = event['axis']
# Ignore our LR axis.
if 'analogStickLR' + ext in self._settings:
lr_axis = self._settings['analogStickLR' + ext]
else:
lr_axis = 5 if self._is_secondary else 1
if axis != lr_axis:
if axis == (6 if self._is_secondary else 2):
self._settings['analogStickUD' + ext] = axis
bui.textwidget(
edit=self._textwidgets['analogStickLR' + ext],
text=self.get_control_value_name(
'analogStickLR' + ext
),
)
bui.getsound('gunCocking').play()
dialog.die()
else:
# For other buttons we just want a button-press.
if event['type'] == 'BUTTONDOWN':
value = event['button']
self._settings[control] = value
# Update the button's text widget.
bui.textwidget(
edit=self._textwidgets[control],
text=self.get_control_value_name(control),
)
bui.getsound('gunCocking').play()
dialog.die()
def _capture_button(
self,
pos: tuple[float, float],
color: tuple[float, float, float],
texture: bui.Texture,
button: str,
*,
scale: float = 1.0,
message: bui.Lstr | None = None,
message2: bui.Lstr | None = None,
maxwidth: float = 80.0,
) -> bui.Widget:
if message is None:
message = bui.Lstr(resource=f'{self._r}.pressAnyButtonText')
base_size = 79
btn = bui.buttonwidget(
parent=self._root_widget,
position=(
pos[0] - base_size * 0.5 * scale,
pos[1] - base_size * 0.5 * scale,
),
autoselect=True,
size=(base_size * scale, base_size * scale),
texture=texture,
label='',
color=color,
)
# Make this in a timer so that it shows up on top of all other buttons.
def doit() -> None:
uiscale = 0.9 * scale
txt = bui.textwidget(
parent=self._root_widget,
position=(pos[0] + 0.0 * scale, pos[1] - 58.0 * scale),
color=(1, 1, 1, 0.3),
size=(0, 0),
h_align='center',
v_align='center',
scale=uiscale,
text=self.get_control_value_name(button),
maxwidth=maxwidth,
)
self._textwidgets[button] = txt
bui.buttonwidget(
edit=btn,
on_activate_call=bui.Call(
AwaitGamepadInputWindow,
self._inputdevice,
button,
self._gamepad_event,
message,
message2,
),
)
bui.pushcall(doit)
return btn
def _cancel(self) -> None:
if self._modal:
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
else:
self.main_window_back()
def _reset(self) -> None:
from bauiv1lib.confirm import ConfirmWindow
assert bui.app.classic is not None
# efro note: I think it's ok to reset without a confirm here
# because the user can see pretty clearly what changes and can
# cancel out of the settings window without saving if they want.
if bool(False):
ConfirmWindow(
# TODO: Implement a translation string for this!
'Are you sure you want to reset your button mapping?\n'
'This will also reset your advanced mappings\n'
'and secondary controller button mappings.',
self._do_reset,
width=490,
height=150,
)
else:
self._do_reset()
def _do_reset(self) -> None:
"""Resets the input's mapping settings."""
from babase import InputDeviceNotFoundError
self._settings = {}
# Unplugging the controller while performing a
# mapping reset makes things go bonkers a little.
try:
self._get_config_mapping(default=True)
except InputDeviceNotFoundError:
pass
self._rebuild_ui(is_reset=True)
bui.getsound('gunCocking').play()
def _do_more(self) -> None:
"""Show a burger menu with extra settings."""
# pylint: disable=cyclic-import
choices: list[str] = [
'advanced',
'reset',
]
choices_display: list[bui.Lstr] = [
bui.Lstr(resource=f'{self._r}.advancedText'),
bui.Lstr(resource='settingsWindowAdvanced.resetText'),
]
uiscale = bui.app.ui_v1.uiscale
PopupMenuWindow(
position=self._more_button.get_screen_space_center(),
scale=(
2.3
if uiscale is bui.UIScale.SMALL
else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
),
width=150,
choices=choices,
choices_display=choices_display,
current_choice='advanced',
delegate=self,
)
def _save(self) -> None:
classic = bui.app.classic
assert classic is not None
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
# If we're a secondary editor we just go away (we were editing our
# parent's settings dict).
if self._is_secondary:
assert self._modal
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
return
assert self._settings is not None
if self._inputdevice:
dst = classic.get_input_device_config(
self._inputdevice, default=True
)
dst2: dict[str, Any] = dst[0][dst[1]]
dst2.clear()
# Store any values that aren't -1.
for key, val in list(self._settings.items()):
if val != -1:
dst2[key] = val
# If we're allowed to phone home, send this config so we can
# generate more defaults in the future.
inputhash = classic.get_input_device_map_hash(self._inputdevice)
classic.master_server_v1_post(
'controllerConfig',
{
'ua': classic.legacy_user_agent_string,
'b': bui.app.env.engine_build_number,
'name': self._name,
'inputMapHash': inputhash,
'config': dst2,
'v': 2,
},
)
bui.app.config.apply_and_commit()
bui.getsound('gunCocking').play()
else:
bui.getsound('error').play()
if self._modal:
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
else:
assert self.main_window_has_control()
self.main_window_back()
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum