# Released under the MIT License. See LICENSE for details.
#
"""Keyboard settings related UI functionality."""
from __future__ import annotations # Docs-generation hack.
from typing import TYPE_CHECKING, override
from bauiv1lib.popup import PopupMenuWindow
import bauiv1 as bui
from bauiv1 import _commonassets, _classicassets
from bauiv1 import _uiv1assets
import bascenev1 as bs
if TYPE_CHECKING:
from typing import Any
from bauiv1lib.popup import PopupWindow
_kbstrs = _classicassets.strings.settings.controllers.keyboard
_gpstrs = _classicassets.strings.settings.controllers.gamepad
[docs]
class ConfigKeyboardWindow(bui.MainWindow):
"""Window for configuring keyboards."""
def __init__(
self,
c: bs.InputDevice,
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
):
self._r = 'configKeyboardWindow'
self._input = c
self._name = self._input.name
self._unique_id = self._input.unique_identifier
dname_raw = self._name
if self._unique_id != '#1':
dname_raw += ' ' + self._unique_id.replace('#', 'P')
self._displayname = bui.Lstr(translate=('inputDeviceNames', dname_raw))
self._width = 700
# Room at the bottom for keyboard 1's enable-P2 checkbox (under
# the '...' button).
self._bottom_extra = 0 if self._unique_id != '#1' else 45
if self._unique_id != '#1':
self._height = 480
else:
self._height = 375 + self._bottom_extra
self._spacing = 40
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._base_scale = (
1.4
if uiscale is bui.UIScale.SMALL
else 0.91 if uiscale is bui.UIScale.MEDIUM else 0.7
)
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height),
scale=self._base_scale,
stack_offset=(0, 5) if uiscale is bui.UIScale.SMALL else (0, 0),
transition=transition,
),
transition=transition,
origin_widget=origin_widget,
)
self._settings: dict[str, int] = {}
self._get_config_mapping()
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 things from self here; if we do it within the lambda
# we'll keep self alive which is bad.
inputdevice = self._input
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
c=inputdevice,
)
)
[docs]
@override
def main_window_should_preserve_selection(self) -> bool:
return False
def _get_config_mapping(self, default: bool = False) -> None:
for button in [
'buttonJump',
'buttonPunch',
'buttonBomb',
'buttonPickUp',
'buttonStart',
'buttonStart2',
'buttonUp',
'buttonDown',
'buttonLeft',
'buttonRight',
]:
assert bui.app.classic is not None
self._settings[button] = (
bui.app.classic.get_input_device_mapped_value(
self._input, button, default
)
)
def _rebuild_ui(self, is_reset: bool = False) -> None:
assert bui.app.classic is not None
for widget in self._root_widget.get_children():
widget.delete()
# b_off = 0 if self._unique_id != '#1' else 9
cancel_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(38, self._height - 85),
size=(170, 60),
label=_commonassets.strings.actions.cancel,
scale=0.9,
on_activate_call=self.main_window_back,
)
save_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(self._width - 190, self._height - 85),
size=(180, 60),
label=_commonassets.strings.actions.save,
scale=0.9,
text_scale=0.9,
on_activate_call=self._save,
)
bui.containerwidget(
edit=self._root_widget,
cancel_button=cancel_button,
start_button=save_button,
)
v = self._height - 74.0
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, v + 15),
size=(0, 0),
# Still legacy: the device display-name is an
# inputDeviceNames translate (those port in the deferred
# translate-category phase; see strings-asset-migration
# D35), so the whole composition stays Lstr until then.
text=bui.Lstr(
resource='configKeyboardWindow.configuringText',
subs=[('${DEVICE}', self._displayname)],
),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
maxwidth=270,
scale=0.83,
)
v -= 20
if self._unique_id != '#1':
v -= 20
v -= self._spacing
bui.textwidget(
parent=self._root_widget,
position=(0, v + 19),
size=(self._width, 50),
text=_kbstrs.keyboard2_note,
scale=0.7,
maxwidth=self._width * 0.75,
max_height=110,
color=bui.app.ui_v1.infotextcolor,
h_align='center',
v_align='top',
)
v -= 40
v -= 10
v -= self._spacing * 2.2
v += 25
v -= 42
h_offs = 160
dist = 70
d_color = (0.4, 0.4, 0.8)
d_up = self._capture_button(
pos=(h_offs, v + 0.95 * dist),
color=d_color,
button='buttonUp',
texture=_classicassets.textures.up_button.get(),
scale=1.0,
)
d_left = self._capture_button(
pos=(h_offs - 1.2 * dist, v),
color=d_color,
button='buttonLeft',
texture=_classicassets.textures.left_button.get(),
scale=1.0,
)
d_right = self._capture_button(
pos=(h_offs + 1.2 * dist, v),
color=d_color,
button='buttonRight',
texture=_classicassets.textures.right_button.get(),
scale=1.0,
)
d_down = self._capture_button(
pos=(h_offs, v - 0.95 * dist),
color=d_color,
button='buttonDown',
texture=_classicassets.textures.down_button.get(),
scale=1.0,
)
if self._unique_id == '#2':
self._capture_button(
pos=(self._width * 0.5, v + 0.1 * dist),
color=(0.4, 0.4, 0.6),
button='buttonStart',
texture=_uiv1assets.textures.start_button.get(),
scale=0.8,
)
h_offs = self._width - 160
a_pickup = self._capture_button(
pos=(h_offs, v + 0.95 * dist),
color=(0.6, 0.4, 0.8),
button='buttonPickUp',
texture=_classicassets.textures.button_pick_up.get(),
scale=1.0,
)
a_punch = self._capture_button(
pos=(h_offs - 1.2 * dist, v),
color=(0.7, 0.5, 0.1),
button='buttonPunch',
texture=_classicassets.textures.button_punch.get(),
scale=1.0,
)
a_bomb = self._capture_button(
pos=(h_offs + 1.2 * dist, v),
color=(0.5, 0.2, 0.1),
button='buttonBomb',
texture=_classicassets.textures.button_bomb.get(),
scale=1.0,
)
a_jump = self._capture_button(
pos=(h_offs, v - 0.95 * dist),
color=(0.2, 0.5, 0.2),
button='buttonJump',
texture=_classicassets.textures.button_jump.get(),
scale=1.0,
)
# Autoselect guesses poorly around the side buttons of each
# cluster; up/down from them should hit the top/bottom one.
for widget in (d_left, d_right):
bui.widget(edit=widget, up_widget=d_up, down_widget=d_down)
for widget in (a_punch, a_bomb):
bui.widget(edit=widget, up_widget=a_pickup, down_widget=a_jump)
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 * 0.5 - 65, 30 + self._bottom_extra),
size=(130, 40),
on_activate_call=self._do_more,
)
bui.widget(edit=d_down, down_widget=self._more_button)
# Keyboard 1 hosts keyboard P2 (a second player on the same
# keyboard), which only gets keys when enabled here.
if self._unique_id == '#1':
# pylint: disable=cyclic-import
from bauiv1lib.config import ConfigCheckBox
p2check = ConfigCheckBox(
parent=self._root_widget,
configkey='Keyboard P2 Enabled',
position=(self._width * 0.5 - 110, 28),
size=(220, 30),
displayname=_kbstrs.enable_keyboard_p2,
scale=0.8,
maxwidth=200,
)
# Purple, like the '...' button above it.
bui.checkboxwidget(
edit=p2check.widget,
color=(0.45, 0.4, 0.5),
textcolor=(0.65, 0.6, 0.7),
)
bui.widget(edit=p2check.widget, up_widget=self._more_button)
if is_reset:
bui.containerwidget(
edit=self._root_widget,
selected_child=self._more_button,
)
def _pretty_button_name(self, button_name: str) -> bui.LangStr:
button_id = self._settings[button_name]
if button_id == -1:
return _gpstrs.unset
return self._input.get_button_name(button_id)
def _capture_button(
self,
pos: tuple[float, float],
color: tuple[float, float, float],
texture: bui.Texture,
button: str,
scale: float = 1.0,
) -> bui.Widget:
base_size = 79
btn = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(
pos[0] - base_size * 0.5 * scale,
pos[1] - base_size * 0.5 * scale,
),
size=(base_size * scale, base_size * scale),
texture=texture,
label='',
color=color,
)
# Do this deferred so it shows up on top of other buttons. (ew.)
def doit() -> None:
if not self._root_widget:
return
uiscale = 0.66 * scale * 2.0
maxwidth = 76.0 * scale
txt = bui.textwidget(
parent=self._root_widget,
position=(pos[0] + 0.0 * scale, pos[1] - (57.0 - 18.0) * scale),
color=(1, 1, 1, 0.3),
size=(0, 0),
h_align='center',
v_align='top',
scale=uiscale,
maxwidth=maxwidth,
text=self._pretty_button_name(button),
)
bui.buttonwidget(
edit=btn,
autoselect=True,
on_activate_call=bui.CallStrict(
AwaitKeyboardInputWindow, button, txt, self._settings
),
)
bui.pushcall(doit)
return btn
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 keyboard settings edit if they want.
if bool(False):
ConfirmWindow(
# TODO: Implement a translation string for this!
'Are you sure you want to reset your button mapping?',
self._do_reset,
width=480,
height=95,
)
else:
self._do_reset()
def _do_reset(self) -> None:
"""Resets the input's mapping settings."""
self._settings = {}
self._get_config_mapping(default=True)
self._rebuild_ui(is_reset=True)
_commonassets.audio.gun_cocking.get().play()
def _do_more(self) -> None:
"""Show a burger menu with extra settings."""
# pylint: disable=cyclic-import
choices: list[str] = [
'reset',
]
choices_display: list[bui.Lstr | bui.LangStr] = [
_commonassets.strings.actions.reset,
]
PopupMenuWindow(
position=self._more_button.get_screen_space_center(),
# Sized relative to the window so it looks the same at any
# ui scale.
scale=1.25 * self._base_scale,
width=150,
choices=choices,
choices_display=choices_display,
current_choice='reset',
delegate=self,
)
def _save(self) -> None:
# no-op if we're not in control.
if not self.main_window_has_control():
return
assert bui.app.classic is not None
_commonassets.audio.gun_cocking.get().play()
# There's a chance the device disappeared; handle that
# gracefully.
if not self._input:
return
dst = bui.app.classic.get_input_device_config(
self._input, default=False
)
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
# Send this config to the master-server so we can generate more
# defaults in the future.
if bui.app.classic is not None:
bui.app.classic.master_server_v1_post(
'controllerConfig',
{
'ua': bui.app.classic.legacy_user_agent_string,
'name': self._name,
'b': bui.app.env.engine_build_number,
'config': dst2,
'v': 2,
},
)
bui.app.config.apply_and_commit()
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, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum