# Released under the MIT License. See LICENSE for details.
#
"""Provides a popup window to view achievements."""
from __future__ import annotations # Docs-generation hack.
from typing import TypedDict, override
from bauiv1lib.utils import apply_toolbar_scroll_fades, get_screen_margins
import bauiv1 as bui
from bauiv1 import _commonassets, _classicassets
class _BackButtonGeometry(TypedDict):
position: tuple[float, float]
size: tuple[float, float]
scale: float
def _back_button_geometry(
uiscale: bui.UIScale, close_style: bool, yoffs: float
) -> _BackButtonGeometry:
"""The window's back/close button's position/size/scale kwargs.
Sized to match doc-ui windows' back/close buttons on screen (as of
2026-09-30): 50x50 close / 60x55 back at 0.66 (medium) / 0.6
(large), centered at (68, yoffs - 30), where the old 60x60-at-0.6
sat.
"""
size = (50.0, 50.0) if close_style else (60.0, 55.0)
scale = 0.66 if uiscale is bui.UIScale.MEDIUM else 0.6
return {
'position': (
68.0 - 0.5 * size[0] * scale,
yoffs - 30.0 - 0.5 * size[1] * scale,
),
'size': size,
'scale': scale,
}
[docs]
class AchievementsWindow(bui.MainWindow):
"""Popup window to view achievements."""
def __init__(
self,
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
auxiliary_style: bool = True,
):
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
from baclassic import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._uiopenstate = bui.UIOpenState('classicachievements')
self._width = 800 if uiscale is bui.UIScale.SMALL else 550
self._height = (
450
if uiscale is bui.UIScale.SMALL
else 488 if uiscale is bui.UIScale.MEDIUM else 594
)
# Do some fancy math to fill all available screen area up to the
# size of our backing container. This lets us fit to the exact
# screen shape at small ui scale.
screensize = bui.get_virtual_screen_size()
scale = (
2.4
if uiscale is bui.UIScale.SMALL
else 1.2 if uiscale is bui.UIScale.MEDIUM else 0.96
)
# Calc screen size in our local container space and clamp to a
# bit smaller than our container size.
target_width = min(self._width - 60, screensize[0] / scale)
target_height = min(self._height - 70, screensize[1] / scale)
# In small ui we extend our scrollable area out into the side
# screen margins (space between the virtual bounds and the
# actual screen edges) while keeping content laid out within the
# virtual bounds. Our top and bottom stay at the virtual
# bounds', where our toolbar fades are calibrated.
margin_left, margin_right, _, _ = (
get_screen_margins(scale)
if uiscale is bui.UIScale.SMALL
else (0.0, 0.0, 0.0, 0.0)
)
# To get top/left coords, go to the center of our window and
# offset by half the width/height of our target area.
yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
scroll_width = target_width
scroll_height = target_height - 25
scroll_bottom = yoffs - 54 - scroll_height
# Go with full-screen scrollable area in small ui.
if uiscale is bui.UIScale.SMALL:
scroll_height += 30
scroll_bottom -= 3
# At medium/large, extend our scroll area's top up behind our
# title (a milder version of what small scale does), with a
# longer top fade keeping the title legible over content
# scrolled up there; our content gets padded by the same amount
# so it stays put.
scroll_top_extend = 0.0 if uiscale is bui.UIScale.SMALL else 40.0
scroll_height += scroll_top_extend
# Extend the scroll area out into the screen margins (content
# stays put; see below).
scroll_x = self._width * 0.5 - scroll_width * 0.5 - margin_left
scroll_width += margin_left + margin_right
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=scale,
),
transition=transition,
origin_widget=origin_widget,
# We're affected by screen size only at small ui-scale.
refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
)
achievements = bui.app.classic.ach.achievements
num_complete = len([a for a in achievements if a.complete])
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
id=f'{self.main_window_id_prefix}|scroll',
size=(scroll_width, scroll_height),
position=(scroll_x, scroll_bottom),
capture_arrows=True,
simple_culling_v=10,
border_opacity=0.4,
fading_scrollbar=True,
# Content fades into our backing at our top and bottom
# (nothing scrolls sideways, so no fades there).
edge_style='fade',
edge_fade=(0.0, 40.0, 0.0, 40.0 + scroll_top_extend),
# Arrows scroll us; past either end they move on to our
# neighbors (toolbars included, via autoselect). No
# selection glow (it clashes with our title overlap).
move_out_at_ends=True,
highlight=False,
)
# (Made after our scroll area, which can reach up behind it:
# presses go to the last-made widget wanting them.)
self._back_button = self._make_back_button(
uiscale, auxiliary_style, yoffs
)
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
# Moving up out of our scroll area lands on our back button
# (the toolbar's in small ui); down finds the toolbars below
# (autoselect).
if self._back_button is None:
back_button = bui.get_special_widget('back_button')
bui.widget(edit=self._scrollwidget, left_widget=back_button)
else:
back_button = self._back_button
bui.widget(edit=back_button, down_widget=self._scrollwidget)
bui.widget(
edit=self._scrollwidget, autoselect=True, up_widget=back_button
)
# With full-screen scrolling, fade content as it approaches
# toolbars.
if uiscale is bui.UIScale.SMALL:
apply_toolbar_scroll_fades(self._scrollwidget)
# In small UI mode when the screen is narrow enough we need to
# go with a smaller title to avoid it overlapping with toolbar
# bits.
if uiscale is bui.UIScale.SMALL and target_width < 630:
self._title_text = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, yoffs - 42),
size=(0, 0),
h_align='center',
v_align='center',
scale=0.6,
text=_commonassets.strings.compose.spaced_pair(
first=_commonassets.strings.compose.heading_suffix(
main=_classicassets.strings.ui.achievements
),
second=f'{num_complete}/{len(achievements)}',
),
maxwidth=86,
color=bui.app.ui_v1.title_color,
)
else:
self._title_text = bui.textwidget(
parent=self._root_widget,
position=(
self._width * 0.5,
yoffs - (42 if uiscale is bui.UIScale.SMALL else 30),
),
size=(0, 0),
h_align='center',
v_align='center',
scale=0.6,
text=_classicassets.strings.account.achievement_progress(
complete=str(num_complete),
total=str(len(achievements)),
),
maxwidth=180,
color=bui.app.ui_v1.title_color,
)
# Draw in front of our scroll area's fade.
bui.widget(edit=self._title_text, depth_range=(0.9, 1.0))
bui.containerwidget(
edit=self._root_widget, cancel_button=self._back_button
)
# Our rows run down from our top (below any room left for the
# toolbar at small scale or for our extension up behind the
# title otherwise), with our points total last. Below that we
# keep enough room to clear the toolbars at small scale, but
# just a little otherwise.
incr = 36
sub_width = scroll_width - 25
top_room = (30.0 if uiscale is bui.UIScale.SMALL else 0.0) + (
scroll_top_extend
)
total_bottom_pad = 65.0 if uiscale is bui.UIScale.SMALL else 30.0
sub_height = (
top_room + 20.0 + len(achievements) * incr + total_bottom_pad
)
# Our scroll area is widened out into the screen margins, but
# content should stay laid out within the virtual bounds; do
# our layout math against this un-widened width offset by the
# left margin.
content_width = sub_width - margin_left - margin_right
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
id=f'{self.main_window_id_prefix}|subcontainer',
size=(sub_width, sub_height),
background=False,
)
basey = sub_height - top_room
total_pts = 0
for i, ach in enumerate(achievements):
complete = ach.complete
bui.textwidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 0.08 - 5,
basey - 20 - incr * i,
),
maxwidth=20,
scale=0.5,
color=(0.6, 0.6, 0.7) if complete else (0.6, 0.6, 0.7, 0.2),
flatness=1.0,
shadow=0.0,
text=str(i + 1),
size=(0, 0),
h_align='right',
v_align='center',
)
bui.imagewidget(
parent=self._subcontainer,
position=(
(
margin_left + content_width * 0.10 + 1,
basey - 20 - incr * i - 9,
)
if complete
else (
margin_left + content_width * 0.10 - 4,
basey - 20 - incr * i - 14,
)
),
size=(18, 18) if complete else (27, 27),
opacity=1.0 if complete else 0.3,
color=ach.get_icon_color(complete)[:3],
texture=ach.get_icon_ui_texture(complete),
)
if complete:
bui.imagewidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 0.10 - 4,
basey - 25 - incr * i - 9,
),
size=(28, 28),
color=(2, 1.4, 0),
texture=_classicassets.textures.achievement_outline.get(),
)
bui.textwidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 0.19,
basey - 19 - incr * i + 3,
),
maxwidth=content_width * 0.62,
scale=0.6,
flatness=1.0,
shadow=0.0,
color=(1, 1, 1) if complete else (1, 1, 1, 0.2),
text=ach.display_name_langstr,
size=(0, 0),
h_align='left',
v_align='center',
)
bui.textwidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 0.19,
basey - 19 - incr * i - 10,
),
maxwidth=content_width * 0.62,
scale=0.4,
flatness=1.0,
shadow=0.0,
color=(0.83, 0.8, 0.85) if complete else (0.8, 0.8, 0.8, 0.2),
text=(
ach.description_full_complete_langstr
if complete
else ach.description_full_langstr
),
size=(0, 0),
h_align='left',
v_align='center',
)
chest_type = ach.get_award_chest_type()
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
chest_type, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
chestsize = 24.0
bui.imagewidget(
parent=self._subcontainer,
opacity=0.0 if complete else 1.0,
position=(
margin_left + content_width * 0.92 - 40.0 - chestsize * 0.5,
basey - 20 - incr * i - chestsize * 0.5,
),
size=(chestsize, chestsize),
color=chestdisplayinfo.color,
texture=chestdisplayinfo.texclosed.ui().get(),
tint_texture=chestdisplayinfo.texclosedtint.ui().get(),
tint_color=chestdisplayinfo.tint,
tint2_color=chestdisplayinfo.tint2,
tint3_color=chestdisplayinfo.tint3,
)
pts = ach.power_ranking_value
bui.textwidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 0.92,
basey - 20 - incr * i,
),
maxwidth=content_width * 0.15,
color=(0.7, 0.8, 1.0) if complete else (0.9, 0.9, 1.0, 0.3),
flatness=1.0,
shadow=0.0,
scale=0.6,
text=_classicassets.strings.coop.power_ranking_points(
number=str(pts)
),
size=(0, 0),
h_align='center',
v_align='center',
)
if complete:
total_pts += pts
bui.textwidget(
parent=self._subcontainer,
position=(
margin_left + content_width * 1.0,
basey - 20 - incr * len(achievements),
),
maxwidth=content_width * 0.5,
scale=0.7,
color=(0.7, 0.8, 1.0),
flatness=1.0,
shadow=0.0,
text=_commonassets.strings.compose.spaced_pair(
first=_commonassets.strings.values.total,
second=(
_classicassets.strings.league
).power_ranking_points_equals(number=str(total_pts)),
),
size=(0, 0),
h_align='right',
v_align='center',
)
def _make_back_button(
self, uiscale: bui.UIScale, auxiliary_style: bool, yoffs: float
) -> bui.Widget | None:
"""Make our back/close button (small scale uses the toolbar's)."""
if uiscale is bui.UIScale.SMALL:
bui.containerwidget(
edit=self._root_widget, on_cancel_call=self.main_window_back
)
return None
btn = bui.buttonwidget(
parent=self._root_widget,
id=f'{self.main_window_id_prefix}|back',
autoselect=True,
**_back_button_geometry(uiscale, auxiliary_style, yoffs),
label=(
bui.SpecialChar.CLOSE
if auxiliary_style
else bui.SpecialChar.BACK
).value,
button_type=None if auxiliary_style else 'backSmall',
on_activate_call=self.main_window_back,
)
# Draw in front of our scroll area's fade.
bui.widget(edit=btn, depth_range=(0.9, 1.0))
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
return btn
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition, origin_widget=origin_widget
)
)
[docs]
@override
def main_window_should_preserve_selection(self) -> bool:
return True
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum