# Released under the MIT License. See LICENSE for details.
#
"""Advanced settings, as a doc-ui page.
A client-local doc-ui domain like the audio settings: the page is
authored here, its controls keep their values in typed page state
mirroring the config, and each change is a typed local action that
writes the config. Buttons leading elsewhere are local actions too,
opening the existing (non-doc-ui) windows.
"""
from __future__ import annotations # Docs-generation hack.
from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, override, assert_never
import weakref
from efro.dataclassio import ioprepped, IOAttrs
import bacommon.docui.v2 as dui2
from bacommon.docui.presets import section_button, button_stack
from bacommon.docui.routes import (
DocUIRoute,
DocUILocalActionBase,
DocUIState,
PressSound,
family_members,
)
from bacommon.langstr import LangStrSpecValue
from bacommon.locale import LocaleResolved, Locale, language_picker_label
import bauiv1 as bui
from bauiv1 import _commonassets, _classicassets
from bauiv1lib.docui import TypedDocUIController
if TYPE_CHECKING:
from typing import Literal
from bacommon.docui import DocUIResponse
from bacommon.langstr import LangStrSpec
from bauiv1lib.docui import DocUILocalAction
_advstrs = _classicassets.strings.settings.advanced
#: The 'follow the OS locale' language choice.
_LANGUAGE_AUTO = 'Auto'
[docs]
class InsecureConnections(Enum):
"""The tri-state ``Insecure Connections`` setting (its config values)."""
ALWAYS = 'always'
AUTO = 'auto'
NEVER = 'never'
[docs]
class Target(Enum):
"""Windows the page's buttons lead to."""
MODDING_GUIDE = 'modding_guide'
DEV_TOOLS = 'dev_tools'
MODS_FOLDER = 'mods_folder'
PLUGINS = 'plugins'
VR_TEST = 'vr_test'
NET_TEST = 'net_test'
BENCHMARKS = 'benchmarks'
SEND_INFO = 'send_info'
[docs]
class AdvancedRoute(DocUIRoute):
"""Family class for the advanced settings routes."""
[docs]
@override
@classmethod
def get_route_types(cls) -> tuple[type[DocUIRoute], ...]:
return family_members(AnyAdvancedRoute)
[docs]
@override
@classmethod
def get_window_layout(cls) -> dui2.WindowLayout:
# A long list of options; tall rather than wide.
return dui2.WindowLayout.SMALL_TALLER
[docs]
@ioprepped
@dataclass
class Root(AdvancedRoute, path='/'):
"""The advanced settings page."""
AnyAdvancedRoute = Root
[docs]
@ioprepped
@dataclass
class AdvancedState(DocUIState, state_id='settings.advanced'):
"""The page's values, mirroring the config."""
#: A locale long-value, or ``_LANGUAGE_AUTO``.
language: str = _LANGUAGE_AUTO
kick_idle_players: bool = False
show_ping: bool = False
show_demos_when_idle: bool = False
disable_camera_shake: bool = False
disable_camera_gyro: bool = False
insecure_connections: InsecureConnections = (
InsecureConnections.AUTO
)
always_use_internal_keyboard: bool = False
allow_extreme_aspect_ratios: bool = False
legacy_character_physics: bool = False
#: Config key for each state field that is a plain config mirror. Built
#: from typed field lookups so a renamed field fails here, not at
#: runtime; the language is not here since switching it is its own
#: (asynchronous) affair.
_CONFIG_KEYS: dict[str, str] = {
AdvancedState.key(lambda s: s.kick_idle_players): 'Kick Idle Players',
AdvancedState.key(lambda s: s.show_ping): 'Show Ping',
AdvancedState.key(lambda s: s.show_demos_when_idle): 'Show Demos When Idle',
AdvancedState.key(lambda s: s.disable_camera_shake): 'Disable Camera Shake',
AdvancedState.key(lambda s: s.disable_camera_gyro): 'Disable Camera Gyro',
AdvancedState.key(lambda s: s.insecure_connections): 'Insecure Connections',
AdvancedState.key(
lambda s: s.always_use_internal_keyboard
): 'Always Use Internal Keyboard',
AdvancedState.key(
lambda s: s.allow_extreme_aspect_ratios
): 'Allow Extreme Aspect Ratios',
AdvancedState.key(
lambda s: s.legacy_character_physics
): 'Legacy Character Physics',
}
[docs]
class AdvancedLocalAction(DocUILocalActionBase):
"""Family class for the advanced settings local-actions."""
[docs]
@override
@classmethod
def get_action_types(cls) -> tuple[type[DocUILocalActionBase], ...]:
return family_members(AnyAdvancedLocalAction)
[docs]
@ioprepped
@dataclass
class SetLanguage(AdvancedLocalAction, name='set_language'):
"""Switch to the language the page's state holds."""
[docs]
@ioprepped
@dataclass
class ApplySetting(AdvancedLocalAction, name='apply_setting'):
"""Write the setting that changed (the trigger) to the config."""
[docs]
@ioprepped
@dataclass
class Open(AdvancedLocalAction, name='open'):
"""Go somewhere."""
[docs]
@override
@classmethod
def get_press_sound(cls) -> PressSound:
return PressSound.SWISH
target: Target
[docs]
@ioprepped
@dataclass
class SendTranslationFeedback(
AdvancedLocalAction, name='send_translation_feedback'
):
"""Report a translation that could be improved."""
AnyAdvancedLocalAction = (
SetLanguage | ApplySetting | Open | SendTranslationFeedback
)
[docs]
class AdvancedSettingsController(
TypedDocUIController[AnyAdvancedRoute, AnyAdvancedLocalAction]
):
"""Doc-ui controller for the advanced settings page."""
[docs]
@override
@classmethod
def get_route_type(cls) -> type[AdvancedRoute]:
return AdvancedRoute
[docs]
@override
@classmethod
def get_local_action_type(cls) -> type[AdvancedLocalAction]:
return AdvancedLocalAction
[docs]
@override
def fulfill_route(self, route: AnyAdvancedRoute) -> DocUIResponse:
match route:
case Root():
_preload_modules()
return _page()
case _:
assert_never(route)
[docs]
@override
def run_local_action(
self, action: AnyAdvancedLocalAction, context: DocUILocalAction
) -> None:
match action:
case SetLanguage():
self._set_language(context)
case ApplySetting():
_apply_setting(context)
case Open():
_open(action.target, context)
case SendTranslationFeedback():
bui.open_url('https://www.ballistica.net/feedback')
case _:
assert_never(action)
def _set_language(self, context: DocUILocalAction) -> None:
state = context.state(AdvancedState)
if state is None:
return
locale_ss = bui.app.locale
# Re-render once the switch is done with, whether or not it went
# through: on success the page comes back in the new language
# with the picker matching; on a cancelled or failed download the
# picker snaps back to the language we are actually still in.
winref = weakref.ref(context.window)
def _on_complete(success: bool) -> None:
del success # Either way, show what is actually current.
window = winref()
if window is not None and not window.locked:
self.replace(window, Root().request(), is_refresh=True)
# Switching is an elective asset-resolve (the target locale's
# language flavor is downloaded if not already local) that
# commits only on success and writes/clears the 'Lang' config
# itself. Auto = no override (follow the OS-default locale).
if state.language == _LANGUAGE_AUTO:
locale_ss.set_locale(
locale_ss.default_locale,
store_to_config=False,
on_complete=_on_complete,
)
else:
locale_ss.set_locale(
Locale.from_long_value(state.language),
store_to_config=True,
on_complete=_on_complete,
)
def _preload_modules() -> None:
"""Import what the buttons lead to here, off the logic thread."""
# pylint: disable=cyclic-import
from babase import modutils as _unused2
from bauiv1lib.settings import vrtesting as _unused3
from bauiv1lib.settings import nettesting as _unused4
from bauiv1lib import sendinfo as _unused7
from bauiv1lib.settings import benchmarks as _unused8
from bauiv1lib.settings import plugins as _unused9
from bauiv1lib.settings import devtools as _unused10
def _language_choices() -> list[tuple[str, LangStrSpec]]:
"""The language picker's options: Auto, then every displayable locale.
Ordered by endonym -- the half of each label the user actually
scans; label shape lives in
:func:`bacommon.locale.language_picker_label`, shared with the
master server's account-settings picker so the two can't drift.
"""
locale_ss = bui.app.locale
locale_strs = _commonassets.strings.locales
choices: list[tuple[str, LangStrSpec]] = [
(
_LANGUAGE_AUTO,
_commonassets.strings.compose.paren_suffix(
main=_commonassets.strings.values.auto,
note=getattr(
locale_strs, locale_ss.default_locale.resolved.value
),
).spec,
)
]
for lresolved in sorted(
(
lr
for lr in LocaleResolved
if locale_ss.can_display_locale(lr.locale)
),
key=lambda lr: lr.endonym_sort_key,
):
choices.append(
(
lresolved.locale.long_value,
LangStrSpecValue.literal(
language_picker_label(
lresolved,
getattr(locale_strs, lresolved.value).evaluate(),
)
),
)
)
return choices
def _page() -> dui2.Response:
"""Build the page (called in a background thread)."""
app = bui.app
assert app.classic is not None
config = app.config
astate = AdvancedState
setstrs = _classicassets.strings.settings
def _bool(key: str) -> bool:
return bool(config.resolve(key))
insecure = config.resolve('Insecure Connections')
try:
insecure_mode = InsecureConnections(insecure)
except ValueError:
insecure_mode = InsecureConnections.AUTO
show_gyro = bui.hasgyro()
show_internal_keyboard = not app.env.vr
show_vr_test = app.env.vr
state = AdvancedState(
language=config.get('Lang', _LANGUAGE_AUTO),
kick_idle_players=_bool('Kick Idle Players'),
show_ping=_bool('Show Ping'),
show_demos_when_idle=_bool('Show Demos When Idle'),
disable_camera_shake=_bool('Disable Camera Shake'),
disable_camera_gyro=_bool('Disable Camera Gyro'),
insecure_connections=insecure_mode,
always_use_internal_keyboard=_bool('Always Use Internal Keyboard'),
allow_extreme_aspect_ratios=_bool('Allow Extreme Aspect Ratios'),
# (Not a native config entry, so no resolve() default.)
legacy_character_physics=bool(
config.get('Legacy Character Physics', False)
),
)
# A stored language we can no longer show reads as Auto.
if state.language not in {c[0] for c in _language_choices()}:
state.language = _LANGUAGE_AUTO
apply = ApplySetting().local(default_sound=False)
rows: list[dui2.Row] = [
astate.choice_row(
lambda s: s.language,
choices=_language_choices(),
label=_advstrs.language.spec,
on_change=SetLanguage().local(default_sound=False),
),
dui2.ButtonControlRow(
label=_advstrs.improve_translations.spec,
button=dui2.Button(
label=_advstrs.translation_feedback.spec,
style=dui2.ButtonStyle.MEDIUM,
size=(150.0, 45.0),
action=SendTranslationFeedback().local(),
),
# (Wrapped at display time by its max-chars-per-line wrap
# params, so each language gets however many lines it needs;
# the row grows to fit.)
footnote=_advstrs.improve_translations_description(
app_name=_classicassets.strings.ui.app_name
).spec,
),
astate.checkbox_row(
lambda s: s.kick_idle_players,
label=_advstrs.kick_idle_players.spec,
on_change=apply,
),
astate.checkbox_row(
lambda s: s.show_ping,
label=_advstrs.show_in_game_ping.spec,
on_change=apply,
),
astate.checkbox_row(
lambda s: s.show_demos_when_idle,
label=_advstrs.show_demos_when_idle.spec,
on_change=apply,
),
astate.checkbox_row(
lambda s: s.disable_camera_shake,
label=_advstrs.disable_camera_shake.spec,
on_change=apply,
),
]
if show_gyro:
rows.append(
astate.checkbox_row(
lambda s: s.disable_camera_gyro,
label=_advstrs.disable_camera_gyro.spec,
on_change=apply,
)
)
rows.append(
astate.choice_row(
lambda s: s.insecure_connections,
choice_label=_insecure_label,
label=_advstrs.insecure_connections.spec,
footnote=_advstrs.insecure_connections_description.spec,
on_change=apply,
)
)
if show_internal_keyboard:
rows.append(
astate.checkbox_row(
lambda s: s.always_use_internal_keyboard,
label=_advstrs.always_use_internal_keyboard.spec,
footnote=_advstrs.always_use_internal_keyboard_description.spec,
on_change=apply,
)
)
rows.append(
astate.checkbox_row(
lambda s: s.allow_extreme_aspect_ratios,
label=_advstrs.allow_extreme_aspect_ratios.spec,
footnote=_advstrs.allow_extreme_aspect_ratios_description.spec,
on_change=apply,
)
)
rows.append(
astate.checkbox_row(
lambda s: s.legacy_character_physics,
label=_advstrs.legacy_character_physics.spec,
footnote=_advstrs.legacy_character_physics_description.spec,
on_change=apply,
)
)
def _button(label: LangStrSpec, target: Target) -> dui2.Button:
return section_button(label, Open(target=target).local())
# One button per row, like the old window, grouped (modding,
# testing, reporting).
testing = [
_button(setstrs.net_testing.title.spec, Target.NET_TEST),
_button(setstrs.benchmarks.title.spec, Target.BENCHMARKS),
]
if show_vr_test:
testing.insert(
0, _button(setstrs.vr_testing.title.spec, Target.VR_TEST)
)
rows += button_stack(
[
[
_button(_advstrs.modding_guide.spec, Target.MODDING_GUIDE),
_button(setstrs.dev_tools.title.spec, Target.DEV_TOOLS),
_button(_advstrs.show_mods_folder.spec, Target.MODS_FOLDER),
_button(setstrs.plugins.title.spec, Target.PLUGINS),
],
testing,
[_button(_advstrs.send_info.spec, Target.SEND_INFO)],
]
)
return dui2.Response(
page=dui2.Page(
title=_advstrs.title.spec, rows=rows, state=state.encode()
)
)
def _insecure_label(mode: InsecureConnections) -> LangStrSpec:
valstrs = _commonassets.strings.values
match mode:
case InsecureConnections.ALWAYS:
return valstrs.always.spec
case InsecureConnections.AUTO:
return valstrs.auto.spec
case InsecureConnections.NEVER:
return valstrs.never.spec
case _:
assert_never(mode)
def _apply_setting(context: DocUILocalAction) -> None:
"""Write the changed setting to the config, as ConfigCheckBox would."""
state = context.state(AdvancedState)
if context.trigger is None or state is None:
return
key = _CONFIG_KEYS.get(context.trigger)
if key is None:
return
# The wire form of the value is exactly the config form (bools;
# the tri-state's string values).
value = state.encode()[context.trigger]
cfg = bui.app.config
cfg[key] = value
cfg.apply_and_commit()
def _open(target: Target, context: DocUILocalAction) -> None:
# pylint: disable=cyclic-import
window = context.window
match target:
case Target.MODDING_GUIDE:
bui.open_url('https://ballistica.net/wiki/modding-guide')
return
case Target.MODS_FOLDER:
from babase.modutils import show_user_scripts
show_user_scripts()
return
case (
Target.DEV_TOOLS
| Target.PLUGINS
| Target.VR_TEST
| Target.NET_TEST
| Target.BENCHMARKS
| Target.SEND_INFO
):
pass
case _:
assert_never(target)
# The rest replace us with another window; no-op if we're not in
# control.
if not window.main_window_has_control():
return
origin = context.widget
match target:
case Target.DEV_TOOLS:
from bauiv1lib.settings import devtools
window.main_window_replace(
lambda: devtools.DevToolsController().create_window(
devtools.Root(),
origin_widget=origin,
auxiliary_style=False,
),
extra_type_id=(
devtools.DevToolsController.get_window_extra_type_id()
),
)
case Target.PLUGINS:
from bauiv1lib.settings.plugins import PluginWindow
window.main_window_replace(
lambda: PluginWindow(origin_widget=origin)
)
case Target.VR_TEST:
from bauiv1lib.settings.vrtesting import VRTestingWindow
window.main_window_replace(
lambda: VRTestingWindow(transition='in_right')
)
case Target.NET_TEST:
from bauiv1lib.settings.nettesting import NetTestingWindow
window.main_window_replace(
lambda: NetTestingWindow(transition='in_right')
)
case Target.BENCHMARKS:
from bauiv1lib.settings import benchmarks
window.main_window_replace(
lambda: benchmarks.BenchmarksController().create_window(
benchmarks.Root(),
origin_widget=origin,
auxiliary_style=False,
),
extra_type_id=(
benchmarks.BenchmarksController.get_window_extra_type_id()
),
)
case Target.SEND_INFO:
from bauiv1lib import sendinfo
window.main_window_replace(
lambda: sendinfo.SendInfoController().create_window(
sendinfo.Root(),
origin_widget=origin,
auxiliary_style=False,
),
extra_type_id=(
sendinfo.SendInfoController.get_window_extra_type_id()
),
)
case _:
assert_never(target)
# 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