# Released under the MIT License. See LICENSE for details.
#
"""Provides popup windows for choosing colors."""
from __future__ import annotations
from typing import TYPE_CHECKING, override
from bauiv1lib.popup import PopupWindow
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Sequence
REQUIRE_PRO = False
[docs]
class ColorPicker(PopupWindow):
"""A popup UI to select from a set of colors.
Passes the color to the delegate's color_picker_selected_color() method.
"""
def __init__(
self,
parent: bui.Widget,
position: tuple[float, float],
*,
initial_color: Sequence[float] = (1.0, 1.0, 1.0),
delegate: Any = None,
scale: float | None = None,
offset: tuple[float, float] = (0.0, 0.0),
tag: Any = '',
):
# pylint: disable=too-many-locals
assert bui.app.classic is not None
c_raw = bui.app.classic.get_player_colors()
assert len(c_raw) == 16
self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
uiscale = bui.app.ui_v1.uiscale
if scale is None:
scale = (
2.3
if uiscale is bui.UIScale.SMALL
else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
)
self._parent = parent
self._position = position
self._scale = scale
self._offset = offset
self._delegate = delegate
self._transitioning_out = False
self._tag = tag
self._initial_color = initial_color
# Create our _root_widget.
super().__init__(
position=position,
size=(210, 240),
scale=scale,
focus_position=(10, 10),
focus_size=(190, 220),
bg_color=(0.5, 0.5, 0.5),
offset=offset,
)
rows: list[list[bui.Widget]] = []
closest_dist = 9999.0
closest = (0, 0)
for y in range(4):
row: list[bui.Widget] = []
rows.append(row)
for x in range(4):
color = self.colors[y][x]
dist = (
abs(color[0] - initial_color[0])
+ abs(color[1] - initial_color[1])
+ abs(color[2] - initial_color[2])
)
if dist < closest_dist:
closest = (x, y)
closest_dist = dist
btn = bui.buttonwidget(
parent=self.root_widget,
position=(22 + 45 * x, 185 - 45 * y),
size=(35, 40),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._select, x, y),
autoselect=True,
color=color,
extra_touch_border_scale=0.0,
)
row.append(btn)
other_button = bui.buttonwidget(
parent=self.root_widget,
position=(105 - 60, 13),
color=(0.7, 0.7, 0.7),
text_scale=0.5,
textcolor=(0.8, 0.8, 0.8),
size=(120, 30),
label=bui.Lstr(
resource='otherText',
fallback_resource='coopSelectWindow.customText',
),
autoselect=True,
on_activate_call=bui.WeakCall(self._select_other),
)
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro():
bui.imagewidget(
parent=self.root_widget,
position=(50, 12),
size=(30, 30),
texture=bui.gettexture('lock'),
draw_controller=other_button,
)
# If their color is close to one of our swatches, select it.
# Otherwise select 'other'.
if closest_dist < 0.03:
bui.containerwidget(
edit=self.root_widget,
selected_child=rows[closest[1]][closest[0]],
)
else:
bui.containerwidget(
edit=self.root_widget, selected_child=other_button
)
[docs]
def get_tag(self) -> Any:
"""Return this popup's tag."""
return self._tag
def _select_other(self) -> None:
from bauiv1lib import purchase
# Requires pro.
assert bui.app.classic is not None
if REQUIRE_PRO and not bui.app.classic.accounts.have_pro():
purchase.PurchaseWindow(items=['pro'])
self._transition_out()
return
ColorPickerExact(
parent=self._parent,
position=self._position,
initial_color=self._initial_color,
delegate=self._delegate,
scale=self._scale,
offset=self._offset,
tag=self._tag,
)
# New picker now 'owns' the delegate; we shouldn't send it any
# more messages.
self._delegate = None
self._transition_out()
def _select(self, x: int, y: int) -> None:
if self._delegate:
self._delegate.color_picker_selected_color(self, self.colors[y][x])
bui.apptimer(0.05, self._transition_out)
def _transition_out(self) -> None:
if not self._transitioning_out:
self._transitioning_out = True
if self._delegate is not None:
self._delegate.color_picker_closing(self)
bui.containerwidget(edit=self.root_widget, transition='out_scale')
[docs]
class ColorPickerExact(PopupWindow):
"""pops up a ui to select from a set of colors.
passes the color to the delegate's color_picker_selected_color() method"""
def __init__(
self,
parent: bui.Widget,
position: tuple[float, float],
*,
initial_color: Sequence[float] = (1.0, 1.0, 1.0),
delegate: Any = None,
scale: float | None = None,
offset: tuple[float, float] = (0.0, 0.0),
tag: Any = '',
):
# pylint: disable=too-many-locals
del parent # Unused var.
assert bui.app.classic is not None
c_raw = bui.app.classic.get_player_colors()
assert len(c_raw) == 16
self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
uiscale = bui.app.ui_v1.uiscale
if scale is None:
scale = (
2.3
if uiscale is bui.UIScale.SMALL
else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
)
self._delegate = delegate
self._transitioning_out = False
self._tag = tag
self._color = list(initial_color)
self._last_press_time = bui.apptime()
self._last_press_color_name: str | None = None
self._last_press_increasing: bool | None = None
self._hex_timer: bui.AppTimer | None = None
self._hex_prev_text: str = '#FFFFFF'
self._change_speed = 1.0
width = 180.0
height = 240.0
# Creates our _root_widget.
super().__init__(
position=position,
size=(width, height),
scale=scale,
focus_position=(10, 10),
focus_size=(width - 20, height - 20),
bg_color=(0.5, 0.5, 0.5),
offset=offset,
)
self._swatch = bui.imagewidget(
parent=self.root_widget,
position=(width * 0.5 - 65 + 5, height - 95),
size=(130, 115),
texture=bui.gettexture('clayStroke'),
color=(1, 0, 0),
)
self._hex_textbox = bui.textwidget(
parent=self.root_widget,
position=(width * 0.5 - 37.5 + 3, height - 51),
max_chars=9,
text='#FFFFFF',
autoselect=True,
size=(75, 30),
v_align='center',
editable=True,
maxwidth=70,
allow_clear_button=False,
glow_type='uniform',
)
x = 50
y = height - 90
self._label_r: bui.Widget
self._label_g: bui.Widget
self._label_b: bui.Widget
for color_name, color_val in [
('r', (1, 0.15, 0.15)),
('g', (0.15, 1, 0.15)),
('b', (0.15, 0.15, 1)),
]:
txt = bui.textwidget(
parent=self.root_widget,
position=(x - 10, y),
size=(0, 0),
h_align='center',
color=color_val,
v_align='center',
text='0.12',
)
setattr(self, '_label_' + color_name, txt)
for b_label, bhval, binc in [('-', 30, False), ('+', 75, True)]:
bui.buttonwidget(
parent=self.root_widget,
position=(x + bhval, y - 15),
scale=0.8,
repeat=True,
text_scale=1.3,
size=(40, 40),
label=b_label,
autoselect=True,
enable_sound=False,
on_activate_call=bui.WeakCall(
self._color_change_press, color_name, binc
),
)
y -= 42
btn = bui.buttonwidget(
parent=self.root_widget,
position=(width * 0.5 - 40, 10),
size=(80, 30),
text_scale=0.6,
color=(0.6, 0.6, 0.6),
textcolor=(0.7, 0.7, 0.7),
label=bui.Lstr(resource='doneText'),
on_activate_call=bui.WeakCall(self._transition_out),
autoselect=True,
)
bui.containerwidget(edit=self.root_widget, start_button=btn)
# Unlike the swatch picker, we stay open and constantly push our
# color to the delegate, so start doing that.
self._update_for_color()
# Update our HEX stuff!
self._update_for_hex()
self._hex_timer = bui.AppTimer(0.025, self._update_for_hex, repeat=True)
def _update_for_hex(self) -> None:
"""Update for any HEX or color change."""
from typing import cast
hextext = cast(str, bui.textwidget(query=self._hex_textbox))
hexcolor: tuple
# Check if our current hex text doesn't match with our old one.
# Convert our current hex text into a color if possible.
if hextext != self._hex_prev_text:
try:
hexcolor = hex_to_color(hextext)
if len(hexcolor) == 4:
r, g, b, a = hexcolor
del a # unused
else:
r, g, b = hexcolor
# Replace the color!
for i, ch in enumerate((r, g, b)):
self._color[i] = max(0.0, min(1.0, ch))
self._update_for_color()
# Usually, a ValueError will occur if the provided hex
# is incomplete, which occurs when in the midst of typing it.
except ValueError:
pass
# Store the current text for our next comparison.
self._hex_prev_text = hextext
# noinspection PyUnresolvedReferences
def _update_for_color(self) -> None:
if not self.root_widget:
return
bui.imagewidget(edit=self._swatch, color=self._color)
# We generate these procedurally, so pylint misses them.
# FIXME: create static attrs instead.
# pylint: disable=consider-using-f-string
bui.textwidget(edit=self._label_r, text='%.2f' % self._color[0])
bui.textwidget(edit=self._label_g, text='%.2f' % self._color[1])
bui.textwidget(edit=self._label_b, text='%.2f' % self._color[2])
if self._delegate is not None:
self._delegate.color_picker_selected_color(self, self._color)
# Show the HEX code of this color.
r, g, b = self._color
hexcode = color_to_hex(r, g, b, None)
self._hex_prev_text = hexcode
bui.textwidget(
edit=self._hex_textbox,
text=hexcode,
color=color_overlay_func(r, g, b),
)
def _color_change_press(self, color_name: str, increasing: bool) -> None:
# If we get rapid-fire presses, eventually start moving faster.
current_time = bui.apptime()
since_last = current_time - self._last_press_time
if (
since_last < 0.2
and self._last_press_color_name == color_name
and self._last_press_increasing == increasing
):
self._change_speed += 0.25
else:
self._change_speed = 1.0
self._last_press_time = current_time
self._last_press_color_name = color_name
self._last_press_increasing = increasing
color_index = ('r', 'g', 'b').index(color_name)
offs = int(self._change_speed) * (0.01 if increasing else -0.01)
self._color[color_index] = max(
0.0, min(1.0, self._color[color_index] + offs)
)
self._update_for_color()
[docs]
def get_tag(self) -> Any:
"""Return this popup's tag value."""
return self._tag
def _transition_out(self) -> None:
# Kill our timer
self._hex_timer = None
if not self._transitioning_out:
self._transitioning_out = True
if self._delegate is not None:
self._delegate.color_picker_closing(self)
bui.containerwidget(edit=self.root_widget, transition='out_scale')
[docs]
def hex_to_color(hex_color: str) -> tuple:
"""Transforms an RGB / RGBA hex code into an rgb1/rgba1 tuple.
Args:
hex_color (str): The HEX color.
Raises:
ValueError: If the provided HEX color isn't 6 or 8 characters long.
Returns:
tuple: The color tuple divided by 255.
"""
# Remove the '#' from the string if provided.
if hex_color.startswith('#'):
hex_color = hex_color.lstrip('#')
# Check if this has a valid length.
hexlength = len(hex_color)
if not hexlength in [6, 8]:
raise ValueError(f'Invalid HEX color provided: "{hex_color}"')
# Convert the hex bytes to their true byte form.
ar, ag, ab, aa = (
(int.from_bytes(bytes.fromhex(hex_color[0:2]))),
(int.from_bytes(bytes.fromhex(hex_color[2:4]))),
(int.from_bytes(bytes.fromhex(hex_color[4:6]))),
(
(int.from_bytes(bytes.fromhex(hex_color[6:8])))
if hexlength == 8
else None
),
)
# Divide all numbers by 255 and return.
nr, ng, nb, na = (
x / 255 if x is not None else None for x in (ar, ag, ab, aa)
)
return (nr, ng, nb, na) if aa is not None else (nr, ng, nb)
[docs]
def color_to_hex(r: float, g: float, b: float, a: float | None = 1.0) -> str:
"""Converts an rgb1 tuple to a HEX color code.
Args:
r: Red.
g: Green.
b: Blue.
a: Alpha. Defaults to 1.0.
Returns:
str: The hexified rgba values.
"""
# Turn our rgb1 to rgb255
nr, ng, nb, na = [
int(min(255, x * 255)) if x is not None else x for x in [r, g, b, a]
]
# Merge all values into their HEX representation.
hex_code = (
f'#{nr:02x}{ng:02x}{nb:02x}{na:02x}'
if na is not None
else f'#{nr:02x}{ng:02x}{nb:02x}'
)
return hex_code
[docs]
def color_overlay_func(
r: float, g: float, b: float, a: float | None = None
) -> tuple[float, ...]:
"""I could NOT come up with a better function name.
Args:
r: Red.
g: Green.
b: Blue.
a: Alpha. Defaults to None.
Returns:
tuple: A brighter color if the provided one is dark,
and a darker one if it's darker.
"""
# Calculate the relative luminance using the formula for sRGB
# https://www.w3.org/TR/WCAG20/#relativeluminancedef
def relative_luminance(color: float) -> Any:
if color <= 0.03928:
return color / 12.92
return ((color + 0.055) / 1.055) ** 2.4
luminance = (
0.2126 * relative_luminance(r)
+ 0.7152 * relative_luminance(g)
+ 0.0722 * relative_luminance(b)
)
# Set our color multiplier depending on the provided color's luminance.
luminant = 1.65 if luminance < 0.33 else 0.2
# Multiply our given numbers, making sure
# they don't blend in the original bg.
avg = (0.7 - (r + g + b / 3)) + 0.15
r, g, b = [max(avg, x * luminant) for x in (r, g, b)]
# Include our alpha and ship it!
return (r, g, b, a) if a is not None else (r, g, b)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum