From 62020ed1d8b23be9b2310905f3cba334a007733f Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Tue, 28 Oct 2025 15:19:42 -0700 Subject: [PATCH 1/3] more ui and cloudui work --- .efrocachemap | 72 +- CHANGELOG.md | 6 +- src/assets/.asset_manifest_public.json | 6 +- src/assets/Makefile | 6 +- src/assets/ba_data/python/babase/_language.py | 19 +- .../ba_data/python/baclassic/_appmode.py | 4 +- src/assets/ba_data/python/baenv.py | 2 +- .../ba_data/python/bauiv1/_appsubsystem.py | 9 +- src/assets/ba_data/python/bauiv1lib/chest.py | 10 +- .../ba_data/python/bauiv1lib/cloudui.py | 1370 ----------------- .../python/bauiv1lib/cloudui/__init__.py | 14 + .../python/bauiv1lib/cloudui/_controller.py | 179 +++ .../ba_data/python/bauiv1lib/cloudui/_prep.py | 815 ++++++++++ .../ba_data/python/bauiv1lib/cloudui/_test.py | 276 ++++ .../python/bauiv1lib/cloudui/_window.py | 377 +++++ .../ba_data/python/bauiv1lib/mainmenu.py | 2 - src/ballistica/base/python/base_python.cc | 10 +- .../python/class/python_class_context_call.cc | 2 +- .../python/class/python_class_context_ref.cc | 2 +- .../python/class/python_class_material.cc | 6 +- .../class/python_class_scene_data_asset.cc | 2 +- src/ballistica/shared/ballistica.cc | 2 +- .../python/methods/python_methods_ui_v1.cc | 10 +- src/ballistica/ui_v1/widget/button_widget.cc | 93 +- src/ballistica/ui_v1/widget/button_widget.h | 12 +- src/ballistica/ui_v1/widget/root_widget.cc | 24 +- src/ballistica/ui_v1/widget/scroll_widget.cc | 11 + tools/bacommon/cloudui/__init__.py | 16 +- tools/bacommon/cloudui/_cloudui.py | 70 +- tools/bacommon/cloudui/v1.py | 68 +- 30 files changed, 1968 insertions(+), 1527 deletions(-) delete mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui.py create mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui/__init__.py create mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui/_controller.py create mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui/_prep.py create mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui/_test.py create mode 100644 src/assets/ba_data/python/bauiv1lib/cloudui/_window.py diff --git a/.efrocachemap b/.efrocachemap index ca911bf6b..39c040b2a 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -1097,10 +1097,10 @@ "build/assets/ba_data/textures/achievementWall.ktx": "9c5b06b616911cecc2331c43e7edc668", "build/assets/ba_data/textures/achievementWall.pvr": "7afe0c2046860cb430c22c94b1bcbea2", "build/assets/ba_data/textures/achievementWall_preview.png": "539096cae5443d9480749c82653d04da", - "build/assets/ba_data/textures/achievementsIcon.dds": "f4dd0a2591930bc33db3681bb281d558", - "build/assets/ba_data/textures/achievementsIcon.ktx": "d71ee31bb40e4406922347699257b4e7", - "build/assets/ba_data/textures/achievementsIcon.pvr": "cf79fb381cb5c1a23bf12f959882d10b", - "build/assets/ba_data/textures/achievementsIcon_preview.png": "28504b2d21ff24b10c8dc73f8c5d946a", + "build/assets/ba_data/textures/achievementsIcon.dds": "f7a5a3ec22e936ff10db40d500c21841", + "build/assets/ba_data/textures/achievementsIcon.ktx": "0dd71ce78f36551d64a38b6df20e65f2", + "build/assets/ba_data/textures/achievementsIcon.pvr": "e04a0eeb3961c13da64fc3e11464611c", + "build/assets/ba_data/textures/achievementsIcon_preview.png": "3f066ef09369ba91bedd21e5cc803d08", "build/assets/ba_data/textures/actionButtons.dds": "c5752f3d092f73ae28cf1ddaa30d55b5", "build/assets/ba_data/textures/actionButtons.ktx": "962f628fed3828f2c8b55e8db1d8d1f9", "build/assets/ba_data/textures/actionButtons.pvr": "0ab1f0f5d6c593ab61ef2f4018efe52a", @@ -1849,10 +1849,10 @@ "build/assets/ba_data/textures/lock.ktx": "9401b3c624fa853d649374320a7dd012", "build/assets/ba_data/textures/lock.pvr": "10325fd3c40d8c794b09123dda3ce109", "build/assets/ba_data/textures/lock_preview.png": "2f8f8bd6bba8d1baed19c4419ed92a91", - "build/assets/ba_data/textures/logIcon.dds": "5c1e6f828f8edde2ec68cb2a35eb8c2e", - "build/assets/ba_data/textures/logIcon.ktx": "67f801f1b6e8192a8a22b29a03007795", - "build/assets/ba_data/textures/logIcon.pvr": "e16534c4133b0807f15ff9af2dbf2ddd", - "build/assets/ba_data/textures/logIcon_preview.png": "9f7ed5f9551f874c06e933761fe31f33", + "build/assets/ba_data/textures/logIcon.dds": "f4c7479bc72896caee2e0e4b63fc0fc9", + "build/assets/ba_data/textures/logIcon.ktx": "33866b0cfdfee47e1f73287b52430377", + "build/assets/ba_data/textures/logIcon.pvr": "c78fbf5a2d743af9fbfae378f167133e", + "build/assets/ba_data/textures/logIcon_preview.png": "c9ca591757ed38c30a509f3ec7d52b69", "build/assets/ba_data/textures/logo.dds": "f87d6e42bbf4695fe80042b3b12e3716", "build/assets/ba_data/textures/logo.ktx": "a114cf87faa542989a54e48d6f7bd896", "build/assets/ba_data/textures/logo.pvr": "55bef5b04c85309415ad65b8ad5daa2a", @@ -4294,26 +4294,26 @@ "build/assets/windows/x64/pythonw.exe": "007fb5e669a3b6b3e8facf0728b87521", "build/assets/windows/x64/pythonw_d.exe": "46925c0fa3ca3fd29a33e54ee31f6cd5", "build/assets/windows/x64/vc_redist.x64.exe": "b9a4d05fb2699c78e6607a385cd784cf", - "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "707a7d51f4077fe0478eeac977b81f4f", - "build/prefab/full/linux_arm64_gui/release/ballisticakit": "bc0b27d97523d870fe6acb4c4ee3cb6e", - "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "0e4ec3c6f5922984cda63a45a5d58701", - "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "1546657c42c85421a42e2613340bee28", - "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "b15d0b08818025e9e81af5ac06be7244", - "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "eb4cb85947e1cd4346876520b4db7c62", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "de1e9d3ff6c3531623285b9f2fa6c89d", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "597df00c3ebcc77ee16c3bb29052bc4d", - "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "e9e19188c49aa4fcf3f87c9317fe1743", - "build/prefab/full/mac_arm64_gui/release/ballisticakit": "f4101cc38b87b3d7ec2ce66a781bb712", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "6f09da42b4f1bc9c2915da161c51e7ea", - "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "e86203b2885d675a3218bcafc8f7718e", - "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "f1131deea7fcfe98e72accfe3a085930", - "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "06d98e3a1b4dbd11ed00d518b6fa8a4e", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "27f38fc3548dddeadf3238a3506090b2", - "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "4cffbcdd5003bbb4791b6639e8f22c9b", - "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "b2229ced2839e55addfe4f4281fe80be", - "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "87fe85ef1dd530f3c7ce77be0b4d0937", - "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "9682e99409ab46fcd6e4a284c7d36526", - "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "0a52dc00ad50a70bafbfc1d6e2330c06", + "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "9e1ede575cefd3797d7f918cbafa9f1f", + "build/prefab/full/linux_arm64_gui/release/ballisticakit": "f430faaf24e687586cea6618c7897c5e", + "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "57695753b413b633320bfda9d99994ba", + "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "7b48bfb18b8f40e87ba524c80863c1a2", + "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "cb43e8568f29f99e67f0986ce3db65c0", + "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "afbec521d18763888b30f947f4699c71", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "6e60688d17adc88bcdbd57a5611dcbcd", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "b97d7947a652371b27ffe7d5f9b5b7b0", + "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "28bb6f2d7ebb7cf2a8b0963e10c7a940", + "build/prefab/full/mac_arm64_gui/release/ballisticakit": "e9c104f938cbd265629c34aecb262393", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "89e6dce5d5507854a5ba33e63a4c8214", + "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "8c64d1eee8c6b0862d98c90857cef9d8", + "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "3edd02e9e961523431f05c48b2b681b2", + "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "b5d9b2f174a060cd71c7b209b64fee8c", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "a2c067044b11bd57a1e29acd91a5b56a", + "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "5e31103eff7d10a31aa2e5338d7af4f6", + "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "20c9b1911a5c822996c977f8a5f225ee", + "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "5117cc0946f0b4ae237db9810a0237dc", + "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "57567afa0833c83eefcde9db1ad5e0fa", + "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "9fe96d70c755e9a5d7f118c9906d0f25", "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "ffba3501949fc0afdd4cdc8bfa3f232d", "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", @@ -4330,14 +4330,14 @@ "build/prefab/lib/mac_x86_64_gui/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", "build/prefab/lib/mac_x86_64_server/debug/libballisticaplus.a": "ce027ca768b327eed0e7168a76e466f9", "build/prefab/lib/mac_x86_64_server/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "502464f22ae468f52d2c0e5dd009a6f6", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "2c1c025d623f5608bb5f0fd5bc4d7d18", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "e6621b14cb58a65016c860749d1f1614", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "f50b35617caadb9763c6ef180739aad0", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "c54c8407f2699abef485368828a44383", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "2eeee3879269ec845b38dc118e9115b3", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "393611824bdecf472af64105df024450", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "8c5799a8f9487947602a32277694ef81", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "d436217c5953f316480efb88127dc2fb", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "d066f5f5a21b61551b3396022ba1155f", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "eb41059b05646846ed5ae0fd2e469680", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "9e0dc91863060d216f952b920d99f1b9", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "34dfd53947a00cfc17bdb31f3dc62d9f", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "d7311bb63a65f69b8dd2d08cc0f41d3d", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "552acd5f3a5474043aa9e68e817f4adf", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "9e6de8de47502201c0627e983ef3e50a", "src/assets/ba_data/python/babase/_mgen/__init__.py": "f885fed7f2ed98ff2ba271f9dbe3391c", "src/assets/ba_data/python/babase/_mgen/enums.py": "f2642a60fef55f7792cb1dfe03446cb1", "src/ballistica/base/mgen/pyembed/binding_base.inc": "943cdbda1dcf399783675b115c22dae5", diff --git a/CHANGELOG.md b/CHANGELOG.md index ea694965a..32ad4b939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ -### 1.7.54 (build 22599, api 9, 2025-10-27) +### 1.7.54 (build 22600, api 9, 2025-10-28) - `scrollwidget` and `hscrollwidget` now center selected items that are too large to fit completely in view instead of unpredictably scrolling to the beginning or end of them. This makes show-buffer values (which effectively make things bigger in the scrollwidget's eyes) more intuitive to use. +- Added new `button_type` values for `bauiv1.buttonwidget()`: 'small', 'medium', + 'large', and 'larger'. These correspond to the styles that are normally + selected based on button dimensions; you can now choose them explicitly if you + like. ### 1.7.53 (build 22597, api 9, 2025-10-25) - Fixes an issue where deleting player profiles would error. diff --git a/src/assets/.asset_manifest_public.json b/src/assets/.asset_manifest_public.json index b008e2039..f48cbd7d4 100644 --- a/src/assets/.asset_manifest_public.json +++ b/src/assets/.asset_manifest_public.json @@ -217,7 +217,11 @@ "ba_data/python/bauiv1lib/appinvite.py", "ba_data/python/bauiv1lib/characterpicker.py", "ba_data/python/bauiv1lib/chest.py", - "ba_data/python/bauiv1lib/cloudui.py", + "ba_data/python/bauiv1lib/cloudui/__init__.py", + "ba_data/python/bauiv1lib/cloudui/_controller.py", + "ba_data/python/bauiv1lib/cloudui/_prep.py", + "ba_data/python/bauiv1lib/cloudui/_test.py", + "ba_data/python/bauiv1lib/cloudui/_window.py", "ba_data/python/bauiv1lib/colorpicker.py", "ba_data/python/bauiv1lib/config.py", "ba_data/python/bauiv1lib/confirm.py", diff --git a/src/assets/Makefile b/src/assets/Makefile index 58dc6f3c7..9ead4ff42 100644 --- a/src/assets/Makefile +++ b/src/assets/Makefile @@ -350,7 +350,11 @@ SCRIPT_TARGETS_PY_PUBLIC = \ $(BUILD_DIR)/ba_data/python/bauiv1lib/appinvite.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/characterpicker.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/chest.py \ - $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui/__init__.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui/_controller.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui/_prep.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui/_test.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/cloudui/_window.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/colorpicker.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/config.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/confirm.py \ diff --git a/src/assets/ba_data/python/babase/_language.py b/src/assets/ba_data/python/babase/_language.py index b8f3c6bf8..a2108bbe5 100644 --- a/src/assets/ba_data/python/babase/_language.py +++ b/src/assets/ba_data/python/babase/_language.py @@ -561,7 +561,7 @@ def evaluate(self) -> str: You should avoid doing this as much as possible and instead pass and store ``Lstr`` values. """ - return _babase.evaluate_lstr(self._get_json()) + return _babase.evaluate_lstr(self.as_json()) def is_flat_value(self) -> bool: """Return whether this instance represents a 'flat' value. @@ -573,22 +573,13 @@ def is_flat_value(self) -> bool: """ return bool('v' in self.args and not self.args.get('s', [])) - def _get_json(self) -> str: - try: - return json.dumps(self.args, separators=(',', ':')) - except Exception: - from babase import _error - - applog.exception('_get_json failed for %s.', self.args) - return 'JSON_ERR' - - @override - def __str__(self) -> str: - return f'' + def as_json(self) -> str: + """Return the json dict representation of the Lstr.""" + return json.dumps(self.args, separators=(',', ':')) @override def __repr__(self) -> str: - return f'' + return f'' @staticmethod def from_json(json_string: str) -> babase.Lstr: diff --git a/src/assets/ba_data/python/baclassic/_appmode.py b/src/assets/ba_data/python/baclassic/_appmode.py index b6e3f2d36..ea4a79d56 100644 --- a/src/assets/ba_data/python/baclassic/_appmode.py +++ b/src/assets/ba_data/python/baclassic/_appmode.py @@ -971,11 +971,11 @@ def _main_win_template_press(self) -> None: show_template_main_window() def _cloud_ui_test_press(self) -> None: - from bauiv1lib.cloudui import show_cloud_ui_window + from bauiv1lib.cloudui import show_test_cloud_ui_window # Unintuitively, swish sounds come from buttons, not windows. # And dev-console buttons don't make sounds. So we need to # explicitly do so here. bui.getsound('swish').play() - show_cloud_ui_window() + show_test_cloud_ui_window() diff --git a/src/assets/ba_data/python/baenv.py b/src/assets/ba_data/python/baenv.py index a6ea0f324..e8937227c 100644 --- a/src/assets/ba_data/python/baenv.py +++ b/src/assets/ba_data/python/baenv.py @@ -56,7 +56,7 @@ # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22599 +TARGET_BALLISTICA_BUILD = 22600 TARGET_BALLISTICA_VERSION = '1.7.54' diff --git a/src/assets/ba_data/python/bauiv1/_appsubsystem.py b/src/assets/ba_data/python/bauiv1/_appsubsystem.py index 8eb6282f8..b279c3f8c 100644 --- a/src/assets/ba_data/python/bauiv1/_appsubsystem.py +++ b/src/assets/ba_data/python/bauiv1/_appsubsystem.py @@ -558,10 +558,17 @@ def auxiliary_window_activate( # Ok, no existing auxiliary stuff was found period. Just # navigate forward to this UI. - current_main_window.main_window_replace( + new_main_win = current_main_window.main_window_replace( win_create_call, is_auxiliary=True ) + # We should always be allowed to replace the main win in this + # case. + assert new_main_win is not None + + # Make sure what got made exactly matches the type we were passed. + assert type(new_main_win) is win_type + def _schedule_main_win_recreate(self) -> None: # If there is a timer set already, do nothing. diff --git a/src/assets/ba_data/python/bauiv1lib/chest.py b/src/assets/ba_data/python/bauiv1lib/chest.py index f4d4617ac..e3bf755ed 100644 --- a/src/assets/ba_data/python/bauiv1lib/chest.py +++ b/src/assets/ba_data/python/bauiv1lib/chest.py @@ -224,7 +224,7 @@ def get_main_window_state(self) -> bui.MainWindowState: def main_window_should_preserve_selection(self) -> bool: # This doesn't really benefit us since we do lots of widget # creates/destroys throughout our lifetime and also we're an - # auxliary window so should never need to restore toolbar + # auxiliary window so should never need to restore toolbar # selections. return False @@ -978,6 +978,10 @@ def _show_about_chest_slots(self) -> None: text=bui.Lstr(resource='chests.slotDescriptionText'), color=(1, 1, 1), ) + # This is somewhat redundant with the close button, but we need + # to have *something* selectable in our window for SMALL ui-mode + # otherwise we can be left unable to select anything. + self._show_done_button(use_ok_label=True) def _show_chest_contents( self, response: bacommon.bs.ChestActionResponse @@ -1210,7 +1214,7 @@ def _set_img(x: float, scale: float) -> None: initial_highlighted_extra=True, ) - def _show_done_button(self) -> None: + def _show_done_button(self, use_ok_label: bool = False) -> None: # No-op if our ui is dead. if not self._root_widget: return @@ -1225,7 +1229,7 @@ def _show_done_button(self) -> None: self._yoffs - 350, ), size=(bwidth, bheight), - label=bui.Lstr(resource='doneText'), + label=bui.Lstr(resource='okText' if use_ok_label else 'doneText'), autoselect=True, on_activate_call=self.main_window_back, ) diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui.py b/src/assets/ba_data/python/bauiv1lib/cloudui.py deleted file mode 100644 index 0fc03c49d..000000000 --- a/src/assets/ba_data/python/bauiv1lib/cloudui.py +++ /dev/null @@ -1,1370 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -# pylint: disable=too-many-lines -"""UIs provided by the cloud (similar-ish to html in concept).""" - -from __future__ import annotations - -import random -from functools import partial -from dataclasses import dataclass -from typing import TYPE_CHECKING, override, assert_never - -import bacommon.cloudui.v1 as clui -import bauiv1 as bui - -from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top - -if TYPE_CHECKING: - from typing import Callable - - -def show_cloud_ui_window() -> None: - """Bust out a cloud-ui window.""" - - # Pop up an auxiliary window wherever we are in the nav stack. - bui.app.ui_v1.auxiliary_window_activate( - win_type=CloudUIWindow, - win_create_call=lambda: CloudUIWindow(state=None), - ) - - -# Prep-structures for our UI - we do all layout math and bake out -# partial ui calls in a background thread so there's as little work to -# do in the ui thread as possible. - - -@dataclass -class _DecorationPrep: - call: Callable[..., bui.Widget] - textures: dict[str, str] - meshes: dict[str, str] - - -@dataclass -class _ButtonPrep: - buttoncall: Callable[..., bui.Widget] - buttoneditcall: Callable | None - decorations: list[_DecorationPrep] - textures: dict[str, str] - - -@dataclass -class _RowPrep: - width: float - height: float - titlecalls: list[Callable[..., bui.Widget]] - hscrollcall: Callable[..., bui.Widget] | None - hscrolleditcall: Callable | None - hsubcall: Callable[..., bui.Widget] | None - buttons: list[_ButtonPrep] - simple_culling_h: float - decorations: list[_DecorationPrep] - - -@dataclass -class _PagePrep: - rootcall: Callable[..., bui.Widget] | None - rows: list[_RowPrep] - width: float - height: float - simple_culling_v: float - - -def _prep_page( - ui: clui.Page, - uiscale: bui.UIScale, - scroll_width: float, - *, - immediate: bool = False, -) -> _PagePrep: - """Prep a ui.""" - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - - # Ok; we've got some buttons. Build our full UI. - row_title_height = 30.0 - row_subtitle_height = 30.0 - top_buffer = 20.0 - bot_buffer = 20.0 - left_buffer = 0.0 - right_buffer = 10.0 # Nudge a bit due to scrollbar. - title_inset = 35.0 - default_button_width = 200.0 - default_button_height = 200.0 - - if uiscale is bui.UIScale.SMALL: - top_bar_overlap = 70 - bot_bar_overlap = 70 - top_buffer += top_bar_overlap - bot_buffer += bot_bar_overlap - else: - top_bar_overlap = 0 - bot_bar_overlap = 0 - - # Should look into why this is necessary. - fudge = 15.0 - hscrollinset = 15.0 - - uiprep = _PagePrep( - rootcall=None, - rows=[], - width=scroll_width + fudge, - height=top_buffer + bot_buffer, - simple_culling_v=ui.simple_culling_v, - ) - - # Precalc basic info like dimensions for all rows. - for row in ui.rows: - assert row.buttons - this_row_width = ( - left_buffer - + right_buffer - + row.padding_left - + row.padding_right - + row.button_spacing * (len(row.buttons) - 1) - ) - button_row_height = 0.0 - for button in row.buttons: - if button.size is None: - bwidth = default_button_width - bheight = default_button_height - else: - bwidth = button.size[0] - bheight = button.size[1] - bscale = button.scale - bwidthfull = bwidth * bscale - bheightfull = bheight * bscale - # Include button padding when calcing full needed height. - button_row_height = max( - button_row_height, - bheightfull + button.padding_top + button.padding_bottom, - ) - this_row_width += ( - bwidthfull + button.padding_left + button.padding_right - ) - this_row_height = ( - row.padding_top + row.padding_bottom + button_row_height - ) - uiprep.rows.append( - _RowPrep( - width=this_row_width, - height=this_row_height, - titlecalls=[], - hscrollcall=None, - hscrolleditcall=None, - hsubcall=None, - buttons=[], - simple_culling_h=row.simple_culling_h, - decorations=[], - ) - ) - assert this_row_height > 0.0 - assert this_row_width > 0.0 - if row.title is not None: - uiprep.height += row_title_height - if row.subtitle is not None: - uiprep.height += row_subtitle_height - uiprep.height += this_row_height - - # Ok; we've got all row dimensions. Now prep calls to make the - # subcontainers to fit everything and fill out all rows. - uiprep.rootcall = partial( - bui.containerwidget, - size=(uiprep.width, uiprep.height), - claims_left_right=True, - background=False, - ) - y = uiprep.height - top_buffer - - for i, (row, rowprep) in enumerate(zip(ui.rows, uiprep.rows, strict=True)): - tdelaybase = 0.12 * (i + 1) - if row.title is not None: - rowprep.titlecalls.append( - partial( - bui.textwidget, - position=( - ( - ((uiprep.width - left_buffer - right_buffer) * 0.5) - if row.center_title - else (left_buffer + title_inset) - ), - y - row_subtitle_height * 0.5, - ), - size=(0, 0), - text=row.title, - color=( - (0.85, 0.95, 0.89, 1.0) - if row.title_color is None - else row.title_color - ), - flatness=row.title_flatness, - shadow=row.title_shadow, - scale=1.0, - maxwidth=( - (uiprep.width - left_buffer - right_buffer) - if row.center_title - else ( - uiprep.width - - left_buffer - - right_buffer - - title_inset - ) - ), - h_align='center' if row.center_title else 'left', - v_align='center', - literal=True, - transition_delay=None if immediate else (tdelaybase + 0.1), - ) - ) - y -= row_title_height - if row.subtitle is not None: - rowprep.titlecalls.append( - partial( - bui.textwidget, - position=( - ( - ((uiprep.width - left_buffer - right_buffer) * 0.5) - if row.center_title - else (left_buffer + title_inset) - ), - y - row_subtitle_height * 0.5, - ), - size=(0, 0), - text=row.subtitle, - color=( - (0.6, 0.74, 0.6) - if row.subtitle_color is None - else row.subtitle_color - ), - flatness=row.subtitle_flatness, - shadow=row.subtitle_shadow, - scale=0.7, - maxwidth=( - (uiprep.width - left_buffer - right_buffer) - if row.center_title - else ( - uiprep.width - - left_buffer - - right_buffer - - title_inset - ) - ), - h_align='center' if row.center_title else 'left', - v_align='center', - literal=True, - transition_delay=None if immediate else (tdelaybase + 0.2), - ) - ) - y -= row_subtitle_height - - y -= rowprep.height # includes padding-top/bottom - - if row.debug: - rowheightfull = rowprep.height - if row.title is not None: - rowheightfull += row_title_height - if row.subtitle is not None: - rowheightfull += row_subtitle_height - _prep_row_debug( - ( - uiprep.width - left_buffer - right_buffer, - rowheightfull, - ), - (left_buffer, y), - None if immediate else tdelaybase, - rowprep.decorations, - ) - - rowprep.hscrollcall = partial( - bui.hscrollwidget, - size=(uiprep.width - hscrollinset, rowprep.height), - position=(hscrollinset, y), - claims_left_right=True, - highlight=False, - border_opacity=0.0, - center_small_content=row.center_content, - simple_culling_h=row.simple_culling_h, - ) - rowprep.hsubcall = partial( - bui.containerwidget, - size=( - # Ideally we could just always use row-width, but - # currently that gets us right-aligned stuff when - # center-small-content is off. - ( - rowprep.width - if row.center_content - else max(uiprep.width - hscrollinset - fudge, rowprep.width) - ), - rowprep.height, - ), - background=False, - ) - x = left_buffer + row.padding_left - # Calc height of buttons themselves (includes button padding but - # not row padding). - button_row_height = ( - rowprep.height - row.padding_top - row.padding_bottom - ) - bcount = len(row.buttons) - for j, button in enumerate(row.buttons): - # Calc amt 1 -> 0 across the row. - tdelayamt = 1.0 - (j / max(1, bcount - 1)) - # Rightmost buttons slide in first. - tdelay = tdelaybase + tdelayamt * (0.03 * bcount) - - xorig = x - x += button.padding_left - bscale = button.scale - if button.size is None: - bwidth = default_button_width - bheight = default_button_height - else: - bwidth = button.size[0] - bheight = button.size[1] - bwidthfull = bscale * bwidth - bheightfull = bscale * bheight - # Vertically center the button plus its padding. - to_button_plus_padding_bottom = ( - button_row_height - - (bheightfull + button.padding_top + button.padding_bottom) - ) * 0.5 - # Move up past bottom padding to get button bottom. - to_button_bottom = ( - to_button_plus_padding_bottom + button.padding_bottom - ) - - center_x = x + bwidthfull * 0.5 - center_y = row.padding_bottom + to_button_bottom + bheightfull * 0.5 - - buttonprep = _ButtonPrep( - buttoncall=partial( - bui.buttonwidget, - position=(x, row.padding_bottom + to_button_bottom), - size=(bwidth, bheight), - scale=bscale, - color=button.color, - textcolor=button.text_color, - text_flatness=(button.text_flatness), - text_scale=button.text_scale, - button_type='square', - opacity=button.opacity, - label='' if button.label is None else button.label, - text_literal=True, - autoselect=True, - transition_delay=None if immediate else tdelay, - ), - buttoneditcall=partial( - bui.widget, - # TODO: Calc left/right vals properly. - show_buffer_left=150, - show_buffer_right=150, - # We explicitly assign all neighbor selection; - # anything left over should go to toolbars. - auto_select_toolbars_only=True, - ), - decorations=[], - textures={}, - ) - if button.texture is not None: - buttonprep.textures['texture'] = button.texture - - # With row-debug on, visualize the area we try to scroll to - # show when each button is selected. Note that we're clamped - # by the h-scroll here so we have to draw a separate box for - # the row title/subtitle. - if row.debug: - _prep_row_debug_button( - ( - bwidthfull + button.padding_left + button.padding_right, - rowprep.height, - ), - (xorig, 0.0), - None if immediate else tdelay, - buttonprep.decorations, - ) - - if button.debug: - _prep_button_debug( - (bwidthfull, bheightfull), - (center_x, center_y), - None if immediate else tdelay, - buttonprep.decorations, - ) - for decoration in button.decorations: - dectypeid = decoration.get_type_id() - if dectypeid is clui.DecorationTypeID.UNKNOWN: - if bui.do_once(): - bui.uilog.exception( - 'CloudUI receieved unknown decoration;' - ' this is likely a server error.' - ) - elif dectypeid is clui.DecorationTypeID.TEXT: - assert isinstance(decoration, clui.Text) - _prep_text( - decoration, - (center_x, center_y), - bscale, - None if immediate else tdelay, - buttonprep.decorations, - ) - - elif dectypeid is clui.DecorationTypeID.IMAGE: - assert isinstance(decoration, clui.Image) - _prep_image( - decoration, - (center_x, center_y), - bscale, - None if immediate else tdelay, - buttonprep.decorations, - ) - - else: - assert_never(dectypeid) - - rowprep.buttons.append(buttonprep) - - x += bwidthfull + button.padding_right + row.button_spacing - - # Add an edit call for our new hscroll to give it proper - # show-buffers. - - # Incorporate top buffer so we scroll all the way up - # when selecting the top row (and stay clear of - # toolbars). - show_buffer_top = top_buffer - show_buffer_bottom = bot_buffer - - # Scroll so title/subtitle is in view when selecting. - # Note that we don't need to account for - # padding-top/bottom since the h-scroll that we're - # applying to encompasses both. - if row.title is not None: - show_buffer_top += row_title_height - if row.subtitle is not None: - show_buffer_top += row_subtitle_height - - rowprep.hscrolleditcall = partial( - bui.widget, - show_buffer_top=show_buffer_top, - show_buffer_bottom=show_buffer_bottom, - ) - return uiprep - - -def _prep_text( - text: clui.Text, - bcenter: tuple[float, float], - bscale: float, - tdelay: float | None, - decorations: list[_DecorationPrep], -) -> None: - # pylint: disable=too-many-branches - xoffs = bcenter[0] + text.position[0] * bscale - yoffs = bcenter[1] + text.position[1] * bscale - - if text.h_align is clui.HAlign.LEFT: - h_align = 'left' - elif text.h_align is clui.HAlign.CENTER: - h_align = 'center' - elif text.h_align is clui.HAlign.RIGHT: - h_align = 'right' - else: - assert_never(text.h_align) - - if text.v_align is clui.VAlign.TOP: - v_align = 'top' - elif text.v_align is clui.VAlign.CENTER: - v_align = 'center' - elif text.v_align is clui.VAlign.BOTTOM: - v_align = 'bottom' - else: - assert_never(text.v_align) - - decorations.append( - _DecorationPrep( - call=partial( - bui.textwidget, - position=(xoffs, yoffs), - scale=text.scale * bscale, - maxwidth=text.max_width * bscale, - max_height=text.max_height * bscale, - flatness=text.flatness, - shadow=text.shadow, - h_align=h_align, - v_align=v_align, - size=(0, 0), - color=(0.5, 0.5, 0.5, 1.0), - text=text.text, - transition_delay=tdelay, - ), - textures={}, - meshes={}, - ) - ) - # Draw square around max width/height in debug mode. - if text.debug: - mwfull = bscale * text.max_width - mhfull = bscale * text.max_height - - if text.h_align is clui.HAlign.LEFT: - mwxoffs = xoffs - elif text.h_align is clui.HAlign.CENTER: - mwxoffs = xoffs - mwfull * 0.5 - elif text.h_align is clui.HAlign.RIGHT: - mwxoffs = xoffs - mwfull - else: - assert_never(text.h_align) - - if text.v_align is clui.VAlign.TOP: - mwyoffs = yoffs - mhfull - elif text.v_align is clui.VAlign.CENTER: - mwyoffs = yoffs - mhfull * 0.5 - elif text.v_align is clui.VAlign.BOTTOM: - mwyoffs = yoffs - else: - assert_never(text.v_align) - - decorations.append( - _DecorationPrep( - call=partial( - bui.imagewidget, - position=(mwxoffs, mwyoffs), - size=(mwfull, mhfull), - color=(1, 0, 0), - opacity=0.2, - transition_delay=tdelay, - ), - textures={'texture': 'white'}, - meshes={}, - ) - ) - - -def _prep_image( - image: clui.Image, - bcenter: tuple[float, float], - bscale: float, - tdelay: float | None, - decorations: list[_DecorationPrep], -) -> None: - xoffs = bcenter[0] + image.position[0] * bscale - yoffs = bcenter[1] + image.position[1] * bscale - - widthfull = bscale * image.size[0] - heightfull = bscale * image.size[1] - - if image.h_align is clui.HAlign.LEFT: - xoffsfin = xoffs - elif image.h_align is clui.HAlign.CENTER: - xoffsfin = xoffs - widthfull * 0.5 - elif image.h_align is clui.HAlign.RIGHT: - xoffsfin = xoffs - widthfull - else: - assert_never(image.h_align) - - if image.v_align is clui.VAlign.TOP: - yoffsfin = yoffs - heightfull - elif image.v_align is clui.VAlign.CENTER: - yoffsfin = yoffs - heightfull * 0.5 - elif image.v_align is clui.VAlign.BOTTOM: - yoffsfin = yoffs - else: - assert_never(image.v_align) - - textures: dict[str, str] = {'texture': image.texture} - if image.tint_texture is not None: - textures['tint_texture'] = image.tint_texture - if image.mask_texture is not None: - textures['mask_texture'] = image.mask_texture - - meshes: dict[str, str] = {} - if image.mesh_opaque is not None: - meshes['mesh_opaque'] = image.mesh_opaque - if image.mesh_transparent is not None: - meshes['mesh_transparent'] = image.mesh_transparent - - decorations.append( - _DecorationPrep( - call=partial( - bui.imagewidget, - position=(xoffsfin, yoffsfin), - size=(widthfull, heightfull), - color=image.color, - opacity=image.opacity, - tint_color=image.tint_color, - tint2_color=image.tint2_color, - transition_delay=tdelay, - ), - textures=textures, - meshes=meshes, - ) - ) - - -def _prep_row_debug( - size: tuple[float, float], - pos: tuple[float, float], - tdelay: float | None, - decorations: list[_DecorationPrep], -) -> None: - - textures: dict[str, str] = {'texture': 'white'} - - # Shrink the square we draw a tiny bit so rows butted up to - # eachother can be seen. - border_shrink = 1.0 - - decorations.append( - _DecorationPrep( - call=partial( - bui.imagewidget, - position=(pos[0], pos[1] + border_shrink), - size=(size[0], size[1] - 2.0 * border_shrink), - color=(1.0, 0.0, 1), - opacity=0.1, - transition_delay=tdelay, - ), - textures=textures, - meshes={}, - ) - ) - - -def _prep_row_debug_button( - bsize: tuple[float, float], - bcorner: tuple[float, float], - tdelay: float | None, - decorations: list[_DecorationPrep], -) -> None: - xoffs = bcorner[0] - yoffs = bcorner[1] - - textures: dict[str, str] = {'texture': 'white'} - - decorations.append( - _DecorationPrep( - call=partial( - bui.imagewidget, - position=(xoffs, yoffs), - size=bsize, - color=(0.0, 0.0, 1), - opacity=0.15, - transition_delay=tdelay, - ), - textures=textures, - meshes={}, - ) - ) - - -def _prep_button_debug( - bsize: tuple[float, float], - bcenter: tuple[float, float], - tdelay: float | None, - decorations: list[_DecorationPrep], -) -> None: - xoffs = bcenter[0] - bsize[0] * 0.5 - yoffs = bcenter[1] - bsize[1] * 0.5 - - textures: dict[str, str] = {'texture': 'white'} - - decorations.append( - _DecorationPrep( - call=partial( - bui.imagewidget, - position=(xoffs, yoffs), - size=bsize, - color=(0, 1, 0), - opacity=0.1, - transition_delay=tdelay, - ), - textures=textures, - meshes={}, - ) - ) - - -def _instantiate_prepped_page( - pageprep: _PagePrep, - scrollwidget: bui.Widget, - backbutton: bui.Widget, - windowbackbutton: bui.Widget | None, -) -> bui.Widget: - # pylint: disable=too-many-locals - # pylint: disable=too-many-branches - outrows: list[tuple[bui.Widget, list[bui.Widget]]] = [] - - # Now go through and run our prepped ui calls to build our - # widgets, plugging in appropriate parent widgets args and - # whatnot as we go. - assert pageprep.rootcall is not None - subcontainer = pageprep.rootcall(parent=scrollwidget) - for rowprep in pageprep.rows: - for uicall in rowprep.titlecalls: - uicall(parent=subcontainer) - assert rowprep.hscrollcall is not None - hscroll = rowprep.hscrollcall(parent=subcontainer) - for decoration in rowprep.decorations: - kwds: dict = {'parent': subcontainer} - for texarg, texname in decoration.textures.items(): - kwds[texarg] = bui.gettexture(texname) - for mesharg, meshname in decoration.meshes.items(): - kwds[mesharg] = bui.getmesh(meshname) - decoration.call(**kwds) - outrow: tuple[bui.Widget, list[bui.Widget]] = (hscroll, []) - assert rowprep.hsubcall is not None - hsub = rowprep.hsubcall(parent=hscroll) - for i, buttonprep in enumerate(rowprep.buttons): - kwds = {'parent': hsub} - for texarg, texname in buttonprep.textures.items(): - kwds[texarg] = bui.gettexture(texname) - btn = buttonprep.buttoncall(**kwds) - assert buttonprep.buttoneditcall is not None - buttonprep.buttoneditcall(edit=btn) - for decoration in buttonprep.decorations: - kwds = {'parent': hsub, 'draw_controller': btn} - for texarg, texname in decoration.textures.items(): - kwds[texarg] = bui.gettexture(texname) - for mesharg, meshname in decoration.meshes.items(): - kwds[mesharg] = bui.getmesh(meshname) - decoration.call(**kwds) - - # Make sure row is scrolled so leftmost button is - # visible (though kinda seems like this should happen by - # default). - if i == 0: - bui.containerwidget(edit=hsub, visible_child=btn) - outrow[1].append(btn) - - outrows.append(outrow) - assert rowprep.hscrolleditcall is not None - rowprep.hscrolleditcall(edit=hscroll) - - # Ok; we've got all widgets. Now wire up directional nav between - # rows/buttons. - for i in range(0, len(outrows) - 1): - topscroll, topbuttons = outrows[i] - botscroll, botbuttons = outrows[i + 1] - for topbutton in topbuttons: - bui.widget(edit=topbutton, down_widget=botscroll) - if i == 0 and windowbackbutton is not None: - bui.widget(edit=topbutton, up_widget=windowbackbutton) - for botbutton in botbuttons: - bui.widget(edit=botbutton, up_widget=topscroll) - bui.widget(edit=topbuttons[0], left_widget=backbutton) - bui.widget(edit=botbuttons[0], left_widget=backbutton) - for _scroll, buttons in outrows: - for i in range(0, len(buttons) - 1): - leftbutton = buttons[i] - rightbutton = buttons[i + 1] - bui.widget(edit=leftbutton, right_widget=rightbutton) - bui.widget(edit=rightbutton, left_widget=leftbutton) - - return subcontainer - - -class CloudUIWindow(bui.MainWindow): - """UI provided by the cloud.""" - - @dataclass - class State: - """Final state window can be set to show.""" - - page: clui.Page | None - - def __init__( - self, - state: State | None, - *, - transition: str | None = 'in_right', - origin_widget: bui.Widget | None = None, - auxiliary_style: bool = True, - ): - ui = bui.app.ui_v1 - - self._state: CloudUIWindow.State | None = None - - # We want to display differently whether we're an auxiliary - # window or not, but unfortunately that value is not yet - # available until we're added to the main-window-stack so it - # must be explicitly passed in. - self._auxiliary_style = auxiliary_style - - # Calc scale and size for our backing window. For medium & large - # ui-scale we aim for a window small enough to always be fully - # visible on-screen and for small mode we aim for a window big - # enough that we never see the window edges; only the window - # texture covering the whole screen. - uiscale = ui.uiscale - self._width = ( - 1400 - if uiscale is bui.UIScale.SMALL - else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 - ) - self._height = ( - 1200 - if uiscale is bui.UIScale.SMALL - else 700 if uiscale is bui.UIScale.MEDIUM else 800 - ) - self._root_scale = ( - 1.5 - if uiscale is bui.UIScale.SMALL - else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 - ) - - # Do some fancy math to calculate our visible area; this will be - # limited by the screen size in small mode and our backing size - # otherwise. - screensize = bui.get_virtual_screen_size() - self._vis_width = min( - self._width - 150, screensize[0] / self._root_scale - ) - self._vis_height = min( - self._height - 80, screensize[1] / self._root_scale - ) - self._vis_top = 0.5 * self._height + 0.5 * self._vis_height - self._vis_left = 0.5 * self._width - 0.5 * self._vis_width - - self._scroll_width = self._vis_width - self._scroll_left = self._vis_left + 0.5 * ( - self._vis_width - self._scroll_width - ) - # Go with full-screen scrollable aread in small ui. - self._scroll_height = self._vis_height - ( - -1 if uiscale is bui.UIScale.SMALL else 43 - ) - self._scroll_bottom = ( - self._vis_top - - (-1 if uiscale is bui.UIScale.SMALL else 32) - - self._scroll_height - ) - - # Nudge our vis area up a bit when we can see the full backing - # (visual fudge factor). - if uiscale is not bui.UIScale.SMALL: - self._vis_top += 12.0 - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - toolbar_visibility='menu_full', - toolbar_cancel_button_style=( - 'close' if auxiliary_style else 'back' - ), - scale=self._root_scale, - ), - transition=transition, - origin_widget=origin_widget, - # We respond to screen size changes only at small ui-scale; - # in other cases we assume our window remains fully visible - # always (flip to windowed mode and resize the app window to - # confirm this). - refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, - ) - # Avoid complaints if nothing is selected under us. - bui.widget(edit=self._root_widget, allow_preserve_selection=False) - - self._subcontainer: bui.Widget | None = None - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - size=(self._scroll_width, self._scroll_height), - position=(self._scroll_left, self._scroll_bottom), - border_opacity=0.4, - center_small_content_horizontally=True, - claims_left_right=True, - ) - # Avoid having to deal with selecting this while its empty. - bui.containerwidget(edit=self._scrollwidget, selectable=False) - - # With full-screen scrolling, fade content as it approaches - # toolbars. - if uiscale is bui.UIScale.SMALL and bool(True): - scroll_fade_top( - self._root_widget, - self._width * 0.5 - self._scroll_width * 0.5, - self._scroll_bottom, - self._scroll_width, - self._scroll_height, - ) - scroll_fade_bottom( - self._root_widget, - self._width * 0.5 - self._scroll_width * 0.5, - self._scroll_bottom, - self._scroll_width, - self._scroll_height, - ) - - # Title. - self._title = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._vis_top - 20), - size=(0, 0), - text='', - color=ui.title_color, - scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, - # Make sure we avoid overlapping meters in small mode. - maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), - h_align='center', - v_align='center', - ) - # Needed to display properly over scrolled content. - bui.widget(edit=self._title, depth_range=(0.9, 1.0)) - - # For small UI-scale we use the system back/close button; - # otherwise we make our own. - if uiscale is bui.UIScale.SMALL: - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) - self._back_button: bui.Widget | None = None - else: - self._back_button = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|close', - scale=0.8, - position=(self._vis_left + 2, self._vis_top - 35), - size=(50, 50) if auxiliary_style else (60, 55), - extra_touch_border_scale=2.0, - button_type=None if auxiliary_style else 'backSmall', - on_activate_call=self.main_window_back, - autoselect=True, - label=bui.charstr( - bui.SpecialChar.CLOSE - if auxiliary_style - else bui.SpecialChar.BACK - ), - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._back_button - ) - - # Show our vis-area bounds (for debugging). - if bool(False): - # Skip top-left since its always overlapping back/close - # buttons. - if bool(False): - bui.textwidget( - parent=self._root_widget, - position=(self._vis_left, self._vis_top), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='TL', - h_align='left', - v_align='top', - ) - bui.textwidget( - parent=self._root_widget, - position=(self._vis_left + self._vis_width, self._vis_top), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='TR', - h_align='right', - v_align='top', - ) - bui.textwidget( - parent=self._root_widget, - position=(self._vis_left, self._vis_top - self._vis_height), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='BL', - h_align='left', - v_align='bottom', - ) - bui.textwidget( - parent=self._root_widget, - position=( - self._vis_left + self._vis_width, - self._vis_top - self._vis_height, - ), - size=(0, 0), - scale=0.5, - color=(1, 1, 1, 0.5), - text='BR', - h_align='right', - v_align='bottom', - ) - - self._spinner: bui.Widget | None = bui.spinnerwidget( - parent=self._root_widget, - position=( - self._vis_left + self._vis_width * 0.5, - self._vis_top - self._vis_height * 0.5, - ), - size=48, - style='bomb', - ) - - if state is not None: - self._set_state(state, immediate=True) - else: - if random.random() < 0.0: - bui.apptimer(0.1, bui.WeakCallStrict(self._on_error_response)) - else: - bui.apptimer(0.1, bui.WeakCallStrict(self._on_response)) - - def _on_error_response(self) -> None: - self._set_state(self.State(None)) - - def _on_response(self) -> None: - page = clui.Page( - title='Testing', - rows=[ - clui.Row( - title='First Row', - debug=True, - padding_left=5.0, - buttons=[ - clui.Button( - label='Test', - size=(180, 200), - decorations=[ - clui.Image( - 'powerupPunch', - position=(-70, 0), - size=(40, 40), - h_align=clui.HAlign.LEFT, - ), - clui.Image( - 'powerupSpeed', - position=(0, 75), - size=(35, 35), - v_align=clui.VAlign.TOP, - ), - clui.Text( - 'TL', - position=(-70, 75), - max_width=50, - max_height=50, - h_align=clui.HAlign.LEFT, - v_align=clui.VAlign.TOP, - debug=True, - ), - clui.Text( - 'TR', - position=(70, 75), - max_width=50, - max_height=50, - h_align=clui.HAlign.RIGHT, - v_align=clui.VAlign.TOP, - debug=True, - ), - clui.Text( - 'BL', - position=(-70, -75), - max_width=50, - max_height=50, - h_align=clui.HAlign.LEFT, - v_align=clui.VAlign.BOTTOM, - debug=True, - ), - clui.Text( - 'BR', - position=(70, -75), - max_width=50, - max_height=50, - h_align=clui.HAlign.RIGHT, - v_align=clui.VAlign.BOTTOM, - debug=True, - ), - ], - ), - clui.Button( - label='Test2', - size=(100, 100), - color=(1, 0, 0), - text_color=(1, 1, 1, 1), - padding_right=4, - ), - # Should look like the first button but - # scaled down. - clui.Button( - label='Test', - size=(180, 200), - scale=0.6, - padding_bottom=30, # Should nudge us up. - debug=True, # Show bounds. - decorations=[ - clui.Image( - 'powerupPunch', - position=(-70, 0), - size=(40, 40), - h_align=clui.HAlign.LEFT, - ), - clui.Image( - 'powerupSpeed', - position=(0, 75), - size=(35, 35), - v_align=clui.VAlign.TOP, - ), - clui.Text( - 'TL', - position=(-70, 75), - max_width=50, - max_height=50, - h_align=clui.HAlign.LEFT, - v_align=clui.VAlign.TOP, - debug=True, - ), - clui.Text( - 'TR', - position=(70, 75), - max_width=50, - max_height=50, - h_align=clui.HAlign.RIGHT, - v_align=clui.VAlign.TOP, - debug=True, - ), - clui.Text( - 'BL', - position=(-70, -75), - max_width=50, - max_height=50, - h_align=clui.HAlign.LEFT, - v_align=clui.VAlign.BOTTOM, - debug=True, - ), - clui.Text( - 'BR', - position=(70, -75), - max_width=50, - max_height=50, - h_align=clui.HAlign.RIGHT, - v_align=clui.VAlign.BOTTOM, - debug=True, - ), - ], - ), - # Testing custom button images and opacity. - clui.Button( - label='Test3', - texture='buttonSquareWide', - padding_left=10.0, - padding_right=10.0, - color=(1, 1, 1), - opacity=0.3, - size=(200, 100), - ), - ], - ), - clui.Row( - title='Second Row', - subtitle='Second row subtitle.', - buttons=[ - clui.Button( - size=(150, 100), - decorations=[ - clui.Text( - 'MaxWidthTest', - position=(0, 25), - max_width=150 * 0.8, - flatness=1.0, - shadow=0.0, - debug=True, - ), - clui.Text( - 'MaxHeightTest\nSecondLine', - position=(0, -20), - max_width=150 * 0.8, - max_height=40, - flatness=1.0, - shadow=0.0, - debug=True, - ), - ], - ), - clui.Button( - size=(150, 100), - decorations=[ - clui.Image( - 'zoeIcon', - position=(0, 0), - size=(70, 70), - tint_texture='zoeIconColorMask', - tint_color=(1, 0, 0), - tint2_color=(0, 1, 0), - mask_texture='characterIconMask', - ), - ], - ), - clui.Button( - size=(150, 100), - decorations=[ - clui.Image( - 'bridgitPreview', - position=(0, 10), - size=(120, 60), - mask_texture='mapPreviewMask', - mesh_opaque='level_select_button_opaque', - mesh_transparent=( - 'level_select_button_transparent' - ), - ), - ], - ), - clui.Button(size=(150, 100)), - clui.Button(size=(150, 100)), - clui.Button(size=(150, 100)), - ], - ), - clui.Row( - buttons=[ - clui.Button( - size=(100, 100), - color=(0.8, 0.8, 0.8), - ), - clui.Button( - size=(100, 100), - color=(0.8, 0.8, 0.8), - ), - ], - ), - clui.Row( - title='Last Row (Faded Title)', - title_color=(0.6, 0.6, 1.0, 0.3), - title_flatness=1.0, - title_shadow=1.0, - subtitle='Testing Centered Title/Content', - subtitle_color=(1.0, 0.5, 1.0, 0.5), - subtitle_flatness=1.0, - subtitle_shadow=0.0, - center_content=True, - center_title=True, - buttons=[ - clui.Button( - 'Hello There!', - size=(200, 120), - color=(0.7, 0.7, 0.9), - ), - ], - ), - ], - ) - self._set_state(self.State(page)) - - def _set_state(self, state: State, immediate: bool = False) -> None: - """Set a final state (error or page contents). - - This state may be instantly restored if the window is recreated - (depending on cache lifespan/etc.) - """ - - assert self._state is None - self._state = state - - ui = bui.app.ui_v1 - uiscale = ui.uiscale - - if self._spinner: - self._spinner.delete() - self._spinner = None - - if state.page is None: - bui.textwidget( - edit=self._title, - literal=False, # Allow Lstr. - text=bui.Lstr(resource='errorText'), - ) - bui.textwidget( - parent=self._root_widget, - position=( - self._vis_left + 0.5 * self._vis_width, - self._vis_top - 0.5 * self._vis_height, - ), - size=(0, 0), - scale=0.6, - text=bui.Lstr(resource='store.loadErrorText'), - h_align='center', - v_align='center', - ) - return - - # Ok; we've got content. - bui.textwidget( - edit=self._title, - literal=True, # Never interpret as Lstr. - text=state.page.title, - ) - - # Make sure there's at least one row and that all rows contain - # at least one button. Otherwise show a 'nothing here' message. - if not state.page.rows or not all( - row.buttons for row in state.page.rows - ): - bui.uilog.exception( - 'Got invalid cloud-ui state;' - ' must contain at least one row' - ' and all rows must contain buttons.' - ) - bui.textwidget( - parent=self._root_widget, - position=( - self._vis_left + 0.5 * self._vis_width, - self._vis_top - 0.5 * self._vis_height, - ), - size=(0, 0), - scale=0.6, - text=bui.Lstr( - translate=('serverResponses', 'There is nothing here.') - ), - h_align='center', - v_align='center', - ) - return - - pageprep = _prep_page( - state.page, uiscale, self._scroll_width, immediate=immediate - ) - - bui.containerwidget(edit=self._scrollwidget, selectable=True) - bui.scrollwidget( - edit=self._scrollwidget, - simple_culling_v=pageprep.simple_culling_v, - center_small_content=state.page.center_vertically, - ) - - self._subcontainer = _instantiate_prepped_page( - pageprep, - self._scrollwidget, - backbutton=( - bui.get_special_widget('back_button') - if self._back_button is None - else self._back_button - ), - windowbackbutton=self._back_button, - ) - - @override - def get_main_window_state(self) -> bui.MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - - # IMPORTANT - Pull values from self HERE; if we do it in the - # lambda below it'll keep self alive which will lead to - # 'ui-not-getting-cleaned-up' warnings and memory leaks. - auxiliary_style = self._auxiliary_style - state = self._state - - return bui.BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - state=state, - transition=transition, - origin_widget=origin_widget, - auxiliary_style=auxiliary_style, - ), - ) - - @override - def main_window_should_preserve_selection(self) -> bool: - return True - - @override - def get_main_window_shared_state_id(self) -> str | None: - return 'cloudui' diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/__init__.py b/src/assets/ba_data/python/bauiv1lib/cloudui/__init__.py new file mode 100644 index 000000000..c876cb14e --- /dev/null +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/__init__.py @@ -0,0 +1,14 @@ +# Released under the MIT License. See LICENSE for details. +"""Functionality for interacting with cloud-ui from the client.""" + +from bauiv1lib.cloudui._test import show_test_cloud_ui_window +from bauiv1lib.cloudui._controller import CloudUIController +from bauiv1lib.cloudui._window import CloudUIWindow +from bauiv1lib.cloudui._prep import CloudUIPagePrep + +__all__ = [ + 'show_test_cloud_ui_window', + 'CloudUIController', + 'CloudUIWindow', + 'CloudUIPagePrep', +] diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_controller.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_controller.py new file mode 100644 index 000000000..b13437730 --- /dev/null +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_controller.py @@ -0,0 +1,179 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Controller functionality for CloudUI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, assert_never +import weakref + +from bacommon.cloudui import CloudUIResponseTypeID, UnknownCloudUIResponse +import bauiv1 as bui + +from bauiv1lib.cloudui._window import CloudUIWindow + +if TYPE_CHECKING: + from typing import Callable + + from bacommon.cloudui import CloudUIRequest, CloudUIResponse + + +class CloudUIController: + """Manages interactions between CloudUI clients and servers. + + Can include logic to handle all requests locally or can submit them + to be handled by some server or can do some combination thereof. + """ + + def __init__(self) -> None: + pass + + def create_window(self, request: CloudUIRequest) -> CloudUIWindow: + """Create a window for some initial request.""" + assert bui.in_logic_thread() + win = CloudUIWindow(state=None) + + bui.app.threadpool.submit_no_wait( + bui.CallStrict(self._request_in_bg, request, weakref.ref(win)) + ) + return win + + def _error_response(self) -> CloudUIResponse: + """Build a simple error dialog.""" + import bacommon.cloudui.v1 as clui1 + + debug = True + return clui1.Response( + code=clui1.ResponseCode.UNKNOWN_ERROR, + page=clui1.Page( + title=bui.Lstr(resource='errorText').as_json(), + title_is_lstr=True, + center_vertically=True, + rows=[ + clui1.Row( + buttons=[ + clui1.Button( + bui.Lstr(resource='okText').as_json(), + text_is_lstr=True, + style=clui1.Button.Style.MEDIUM, + size=(130, 50), + padding_left=200, + padding_right=200, + padding_top=100, + decorations=[ + clui1.Text( + bui.Lstr( + translate=( + 'serverResponses', + 'An error has occurred;' + ' please try again later.', + ) + ).as_json(), + is_lstr=True, + position=(0, 80), + size=(480, 50), + highlight=False, + debug=debug, + ), + ], + debug=debug, + ), + ], + center_content=True, + debug=debug, + ), + ], + ), + ) + + def _request_in_bg( + self, request: CloudUIRequest, weakwin: weakref.ref[CloudUIWindow] + ) -> None: + """Submit a request to the controller. + + This must be called from the UI thread and results will be + delivered in the UI thread. + + This will always return a response, even on error conditions. + """ + assert not bui.in_logic_thread() + + response: CloudUIResponse | None + + try: + response = self.fulfill_request(request) + except Exception: + bui.uilog.debug('Error fulfilling cloudui request.', exc_info=True) + response = None + + # Validate any response we got. + if response is not None: + responsetype = response.get_type_id() + + if responsetype is CloudUIResponseTypeID.V1: + import bacommon.cloudui.v1 as clui1 + + assert isinstance(response, clui1.Response) + + # Make sure there's at least one row and that all rows + # contain at least one button. + if not response.page.rows or not all( + row.buttons for row in response.page.rows + ): + bui.uilog.exception( + 'Got invalid cloud-ui response;' + ' page must contain at least one row' + ' and all rows must contain buttons.' + ) + response = None + elif responsetype is CloudUIResponseTypeID.UNKNOWN: + assert isinstance(response, UnknownCloudUIResponse) + bui.uilog.debug( + 'Got unsupported cloudui response.', exc_info=True + ) + response = None + else: + # Make sure we cover all types we're aware of. + assert_never(responsetype) + + if response is None: + response = self._error_response() + + # Go ahead and just push the response along with our weakref + # back to the logic thread for handling. We could quick-out here + # if the window is dead, but wrangling its refs here could + # theoretically lead to it being deallocated here which could be + # problematic. + bui.pushcall( + bui.CallStrict( + self._handle_response_in_ui_thread, response, weakwin + ), + from_other_thread=True, + ) + + def _handle_response_in_ui_thread( + self, response: CloudUIResponse, weakwin: weakref.ref[CloudUIWindow] + ) -> None: + import bacommon.cloudui.v1 as clui1 + + assert bui.in_logic_thread() + + # Our target window died since we made the request; no biggie. + win = weakwin() + if win is None: + return + + # Currently should only be sending ourself v1 responses here. + assert isinstance(response, clui1.Response) + + win.set_state(win.State(self, response.page)) + + def fulfill_request(self, request: CloudUIRequest) -> CloudUIResponse: + """Override this to handle request fulfillment. + + Exceptions should be raised for any errors; the base class will + handle converting those to a Response. + + Be aware that this will always be called in a background thread. + """ + raise NotImplementedError() diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_prep.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_prep.py new file mode 100644 index 000000000..a958b80b7 --- /dev/null +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_prep.py @@ -0,0 +1,815 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Prep functionality for our UI. + +We do all layout math and bake out partial ui calls in a background +thread so there's as little work to do in the ui thread as possible. +""" + +from __future__ import annotations + +from functools import partial +from dataclasses import dataclass +from typing import TYPE_CHECKING, assert_never + +import bacommon.cloudui.v1 as clui +import bauiv1 as bui + + +if TYPE_CHECKING: + from typing import Callable + + +@dataclass +class _DecorationPrep: + call: Callable[..., bui.Widget] + textures: dict[str, str] + meshes: dict[str, str] + highlight: bool + + +@dataclass +class _ButtonPrep: + buttoncall: Callable[..., bui.Widget] + buttoneditcall: Callable | None + decorations: list[_DecorationPrep] + textures: dict[str, str] + + +@dataclass +class _RowPrep: + width: float + height: float + titlecalls: list[Callable[..., bui.Widget]] + hscrollcall: Callable[..., bui.Widget] | None + hscrolleditcall: Callable | None + hsubcall: Callable[..., bui.Widget] | None + buttons: list[_ButtonPrep] + simple_culling_h: float + decorations: list[_DecorationPrep] + + +class CloudUIPagePrep: + """Preps a page. + + Generally does its work in a background thread. + """ + + def __init__( + self, + page: clui.Page, + uiscale: bui.UIScale, + scroll_width: float, + idprefix: str, + *, + immediate: bool = False, + ) -> None: + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + + # Ok; we've got some buttons. Build our full UI. + row_title_height = 30.0 + row_subtitle_height = 30.0 + top_buffer = 20.0 + bot_buffer = 20.0 + left_buffer = 0.0 + right_buffer = 10.0 # Nudge a bit due to scrollbar. + title_inset = 35.0 + default_button_width = 200.0 + default_button_height = 200.0 + + if uiscale is bui.UIScale.SMALL: + top_bar_overlap = 70 + bot_bar_overlap = 70 + top_buffer += top_bar_overlap + bot_buffer += bot_bar_overlap + else: + top_bar_overlap = 0 + bot_bar_overlap = 0 + + # Should look into why this is necessary. + fudge = 15.0 + hscrollinset = 15.0 + + self.rootcall: Callable[..., bui.Widget] | None = None + self.rows: list[_RowPrep] = [] + self.width: float = scroll_width + fudge + self.height: float = top_buffer + bot_buffer + self.simple_culling_v: float = page.simple_culling_v + + nextbuttonid = 0 + + # Precalc basic info like dimensions for all rows. + for row in page.rows: + assert row.buttons + this_row_width = ( + left_buffer + + right_buffer + + row.padding_left + + row.padding_right + + row.button_spacing * (len(row.buttons) - 1) + ) + button_row_height = 0.0 + for button in row.buttons: + if button.size is None: + bwidth = default_button_width + bheight = default_button_height + else: + bwidth = button.size[0] + bheight = button.size[1] + bscale = button.scale + bwidthfull = bwidth * bscale + bheightfull = bheight * bscale + # Include button padding when calcing full needed height. + button_row_height = max( + button_row_height, + bheightfull + + (button.padding_top + button.padding_bottom) + * button.scale, + ) + this_row_width += ( + bwidthfull + + (button.padding_left + button.padding_right) + * button.scale + ) + this_row_height = ( + row.padding_top + row.padding_bottom + button_row_height + ) + self.rows.append( + _RowPrep( + width=this_row_width, + height=this_row_height, + titlecalls=[], + hscrollcall=None, + hscrolleditcall=None, + hsubcall=None, + buttons=[], + simple_culling_h=row.simple_culling_h, + decorations=[], + ) + ) + assert this_row_height > 0.0 + assert this_row_width > 0.0 + if row.title is not None: + self.height += row_title_height + if row.subtitle is not None: + self.height += row_subtitle_height + self.height += this_row_height + + # Ok; we've got all row dimensions. Now prep calls to make the + # subcontainers to fit everything and fill out all rows. + self.rootcall = partial( + bui.containerwidget, + size=(self.width, self.height), + claims_left_right=True, + background=False, + ) + y = self.height - top_buffer + + for i, (row, rowprep) in enumerate( + zip(page.rows, self.rows, strict=True) + ): + tdelaybase = 0.12 * (i + 1) + if row.title is not None: + rowprep.titlecalls.append( + partial( + bui.textwidget, + position=( + ( + ( + (self.width - left_buffer - right_buffer) + * 0.5 + ) + if row.center_title + else (left_buffer + title_inset) + ), + y - row_subtitle_height * 0.5, + ), + size=(0, 0), + text=row.title, + color=( + (0.85, 0.95, 0.89, 1.0) + if row.title_color is None + else row.title_color + ), + flatness=row.title_flatness, + shadow=row.title_shadow, + scale=1.0, + maxwidth=( + (self.width - left_buffer - right_buffer) + if row.center_title + else ( + self.width + - left_buffer + - right_buffer + - title_inset + ) + ), + h_align='center' if row.center_title else 'left', + v_align='center', + literal=not row.title_is_lstr, + transition_delay=( + None if immediate else (tdelaybase + 0.1) + ), + ) + ) + y -= row_title_height + if row.subtitle is not None: + rowprep.titlecalls.append( + partial( + bui.textwidget, + position=( + ( + ( + (self.width - left_buffer - right_buffer) + * 0.5 + ) + if row.center_title + else (left_buffer + title_inset) + ), + y - row_subtitle_height * 0.5, + ), + size=(0, 0), + text=row.subtitle, + color=( + (0.6, 0.74, 0.6) + if row.subtitle_color is None + else row.subtitle_color + ), + flatness=row.subtitle_flatness, + shadow=row.subtitle_shadow, + scale=0.7, + maxwidth=( + (self.width - left_buffer - right_buffer) + if row.center_title + else ( + self.width + - left_buffer + - right_buffer + - title_inset + ) + ), + h_align='center' if row.center_title else 'left', + v_align='center', + literal=not row.subtitle_is_lstr, + transition_delay=( + None if immediate else (tdelaybase + 0.2) + ), + ) + ) + y -= row_subtitle_height + + y -= rowprep.height # includes padding-top/bottom + + if row.debug: + rowheightfull = rowprep.height + if row.title is not None: + rowheightfull += row_title_height + if row.subtitle is not None: + rowheightfull += row_subtitle_height + _prep_row_debug( + ( + self.width - left_buffer - right_buffer, + rowheightfull, + ), + (left_buffer, y), + None if immediate else tdelaybase, + rowprep.decorations, + ) + + rowprep.hscrollcall = partial( + bui.hscrollwidget, + size=(self.width - hscrollinset, rowprep.height), + position=(hscrollinset, y), + claims_left_right=True, + highlight=False, + border_opacity=0.0, + center_small_content=row.center_content, + simple_culling_h=row.simple_culling_h, + ) + rowprep.hsubcall = partial( + bui.containerwidget, + size=( + # Ideally we could just always use row-width, but + # currently that gets us right-aligned stuff when + # center-small-content is off. + ( + rowprep.width + if row.center_content + else max( + self.width - hscrollinset - fudge, rowprep.width + ) + ), + rowprep.height, + ), + background=False, + ) + x = left_buffer + row.padding_left + # Calc height of buttons themselves (includes button padding but + # not row padding). + button_row_height = ( + rowprep.height - row.padding_top - row.padding_bottom + ) + bcount = len(row.buttons) + for j, button in enumerate(row.buttons): + # Calc amt 1 -> 0 across the row. + tdelayamt = 1.0 - (j / max(1, bcount - 1)) + # Rightmost buttons slide in first. + tdelay = tdelaybase + tdelayamt * (0.03 * bcount) + + xorig = x + x += button.padding_left * button.scale + bscale = button.scale + if button.size is None: + bwidth = default_button_width + bheight = default_button_height + else: + bwidth = button.size[0] + bheight = button.size[1] + bwidthfull = bscale * bwidth + bheightfull = bscale * bheight + # Vertically center the button plus its padding. + to_button_plus_padding_bottom = ( + button_row_height + - ( + bheightfull + + (button.padding_top + button.padding_bottom) + * button.scale + ) + ) * 0.5 + # Move up past bottom padding to get button bottom. + to_button_bottom = ( + to_button_plus_padding_bottom + + button.padding_bottom * button.scale + ) + + center_x = x + bwidthfull * 0.5 + center_y = ( + row.padding_bottom + to_button_bottom + bheightfull * 0.5 + ) + + bstyle: str + if button.style is clui.Button.Style.SQUARE: + bstyle = 'square' + elif button.style is clui.Button.Style.TAB: + bstyle = 'tab' + elif button.style is clui.Button.Style.SMALL: + bstyle = 'small' + elif button.style is clui.Button.Style.MEDIUM: + bstyle = 'medium' + elif button.style is clui.Button.Style.LARGE: + bstyle = 'large' + elif button.style is clui.Button.Style.LARGER: + bstyle = 'larger' + else: + assert_never(button.style) + + buttonprep = _ButtonPrep( + buttoncall=partial( + bui.buttonwidget, + id=f'{idprefix}|button{nextbuttonid}', + position=(x, row.padding_bottom + to_button_bottom), + size=(bwidth, bheight), + scale=bscale, + color=button.color, + textcolor=button.text_color, + text_flatness=(button.text_flatness), + text_scale=button.text_scale, + button_type=bstyle, + opacity=button.opacity, + label='' if button.label is None else button.label, + text_literal=not button.text_is_lstr, + autoselect=True, + transition_delay=None if immediate else tdelay, + ), + buttoneditcall=partial( + bui.widget, + # TODO: Calc left/right vals properly based on + # our size and padding. + show_buffer_left=150, + show_buffer_right=150, + # We explicitly assign all neighbor selection; + # anything left over should go to toolbars. + auto_select_toolbars_only=True, + ), + decorations=[], + textures={}, + ) + nextbuttonid += 1 + if button.texture is not None: + buttonprep.textures['texture'] = button.texture + + # With row-debug on, visualize the area we try to scroll to + # show when each button is selected. Note that we're clamped + # by the h-scroll here so we have to draw a separate box for + # the row title/subtitle. + if row.debug: + _prep_row_debug_button( + ( + bwidthfull + + (button.padding_left + button.padding_right) + * button.scale, + rowprep.height, + ), + (xorig, 0.0), + None if immediate else tdelay, + buttonprep.decorations, + ) + + if button.debug: + _prep_button_debug( + (bwidthfull, bheightfull), + (center_x, center_y), + None if immediate else tdelay, + buttonprep.decorations, + ) + for decoration in button.decorations: + dectypeid = decoration.get_type_id() + if dectypeid is clui.DecorationTypeID.UNKNOWN: + if bui.do_once(): + bui.uilog.exception( + 'CloudUI receieved unknown decoration;' + ' this is likely a server error.' + ) + elif dectypeid is clui.DecorationTypeID.TEXT: + assert isinstance(decoration, clui.Text) + _prep_text( + decoration, + (center_x, center_y), + bscale, + None if immediate else tdelay, + buttonprep.decorations, + ) + + elif dectypeid is clui.DecorationTypeID.IMAGE: + assert isinstance(decoration, clui.Image) + _prep_image( + decoration, + (center_x, center_y), + bscale, + None if immediate else tdelay, + buttonprep.decorations, + ) + + else: + assert_never(dectypeid) + + rowprep.buttons.append(buttonprep) + + x += ( + bwidthfull + + (button.padding_right * button.scale) + + row.button_spacing + ) + + # Add an edit call for our new hscroll to give it proper + # show-buffers. + + # Incorporate top buffer so we scroll all the way up + # when selecting the top row (and stay clear of + # toolbars). + show_buffer_top = top_buffer + show_buffer_bottom = bot_buffer + + # Scroll so title/subtitle is in view when selecting. + # Note that we don't need to account for + # padding-top/bottom since the h-scroll that we're + # applying to encompasses both. + if row.title is not None: + show_buffer_top += row_title_height + if row.subtitle is not None: + show_buffer_top += row_subtitle_height + + rowprep.hscrolleditcall = partial( + bui.widget, + show_buffer_top=show_buffer_top, + show_buffer_bottom=show_buffer_bottom, + ) + + def instantiate( + self, + scrollwidget: bui.Widget, + backbutton: bui.Widget, + windowbackbutton: bui.Widget | None, + ) -> bui.Widget: + """Create a UI using prepped data.""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + outrows: list[tuple[bui.Widget, list[bui.Widget]]] = [] + + # Clear any existin children. + for child in scrollwidget.get_children(): + child.delete() + + # Now go through and run our prepped ui calls to build our + # widgets, plugging in appropriate parent widgets args and + # whatnot as we go. + assert self.rootcall is not None + subcontainer = self.rootcall(parent=scrollwidget) + for rowprep in self.rows: + for uicall in rowprep.titlecalls: + uicall(parent=subcontainer) + assert rowprep.hscrollcall is not None + hscroll = rowprep.hscrollcall(parent=subcontainer) + for decoration in rowprep.decorations: + kwds: dict = {'parent': subcontainer} + for texarg, texname in decoration.textures.items(): + kwds[texarg] = bui.gettexture(texname) + for mesharg, meshname in decoration.meshes.items(): + kwds[mesharg] = bui.getmesh(meshname) + decoration.call(**kwds) + outrow: tuple[bui.Widget, list[bui.Widget]] = (hscroll, []) + assert rowprep.hsubcall is not None + hsub = rowprep.hsubcall(parent=hscroll) + for i, buttonprep in enumerate(rowprep.buttons): + kwds = {'parent': hsub} + for texarg, texname in buttonprep.textures.items(): + kwds[texarg] = bui.gettexture(texname) + btn = buttonprep.buttoncall(**kwds) + assert buttonprep.buttoneditcall is not None + buttonprep.buttoneditcall(edit=btn) + for decoration in buttonprep.decorations: + kwds = {'parent': hsub} + if decoration.highlight: + kwds['draw_controller'] = btn + for texarg, texname in decoration.textures.items(): + kwds[texarg] = bui.gettexture(texname) + for mesharg, meshname in decoration.meshes.items(): + kwds[mesharg] = bui.getmesh(meshname) + decoration.call(**kwds) + + # Make sure row is scrolled so leftmost button is + # visible (though kinda seems like this should happen by + # default). + if i == 0: + bui.containerwidget(edit=hsub, visible_child=btn) + outrow[1].append(btn) + + outrows.append(outrow) + assert rowprep.hscrolleditcall is not None + rowprep.hscrolleditcall(edit=hscroll) + + # Ok; we've got all widgets. Now wire up directional nav between + # rows/buttons. + if windowbackbutton is not None and outrows: + _scroll, buttons = outrows[0] + for button in buttons: + bui.widget(edit=button, up_widget=windowbackbutton) + for i in range(0, len(outrows) - 1): + topscroll, topbuttons = outrows[i] + botscroll, botbuttons = outrows[i + 1] + for topbutton in topbuttons: + bui.widget(edit=topbutton, down_widget=botscroll) + for botbutton in botbuttons: + bui.widget(edit=botbutton, up_widget=topscroll) + bui.widget(edit=topbuttons[0], left_widget=backbutton) + bui.widget(edit=botbuttons[0], left_widget=backbutton) + for _scroll, buttons in outrows: + for i in range(0, len(buttons) - 1): + leftbutton = buttons[i] + rightbutton = buttons[i + 1] + bui.widget(edit=leftbutton, right_widget=rightbutton) + bui.widget(edit=rightbutton, left_widget=leftbutton) + + return subcontainer + + +def _prep_text( + text: clui.Text, + bcenter: tuple[float, float], + bscale: float, + tdelay: float | None, + decorations: list[_DecorationPrep], +) -> None: + # pylint: disable=too-many-branches + xoffs = bcenter[0] + text.position[0] * bscale + yoffs = bcenter[1] + text.position[1] * bscale + + if text.h_align is clui.HAlign.LEFT: + h_align = 'left' + elif text.h_align is clui.HAlign.CENTER: + h_align = 'center' + elif text.h_align is clui.HAlign.RIGHT: + h_align = 'right' + else: + assert_never(text.h_align) + + if text.v_align is clui.VAlign.TOP: + v_align = 'top' + elif text.v_align is clui.VAlign.CENTER: + v_align = 'center' + elif text.v_align is clui.VAlign.BOTTOM: + v_align = 'bottom' + else: + assert_never(text.v_align) + + decorations.append( + _DecorationPrep( + call=partial( + bui.textwidget, + position=(xoffs, yoffs), + scale=text.scale * bscale, + maxwidth=text.size[0] * bscale, + max_height=text.size[1] * bscale, + flatness=text.flatness, + shadow=text.shadow, + h_align=h_align, + v_align=v_align, + size=(0, 0), + color=(0.5, 0.5, 0.5, 1.0), + text=text.text, + literal=not text.is_lstr, + transition_delay=tdelay, + ), + textures={}, + meshes={}, + highlight=text.highlight, + ) + ) + # Draw square around max width/height in debug mode. + if text.debug: + mwfull = bscale * text.size[0] + mhfull = bscale * text.size[1] + + if text.h_align is clui.HAlign.LEFT: + mwxoffs = xoffs + elif text.h_align is clui.HAlign.CENTER: + mwxoffs = xoffs - mwfull * 0.5 + elif text.h_align is clui.HAlign.RIGHT: + mwxoffs = xoffs - mwfull + else: + assert_never(text.h_align) + + if text.v_align is clui.VAlign.TOP: + mwyoffs = yoffs - mhfull + elif text.v_align is clui.VAlign.CENTER: + mwyoffs = yoffs - mhfull * 0.5 + elif text.v_align is clui.VAlign.BOTTOM: + mwyoffs = yoffs + else: + assert_never(text.v_align) + + decorations.append( + _DecorationPrep( + call=partial( + bui.imagewidget, + position=(mwxoffs, mwyoffs), + size=(mwfull, mhfull), + color=(1, 0, 0), + opacity=0.2, + transition_delay=tdelay, + ), + textures={'texture': 'white'}, + meshes={}, + highlight=True, + ) + ) + + +def _prep_image( + image: clui.Image, + bcenter: tuple[float, float], + bscale: float, + tdelay: float | None, + decorations: list[_DecorationPrep], +) -> None: + xoffs = bcenter[0] + image.position[0] * bscale + yoffs = bcenter[1] + image.position[1] * bscale + + widthfull = bscale * image.size[0] + heightfull = bscale * image.size[1] + + if image.h_align is clui.HAlign.LEFT: + xoffsfin = xoffs + elif image.h_align is clui.HAlign.CENTER: + xoffsfin = xoffs - widthfull * 0.5 + elif image.h_align is clui.HAlign.RIGHT: + xoffsfin = xoffs - widthfull + else: + assert_never(image.h_align) + + if image.v_align is clui.VAlign.TOP: + yoffsfin = yoffs - heightfull + elif image.v_align is clui.VAlign.CENTER: + yoffsfin = yoffs - heightfull * 0.5 + elif image.v_align is clui.VAlign.BOTTOM: + yoffsfin = yoffs + else: + assert_never(image.v_align) + + textures: dict[str, str] = {'texture': image.texture} + if image.tint_texture is not None: + textures['tint_texture'] = image.tint_texture + if image.mask_texture is not None: + textures['mask_texture'] = image.mask_texture + + meshes: dict[str, str] = {} + if image.mesh_opaque is not None: + meshes['mesh_opaque'] = image.mesh_opaque + if image.mesh_transparent is not None: + meshes['mesh_transparent'] = image.mesh_transparent + + decorations.append( + _DecorationPrep( + call=partial( + bui.imagewidget, + position=(xoffsfin, yoffsfin), + size=(widthfull, heightfull), + color=image.color, + opacity=image.opacity, + tint_color=image.tint_color, + tint2_color=image.tint2_color, + transition_delay=tdelay, + ), + textures=textures, + meshes=meshes, + highlight=image.highlight, + ) + ) + + +def _prep_row_debug( + size: tuple[float, float], + pos: tuple[float, float], + tdelay: float | None, + decorations: list[_DecorationPrep], +) -> None: + + textures: dict[str, str] = {'texture': 'white'} + + # Shrink the square we draw a tiny bit so rows butted up to + # eachother can be seen. + border_shrink = 1.0 + + decorations.append( + _DecorationPrep( + call=partial( + bui.imagewidget, + position=(pos[0], pos[1] + border_shrink), + size=(size[0], size[1] - 2.0 * border_shrink), + color=(0.0, 1.0, 1.0), + opacity=0.06, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def _prep_row_debug_button( + bsize: tuple[float, float], + bcorner: tuple[float, float], + tdelay: float | None, + decorations: list[_DecorationPrep], +) -> None: + xoffs = bcorner[0] + yoffs = bcorner[1] + + textures: dict[str, str] = {'texture': 'white'} + + decorations.append( + _DecorationPrep( + call=partial( + bui.imagewidget, + position=(xoffs, yoffs), + size=bsize, + color=(0.0, 0.0, 1), + opacity=0.15, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def _prep_button_debug( + bsize: tuple[float, float], + bcenter: tuple[float, float], + tdelay: float | None, + decorations: list[_DecorationPrep], +) -> None: + textures: dict[str, str] = {'texture': 'white'} + + decorations.append( + _DecorationPrep( + call=partial( + bui.imagewidget, + position=( + bcenter[0] - bsize[0] * 0.5, + bcenter[1] - bsize[1] * 0.5, + ), + size=bsize, + color=(0, 1, 0), + opacity=0.1, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py new file mode 100644 index 000000000..1ac9ac719 --- /dev/null +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py @@ -0,0 +1,276 @@ +# Released under the MIT License. See LICENSE for details. +# +"""UIs provided by the cloud (similar-ish to html in concept).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from bacommon.cloudui import CloudUIRequest +import bauiv1 as bui + +from bauiv1lib.cloudui._window import CloudUIWindow +from bauiv1lib.cloudui._controller import CloudUIController + +if TYPE_CHECKING: + from bacommon.cloudui import CloudUIResponse + import bacommon.cloudui.v1 + + +def show_test_cloud_ui_window() -> None: + """Bust out a cloud-ui window.""" + + # Pop up an auxiliary window wherever we are in the nav stack. + bui.app.ui_v1.auxiliary_window_activate( + win_type=CloudUIWindow, + win_create_call=bui.CallStrict( + TestCloudUIController().create_window, CloudUIRequest('/') + ), + ) + + +class TestCloudUIController(CloudUIController): + """Provides various tests/demonstrations of cloudui functionality.""" + + @override + def fulfill_request(self, request: CloudUIRequest) -> CloudUIResponse: + """Fulfill a request. + + Will be called in a background thread. + """ + import bacommon.cloudui.v1 as clui + + return clui.Response( + code=clui.ResponseCode.SUCCESS, + page=get_test_page(), + ) + + +def get_test_page() -> bacommon.cloudui.v1.Page: + """Return test page.""" + import bacommon.cloudui.v1 as clui + + return clui.Page( + title='Testing', + rows=[ + clui.Row( + title='First Row', + debug=True, + padding_left=5.0, + buttons=[ + clui.Button( + label='Test', + size=(180, 200), + decorations=[ + clui.Image( + 'powerupPunch', + position=(-70, 0), + size=(40, 40), + h_align=clui.HAlign.LEFT, + ), + clui.Image( + 'powerupSpeed', + position=(0, 75), + size=(35, 35), + v_align=clui.VAlign.TOP, + ), + clui.Text( + 'TL', + position=(-70, 75), + size=(50, 50), + h_align=clui.HAlign.LEFT, + v_align=clui.VAlign.TOP, + debug=True, + ), + clui.Text( + 'TR', + position=(70, 75), + size=(50, 50), + h_align=clui.HAlign.RIGHT, + v_align=clui.VAlign.TOP, + debug=True, + ), + clui.Text( + 'BL', + position=(-70, -75), + size=(50, 50), + h_align=clui.HAlign.LEFT, + v_align=clui.VAlign.BOTTOM, + debug=True, + ), + clui.Text( + 'BR', + position=(70, -75), + size=(50, 50), + h_align=clui.HAlign.RIGHT, + v_align=clui.VAlign.BOTTOM, + debug=True, + ), + ], + ), + clui.Button( + label='Test2', + size=(100, 100), + color=(1, 0, 0), + text_color=(1, 1, 1, 1), + padding_right=4, + ), + # Should look like the first button but + # scaled down. + clui.Button( + label='Test', + size=(180, 200), + scale=0.6, + padding_bottom=30, # Should nudge us up. + debug=True, # Show bounds. + decorations=[ + clui.Image( + 'powerupPunch', + position=(-70, 0), + size=(40, 40), + h_align=clui.HAlign.LEFT, + ), + clui.Image( + 'powerupSpeed', + position=(0, 75), + size=(35, 35), + v_align=clui.VAlign.TOP, + ), + clui.Text( + 'TL', + position=(-70, 75), + size=(50, 50), + h_align=clui.HAlign.LEFT, + v_align=clui.VAlign.TOP, + debug=True, + ), + clui.Text( + 'TR', + position=(70, 75), + size=(50, 50), + h_align=clui.HAlign.RIGHT, + v_align=clui.VAlign.TOP, + debug=True, + ), + clui.Text( + 'BL', + position=(-70, -75), + size=(50, 50), + h_align=clui.HAlign.LEFT, + v_align=clui.VAlign.BOTTOM, + debug=True, + ), + clui.Text( + 'BR', + position=(70, -75), + size=(50, 50), + h_align=clui.HAlign.RIGHT, + v_align=clui.VAlign.BOTTOM, + debug=True, + ), + ], + ), + # Testing custom button images and opacity. + clui.Button( + label='Test3', + texture='buttonSquareWide', + padding_left=10.0, + padding_right=10.0, + color=(1, 1, 1), + opacity=0.3, + size=(200, 100), + ), + ], + ), + clui.Row( + title='Second Row', + subtitle='Second row subtitle.', + buttons=[ + clui.Button( + size=(150, 100), + decorations=[ + clui.Text( + 'MaxWidthTest', + position=(0, 25), + size=(150 * 0.8, 32.0), + flatness=1.0, + shadow=0.0, + debug=True, + ), + clui.Text( + 'MaxHeightTest\nSecondLine', + position=(0, -20), + size=(150 * 0.8, 40), + flatness=1.0, + shadow=0.0, + debug=True, + ), + ], + ), + clui.Button( + size=(150, 100), + decorations=[ + clui.Image( + 'zoeIcon', + position=(0, 0), + size=(70, 70), + tint_texture='zoeIconColorMask', + tint_color=(1, 0, 0), + tint2_color=(0, 1, 0), + mask_texture='characterIconMask', + ), + ], + ), + clui.Button( + size=(150, 100), + decorations=[ + clui.Image( + 'bridgitPreview', + position=(0, 10), + size=(120, 60), + mask_texture='mapPreviewMask', + mesh_opaque='level_select_button_opaque', + mesh_transparent=( + 'level_select_button_transparent' + ), + ), + ], + ), + clui.Button(size=(150, 100)), + clui.Button(size=(150, 100)), + clui.Button(size=(150, 100)), + ], + ), + clui.Row( + buttons=[ + clui.Button( + size=(100, 100), + color=(0.8, 0.8, 0.8), + ), + clui.Button( + size=(100, 100), + color=(0.8, 0.8, 0.8), + ), + ], + ), + clui.Row( + title='Last Row (Faded Title)', + title_color=(0.6, 0.6, 1.0, 0.3), + title_flatness=1.0, + title_shadow=1.0, + subtitle='Testing Centered Title/Content', + subtitle_color=(1.0, 0.5, 1.0, 0.5), + subtitle_flatness=1.0, + subtitle_shadow=0.0, + center_content=True, + center_title=True, + buttons=[ + clui.Button( + 'Hello There!', + size=(200, 120), + color=(0.7, 0.7, 0.9), + ), + ], + ), + ], + ) diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py new file mode 100644 index 000000000..88dae68e9 --- /dev/null +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py @@ -0,0 +1,377 @@ +# Released under the MIT License. See LICENSE for details. +# +"""UIs provided by the cloud (similar-ish to html in concept).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +import bauiv1 as bui + +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top +from bauiv1lib.cloudui._prep import CloudUIPagePrep + +if TYPE_CHECKING: + from typing import Callable + + import bacommon.cloudui.v1 as clui + from bauiv1lib.cloudui._controller import CloudUIController + + +class CloudUIWindow(bui.MainWindow): + """UI provided by the cloud.""" + + @dataclass + class State: + """Final state window can be set to show.""" + + controller: CloudUIController + page: clui.Page + + def __init__( + self, + state: State | None, + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + auxiliary_style: bool = True, + ): + ui = bui.app.ui_v1 + + self._state: CloudUIWindow.State | None = None + + # We want to display differently whether we're an auxiliary + # window or not, but unfortunately that value is not yet + # available until we're added to the main-window-stack so it + # must be explicitly passed in. + self._auxiliary_style = auxiliary_style + + # Calc scale and size for our backing window. For medium & large + # ui-scale we aim for a window small enough to always be fully + # visible on-screen and for small mode we aim for a window big + # enough that we never see the window edges; only the window + # texture covering the whole screen. + uiscale = ui.uiscale + self._width = ( + 1400 + if uiscale is bui.UIScale.SMALL + else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 + ) + self._height = ( + 1200 + if uiscale is bui.UIScale.SMALL + else 700 if uiscale is bui.UIScale.MEDIUM else 800 + ) + self._root_scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + + # Do some fancy math to calculate our visible area; this will be + # limited by the screen size in small mode and our backing size + # otherwise. + screensize = bui.get_virtual_screen_size() + self._vis_width = min( + self._width - 150, screensize[0] / self._root_scale + ) + self._vis_height = min( + self._height - 80, screensize[1] / self._root_scale + ) + self._vis_top = 0.5 * self._height + 0.5 * self._vis_height + self._vis_left = 0.5 * self._width - 0.5 * self._vis_width + + self._scroll_width = self._vis_width + self._scroll_left = self._vis_left + 0.5 * ( + self._vis_width - self._scroll_width + ) + # Go with full-screen scrollable aread in small ui. + self._scroll_height = self._vis_height - ( + -1 if uiscale is bui.UIScale.SMALL else 43 + ) + self._scroll_bottom = ( + self._vis_top + - (-1 if uiscale is bui.UIScale.SMALL else 32) + - self._scroll_height + ) + + # Nudge our vis area up a bit when we can see the full backing + # (visual fudge factor). + if uiscale is not bui.UIScale.SMALL: + self._vis_top += 12.0 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility='menu_full', + toolbar_cancel_button_style=( + 'close' if auxiliary_style else 'back' + ), + scale=self._root_scale, + ), + transition=transition, + origin_widget=origin_widget, + # We respond to screen size changes only at small ui-scale; + # in other cases we assume our window remains fully visible + # always (flip to windowed mode and resize the app window to + # confirm this). + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + # Avoid complaints if nothing is selected under us. + bui.widget(edit=self._root_widget, allow_preserve_selection=False) + + self._subcontainer: bui.Widget | None = None + + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + highlight=True, # Will turn off once we have UI. + size=(self._scroll_width, self._scroll_height), + position=(self._scroll_left, self._scroll_bottom), + border_opacity=0.4, + center_small_content_horizontally=True, + claims_left_right=True, + ) + # Avoid having to deal with selecting this while its empty. + # bui.containerwidget(edit=self._scrollwidget, selectable=False) + bui.widget(edit=self._scrollwidget, autoselect=True) + + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL and bool(True): + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + + # Title. + self._title = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._vis_top - 20), + size=(0, 0), + text='', + color=ui.title_color, + scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, + # Make sure we avoid overlapping meters in small mode. + maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), + h_align='center', + v_align='center', + ) + # Needed to display properly over scrolled content. + bui.widget(edit=self._title, depth_range=(0.9, 1.0)) + + # For small UI-scale we use the system back/close button; + # otherwise we make our own. + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button: bui.Widget | None = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|close', + scale=0.8, + position=(self._vis_left + 2, self._vis_top - 35), + size=(50, 50) if auxiliary_style else (60, 55), + extra_touch_border_scale=2.0, + button_type=None if auxiliary_style else 'backSmall', + on_activate_call=self.main_window_back, + autoselect=True, + label=bui.charstr( + bui.SpecialChar.CLOSE + if auxiliary_style + else bui.SpecialChar.BACK + ), + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + + # Show our vis-area bounds (for debugging). + if bool(False): + # Skip top-left since its always overlapping back/close + # buttons. + if bool(False): + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TL', + h_align='left', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left + self._vis_width, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TR', + h_align='right', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top - self._vis_height), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='BL', + h_align='left', + v_align='bottom', + ) + bui.textwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width, + self._vis_top - self._vis_height, + ), + size=(0, 0), + scale=0.5, + color=(1, 1, 1, 0.5), + text='BR', + h_align='right', + v_align='bottom', + ) + + self._spinner: bui.Widget | None = bui.spinnerwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width * 0.5, + self._vis_top - self._vis_height * 0.5, + ), + size=48, + style='bomb', + ) + + if state is not None: + self.set_state(state, immediate=True) + + def set_state(self, state: State, immediate: bool = False) -> None: + """Set a final state (error or page contents). + + This state may be instantly restored if the window is recreated + (depending on cache lifespan/etc.) + """ + assert bui.in_logic_thread() + + assert self._state is None + self._state = state + + ui = bui.app.ui_v1 + uiscale = ui.uiscale + + if self._spinner: + self._spinner.delete() + self._spinner = None + + # Ok; we've got content. + bui.textwidget( + edit=self._title, + literal=not state.page.title_is_lstr, + text=state.page.title, + ) + + # Make sure there's at least one row and that all rows contain + # at least one button. Otherwise show a 'nothing here' message. + if not state.page.rows or not all( + row.buttons for row in state.page.rows + ): + bui.uilog.exception( + 'Got invalid cloud-ui state;' + ' must contain at least one row' + ' and all rows must contain buttons.' + ) + bui.textwidget( + parent=self._root_widget, + position=( + self._vis_left + 0.5 * self._vis_width, + self._vis_top - 0.5 * self._vis_height, + ), + size=(0, 0), + scale=0.6, + text=bui.Lstr( + translate=('serverResponses', 'There is nothing here.') + ), + h_align='center', + v_align='center', + ) + return + + pageprep = CloudUIPagePrep( + state.page, + uiscale, + self._scroll_width, + immediate=immediate, + idprefix=self.main_window_id_prefix, + ) + + # We left highlighting on so the user could see something if + # selecting our empty window, but let's kill it now that we're + # no longer empty. + bui.scrollwidget(edit=self._scrollwidget, highlight=False) + + bui.scrollwidget( + edit=self._scrollwidget, + simple_culling_v=pageprep.simple_culling_v, + center_small_content=state.page.center_vertically, + ) + + self._subcontainer = pageprep.instantiate( + self._scrollwidget, + backbutton=( + bui.get_special_widget('back_button') + if self._back_button is None + else self._back_button + ), + windowbackbutton=self._back_button, + ) + + # Most of our UI won't exist until this point so we need to + # explicitly restore state for selection restore to work. + # + # Note to self: perhaps we should *not* do this if significant + # time has passed since the window was made or if input commands + # have happened. + self.main_window_restore_shared_state() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # IMPORTANT - Pull values from self HERE; if we do it in the + # lambda below it'll keep self alive which will lead to + # 'ui-not-getting-cleaned-up' warnings and memory leaks. + auxiliary_style = self._auxiliary_style + state = self._state + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + state=state, + transition=transition, + origin_widget=origin_widget, + auxiliary_style=auxiliary_style, + ), + ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True + + @override + def get_main_window_shared_state_id(self) -> str | None: + return 'cloudui' diff --git a/src/assets/ba_data/python/bauiv1lib/mainmenu.py b/src/assets/ba_data/python/bauiv1lib/mainmenu.py index f0e1bb308..c59281f19 100644 --- a/src/assets/ba_data/python/bauiv1lib/mainmenu.py +++ b/src/assets/ba_data/python/bauiv1lib/mainmenu.py @@ -75,9 +75,7 @@ def get_main_window_state(self) -> bui.MainWindowState: create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget, - # id_prefix=id_prefix, ), - # restore_selection=True, ) @override diff --git a/src/ballistica/base/python/base_python.cc b/src/ballistica/base/python/base_python.cc index e1874f636..05b9dc112 100644 --- a/src/ballistica/base/python/base_python.cc +++ b/src/ballistica/base/python/base_python.cc @@ -267,12 +267,12 @@ auto BasePython::GetPyLString(PyObject* o) -> std::string { if (result == 1) { // At this point its not a simple type error if something goes wonky. // Perhaps we should try to preserve any error type raised by - // the _get_json() call... + // the as_json() call... exctype = PyExcType::kRuntime; - PythonRef get_json_call(PyObject_GetAttrString(o, "_get_json"), - PythonRef::kSteal); - if (get_json_call.CallableCheck()) { - PythonRef json = get_json_call.Call(); + PythonRef as_json_call(PyObject_GetAttrString(o, "as_json"), + PythonRef::kSteal); + if (as_json_call.CallableCheck()) { + PythonRef json = as_json_call.Call(); if (PyUnicode_Check(json.get())) { return PyUnicode_AsUTF8(json.get()); } diff --git a/src/ballistica/base/python/class/python_class_context_call.cc b/src/ballistica/base/python/class/python_class_context_call.cc index e006fa2cc..7b64924a9 100644 --- a/src/ballistica/base/python/class/python_class_context_call.cc +++ b/src/ballistica/base/python/class/python_class_context_call.cc @@ -110,7 +110,7 @@ auto PythonClassContextCall::tp_repr(PythonClassContextCall* self) BA_PYTHON_TRY; assert(self->context_call_->exists()); return PyUnicode_FromString( - ("") .c_str()); BA_PYTHON_CATCH; diff --git a/src/ballistica/base/python/class/python_class_context_ref.cc b/src/ballistica/base/python/class/python_class_context_ref.cc index ffbd576d6..3bd14bb35 100644 --- a/src/ballistica/base/python/class/python_class_context_ref.cc +++ b/src/ballistica/base/python/class/python_class_context_ref.cc @@ -80,7 +80,7 @@ auto PythonClassContextRef::tp_repr(PythonClassContextRef* self) -> PyObject* { BA_PYTHON_TRY; auto context_str = - "context_ref_->GetDescription() + ")>"; + "context_ref_->GetDescription() + ")>"; return PyUnicode_FromString(context_str.c_str()); BA_PYTHON_CATCH; } diff --git a/src/ballistica/scene_v1/python/class/python_class_material.cc b/src/ballistica/scene_v1/python/class/python_class_material.cc index 7b19b26e8..f0445e6a5 100644 --- a/src/ballistica/scene_v1/python/class/python_class_material.cc +++ b/src/ballistica/scene_v1/python/class/python_class_material.cc @@ -169,9 +169,9 @@ void PythonClassMaterial::tp_dealloc(PythonClassMaterial* self) { auto PythonClassMaterial::tp_repr(PythonClassMaterial* self) -> PyObject* { BA_PYTHON_TRY; - return Py_BuildValue( - "s", - std::string("").c_str()); + return Py_BuildValue("s", std::string("") + .c_str()); BA_PYTHON_CATCH; } diff --git a/src/ballistica/scene_v1/python/class/python_class_scene_data_asset.cc b/src/ballistica/scene_v1/python/class/python_class_scene_data_asset.cc index 081a8d790..6debc8a50 100644 --- a/src/ballistica/scene_v1/python/class/python_class_scene_data_asset.cc +++ b/src/ballistica/scene_v1/python/class/python_class_scene_data_asset.cc @@ -15,7 +15,7 @@ auto PythonClassSceneDataAsset::tp_repr(PythonClassSceneDataAsset* self) BA_PYTHON_TRY; auto&& m = *self->data_; return Py_BuildValue( - "s", (std::string("name() + "\"") : "(empty ref)") + ">") .c_str()); BA_PYTHON_CATCH; diff --git a/src/ballistica/shared/ballistica.cc b/src/ballistica/shared/ballistica.cc index 21f5e55a7..bcd7222e1 100644 --- a/src/ballistica/shared/ballistica.cc +++ b/src/ballistica/shared/ballistica.cc @@ -44,7 +44,7 @@ auto main(int argc, char** argv) -> int { namespace ballistica { // These are set automatically via script; don't modify them here. -const int kEngineBuildNumber = 22599; +const int kEngineBuildNumber = 22600; const char* kEngineVersion = "1.7.54"; const int kEngineApiVersion = 9; diff --git a/src/ballistica/ui_v1/python/methods/python_methods_ui_v1.cc b/src/ballistica/ui_v1/python/methods/python_methods_ui_v1.cc index a53ae8d59..b92a868e3 100644 --- a/src/ballistica/ui_v1/python/methods/python_methods_ui_v1.cc +++ b/src/ballistica/ui_v1/python/methods/python_methods_ui_v1.cc @@ -398,8 +398,16 @@ static auto PyButtonWidget(PyObject* self, PyObject* args, PyObject* keywds) b->set_style(ButtonWidget::Style::kSquare); } else if (button_type == "tab") { b->set_style(ButtonWidget::Style::kTab); + } else if (button_type == "small") { + b->set_style(ButtonWidget::Style::kSmall); + } else if (button_type == "medium") { + b->set_style(ButtonWidget::Style::kMedium); + } else if (button_type == "large") { + b->set_style(ButtonWidget::Style::kLarge); + } else if (button_type == "larger") { + b->set_style(ButtonWidget::Style::kLarger); } else { - throw Exception("Invalid button type: " + button_type + ".", + throw Exception("Invalid button type: '" + button_type + "'.", PyExcType::kValue); } } diff --git a/src/ballistica/ui_v1/widget/button_widget.cc b/src/ballistica/ui_v1/widget/button_widget.cc index 3c8850bd2..d2cd4b4d7 100644 --- a/src/ballistica/ui_v1/widget/button_widget.cc +++ b/src/ballistica/ui_v1/widget/button_widget.cc @@ -283,6 +283,19 @@ void ButtonWidget::Draw(base::RenderPass* pass, bool draw_transparent) { base::SysMeshID mesh_id; base::SysTextureID tex_id; + // Regular style means pick based on our aspect ratio. + if (style_ == Style::kRegular) { + if ((r_orig - l_orig) / (t_orig - b_orig) < 50.0f / 30.0f) { + style_ = Style::kSmall; + } else if ((r_orig - l_orig) / (t_orig - b_orig) < 200.0f / 35.0f) { + style_ = Style::kMedium; + } else if ((r_orig - l_orig) / (t_orig - b_orig) < 300.0f / 35.0f) { + style_ = Style::kLarge; + } else { + style_ = Style::kLarger; + } + } + switch (style_) { case Style::kBack: { tex_id = base::SysTextureID::kUIAtlas; @@ -326,44 +339,50 @@ void ButtonWidget::Draw(base::RenderPass* pass, bool draw_transparent) { t_border = 6; break; } + case Style::kLarger: { + tex_id = base::SysTextureID::kUIAtlas; + mesh_id = draw_transparent + ? base::SysMeshID::kButtonLargerTransparent + : base::SysMeshID::kButtonLargerOpaque; + l_border = 7; + r_border = 11; + b_border = 10; + t_border = 4; + break; + } + case Style::kLarge: { + tex_id = base::SysTextureID::kUIAtlas; + mesh_id = draw_transparent + ? base::SysMeshID::kButtonLargeTransparent + : base::SysMeshID::kButtonLargeOpaque; + l_border = 7; + r_border = 10; + b_border = 10; + t_border = 5; + break; + } + case Style::kMedium: { + tex_id = base::SysTextureID::kUIAtlas; + mesh_id = draw_transparent + ? base::SysMeshID::kButtonMediumTransparent + : base::SysMeshID::kButtonMediumOpaque; + l_border = 6; + r_border = 10; + b_border = 5; + t_border = 2; + break; + } + default: { - if ((r_orig - l_orig) / (t_orig - b_orig) < 50.0f / 30.0f) { - tex_id = base::SysTextureID::kUIAtlas; - mesh_id = draw_transparent - ? base::SysMeshID::kButtonSmallTransparent - : base::SysMeshID::kButtonSmallOpaque; - l_border = 10; - r_border = 14; - b_border = 9; - t_border = 5; - } else if ((r_orig - l_orig) / (t_orig - b_orig) < 200.0f / 35.0f) { - tex_id = base::SysTextureID::kUIAtlas; - mesh_id = draw_transparent - ? base::SysMeshID::kButtonMediumTransparent - : base::SysMeshID::kButtonMediumOpaque; - l_border = 6; - r_border = 10; - b_border = 5; - t_border = 2; - } else if ((r_orig - l_orig) / (t_orig - b_orig) < 300.0f / 35.0f) { - tex_id = base::SysTextureID::kUIAtlas; - mesh_id = draw_transparent - ? base::SysMeshID::kButtonLargeTransparent - : base::SysMeshID::kButtonLargeOpaque; - l_border = 7; - r_border = 10; - b_border = 10; - t_border = 5; - } else { - tex_id = base::SysTextureID::kUIAtlas; - mesh_id = draw_transparent - ? base::SysMeshID::kButtonLargerTransparent - : base::SysMeshID::kButtonLargerOpaque; - l_border = 7; - r_border = 11; - b_border = 10; - t_border = 4; - } + assert(style_ == Style::kSmall); + tex_id = base::SysTextureID::kUIAtlas; + mesh_id = draw_transparent + ? base::SysMeshID::kButtonSmallTransparent + : base::SysMeshID::kButtonSmallOpaque; + l_border = 10; + r_border = 14; + b_border = 9; + t_border = 5; break; } } diff --git a/src/ballistica/ui_v1/widget/button_widget.h b/src/ballistica/ui_v1/widget/button_widget.h index 95420b028..282cf836a 100644 --- a/src/ballistica/ui_v1/widget/button_widget.h +++ b/src/ballistica/ui_v1/widget/button_widget.h @@ -50,7 +50,17 @@ class ButtonWidget : public Widget { void set_flatness(float val) { flatness_ = val; } auto set_text_flatness(float f) { text_flatness_ = f; } - enum class Style : uint8_t { kRegular, kBack, kBackSmall, kTab, kSquare }; + enum class Style : uint8_t { + kRegular, + kBack, + kBackSmall, + kTab, + kSquare, + kSmall, + kMedium, + kLarge, + kLarger + }; auto set_style(Style s) { style_ = s; } enum class IconType : uint8_t { kNone, kCancel, kStart }; void SetTextLiteral(bool val); diff --git a/src/ballistica/ui_v1/widget/root_widget.cc b/src/ballistica/ui_v1/widget/root_widget.cc index ca5471d30..0f8a99106 100644 --- a/src/ballistica/ui_v1/widget/root_widget.cc +++ b/src/ballistica/ui_v1/widget/root_widget.cc @@ -1678,10 +1678,8 @@ void RootWidget::StepChildWidgets_(seconds_t dt) { if (auto* btn = account_button_) { if (counts.find("accountsettings") != counts.end()) { account_button_mult_ = {1.2f, 2.0f, 1.2f}; - // btn->widget->set_flatness(0.75f); // Not currently supported. } else { account_button_mult_ = {1.0f, 1.0f, 1.0f}; - // btn->widget->set_flatness(0.0f); // Not currently supported. } UpdateAccountButtonColor_(); } else { @@ -1791,9 +1789,9 @@ void RootWidget::StepChildWidgets_(seconds_t dt) { // Inbox if (auto* btn = inbox_button_) { if (counts.find("classicinbox") != counts.end()) { - btn->widget->set_color(kBotLeftColorR * 0.2f, kBotLeftColorG * 1.1f, - kBotLeftColorB * 0.2f); - btn->widget->set_flatness(0.7f); + btn->widget->set_color(kBotLeftColorR * 0.3f, kBotLeftColorG * 1.3f, + kBotLeftColorB * 0.3f); + btn->widget->set_flatness(0.8f); } else { btn->widget->set_color(kBotLeftColorR, kBotLeftColorG, kBotLeftColorB); btn->widget->set_flatness(0.0f); @@ -1806,9 +1804,9 @@ void RootWidget::StepChildWidgets_(seconds_t dt) { // Achievements if (auto* btn = achievements_button_) { if (counts.find("classicachievements") != counts.end()) { - btn->widget->set_color(kBotLeftColorR * 0.2f, kBotLeftColorG * 1.1f, - kBotLeftColorB * 0.2f); - btn->widget->set_flatness(0.7f); + btn->widget->set_color(kBotLeftColorR * 0.3f, kBotLeftColorG * 1.3f, + kBotLeftColorB * 0.3f); + btn->widget->set_flatness(0.8f); } else { btn->widget->set_color(kBotLeftColorR, kBotLeftColorG, kBotLeftColorB); btn->widget->set_flatness(0.0f); @@ -1822,9 +1820,9 @@ void RootWidget::StepChildWidgets_(seconds_t dt) { // Settings if (auto* btn = settings_button_) { if (counts.find("settings") != counts.end()) { - btn->widget->set_color(kBotLeftColorR * 0.2f, kBotLeftColorG * 1.1f, - kBotLeftColorB * 0.2f); - btn->widget->set_flatness(0.7f); + btn->widget->set_color(kBotLeftColorR * 0.3f, kBotLeftColorG * 1.3f, + kBotLeftColorB * 0.3f); + btn->widget->set_flatness(0.8f); } else { btn->widget->set_color(kBotLeftColorR, kBotLeftColorG, kBotLeftColorB); btn->widget->set_flatness(0.0f); @@ -2802,8 +2800,8 @@ void RootWidget::UpdateChests_() { // Show in flat green if ui is open. if (uiopen) { - slot.button->widget->set_color(0.2f, 1.1f, 0.2f); - slot.button->widget->set_flatness(0.6f); + slot.button->widget->set_color(0.1f, 0.7f, 0.1f); + slot.button->widget->set_flatness(0.7f); } else { slot.button->widget->set_color(0.473f, 0.44f, 0.583f); slot.button->widget->set_flatness(0.0f); diff --git a/src/ballistica/ui_v1/widget/scroll_widget.cc b/src/ballistica/ui_v1/widget/scroll_widget.cc index 02a1541a6..ffe13ef8e 100644 --- a/src/ballistica/ui_v1/widget/scroll_widget.cc +++ b/src/ballistica/ui_v1/widget/scroll_widget.cc @@ -232,6 +232,11 @@ auto ScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { float x = m.fval1; float y = m.fval2; + // Don't scroll if everything is visible. + if (amount_visible_ >= 1.0f) { + break; + } + // Keep track of the average scrolling going on. (only update when we // get non-momentum events). if (std::abs(m.fval3) > 0.001f && !has_momentum_) { @@ -314,6 +319,12 @@ auto ScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { if ((x >= 0.0f) && (x < width()) && (y >= 0.0f) && (y < height())) { claimed = true; pass = false; + + // Don't scroll if everything is visible. + if (amount_visible_ >= 1.0f) { + break; + } + inertia_scroll_rate_ -= m.fval3 * 0.003f; MarkForUpdate(); } else { diff --git a/tools/bacommon/cloudui/__init__.py b/tools/bacommon/cloudui/__init__.py index 4bad839c7..5173487ac 100644 --- a/tools/bacommon/cloudui/__init__.py +++ b/tools/bacommon/cloudui/__init__.py @@ -3,14 +3,18 @@ """Common CloudUI bits.""" from bacommon.cloudui._cloudui import ( - CloudUIPage, - CloudUIPageTypeID, - UnknownCloudUIPage, + CloudUIRequestMethod, + CloudUIRequest, + CloudUIResponse, + CloudUIResponseTypeID, + UnknownCloudUIResponse, ) __all__ = [ - 'CloudUIPage', - 'CloudUIPageTypeID', - 'UnknownCloudUIPage', + 'CloudUIRequestMethod', + 'CloudUIRequest', + 'CloudUIResponse', + 'CloudUIResponseTypeID', + 'UnknownCloudUIResponse', ] diff --git a/tools/bacommon/cloudui/_cloudui.py b/tools/bacommon/cloudui/_cloudui.py index 0f2021f04..be1df9f31 100644 --- a/tools/bacommon/cloudui/_cloudui.py +++ b/tools/bacommon/cloudui/_cloudui.py @@ -5,31 +5,65 @@ from __future__ import annotations from enum import Enum -from dataclasses import dataclass -from typing import override, assert_never, TYPE_CHECKING +from dataclasses import dataclass, field +from typing import override, assert_never, TYPE_CHECKING, Annotated -from efro.dataclassio import ioprepped, IOMultiType +from efro.dataclassio import ioprepped, IOMultiType, IOAttrs if TYPE_CHECKING: pass -class CloudUIPageTypeID(Enum): +class CloudUIRequestMethod(Enum): + """Typeof of requests that can be made to cloud-ui servers.""" + + #: An unknown request method. This can appear if a newer client is + #: requesting some method from an older server that is not known to + #: the server. + UNKNOWN = 'u' + + #: Fetch some resource. This can be retried and its results can + #: optionally be cached for some amount of time. + GET = 'g' + + #: Change some resource. This cannot be implicitly retried (at least + #: without deduplication), nor can it be cached. + POST = 'p' + + +@ioprepped +@dataclass +class CloudUIRequest: + """Full request to cloud-ui.""" + + path: Annotated[str, IOAttrs('p')] + method: Annotated[ + CloudUIRequestMethod, + IOAttrs( + 'm', store_default=False, enum_fallback=CloudUIRequestMethod.UNKNOWN + ), + ] = CloudUIRequestMethod.GET + params: Annotated[dict, IOAttrs('r', store_default=False)] = field( + default_factory=dict + ) + + +class CloudUIResponseTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' V1 = 'v1' -class CloudUIPage(IOMultiType[CloudUIPageTypeID]): +class CloudUIResponse(IOMultiType[CloudUIResponseTypeID]): """UI defined by the cloud. - Conceptually similar to a basic html page, except using app UI. + Conceptually similar to a basic html response, except using app UI. """ @override @classmethod - def get_type_id(cls) -> CloudUIPageTypeID: + def get_type_id(cls) -> CloudUIResponseTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. @@ -37,27 +71,27 @@ def get_type_id(cls) -> CloudUIPageTypeID: @override @classmethod - def get_type(cls, type_id: CloudUIPageTypeID) -> type[CloudUIPage]: + def get_type(cls, type_id: CloudUIResponseTypeID) -> type[CloudUIResponse]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import - t = CloudUIPageTypeID + t = CloudUIResponseTypeID if type_id is t.UNKNOWN: - return UnknownCloudUIPage + return UnknownCloudUIResponse if type_id is t.V1: - from bacommon.cloudui.v1 import Page + from bacommon.cloudui.v1 import Response - return Page + return Response # Make sure we cover all types. assert_never(type_id) @override @classmethod - def get_unknown_type_fallback(cls) -> CloudUIPage: + def get_unknown_type_fallback(cls) -> CloudUIResponse: # If we encounter some future type we don't know anything about, # drop in a placeholder. - return UnknownCloudUIPage() + return UnknownCloudUIResponse() @override @classmethod @@ -67,13 +101,13 @@ def get_type_id_storage_name(cls) -> str: @ioprepped @dataclass -class UnknownCloudUIPage(CloudUIPage): +class UnknownCloudUIResponse(CloudUIResponse): """Fallback type for unrecognized UI types. - Will show the client a 'cannot display this UI' placeholder page. + Will show the client a 'cannot display this UI' placeholder response. """ @override @classmethod - def get_type_id(cls) -> CloudUIPageTypeID: - return CloudUIPageTypeID.UNKNOWN + def get_type_id(cls) -> CloudUIResponseTypeID: + return CloudUIResponseTypeID.UNKNOWN diff --git a/tools/bacommon/cloudui/v1.py b/tools/bacommon/cloudui/v1.py index a277956b3..f9d086699 100644 --- a/tools/bacommon/cloudui/v1.py +++ b/tools/bacommon/cloudui/v1.py @@ -10,7 +10,7 @@ from efro.dataclassio import ioprepped, IOAttrs, IOMultiType -from bacommon.cloudui._cloudui import CloudUIPage, CloudUIPageTypeID +from bacommon.cloudui._cloudui import CloudUIResponse, CloudUIResponseTypeID class HAlign(Enum): @@ -103,8 +103,9 @@ class Text(Decoration): #: support. text: Annotated[str, IOAttrs('t')] position: Annotated[tuple[float, float], IOAttrs('p')] - max_width: Annotated[float, IOAttrs('w')] - max_height: Annotated[float, IOAttrs('h', store_default=False)] = 32.0 + + #: Note: This effectively is max-width and max-height. + size: Annotated[tuple[float, float], IOAttrs('z')] scale: Annotated[float, IOAttrs('s', store_default=False)] = 1.0 h_align: Annotated[HAlign, IOAttrs('ha', store_default=False)] = ( HAlign.CENTER @@ -115,6 +116,10 @@ class Text(Decoration): flatness: Annotated[float | None, IOAttrs('f', store_default=False)] = None shadow: Annotated[float | None, IOAttrs('sh', store_default=False)] = None + is_lstr: Annotated[bool, IOAttrs('l', store_default=False)] = False + + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True + #: Show max-width/height bounds; useful during development. debug: Annotated[bool, IOAttrs('d', store_default=False)] = False @@ -160,6 +165,7 @@ class Image(Decoration): mesh_transparent: Annotated[ str | None, IOAttrs('mn', store_default=False) ] = None + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True @override @classmethod @@ -170,7 +176,21 @@ def get_type_id(cls) -> DecorationTypeID: @ioprepped @dataclass class Button: - """A button in our cloud ui.""" + """A button in our cloud ui. + + Note that size, padding, and all decorations are scaled consistently + with 'scale'. + """ + + class Style(Enum): + """Styles a button can be.""" + + SQUARE = 'q' + TAB = 't' + SMALL = 's' + MEDIUM = 'm' + LARGE = 'l' + LARGER = 'xl' #: Note that cloud-ui accepts only raw :class:`str` values for text; #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language @@ -202,6 +222,10 @@ class Button: decorations: Annotated[ list[Decoration], IOAttrs('c', store_default=False) ] = field(default_factory=list) + text_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + style: Annotated[Style, IOAttrs('y', store_default=False)] = Style.SQUARE + + #: Draw bounds of the button. debug: Annotated[bool, IOAttrs('d', store_default=False)] = False @@ -225,6 +249,7 @@ class Row: title_shadow: Annotated[ float | None, IOAttrs('ts', store_default=False) ] = None + title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False subtitle: Annotated[str | None, IOAttrs('s', store_default=False)] = None subtitle_color: Annotated[ tuple[float, float, float, float] | None, @@ -236,6 +261,9 @@ class Row: subtitle_shadow: Annotated[ float | None, IOAttrs('ss', store_default=False) ] = None + subtitle_is_lstr: Annotated[bool, IOAttrs('sl', store_default=False)] = ( + False + ) button_spacing: Annotated[float, IOAttrs('bs', store_default=False)] = 5.0 padding_left: Annotated[float, IOAttrs('pl', store_default=False)] = 10.0 padding_right: Annotated[float, IOAttrs('pr', store_default=False)] = 10.0 @@ -249,12 +277,17 @@ class Row: 100.0 ) + #: Draw bounds of the overall row and individual button columns + #: (including padding). The UI will scroll to keep these areas + #: visible in their entirety when changing selection via directional + #: controls, so try to make sure all decorations for a button are + #: within these bounds. debug: Annotated[bool, IOAttrs('d', store_default=False)] = False @ioprepped @dataclass -class Page(CloudUIPage): +class Page: """Cloud-UI page version 1.""" #: Note that cloud-ui accepts only raw :class:`str` values for text; @@ -275,7 +308,28 @@ class Page(CloudUIPage): 100.0 ) + #: Whether the title is a json dict representing an Lstr. Generally + #: cloud-ui translation should be handled server-side, but this can + #: allow client-side translation. + title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + + +class ResponseCode(Enum): + """The overall result of a request.""" + + SUCCESS = 0 + UNKNOWN_ERROR = 1 + + +@ioprepped +@dataclass +class Response(CloudUIResponse): + """Full cloudui response.""" + + code: Annotated[ResponseCode, IOAttrs('c')] + page: Annotated[Page, IOAttrs('p')] + @override @classmethod - def get_type_id(cls) -> CloudUIPageTypeID: - return CloudUIPageTypeID.V1 + def get_type_id(cls) -> CloudUIResponseTypeID: + return CloudUIResponseTypeID.V1 From 6553abc79fb017f50c2f90a7b4c25aef54f17407 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Tue, 28 Oct 2025 16:14:53 -0700 Subject: [PATCH 2/3] docs fixes --- .efrocachemap | 32 +++++++++---------- .../python/bauiv1lib/cloudui/_window.py | 4 +-- tools/bacommon/cloudui/v1.py | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.efrocachemap b/.efrocachemap index 39c040b2a..13c23dd42 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -4302,18 +4302,18 @@ "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "afbec521d18763888b30f947f4699c71", "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "6e60688d17adc88bcdbd57a5611dcbcd", "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "b97d7947a652371b27ffe7d5f9b5b7b0", - "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "28bb6f2d7ebb7cf2a8b0963e10c7a940", + "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "32f58bd26cb3a5bf45ff2dc258cab5dd", "build/prefab/full/mac_arm64_gui/release/ballisticakit": "e9c104f938cbd265629c34aecb262393", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "89e6dce5d5507854a5ba33e63a4c8214", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "3a27287a12dba68dd493027bdbef6def", "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "8c64d1eee8c6b0862d98c90857cef9d8", - "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "3edd02e9e961523431f05c48b2b681b2", + "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "e8a6f2876e1c4c58d07e25ad11ceac85", "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "b5d9b2f174a060cd71c7b209b64fee8c", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "a2c067044b11bd57a1e29acd91a5b56a", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "99e994eb84c13402f0eeebfd4fd0a09d", "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "5e31103eff7d10a31aa2e5338d7af4f6", - "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "20c9b1911a5c822996c977f8a5f225ee", - "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "5117cc0946f0b4ae237db9810a0237dc", - "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "57567afa0833c83eefcde9db1ad5e0fa", - "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "9fe96d70c755e9a5d7f118c9906d0f25", + "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "ad5ca5caa26dce0cf313f9b51fcb271f", + "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "5de76f42e2a454277efed0611598bd23", + "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "32623525cfe2078b34b42d3d17dfe8cd", + "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "62be53a21b226a658ac330a7c0be4fe8", "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "ffba3501949fc0afdd4cdc8bfa3f232d", "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", @@ -4330,14 +4330,14 @@ "build/prefab/lib/mac_x86_64_gui/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", "build/prefab/lib/mac_x86_64_server/debug/libballisticaplus.a": "ce027ca768b327eed0e7168a76e466f9", "build/prefab/lib/mac_x86_64_server/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "d436217c5953f316480efb88127dc2fb", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "d066f5f5a21b61551b3396022ba1155f", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "eb41059b05646846ed5ae0fd2e469680", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "9e0dc91863060d216f952b920d99f1b9", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "34dfd53947a00cfc17bdb31f3dc62d9f", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "d7311bb63a65f69b8dd2d08cc0f41d3d", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "552acd5f3a5474043aa9e68e817f4adf", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "9e6de8de47502201c0627e983ef3e50a", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "b0d065df8123ee586e68c43a805f8e11", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "6e82483ca9b8537b5592f59bb14577f9", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "c067cdf6f11a263bb4891974dd39a9be", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "f0afa1866592b7e9fca6c3fecfe9a585", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "9900804e25791b1ad5deeacfd54a6c67", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "ac556cf206fd79f0b92b4f0fd24908c2", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "875b96a2cbd2f26d4770fb39b2098d2f", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "a911126bf57d40c83397108df61324fe", "src/assets/ba_data/python/babase/_mgen/__init__.py": "f885fed7f2ed98ff2ba271f9dbe3391c", "src/assets/ba_data/python/babase/_mgen/enums.py": "f2642a60fef55f7792cb1dfe03446cb1", "src/ballistica/base/mgen/pyembed/binding_base.inc": "943cdbda1dcf399783675b115c22dae5", diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py index 88dae68e9..ed292fe84 100644 --- a/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_window.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from typing import Callable - import bacommon.cloudui.v1 as clui + import bacommon.cloudui.v1 from bauiv1lib.cloudui._controller import CloudUIController @@ -27,7 +27,7 @@ class State: """Final state window can be set to show.""" controller: CloudUIController - page: clui.Page + page: bacommon.cloudui.v1.Page def __init__( self, diff --git a/tools/bacommon/cloudui/v1.py b/tools/bacommon/cloudui/v1.py index f9d086699..1a79da3bd 100644 --- a/tools/bacommon/cloudui/v1.py +++ b/tools/bacommon/cloudui/v1.py @@ -104,7 +104,7 @@ class Text(Decoration): text: Annotated[str, IOAttrs('t')] position: Annotated[tuple[float, float], IOAttrs('p')] - #: Note: This effectively is max-width and max-height. + #: Note that this effectively is max-width and max-height. size: Annotated[tuple[float, float], IOAttrs('z')] scale: Annotated[float, IOAttrs('s', store_default=False)] = 1.0 h_align: Annotated[HAlign, IOAttrs('ha', store_default=False)] = ( From 2247b798cdb745d48fde466b3009221a00f7431d Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Wed, 29 Oct 2025 14:40:00 -0700 Subject: [PATCH 3/3] ever more cloudui --- .efrocachemap | 58 ++++++------- CHANGELOG.md | 2 +- src/assets/ba_data/python/babase/_app.py | 6 +- src/assets/ba_data/python/baenv.py | 2 +- .../ba_data/python/bauiv1lib/cloudui/_test.py | 10 ++- src/ballistica/shared/ballistica.cc | 2 +- .../ui_v1/widget/container_widget.cc | 37 ++++---- .../ui_v1/widget/container_widget.h | 6 +- .../ui_v1/widget/h_scroll_widget.cc | 69 ++++++++------- tools/bacommon/cloudui/__init__.py | 6 +- tools/bacommon/cloudui/_cloudui.py | 85 +++++++++++++------ tools/bacommon/cloudui/v1.py | 75 +++++++++++++++- 12 files changed, 239 insertions(+), 119 deletions(-) diff --git a/.efrocachemap b/.efrocachemap index 13c23dd42..348c166d7 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -4293,27 +4293,27 @@ "build/assets/windows/x64/python_d.exe": "d74c06925b7eefca7ba697b1eb13f6a1", "build/assets/windows/x64/pythonw.exe": "007fb5e669a3b6b3e8facf0728b87521", "build/assets/windows/x64/pythonw_d.exe": "46925c0fa3ca3fd29a33e54ee31f6cd5", - "build/assets/windows/x64/vc_redist.x64.exe": "b9a4d05fb2699c78e6607a385cd784cf", - "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "9e1ede575cefd3797d7f918cbafa9f1f", - "build/prefab/full/linux_arm64_gui/release/ballisticakit": "f430faaf24e687586cea6618c7897c5e", - "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "57695753b413b633320bfda9d99994ba", - "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "7b48bfb18b8f40e87ba524c80863c1a2", - "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "cb43e8568f29f99e67f0986ce3db65c0", - "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "afbec521d18763888b30f947f4699c71", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "6e60688d17adc88bcdbd57a5611dcbcd", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "b97d7947a652371b27ffe7d5f9b5b7b0", - "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "32f58bd26cb3a5bf45ff2dc258cab5dd", - "build/prefab/full/mac_arm64_gui/release/ballisticakit": "e9c104f938cbd265629c34aecb262393", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "3a27287a12dba68dd493027bdbef6def", - "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "8c64d1eee8c6b0862d98c90857cef9d8", - "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "e8a6f2876e1c4c58d07e25ad11ceac85", - "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "b5d9b2f174a060cd71c7b209b64fee8c", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "99e994eb84c13402f0eeebfd4fd0a09d", - "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "5e31103eff7d10a31aa2e5338d7af4f6", - "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "ad5ca5caa26dce0cf313f9b51fcb271f", - "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "5de76f42e2a454277efed0611598bd23", - "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "32623525cfe2078b34b42d3d17dfe8cd", - "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "62be53a21b226a658ac330a7c0be4fe8", + "build/assets/windows/x64/vc_redist.x64.exe": "a8cf8406eae3c38b6bc3285685266b47", + "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "21cee91b1ece573534091cd6c935104f", + "build/prefab/full/linux_arm64_gui/release/ballisticakit": "90ec6b92f8e2ba29050d5c05bf894283", + "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "d8c456a348b76913b14fa33700e726aa", + "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "9ca3acf7cf40566d6e34ec05c4bbc7a6", + "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "3fd046d38e6892b0e43b89063de17a0e", + "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "5e94c02c4f20723f7634467b795ffe75", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "d915c31feff3c3118391ba78a56b0461", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "d1278e2331dcf42014f59ba933a8111d", + "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "ab435bd7ffd171a368381fabdba1da04", + "build/prefab/full/mac_arm64_gui/release/ballisticakit": "190e50193ddc8fbd013450a5e11888cd", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "5744d736182c405fd2e600625cb24082", + "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "afba5ed4f43c4a855e3e809a94cdcccd", + "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "ea7e43cecc07cda5b7014b15df8037d5", + "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "690ffb86ee91f1bc852e45d39ef3cba9", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "5e5f18f9b25b9634cdda56e7376b98bb", + "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "a3157bfb3c176a70a4ba476741b4ea44", + "build/prefab/full/windows_x86_64_gui/debug/BallisticaKit.exe": "4df752bc4397776685da8428819a52af", + "build/prefab/full/windows_x86_64_gui/release/BallisticaKit.exe": "bc04149c811a631d0782ea622ca975e6", + "build/prefab/full/windows_x86_64_server/debug/dist/BallisticaKitHeadless.exe": "4c8cdabb0ee77273071974ea5fe68c81", + "build/prefab/full/windows_x86_64_server/release/dist/BallisticaKitHeadless.exe": "198586a8d2a21a3640f5a1298364419d", "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "ffba3501949fc0afdd4cdc8bfa3f232d", "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "f240ea6a101cc0639b38b2b2ef8be765", @@ -4330,14 +4330,14 @@ "build/prefab/lib/mac_x86_64_gui/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", "build/prefab/lib/mac_x86_64_server/debug/libballisticaplus.a": "ce027ca768b327eed0e7168a76e466f9", "build/prefab/lib/mac_x86_64_server/release/libballisticaplus.a": "11ad60788139bf7f2e3dac01452d4f8b", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "b0d065df8123ee586e68c43a805f8e11", - "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "6e82483ca9b8537b5592f59bb14577f9", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "c067cdf6f11a263bb4891974dd39a9be", - "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "f0afa1866592b7e9fca6c3fecfe9a585", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "9900804e25791b1ad5deeacfd54a6c67", - "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "ac556cf206fd79f0b92b4f0fd24908c2", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "875b96a2cbd2f26d4770fb39b2098d2f", - "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "a911126bf57d40c83397108df61324fe", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.lib": "e2525dcf0455e8297f3ed1001e4be8a7", + "build/prefab/lib/windows/Debug_x64/BallisticaKitGenericPlus.pdb": "ff685763cc97256368a4a7e420cc33fd", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.lib": "1cb757f5af077ea54544f8a883613845", + "build/prefab/lib/windows/Debug_x64/BallisticaKitHeadlessPlus.pdb": "03ff4c5f0e1722e22fd8f09a2b8cb0b7", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.lib": "7ee67fba7d5bffb84e28f900e35ce02c", + "build/prefab/lib/windows/Release_x64/BallisticaKitGenericPlus.pdb": "a2102df90aa6358eee50f6af51553551", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.lib": "58a5f4d41cbc855cde142b75db7296a2", + "build/prefab/lib/windows/Release_x64/BallisticaKitHeadlessPlus.pdb": "aaf691cfa9db1995718b2bb39737d3a2", "src/assets/ba_data/python/babase/_mgen/__init__.py": "f885fed7f2ed98ff2ba271f9dbe3391c", "src/assets/ba_data/python/babase/_mgen/enums.py": "f2642a60fef55f7792cb1dfe03446cb1", "src/ballistica/base/mgen/pyembed/binding_base.inc": "943cdbda1dcf399783675b115c22dae5", diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ad4b939..cd4fb6aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 1.7.54 (build 22600, api 9, 2025-10-28) +### 1.7.54 (build 22604, api 9, 2025-10-29) - `scrollwidget` and `hscrollwidget` now center selected items that are too large to fit completely in view instead of unpredictably scrolling to the beginning or end of them. This makes show-buffer values (which effectively diff --git a/src/assets/ba_data/python/babase/_app.py b/src/assets/ba_data/python/babase/_app.py index 8a2673f8d..b991bd653 100644 --- a/src/assets/ba_data/python/babase/_app.py +++ b/src/assets/ba_data/python/babase/_app.py @@ -1059,8 +1059,12 @@ async def _run_shutdown_task( task = asyncio.create_task(coro) try: await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS) + except TimeoutError: + # Log simple error message if it times out. + logging.error('Timed out waiting for shutdown task %s.', coro) except Exception: - logging.exception('Error in shutdown task (%s).', coro) + # Go with full ugly stack trace for anything unexpected. + logging.exception('Error in shutdown task %s.', coro) def _on_suspend(self) -> None: """Called when the app goes to a suspended state.""" diff --git a/src/assets/ba_data/python/baenv.py b/src/assets/ba_data/python/baenv.py index e8937227c..8a36b210b 100644 --- a/src/assets/ba_data/python/baenv.py +++ b/src/assets/ba_data/python/baenv.py @@ -56,7 +56,7 @@ # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22600 +TARGET_BALLISTICA_BUILD = 22604 TARGET_BALLISTICA_VERSION = '1.7.54' diff --git a/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py b/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py index 1ac9ac719..f6cce2573 100644 --- a/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py +++ b/src/assets/ba_data/python/bauiv1lib/cloudui/_test.py @@ -6,25 +6,25 @@ from typing import TYPE_CHECKING, override -from bacommon.cloudui import CloudUIRequest import bauiv1 as bui from bauiv1lib.cloudui._window import CloudUIWindow from bauiv1lib.cloudui._controller import CloudUIController if TYPE_CHECKING: - from bacommon.cloudui import CloudUIResponse + from bacommon.cloudui import CloudUIRequest, CloudUIResponse import bacommon.cloudui.v1 def show_test_cloud_ui_window() -> None: """Bust out a cloud-ui window.""" + import bacommon.cloudui.v1 as clui # Pop up an auxiliary window wherever we are in the nav stack. bui.app.ui_v1.auxiliary_window_activate( win_type=CloudUIWindow, win_create_call=bui.CallStrict( - TestCloudUIController().create_window, CloudUIRequest('/') + TestCloudUIController().create_window, clui.Request('/') ), ) @@ -61,6 +61,10 @@ def get_test_page() -> bacommon.cloudui.v1.Page: clui.Button( label='Test', size=(180, 200), + request=clui.Request('/test'), + target=clui.Target( + behavior=clui.TargetBehavior.REPLACE + ), decorations=[ clui.Image( 'powerupPunch', diff --git a/src/ballistica/shared/ballistica.cc b/src/ballistica/shared/ballistica.cc index bcd7222e1..3f75e6dda 100644 --- a/src/ballistica/shared/ballistica.cc +++ b/src/ballistica/shared/ballistica.cc @@ -44,7 +44,7 @@ auto main(int argc, char** argv) -> int { namespace ballistica { // These are set automatically via script; don't modify them here. -const int kEngineBuildNumber = 22600; +const int kEngineBuildNumber = 22604; const char* kEngineVersion = "1.7.54"; const int kEngineApiVersion = 9; diff --git a/src/ballistica/ui_v1/widget/container_widget.cc b/src/ballistica/ui_v1/widget/container_widget.cc index 3c2fb4ed3..702b73ff5 100644 --- a/src/ballistica/ui_v1/widget/container_widget.cc +++ b/src/ballistica/ui_v1/widget/container_widget.cc @@ -804,12 +804,13 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { if (!draw_transparent) { if (transition_type_ == TransitionType::kInScale) { - if (display_time_ms - dynamics_update_time_millisecs_ > 1000) + if (display_time_ms - dynamics_update_time_millisecs_ > 1000) { dynamics_update_time_millisecs_ = display_time_ms - 1000; + } while (display_time_ms - dynamics_update_time_millisecs_ > 5) { dynamics_update_time_millisecs_ += 5; d_transition_scale_ += - std::min(0.2f, (1.0f - transition_scale_)) * 0.04f; + std::min(0.2f, (1.0f - transition_scale_)) * 0.03f; d_transition_scale_ *= 0.87f; transition_scale_ += d_transition_scale_; @@ -829,7 +830,7 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { while (display_time_ms - dynamics_update_time_millisecs_ > 5) { dynamics_update_time_millisecs_ += 5; - transition_scale_ -= 0.04f; + transition_scale_ -= 0.03f; if (transition_scale_ <= 0.0f) { transition_scale_ = 0.0f; @@ -932,11 +933,10 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { // zoom from a point somewhere else on screen). if (transition_type_ == TransitionType::kInScale || transition_type_ == TransitionType::kOutScale) { - // Add a fudge factor since our scale point isn't exactly in our center. - // :-( float xdiff = scale_origin_stack_offset_x_ - stack_offset_x() - + GetWidth() * -0.05f; - float ydiff = scale_origin_stack_offset_y_ - stack_offset_y(); + + GetWidth() * bg_center_fudge_x_; + float ydiff = scale_origin_stack_offset_y_ - stack_offset_y() + + GetHeight() * bg_center_fudge_y_; transition_scale_offset_x_ = ((1.0f - transition_scale_) * xdiff) / scale(); transition_scale_offset_y_ = @@ -962,26 +962,33 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { // so always calc them). if (bg_dirty_) { base::SysTextureID tex_id; - float l_border, r_border, b_border, t_border; + float l_border, r_border, b_border, t_border, center_x_amt, center_y_amt; float width = r - l; float height = t - b; if (height > width * 0.6f) { tex_id = base::SysTextureID::kWindowHSmallVMed; - bg_mesh_transparent_i_d_ = base::SysMeshID::kWindowHSmallVMedTransparent; - bg_mesh_opaque_i_d_ = base::SysMeshID::kWindowHSmallVMedOpaque; + bg_mesh_transparent_id_ = base::SysMeshID::kWindowHSmallVMedTransparent; + bg_mesh_opaque_id_ = base::SysMeshID::kWindowHSmallVMedOpaque; l_border = width * 0.07f; r_border = width * 0.19f; b_border = height * 0.1f; t_border = height * 0.07f; + // These need to be fudged until scaling in/out hits exact target + // point. Should look into why this math is off. + bg_center_fudge_x_ = -0.05f; + bg_center_fudge_y_ = 0.0f; } else { tex_id = base::SysTextureID::kWindowHSmallVSmall; - bg_mesh_transparent_i_d_ = - base::SysMeshID::kWindowHSmallVSmallTransparent; - bg_mesh_opaque_i_d_ = base::SysMeshID::kWindowHSmallVSmallOpaque; + bg_mesh_transparent_id_ = base::SysMeshID::kWindowHSmallVSmallTransparent; + bg_mesh_opaque_id_ = base::SysMeshID::kWindowHSmallVSmallOpaque; l_border = width * 0.12f; r_border = width * 0.19f; b_border = height * 0.45f; t_border = height * 0.23f; + // These need to be fudged until scaling in/out hits exact target + // point. Should look into why this math is off. + bg_center_fudge_x_ = -0.03f; + bg_center_fudge_y_ = 0.1f; } bg_width_ = r - l + l_border + r_border; bg_height_ = t - b + b_border + t_border; @@ -1000,7 +1007,7 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { } // Draw our window backing if we have one. - if ((w > 0) && (h > 0)) { + if (w > 0.0f && h > 0.0f) { if (background_) { float zoffs{}; if (darken_behind_) { @@ -1077,7 +1084,7 @@ void ContainerWidget::Draw(base::RenderPass* pass, bool draw_transparent) { c.Translate(bg_center_x_, bg_center_y_, zoffs); c.Scale(bg_width_ * transition_scale_, bg_height_ * transition_scale_); c.DrawMeshAsset(g_base->assets->SysMesh( - draw_transparent ? bg_mesh_transparent_i_d_ : bg_mesh_opaque_i_d_)); + draw_transparent ? bg_mesh_transparent_id_ : bg_mesh_opaque_id_)); } c.Submit(); } diff --git a/src/ballistica/ui_v1/widget/container_widget.h b/src/ballistica/ui_v1/widget/container_widget.h index ab8a802a9..4d798d142 100644 --- a/src/ballistica/ui_v1/widget/container_widget.h +++ b/src/ballistica/ui_v1/widget/container_widget.h @@ -218,8 +218,8 @@ class ContainerWidget : public Widget { Object::WeakRef start_button_; Widget* selected_widget_{}; Widget* prev_selected_widget_{}; - base::SysMeshID bg_mesh_transparent_i_d_{}; - base::SysMeshID bg_mesh_opaque_i_d_{}; + base::SysMeshID bg_mesh_transparent_id_{}; + base::SysMeshID bg_mesh_opaque_id_{}; TransitionType transition_type_{}; float width_{}; float height_{}; @@ -244,6 +244,8 @@ class ContainerWidget : public Widget { float transition_start_offset_{}; float transition_scale_{1.0f}; float d_transition_scale_{}; + float bg_center_fudge_x_{}; + float bg_center_fudge_y_{}; millisecs_t last_activate_time_millisecs_{}; millisecs_t transition_start_time_{}; millisecs_t dynamics_update_time_millisecs_{}; diff --git a/src/ballistica/ui_v1/widget/h_scroll_widget.cc b/src/ballistica/ui_v1/widget/h_scroll_widget.cc index c32f67522..48f1b86ee 100644 --- a/src/ballistica/ui_v1/widget/h_scroll_widget.cc +++ b/src/ballistica/ui_v1/widget/h_scroll_widget.cc @@ -60,7 +60,7 @@ void HScrollWidget::ClampThumb_(bool velocity_clamp, bool position_clamp) { float child_w = (**i).GetWidth(); if (velocity_clamp) { - if (child_offset_h_ < 0) { + if (child_offset_h_ < 0.0f) { // Even in velocity case do some sane clamping. float diff = child_offset_h_; inertia_scroll_rate_ += @@ -68,11 +68,12 @@ void HScrollWidget::ClampThumb_(bool velocity_clamp, bool position_clamp) { inertia_scroll_rate_ *= 0.9f; } else if (child_offset_h_ - > child_w - (width() - 2 * (border_width_ + kMarginH))) { + > child_w - (width() - 2.0f * (border_width_ + kMarginH))) { float diff = child_offset_h_ - (child_w - - std::min(child_w, (width() - 2 * (border_width_ + kMarginH)))); + - std::min(child_w, + (width() - 2.0f * (border_width_ + kMarginH)))); inertia_scroll_rate_ += diff * (is_scrolling ? strong_force : weak_force); inertia_scroll_rate_ *= 0.9f; @@ -82,19 +83,20 @@ void HScrollWidget::ClampThumb_(bool velocity_clamp, bool position_clamp) { // Hard clipping if we're dragging the scrollbar. if (position_clamp) { if (child_offset_h_smoothed_ - > child_w - (width() - 2 * (border_width_ + kMarginH))) { + > child_w - (width() - 2.0f * (border_width_ + kMarginH))) { child_offset_h_smoothed_ = - child_w - (width() - 2 * (border_width_ + kMarginH)); + child_w - (width() - 2.0f * (border_width_ + kMarginH)); } - if (child_offset_h_smoothed_ < 0) { - child_offset_h_smoothed_ = 0; + if (child_offset_h_smoothed_ < 0.0f) { + child_offset_h_smoothed_ = 0.0f; } if (child_offset_h_ - > child_w - (width() - 2 * (border_width_ + kMarginH))) { - child_offset_h_ = child_w - (width() - 2 * (border_width_ + kMarginH)); + > child_w - (width() - 2.0f * (border_width_ + kMarginH))) { + child_offset_h_ = + child_w - (width() - 2.0f * (border_width_ + kMarginH)); } - if (child_offset_h_ < 0) { - child_offset_h_ = 0; + if (child_offset_h_ < 0.0f) { + child_offset_h_ = 0.0f; } } } @@ -378,8 +380,10 @@ auto HScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { child_offset_h_ - (child_h - std::min(child_h, - (width() - 2 * (border_width_ + kMarginH)))); - if (diff > 0) past_end = true; + (width() - 2.0f * (border_width_ + kMarginH)))); + if (diff > 0.0f) { + past_end = true; + } } if (past_end) { new_val = scroll_speed * 0.1f * m.fval3; @@ -402,7 +406,7 @@ auto HScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { case base::WidgetMessage::Type::kMouseWheelH: { float x = m.fval1; float y = m.fval2; - if ((x >= 0.0f) && (x < width()) && (y >= 0.0f) && (y < height())) { + if (x >= 0.0f && x < width() && y >= 0.0f && y < height()) { claimed = true; pass = false; @@ -474,7 +478,7 @@ auto HScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { float s_right = width() - border_width_; float s_left = border_width_; float sb_thumb_width = - amount_visible_ * (width() - 2 * border_width_); + amount_visible_ * (width() - 2.0f * border_width_); float sb_thumb_right = s_right - child_offset_h_ / child_max_offset_ @@ -483,7 +487,7 @@ auto HScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { // To right of thumb (page-right). if (x >= sb_thumb_right) { smoothing_amount_ = 1.0f; // So we can see the transition. - child_offset_h_ -= (width() - 2 * (border_width_ + kMarginH)); + child_offset_h_ -= (width() - 2.0f * (border_width_ + kMarginH)); MarkForUpdate(); ClampThumb_(false, true); } else if (x >= sb_thumb_right - sb_thumb_width) { @@ -494,7 +498,7 @@ auto HScrollWidget::HandleMessage(const base::WidgetMessage& m) -> bool { } else if (x >= s_left) { // To left of thumb (page left). smoothing_amount_ = 1.0f; // So we can see the transition. - child_offset_h_ += (width() - 2 * (border_width_ + kMarginH)); + child_offset_h_ += (width() - 2.0f * (border_width_ + kMarginH)); MarkForUpdate(); ClampThumb_(false, true); } @@ -534,7 +538,7 @@ void HScrollWidget::UpdateLayout() { } float child_w = (**i).GetWidth(); child_max_offset_ = child_w - (width() - 2.0f * (border_width_ + kMarginH)); - amount_visible_ = (width() - 2 * (border_width_ + kMarginH)) / child_w; + amount_visible_ = (width() - 2.0f * (border_width_ + kMarginH)) / child_w; if (amount_visible_ > 1.0f) { amount_visible_ = 1.0f; if (center_small_content_) { @@ -599,7 +603,7 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { if (!has_momentum_ && (current_time_ms - last_velocity_event_time_millisecs_ > 1000 / 30)) { - inertia_scroll_rate_ = 0; + inertia_scroll_rate_ = 0.0f; } // Lastly we apply smoothing so that if we're snapping to a specific @@ -637,9 +641,10 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { { base::EmptyComponent c(pass); c.SetTransparent(draw_transparent); - auto scissor = c.ScopedScissor({l + border_width_, b + border_height_ + 1, - l + (width() - border_width_ - 0), - b + (height() - border_height_) - 1}); + auto scissor = + c.ScopedScissor({l + border_width_, b + border_height_ + 1.0f, + l + (width() - border_width_ - 0.0f), + b + (height() - border_height_) - 1.0f}); c.Submit(); // Get out of the way for child drawing. set_simple_culling_left(l + border_width_); @@ -705,7 +710,7 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { float b_border, t_border, l_border, r_border; b_border = 6.0f; t_border = 3.0f; - if (sb_thumb_width > 100) { + if (sb_thumb_width > 100.0f) { auto wd = r2 - l2; l_border = wd * 0.04f; r_border = wd * 0.06f; @@ -724,12 +729,6 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { base::SimpleComponent c(pass); c.SetTransparent(draw_transparent); - // float c_scale = 1.0f; - // if (mouse_held_thumb_) { - // c_scale = 1.8f; - // } else if (mouse_over_thumb_) { - // c_scale = 1.25f; - // } float frame_duration = frame_def->display_time_elapsed(); @@ -744,14 +743,14 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { if (smooth_diff || (touch_held_ && touch_is_scrolling_) || std::abs(inertia_scroll_rate_) > 1.0f || (mouse_over_ - && frame_def->display_time() - last_mouse_move_time_ < 0.1)) { + && frame_def->display_time() - last_mouse_move_time_ < 0.1f)) { last_scroll_bar_show_time_ = frame_def->display_time(); } } // Fade in if we want to see the scrollbar. Start fading out a moment // after we stop wanting to see it. - if (frame_def->display_time() - last_scroll_bar_show_time_ < 1.0) { + if (frame_def->display_time() - last_scroll_bar_show_time_ < 1.0f) { touch_fade_ = std::min(1.5f, touch_fade_ + 2.0f * frame_duration); } else { touch_fade_ = std::max(0.0f, touch_fade_ - frame_duration); @@ -761,17 +760,17 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { { auto scissor = - c.ScopedScissor({l + border_width_, b + border_height_ + 1, + c.ScopedScissor({l + border_width_, b + border_height_ + 1.0f, l + (width()), b + (height() * 0.995f)}); auto xf = c.ScopedTransform(); c.Translate(thumb_center_x_, thumb_center_y_, 0.75f); c.Scale(-thumb_width_, thumb_height_, 0.1f); c.FlipCullFace(); - c.Rotate(-90, 0, 0, 1); + c.Rotate(-90.0f, 0.0f, 0.0f, 1.0f); if (draw_transparent) { c.DrawMeshAsset(g_base->assets->SysMesh( - sb_thumb_width > 100 + sb_thumb_width > 100.0f ? base::SysMeshID::kScrollBarThumbSimple : base::SysMeshID::kScrollBarThumbShortSimple)); } @@ -801,7 +800,7 @@ void HScrollWidget::Draw(base::RenderPass* pass, bool draw_transparent) { } base::SimpleComponent c(pass); c.SetTransparent(true); - c.SetColor(1, 1, 1, border_opacity_); + c.SetColor(1.0f, 1.0f, 1.0f, border_opacity_); c.SetTexture(g_base->assets->SysTexture(base::SysTextureID::kScrollWidget)); { auto xf = c.ScopedTransform(); diff --git a/tools/bacommon/cloudui/__init__.py b/tools/bacommon/cloudui/__init__.py index 5173487ac..aa5db663e 100644 --- a/tools/bacommon/cloudui/__init__.py +++ b/tools/bacommon/cloudui/__init__.py @@ -3,8 +3,9 @@ """Common CloudUI bits.""" from bacommon.cloudui._cloudui import ( - CloudUIRequestMethod, CloudUIRequest, + CloudUIRequestTypeID, + UnknownCloudUIRequest, CloudUIResponse, CloudUIResponseTypeID, UnknownCloudUIResponse, @@ -12,8 +13,9 @@ __all__ = [ - 'CloudUIRequestMethod', 'CloudUIRequest', + 'CloudUIRequestTypeID', + 'UnknownCloudUIRequest', 'CloudUIResponse', 'CloudUIResponseTypeID', 'UnknownCloudUIResponse', diff --git a/tools/bacommon/cloudui/_cloudui.py b/tools/bacommon/cloudui/_cloudui.py index be1df9f31..a22fe4fb9 100644 --- a/tools/bacommon/cloudui/_cloudui.py +++ b/tools/bacommon/cloudui/_cloudui.py @@ -5,47 +5,78 @@ from __future__ import annotations from enum import Enum -from dataclasses import dataclass, field -from typing import override, assert_never, TYPE_CHECKING, Annotated +from dataclasses import dataclass +from typing import override, assert_never, TYPE_CHECKING -from efro.dataclassio import ioprepped, IOMultiType, IOAttrs +from efro.dataclassio import ioprepped, IOMultiType if TYPE_CHECKING: pass -class CloudUIRequestMethod(Enum): - """Typeof of requests that can be made to cloud-ui servers.""" +class CloudUIRequestTypeID(Enum): + """Type ID for each of our subclasses.""" - #: An unknown request method. This can appear if a newer client is - #: requesting some method from an older server that is not known to - #: the server. UNKNOWN = 'u' + V1 = 'v1' + + +class CloudUIRequest(IOMultiType[CloudUIRequestTypeID]): + """UI defined by the cloud. + + Conceptually similar to a basic html request, except using app UI. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudUIRequestTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: CloudUIRequestTypeID) -> type[CloudUIRequest]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = CloudUIRequestTypeID + if type_id is t.UNKNOWN: + return UnknownCloudUIRequest + if type_id is t.V1: + from bacommon.cloudui.v1 import Request + + return Request + + # Make sure we cover all types. + assert_never(type_id) - #: Fetch some resource. This can be retried and its results can - #: optionally be cached for some amount of time. - GET = 'g' + @override + @classmethod + def get_unknown_type_fallback(cls) -> CloudUIRequest: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownCloudUIRequest() - #: Change some resource. This cannot be implicitly retried (at least - #: without deduplication), nor can it be cached. - POST = 'p' + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' @ioprepped @dataclass -class CloudUIRequest: - """Full request to cloud-ui.""" - - path: Annotated[str, IOAttrs('p')] - method: Annotated[ - CloudUIRequestMethod, - IOAttrs( - 'm', store_default=False, enum_fallback=CloudUIRequestMethod.UNKNOWN - ), - ] = CloudUIRequestMethod.GET - params: Annotated[dict, IOAttrs('r', store_default=False)] = field( - default_factory=dict - ) +class UnknownCloudUIRequest(CloudUIRequest): + """Fallback type for unrecognized UI types. + + Will show the client a 'cannot display this UI' placeholder request. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudUIRequestTypeID: + return CloudUIRequestTypeID.UNKNOWN class CloudUIResponseTypeID(Enum): diff --git a/tools/bacommon/cloudui/v1.py b/tools/bacommon/cloudui/v1.py index 1a79da3bd..574fbf9db 100644 --- a/tools/bacommon/cloudui/v1.py +++ b/tools/bacommon/cloudui/v1.py @@ -10,7 +10,74 @@ from efro.dataclassio import ioprepped, IOAttrs, IOMultiType -from bacommon.cloudui._cloudui import CloudUIResponse, CloudUIResponseTypeID +from bacommon.cloudui._cloudui import ( + CloudUIRequest, + CloudUIRequestTypeID, + CloudUIResponse, + CloudUIResponseTypeID, +) + + +class RequestMethod(Enum): + """Typeof of requests that can be made to cloud-ui servers.""" + + #: An unknown request method. This can appear if a newer client is + #: requesting some method from an older server that is not known to + #: the server. + UNKNOWN = 'u' + + #: Fetch some resource. This can be retried and its results can + #: optionally be cached for some amount of time. + GET = 'g' + + #: Change some resource. This cannot be implicitly retried (at least + #: without deduplication), nor can it be cached. + POST = 'p' + + +@ioprepped +@dataclass +class Request(CloudUIRequest): + """Full request to cloud-ui.""" + + path: Annotated[str, IOAttrs('p')] + method: Annotated[ + RequestMethod, + IOAttrs('m', store_default=False, enum_fallback=RequestMethod.UNKNOWN), + ] = RequestMethod.GET + params: Annotated[dict, IOAttrs('r', store_default=False)] = field( + default_factory=dict + ) + + @override + @classmethod + def get_type_id(cls) -> CloudUIRequestTypeID: + return CloudUIRequestTypeID.V1 + + +class TargetBehavior(Enum): + """How a cloud-ui request should be fulfilled.""" + + #: Default target - adds a new window to the nav stack and fulfills + #: the request there. + DEFAULT = 'd' + + #: Immediately replaces the contents of the current window with no + #: transitions; used for dynamic UIs. + REPLACE = 'r' + + #: Close the current window. Request is ignored. + CLOSE = 'c' + + +@ioprepped +@dataclass +class Target: + """Defines where and how a request should be fulfilled.""" + + behavior: Annotated[TargetBehavior, IOAttrs('b', store_default=False)] = ( + TargetBehavior.DEFAULT + ) class HAlign(Enum): @@ -196,6 +263,10 @@ class Style(Enum): #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language #: support. label: Annotated[str | None, IOAttrs('l', store_default=False)] = None + + request: Annotated[Request | None, IOAttrs('r', store_default=False)] = None + target: Annotated[Target | None, IOAttrs('t', store_default=False)] = None + size: Annotated[ tuple[float, float] | None, IOAttrs('sz', store_default=False) ] = None @@ -213,7 +284,7 @@ class Style(Enum): text_scale: Annotated[float | None, IOAttrs('ts', store_default=False)] = ( None ) - texture: Annotated[str | None, IOAttrs('t', store_default=False)] = None + texture: Annotated[str | None, IOAttrs('tex', store_default=False)] = None scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 padding_left: Annotated[float, IOAttrs('pl', store_default=False)] = 0.0 padding_top: Annotated[float, IOAttrs('pt', store_default=False)] = 0.0