bauiv1lib.colorpicker

Provides popup windows for choosing colors.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides popup windows for choosing colors."""
  4
  5from __future__ import annotations
  6
  7from typing import TYPE_CHECKING, override
  8
  9from bauiv1lib.popup import PopupWindow
 10import bauiv1 as bui
 11
 12if TYPE_CHECKING:
 13    from typing import Any, Sequence
 14
 15
 16class ColorPicker(PopupWindow):
 17    """A popup UI to select from a set of colors.
 18
 19    Passes the color to the delegate's color_picker_selected_color() method.
 20    """
 21
 22    def __init__(
 23        self,
 24        parent: bui.Widget,
 25        position: tuple[float, float],
 26        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
 27        delegate: Any = None,
 28        scale: float | None = None,
 29        offset: tuple[float, float] = (0.0, 0.0),
 30        tag: Any = '',
 31    ):
 32        # pylint: disable=too-many-locals
 33        assert bui.app.classic is not None
 34
 35        c_raw = bui.app.classic.get_player_colors()
 36        assert len(c_raw) == 16
 37        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
 38
 39        uiscale = bui.app.ui_v1.uiscale
 40        if scale is None:
 41            scale = (
 42                2.3
 43                if uiscale is bui.UIScale.SMALL
 44                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 45            )
 46        self._parent = parent
 47        self._position = position
 48        self._scale = scale
 49        self._offset = offset
 50        self._delegate = delegate
 51        self._transitioning_out = False
 52        self._tag = tag
 53        self._initial_color = initial_color
 54
 55        # Create our _root_widget.
 56        super().__init__(
 57            position=position,
 58            size=(210, 240),
 59            scale=scale,
 60            focus_position=(10, 10),
 61            focus_size=(190, 220),
 62            bg_color=(0.5, 0.5, 0.5),
 63            offset=offset,
 64        )
 65        rows: list[list[bui.Widget]] = []
 66        closest_dist = 9999.0
 67        closest = (0, 0)
 68        for y in range(4):
 69            row: list[bui.Widget] = []
 70            rows.append(row)
 71            for x in range(4):
 72                color = self.colors[y][x]
 73                dist = (
 74                    abs(color[0] - initial_color[0])
 75                    + abs(color[1] - initial_color[1])
 76                    + abs(color[2] - initial_color[2])
 77                )
 78                if dist < closest_dist:
 79                    closest = (x, y)
 80                    closest_dist = dist
 81                btn = bui.buttonwidget(
 82                    parent=self.root_widget,
 83                    position=(22 + 45 * x, 185 - 45 * y),
 84                    size=(35, 40),
 85                    label='',
 86                    button_type='square',
 87                    on_activate_call=bui.WeakCall(self._select, x, y),
 88                    autoselect=True,
 89                    color=color,
 90                    extra_touch_border_scale=0.0,
 91                )
 92                row.append(btn)
 93        other_button = bui.buttonwidget(
 94            parent=self.root_widget,
 95            position=(105 - 60, 13),
 96            color=(0.7, 0.7, 0.7),
 97            text_scale=0.5,
 98            textcolor=(0.8, 0.8, 0.8),
 99            size=(120, 30),
100            label=bui.Lstr(
101                resource='otherText',
102                fallback_resource='coopSelectWindow.customText',
103            ),
104            autoselect=True,
105            on_activate_call=bui.WeakCall(self._select_other),
106        )
107
108        # Custom colors are limited to pro currently.
109        assert bui.app.classic is not None
110        if not bui.app.classic.accounts.have_pro():
111            bui.imagewidget(
112                parent=self.root_widget,
113                position=(50, 12),
114                size=(30, 30),
115                texture=bui.gettexture('lock'),
116                draw_controller=other_button,
117            )
118
119        # If their color is close to one of our swatches, select it.
120        # Otherwise select 'other'.
121        if closest_dist < 0.03:
122            bui.containerwidget(
123                edit=self.root_widget,
124                selected_child=rows[closest[1]][closest[0]],
125            )
126        else:
127            bui.containerwidget(
128                edit=self.root_widget, selected_child=other_button
129            )
130
131    def get_tag(self) -> Any:
132        """Return this popup's tag."""
133        return self._tag
134
135    def _select_other(self) -> None:
136        from bauiv1lib import purchase
137
138        # Requires pro.
139        assert bui.app.classic is not None
140        if not bui.app.classic.accounts.have_pro():
141            purchase.PurchaseWindow(items=['pro'])
142            self._transition_out()
143            return
144        ColorPickerExact(
145            parent=self._parent,
146            position=self._position,
147            initial_color=self._initial_color,
148            delegate=self._delegate,
149            scale=self._scale,
150            offset=self._offset,
151            tag=self._tag,
152        )
153
154        # New picker now 'owns' the delegate; we shouldn't send it any
155        # more messages.
156        self._delegate = None
157        self._transition_out()
158
159    def _select(self, x: int, y: int) -> None:
160        if self._delegate:
161            self._delegate.color_picker_selected_color(self, self.colors[y][x])
162        bui.apptimer(0.05, self._transition_out)
163
164    def _transition_out(self) -> None:
165        if not self._transitioning_out:
166            self._transitioning_out = True
167            if self._delegate is not None:
168                self._delegate.color_picker_closing(self)
169            bui.containerwidget(edit=self.root_widget, transition='out_scale')
170
171    @override
172    def on_popup_cancel(self) -> None:
173        if not self._transitioning_out:
174            bui.getsound('swish').play()
175        self._transition_out()
176
177
178class ColorPickerExact(PopupWindow):
179    """pops up a ui to select from a set of colors.
180    passes the color to the delegate's color_picker_selected_color() method"""
181
182    def __init__(
183        self,
184        parent: bui.Widget,
185        position: tuple[float, float],
186        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
187        delegate: Any = None,
188        scale: float | None = None,
189        offset: tuple[float, float] = (0.0, 0.0),
190        tag: Any = '',
191    ):
192        # pylint: disable=too-many-locals
193        del parent  # Unused var.
194        assert bui.app.classic is not None
195
196        c_raw = bui.app.classic.get_player_colors()
197        assert len(c_raw) == 16
198        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
199
200        uiscale = bui.app.ui_v1.uiscale
201        if scale is None:
202            scale = (
203                2.3
204                if uiscale is bui.UIScale.SMALL
205                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
206            )
207        self._delegate = delegate
208        self._transitioning_out = False
209        self._tag = tag
210        self._color = list(initial_color)
211        self._last_press_time = bui.apptime()
212        self._last_press_color_name: str | None = None
213        self._last_press_increasing: bool | None = None
214        self._hex_timer: bui.AppTimer | None = None
215        self._hex_prev_text: str = '#FFFFFF'
216        self._change_speed = 1.0
217        width = 180.0
218        height = 240.0
219
220        # Creates our _root_widget.
221        super().__init__(
222            position=position,
223            size=(width, height),
224            scale=scale,
225            focus_position=(10, 10),
226            focus_size=(width - 20, height - 20),
227            bg_color=(0.5, 0.5, 0.5),
228            offset=offset,
229        )
230        self._swatch = bui.imagewidget(
231            parent=self.root_widget,
232            position=(width * 0.5 - 65 + 5, height - 95),
233            size=(130, 115),
234            texture=bui.gettexture('clayStroke'),
235            color=(1, 0, 0),
236        )
237        self._hex_textbox = bui.textwidget(
238            parent=self.root_widget,
239            position=(width * 0.5 - 37.5 + 3, height - 51),
240            max_chars=9,
241            text='#FFFFFF',
242            autoselect=True,
243            size=(75, 30),
244            v_align='center',
245            editable=True,
246            maxwidth=70,
247            allow_clear_button=False,
248            force_internal_editing=True,
249            glow_type='uniform',
250        )
251
252        x = 50
253        y = height - 90
254        self._label_r: bui.Widget
255        self._label_g: bui.Widget
256        self._label_b: bui.Widget
257        for color_name, color_val in [
258            ('r', (1, 0.15, 0.15)),
259            ('g', (0.15, 1, 0.15)),
260            ('b', (0.15, 0.15, 1)),
261        ]:
262            txt = bui.textwidget(
263                parent=self.root_widget,
264                position=(x - 10, y),
265                size=(0, 0),
266                h_align='center',
267                color=color_val,
268                v_align='center',
269                text='0.12',
270            )
271            setattr(self, '_label_' + color_name, txt)
272            for b_label, bhval, binc in [('-', 30, False), ('+', 75, True)]:
273                bui.buttonwidget(
274                    parent=self.root_widget,
275                    position=(x + bhval, y - 15),
276                    scale=0.8,
277                    repeat=True,
278                    text_scale=1.3,
279                    size=(40, 40),
280                    label=b_label,
281                    autoselect=True,
282                    enable_sound=False,
283                    on_activate_call=bui.WeakCall(
284                        self._color_change_press, color_name, binc
285                    ),
286                )
287            y -= 42
288
289        btn = bui.buttonwidget(
290            parent=self.root_widget,
291            position=(width * 0.5 - 40, 10),
292            size=(80, 30),
293            text_scale=0.6,
294            color=(0.6, 0.6, 0.6),
295            textcolor=(0.7, 0.7, 0.7),
296            label=bui.Lstr(resource='doneText'),
297            on_activate_call=bui.WeakCall(self._transition_out),
298            autoselect=True,
299        )
300        bui.containerwidget(edit=self.root_widget, start_button=btn)
301
302        # Unlike the swatch picker, we stay open and constantly push our
303        # color to the delegate, so start doing that.
304        self._update_for_color()
305
306        # Update our HEX stuff!
307        self._update_for_hex()
308        self._hex_timer = bui.AppTimer(0.025, self._update_for_hex, repeat=True)
309
310    def _update_for_hex(self) -> None:
311        """Update for any HEX or color change."""
312        from typing import cast
313
314        hextext = cast(str, bui.textwidget(query=self._hex_textbox))
315        hexcolor: tuple
316        # Check if our current hex text doesn't match with our old one.
317        # Convert our current hex text into a color if possible.
318        if hextext != self._hex_prev_text:
319            try:
320                hexcolor = hex_to_color(hextext)
321                if len(hexcolor) == 4:
322                    r, g, b, a = hexcolor
323                    del a  # unused
324                else:
325                    r, g, b = hexcolor
326                # Replace the color!
327                for i, ch in enumerate((r, g, b)):
328                    self._color[i] = max(0.0, min(1.0, ch))
329                self._update_for_color()
330            # Usually, a ValueError will occur if the provided hex
331            # is incomplete, which occurs when in the midst of typing it.
332            except ValueError:
333                pass
334        # Store the current text for our next comparison.
335        self._hex_prev_text = hextext
336
337    # noinspection PyUnresolvedReferences
338    def _update_for_color(self) -> None:
339        if not self.root_widget:
340            return
341        bui.imagewidget(edit=self._swatch, color=self._color)
342
343        # We generate these procedurally, so pylint misses them.
344        # FIXME: create static attrs instead.
345        # pylint: disable=consider-using-f-string
346        bui.textwidget(edit=self._label_r, text='%.2f' % self._color[0])
347        bui.textwidget(edit=self._label_g, text='%.2f' % self._color[1])
348        bui.textwidget(edit=self._label_b, text='%.2f' % self._color[2])
349        if self._delegate is not None:
350            self._delegate.color_picker_selected_color(self, self._color)
351
352        # Show the HEX code of this color.
353        r, g, b = self._color
354        hexcode = color_to_hex(r, g, b, None)
355        self._hex_prev_text = hexcode
356        bui.textwidget(
357            edit=self._hex_textbox,
358            text=hexcode,
359            color=color_overlay_func(r, g, b),
360        )
361
362    def _color_change_press(self, color_name: str, increasing: bool) -> None:
363        # If we get rapid-fire presses, eventually start moving faster.
364        current_time = bui.apptime()
365        since_last = current_time - self._last_press_time
366        if (
367            since_last < 0.2
368            and self._last_press_color_name == color_name
369            and self._last_press_increasing == increasing
370        ):
371            self._change_speed += 0.25
372        else:
373            self._change_speed = 1.0
374        self._last_press_time = current_time
375        self._last_press_color_name = color_name
376        self._last_press_increasing = increasing
377
378        color_index = ('r', 'g', 'b').index(color_name)
379        offs = int(self._change_speed) * (0.01 if increasing else -0.01)
380        self._color[color_index] = max(
381            0.0, min(1.0, self._color[color_index] + offs)
382        )
383        self._update_for_color()
384
385    def get_tag(self) -> Any:
386        """Return this popup's tag value."""
387        return self._tag
388
389    def _transition_out(self) -> None:
390        # Kill our timer
391        self._hex_timer = None
392        if not self._transitioning_out:
393            self._transitioning_out = True
394            if self._delegate is not None:
395                self._delegate.color_picker_closing(self)
396            bui.containerwidget(edit=self.root_widget, transition='out_scale')
397
398    @override
399    def on_popup_cancel(self) -> None:
400        if not self._transitioning_out:
401            bui.getsound('swish').play()
402        self._transition_out()
403
404
405def hex_to_color(hex_color: str) -> tuple:
406    """Transforms an RGB / RGBA hex code into an rgb1/rgba1 tuple.
407
408    Args:
409        hex_color (str): The HEX color.
410    Raises:
411        ValueError: If the provided HEX color isn't 6 or 8 characters long.
412    Returns:
413        tuple: The color tuple divided by 255.
414    """
415    # Remove the '#' from the string if provided.
416    if hex_color.startswith('#'):
417        hex_color = hex_color.lstrip('#')
418    # Check if this has a valid length.
419    hexlength = len(hex_color)
420    if not hexlength in [6, 8]:
421        raise ValueError(f'Invalid HEX color provided: "{hex_color}"')
422
423    # Convert the hex bytes to their true byte form.
424    ar, ag, ab, aa = (
425        (int.from_bytes(bytes.fromhex(hex_color[0:2]))),
426        (int.from_bytes(bytes.fromhex(hex_color[2:4]))),
427        (int.from_bytes(bytes.fromhex(hex_color[4:6]))),
428        (
429            (int.from_bytes(bytes.fromhex(hex_color[6:8])))
430            if hexlength == 8
431            else None
432        ),
433    )
434    # Divide all numbers by 255 and return.
435    nr, ng, nb, na = (
436        x / 255 if x is not None else None for x in (ar, ag, ab, aa)
437    )
438    return (nr, ng, nb, na) if aa is not None else (nr, ng, nb)
439
440
441def color_to_hex(r: float, g: float, b: float, a: float | None = 1.0) -> str:
442    """Converts an rgb1 tuple to a HEX color code.
443
444    Args:
445        r (float): Red.
446        g (float): Green.
447        b (float): Blue.
448        a (float, optional): Alpha. Defaults to 1.0.
449
450    Returns:
451        str: The hexified rgba values.
452    """
453    # Turn our rgb1 to rgb255
454    nr, ng, nb, na = [
455        int(min(255, x * 255)) if x is not None else x for x in [r, g, b, a]
456    ]
457    # Merge all values into their HEX representation.
458    hex_code = (
459        f'#{nr:02x}{ng:02x}{nb:02x}{na:02x}'
460        if na is not None
461        else f'#{nr:02x}{ng:02x}{nb:02x}'
462    )
463    return hex_code
464
465
466def color_overlay_func(
467    r: float, g: float, b: float, a: float | None = None
468) -> tuple:
469    """I could NOT come up with a better function name.
470
471    Args:
472        r (float): Red.
473        g (float): Green.
474        b (float): Blue.
475        a (float | None, optional): Alpha. Defaults to None.
476
477    Returns:
478        tuple: A brighter color if the provided one is dark,
479               and a darker one if it's darker.
480    """
481
482    # Calculate the relative luminance using the formula for sRGB
483    # https://www.w3.org/TR/WCAG20/#relativeluminancedef
484    def relative_luminance(color: float) -> Any:
485        if color <= 0.03928:
486            return color / 12.92
487        return ((color + 0.055) / 1.055) ** 2.4
488
489    luminance = (
490        0.2126 * relative_luminance(r)
491        + 0.7152 * relative_luminance(g)
492        + 0.0722 * relative_luminance(b)
493    )
494    # Set our color multiplier depending on the provided color's luminance.
495    luminant = 1.65 if luminance < 0.33 else 0.2
496    # Multiply our given numbers, making sure
497    # they don't blend in the original bg.
498    avg = (0.7 - (r + g + b / 3)) + 0.15
499    r, g, b = [max(avg, x * luminant) for x in (r, g, b)]
500    # Include our alpha and ship it!
501    return (r, g, b, a) if a is not None else (r, g, b)
class ColorPicker(bauiv1lib.popup.PopupWindow):
 17class ColorPicker(PopupWindow):
 18    """A popup UI to select from a set of colors.
 19
 20    Passes the color to the delegate's color_picker_selected_color() method.
 21    """
 22
 23    def __init__(
 24        self,
 25        parent: bui.Widget,
 26        position: tuple[float, float],
 27        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
 28        delegate: Any = None,
 29        scale: float | None = None,
 30        offset: tuple[float, float] = (0.0, 0.0),
 31        tag: Any = '',
 32    ):
 33        # pylint: disable=too-many-locals
 34        assert bui.app.classic is not None
 35
 36        c_raw = bui.app.classic.get_player_colors()
 37        assert len(c_raw) == 16
 38        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
 39
 40        uiscale = bui.app.ui_v1.uiscale
 41        if scale is None:
 42            scale = (
 43                2.3
 44                if uiscale is bui.UIScale.SMALL
 45                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 46            )
 47        self._parent = parent
 48        self._position = position
 49        self._scale = scale
 50        self._offset = offset
 51        self._delegate = delegate
 52        self._transitioning_out = False
 53        self._tag = tag
 54        self._initial_color = initial_color
 55
 56        # Create our _root_widget.
 57        super().__init__(
 58            position=position,
 59            size=(210, 240),
 60            scale=scale,
 61            focus_position=(10, 10),
 62            focus_size=(190, 220),
 63            bg_color=(0.5, 0.5, 0.5),
 64            offset=offset,
 65        )
 66        rows: list[list[bui.Widget]] = []
 67        closest_dist = 9999.0
 68        closest = (0, 0)
 69        for y in range(4):
 70            row: list[bui.Widget] = []
 71            rows.append(row)
 72            for x in range(4):
 73                color = self.colors[y][x]
 74                dist = (
 75                    abs(color[0] - initial_color[0])
 76                    + abs(color[1] - initial_color[1])
 77                    + abs(color[2] - initial_color[2])
 78                )
 79                if dist < closest_dist:
 80                    closest = (x, y)
 81                    closest_dist = dist
 82                btn = bui.buttonwidget(
 83                    parent=self.root_widget,
 84                    position=(22 + 45 * x, 185 - 45 * y),
 85                    size=(35, 40),
 86                    label='',
 87                    button_type='square',
 88                    on_activate_call=bui.WeakCall(self._select, x, y),
 89                    autoselect=True,
 90                    color=color,
 91                    extra_touch_border_scale=0.0,
 92                )
 93                row.append(btn)
 94        other_button = bui.buttonwidget(
 95            parent=self.root_widget,
 96            position=(105 - 60, 13),
 97            color=(0.7, 0.7, 0.7),
 98            text_scale=0.5,
 99            textcolor=(0.8, 0.8, 0.8),
100            size=(120, 30),
101            label=bui.Lstr(
102                resource='otherText',
103                fallback_resource='coopSelectWindow.customText',
104            ),
105            autoselect=True,
106            on_activate_call=bui.WeakCall(self._select_other),
107        )
108
109        # Custom colors are limited to pro currently.
110        assert bui.app.classic is not None
111        if not bui.app.classic.accounts.have_pro():
112            bui.imagewidget(
113                parent=self.root_widget,
114                position=(50, 12),
115                size=(30, 30),
116                texture=bui.gettexture('lock'),
117                draw_controller=other_button,
118            )
119
120        # If their color is close to one of our swatches, select it.
121        # Otherwise select 'other'.
122        if closest_dist < 0.03:
123            bui.containerwidget(
124                edit=self.root_widget,
125                selected_child=rows[closest[1]][closest[0]],
126            )
127        else:
128            bui.containerwidget(
129                edit=self.root_widget, selected_child=other_button
130            )
131
132    def get_tag(self) -> Any:
133        """Return this popup's tag."""
134        return self._tag
135
136    def _select_other(self) -> None:
137        from bauiv1lib import purchase
138
139        # Requires pro.
140        assert bui.app.classic is not None
141        if not bui.app.classic.accounts.have_pro():
142            purchase.PurchaseWindow(items=['pro'])
143            self._transition_out()
144            return
145        ColorPickerExact(
146            parent=self._parent,
147            position=self._position,
148            initial_color=self._initial_color,
149            delegate=self._delegate,
150            scale=self._scale,
151            offset=self._offset,
152            tag=self._tag,
153        )
154
155        # New picker now 'owns' the delegate; we shouldn't send it any
156        # more messages.
157        self._delegate = None
158        self._transition_out()
159
160    def _select(self, x: int, y: int) -> None:
161        if self._delegate:
162            self._delegate.color_picker_selected_color(self, self.colors[y][x])
163        bui.apptimer(0.05, self._transition_out)
164
165    def _transition_out(self) -> None:
166        if not self._transitioning_out:
167            self._transitioning_out = True
168            if self._delegate is not None:
169                self._delegate.color_picker_closing(self)
170            bui.containerwidget(edit=self.root_widget, transition='out_scale')
171
172    @override
173    def on_popup_cancel(self) -> None:
174        if not self._transitioning_out:
175            bui.getsound('swish').play()
176        self._transition_out()

A popup UI to select from a set of colors.

Passes the color to the delegate's color_picker_selected_color() method.

ColorPicker( parent: _bauiv1.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 = '')
 23    def __init__(
 24        self,
 25        parent: bui.Widget,
 26        position: tuple[float, float],
 27        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
 28        delegate: Any = None,
 29        scale: float | None = None,
 30        offset: tuple[float, float] = (0.0, 0.0),
 31        tag: Any = '',
 32    ):
 33        # pylint: disable=too-many-locals
 34        assert bui.app.classic is not None
 35
 36        c_raw = bui.app.classic.get_player_colors()
 37        assert len(c_raw) == 16
 38        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
 39
 40        uiscale = bui.app.ui_v1.uiscale
 41        if scale is None:
 42            scale = (
 43                2.3
 44                if uiscale is bui.UIScale.SMALL
 45                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 46            )
 47        self._parent = parent
 48        self._position = position
 49        self._scale = scale
 50        self._offset = offset
 51        self._delegate = delegate
 52        self._transitioning_out = False
 53        self._tag = tag
 54        self._initial_color = initial_color
 55
 56        # Create our _root_widget.
 57        super().__init__(
 58            position=position,
 59            size=(210, 240),
 60            scale=scale,
 61            focus_position=(10, 10),
 62            focus_size=(190, 220),
 63            bg_color=(0.5, 0.5, 0.5),
 64            offset=offset,
 65        )
 66        rows: list[list[bui.Widget]] = []
 67        closest_dist = 9999.0
 68        closest = (0, 0)
 69        for y in range(4):
 70            row: list[bui.Widget] = []
 71            rows.append(row)
 72            for x in range(4):
 73                color = self.colors[y][x]
 74                dist = (
 75                    abs(color[0] - initial_color[0])
 76                    + abs(color[1] - initial_color[1])
 77                    + abs(color[2] - initial_color[2])
 78                )
 79                if dist < closest_dist:
 80                    closest = (x, y)
 81                    closest_dist = dist
 82                btn = bui.buttonwidget(
 83                    parent=self.root_widget,
 84                    position=(22 + 45 * x, 185 - 45 * y),
 85                    size=(35, 40),
 86                    label='',
 87                    button_type='square',
 88                    on_activate_call=bui.WeakCall(self._select, x, y),
 89                    autoselect=True,
 90                    color=color,
 91                    extra_touch_border_scale=0.0,
 92                )
 93                row.append(btn)
 94        other_button = bui.buttonwidget(
 95            parent=self.root_widget,
 96            position=(105 - 60, 13),
 97            color=(0.7, 0.7, 0.7),
 98            text_scale=0.5,
 99            textcolor=(0.8, 0.8, 0.8),
100            size=(120, 30),
101            label=bui.Lstr(
102                resource='otherText',
103                fallback_resource='coopSelectWindow.customText',
104            ),
105            autoselect=True,
106            on_activate_call=bui.WeakCall(self._select_other),
107        )
108
109        # Custom colors are limited to pro currently.
110        assert bui.app.classic is not None
111        if not bui.app.classic.accounts.have_pro():
112            bui.imagewidget(
113                parent=self.root_widget,
114                position=(50, 12),
115                size=(30, 30),
116                texture=bui.gettexture('lock'),
117                draw_controller=other_button,
118            )
119
120        # If their color is close to one of our swatches, select it.
121        # Otherwise select 'other'.
122        if closest_dist < 0.03:
123            bui.containerwidget(
124                edit=self.root_widget,
125                selected_child=rows[closest[1]][closest[0]],
126            )
127        else:
128            bui.containerwidget(
129                edit=self.root_widget, selected_child=other_button
130            )
colors
def get_tag(self) -> Any:
132    def get_tag(self) -> Any:
133        """Return this popup's tag."""
134        return self._tag

Return this popup's tag.

@override
def on_popup_cancel(self) -> None:
172    @override
173    def on_popup_cancel(self) -> None:
174        if not self._transitioning_out:
175            bui.getsound('swish').play()
176        self._transition_out()

Called when the popup is canceled.

Cancels can occur due to clicking outside the window, hitting escape, etc.

class ColorPickerExact(bauiv1lib.popup.PopupWindow):
179class ColorPickerExact(PopupWindow):
180    """pops up a ui to select from a set of colors.
181    passes the color to the delegate's color_picker_selected_color() method"""
182
183    def __init__(
184        self,
185        parent: bui.Widget,
186        position: tuple[float, float],
187        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
188        delegate: Any = None,
189        scale: float | None = None,
190        offset: tuple[float, float] = (0.0, 0.0),
191        tag: Any = '',
192    ):
193        # pylint: disable=too-many-locals
194        del parent  # Unused var.
195        assert bui.app.classic is not None
196
197        c_raw = bui.app.classic.get_player_colors()
198        assert len(c_raw) == 16
199        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
200
201        uiscale = bui.app.ui_v1.uiscale
202        if scale is None:
203            scale = (
204                2.3
205                if uiscale is bui.UIScale.SMALL
206                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
207            )
208        self._delegate = delegate
209        self._transitioning_out = False
210        self._tag = tag
211        self._color = list(initial_color)
212        self._last_press_time = bui.apptime()
213        self._last_press_color_name: str | None = None
214        self._last_press_increasing: bool | None = None
215        self._hex_timer: bui.AppTimer | None = None
216        self._hex_prev_text: str = '#FFFFFF'
217        self._change_speed = 1.0
218        width = 180.0
219        height = 240.0
220
221        # Creates our _root_widget.
222        super().__init__(
223            position=position,
224            size=(width, height),
225            scale=scale,
226            focus_position=(10, 10),
227            focus_size=(width - 20, height - 20),
228            bg_color=(0.5, 0.5, 0.5),
229            offset=offset,
230        )
231        self._swatch = bui.imagewidget(
232            parent=self.root_widget,
233            position=(width * 0.5 - 65 + 5, height - 95),
234            size=(130, 115),
235            texture=bui.gettexture('clayStroke'),
236            color=(1, 0, 0),
237        )
238        self._hex_textbox = bui.textwidget(
239            parent=self.root_widget,
240            position=(width * 0.5 - 37.5 + 3, height - 51),
241            max_chars=9,
242            text='#FFFFFF',
243            autoselect=True,
244            size=(75, 30),
245            v_align='center',
246            editable=True,
247            maxwidth=70,
248            allow_clear_button=False,
249            force_internal_editing=True,
250            glow_type='uniform',
251        )
252
253        x = 50
254        y = height - 90
255        self._label_r: bui.Widget
256        self._label_g: bui.Widget
257        self._label_b: bui.Widget
258        for color_name, color_val in [
259            ('r', (1, 0.15, 0.15)),
260            ('g', (0.15, 1, 0.15)),
261            ('b', (0.15, 0.15, 1)),
262        ]:
263            txt = bui.textwidget(
264                parent=self.root_widget,
265                position=(x - 10, y),
266                size=(0, 0),
267                h_align='center',
268                color=color_val,
269                v_align='center',
270                text='0.12',
271            )
272            setattr(self, '_label_' + color_name, txt)
273            for b_label, bhval, binc in [('-', 30, False), ('+', 75, True)]:
274                bui.buttonwidget(
275                    parent=self.root_widget,
276                    position=(x + bhval, y - 15),
277                    scale=0.8,
278                    repeat=True,
279                    text_scale=1.3,
280                    size=(40, 40),
281                    label=b_label,
282                    autoselect=True,
283                    enable_sound=False,
284                    on_activate_call=bui.WeakCall(
285                        self._color_change_press, color_name, binc
286                    ),
287                )
288            y -= 42
289
290        btn = bui.buttonwidget(
291            parent=self.root_widget,
292            position=(width * 0.5 - 40, 10),
293            size=(80, 30),
294            text_scale=0.6,
295            color=(0.6, 0.6, 0.6),
296            textcolor=(0.7, 0.7, 0.7),
297            label=bui.Lstr(resource='doneText'),
298            on_activate_call=bui.WeakCall(self._transition_out),
299            autoselect=True,
300        )
301        bui.containerwidget(edit=self.root_widget, start_button=btn)
302
303        # Unlike the swatch picker, we stay open and constantly push our
304        # color to the delegate, so start doing that.
305        self._update_for_color()
306
307        # Update our HEX stuff!
308        self._update_for_hex()
309        self._hex_timer = bui.AppTimer(0.025, self._update_for_hex, repeat=True)
310
311    def _update_for_hex(self) -> None:
312        """Update for any HEX or color change."""
313        from typing import cast
314
315        hextext = cast(str, bui.textwidget(query=self._hex_textbox))
316        hexcolor: tuple
317        # Check if our current hex text doesn't match with our old one.
318        # Convert our current hex text into a color if possible.
319        if hextext != self._hex_prev_text:
320            try:
321                hexcolor = hex_to_color(hextext)
322                if len(hexcolor) == 4:
323                    r, g, b, a = hexcolor
324                    del a  # unused
325                else:
326                    r, g, b = hexcolor
327                # Replace the color!
328                for i, ch in enumerate((r, g, b)):
329                    self._color[i] = max(0.0, min(1.0, ch))
330                self._update_for_color()
331            # Usually, a ValueError will occur if the provided hex
332            # is incomplete, which occurs when in the midst of typing it.
333            except ValueError:
334                pass
335        # Store the current text for our next comparison.
336        self._hex_prev_text = hextext
337
338    # noinspection PyUnresolvedReferences
339    def _update_for_color(self) -> None:
340        if not self.root_widget:
341            return
342        bui.imagewidget(edit=self._swatch, color=self._color)
343
344        # We generate these procedurally, so pylint misses them.
345        # FIXME: create static attrs instead.
346        # pylint: disable=consider-using-f-string
347        bui.textwidget(edit=self._label_r, text='%.2f' % self._color[0])
348        bui.textwidget(edit=self._label_g, text='%.2f' % self._color[1])
349        bui.textwidget(edit=self._label_b, text='%.2f' % self._color[2])
350        if self._delegate is not None:
351            self._delegate.color_picker_selected_color(self, self._color)
352
353        # Show the HEX code of this color.
354        r, g, b = self._color
355        hexcode = color_to_hex(r, g, b, None)
356        self._hex_prev_text = hexcode
357        bui.textwidget(
358            edit=self._hex_textbox,
359            text=hexcode,
360            color=color_overlay_func(r, g, b),
361        )
362
363    def _color_change_press(self, color_name: str, increasing: bool) -> None:
364        # If we get rapid-fire presses, eventually start moving faster.
365        current_time = bui.apptime()
366        since_last = current_time - self._last_press_time
367        if (
368            since_last < 0.2
369            and self._last_press_color_name == color_name
370            and self._last_press_increasing == increasing
371        ):
372            self._change_speed += 0.25
373        else:
374            self._change_speed = 1.0
375        self._last_press_time = current_time
376        self._last_press_color_name = color_name
377        self._last_press_increasing = increasing
378
379        color_index = ('r', 'g', 'b').index(color_name)
380        offs = int(self._change_speed) * (0.01 if increasing else -0.01)
381        self._color[color_index] = max(
382            0.0, min(1.0, self._color[color_index] + offs)
383        )
384        self._update_for_color()
385
386    def get_tag(self) -> Any:
387        """Return this popup's tag value."""
388        return self._tag
389
390    def _transition_out(self) -> None:
391        # Kill our timer
392        self._hex_timer = None
393        if not self._transitioning_out:
394            self._transitioning_out = True
395            if self._delegate is not None:
396                self._delegate.color_picker_closing(self)
397            bui.containerwidget(edit=self.root_widget, transition='out_scale')
398
399    @override
400    def on_popup_cancel(self) -> None:
401        if not self._transitioning_out:
402            bui.getsound('swish').play()
403        self._transition_out()

pops up a ui to select from a set of colors. passes the color to the delegate's color_picker_selected_color() method

ColorPickerExact( parent: _bauiv1.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 = '')
183    def __init__(
184        self,
185        parent: bui.Widget,
186        position: tuple[float, float],
187        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
188        delegate: Any = None,
189        scale: float | None = None,
190        offset: tuple[float, float] = (0.0, 0.0),
191        tag: Any = '',
192    ):
193        # pylint: disable=too-many-locals
194        del parent  # Unused var.
195        assert bui.app.classic is not None
196
197        c_raw = bui.app.classic.get_player_colors()
198        assert len(c_raw) == 16
199        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
200
201        uiscale = bui.app.ui_v1.uiscale
202        if scale is None:
203            scale = (
204                2.3
205                if uiscale is bui.UIScale.SMALL
206                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
207            )
208        self._delegate = delegate
209        self._transitioning_out = False
210        self._tag = tag
211        self._color = list(initial_color)
212        self._last_press_time = bui.apptime()
213        self._last_press_color_name: str | None = None
214        self._last_press_increasing: bool | None = None
215        self._hex_timer: bui.AppTimer | None = None
216        self._hex_prev_text: str = '#FFFFFF'
217        self._change_speed = 1.0
218        width = 180.0
219        height = 240.0
220
221        # Creates our _root_widget.
222        super().__init__(
223            position=position,
224            size=(width, height),
225            scale=scale,
226            focus_position=(10, 10),
227            focus_size=(width - 20, height - 20),
228            bg_color=(0.5, 0.5, 0.5),
229            offset=offset,
230        )
231        self._swatch = bui.imagewidget(
232            parent=self.root_widget,
233            position=(width * 0.5 - 65 + 5, height - 95),
234            size=(130, 115),
235            texture=bui.gettexture('clayStroke'),
236            color=(1, 0, 0),
237        )
238        self._hex_textbox = bui.textwidget(
239            parent=self.root_widget,
240            position=(width * 0.5 - 37.5 + 3, height - 51),
241            max_chars=9,
242            text='#FFFFFF',
243            autoselect=True,
244            size=(75, 30),
245            v_align='center',
246            editable=True,
247            maxwidth=70,
248            allow_clear_button=False,
249            force_internal_editing=True,
250            glow_type='uniform',
251        )
252
253        x = 50
254        y = height - 90
255        self._label_r: bui.Widget
256        self._label_g: bui.Widget
257        self._label_b: bui.Widget
258        for color_name, color_val in [
259            ('r', (1, 0.15, 0.15)),
260            ('g', (0.15, 1, 0.15)),
261            ('b', (0.15, 0.15, 1)),
262        ]:
263            txt = bui.textwidget(
264                parent=self.root_widget,
265                position=(x - 10, y),
266                size=(0, 0),
267                h_align='center',
268                color=color_val,
269                v_align='center',
270                text='0.12',
271            )
272            setattr(self, '_label_' + color_name, txt)
273            for b_label, bhval, binc in [('-', 30, False), ('+', 75, True)]:
274                bui.buttonwidget(
275                    parent=self.root_widget,
276                    position=(x + bhval, y - 15),
277                    scale=0.8,
278                    repeat=True,
279                    text_scale=1.3,
280                    size=(40, 40),
281                    label=b_label,
282                    autoselect=True,
283                    enable_sound=False,
284                    on_activate_call=bui.WeakCall(
285                        self._color_change_press, color_name, binc
286                    ),
287                )
288            y -= 42
289
290        btn = bui.buttonwidget(
291            parent=self.root_widget,
292            position=(width * 0.5 - 40, 10),
293            size=(80, 30),
294            text_scale=0.6,
295            color=(0.6, 0.6, 0.6),
296            textcolor=(0.7, 0.7, 0.7),
297            label=bui.Lstr(resource='doneText'),
298            on_activate_call=bui.WeakCall(self._transition_out),
299            autoselect=True,
300        )
301        bui.containerwidget(edit=self.root_widget, start_button=btn)
302
303        # Unlike the swatch picker, we stay open and constantly push our
304        # color to the delegate, so start doing that.
305        self._update_for_color()
306
307        # Update our HEX stuff!
308        self._update_for_hex()
309        self._hex_timer = bui.AppTimer(0.025, self._update_for_hex, repeat=True)
colors
def get_tag(self) -> Any:
386    def get_tag(self) -> Any:
387        """Return this popup's tag value."""
388        return self._tag

Return this popup's tag value.

@override
def on_popup_cancel(self) -> None:
399    @override
400    def on_popup_cancel(self) -> None:
401        if not self._transitioning_out:
402            bui.getsound('swish').play()
403        self._transition_out()

Called when the popup is canceled.

Cancels can occur due to clicking outside the window, hitting escape, etc.

def hex_to_color(hex_color: str) -> tuple:
406def hex_to_color(hex_color: str) -> tuple:
407    """Transforms an RGB / RGBA hex code into an rgb1/rgba1 tuple.
408
409    Args:
410        hex_color (str): The HEX color.
411    Raises:
412        ValueError: If the provided HEX color isn't 6 or 8 characters long.
413    Returns:
414        tuple: The color tuple divided by 255.
415    """
416    # Remove the '#' from the string if provided.
417    if hex_color.startswith('#'):
418        hex_color = hex_color.lstrip('#')
419    # Check if this has a valid length.
420    hexlength = len(hex_color)
421    if not hexlength in [6, 8]:
422        raise ValueError(f'Invalid HEX color provided: "{hex_color}"')
423
424    # Convert the hex bytes to their true byte form.
425    ar, ag, ab, aa = (
426        (int.from_bytes(bytes.fromhex(hex_color[0:2]))),
427        (int.from_bytes(bytes.fromhex(hex_color[2:4]))),
428        (int.from_bytes(bytes.fromhex(hex_color[4:6]))),
429        (
430            (int.from_bytes(bytes.fromhex(hex_color[6:8])))
431            if hexlength == 8
432            else None
433        ),
434    )
435    # Divide all numbers by 255 and return.
436    nr, ng, nb, na = (
437        x / 255 if x is not None else None for x in (ar, ag, ab, aa)
438    )
439    return (nr, ng, nb, na) if aa is not None else (nr, ng, nb)

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.

def color_to_hex(r: float, g: float, b: float, a: float | None = 1.0) -> str:
442def color_to_hex(r: float, g: float, b: float, a: float | None = 1.0) -> str:
443    """Converts an rgb1 tuple to a HEX color code.
444
445    Args:
446        r (float): Red.
447        g (float): Green.
448        b (float): Blue.
449        a (float, optional): Alpha. Defaults to 1.0.
450
451    Returns:
452        str: The hexified rgba values.
453    """
454    # Turn our rgb1 to rgb255
455    nr, ng, nb, na = [
456        int(min(255, x * 255)) if x is not None else x for x in [r, g, b, a]
457    ]
458    # Merge all values into their HEX representation.
459    hex_code = (
460        f'#{nr:02x}{ng:02x}{nb:02x}{na:02x}'
461        if na is not None
462        else f'#{nr:02x}{ng:02x}{nb:02x}'
463    )
464    return hex_code

Converts an rgb1 tuple to a HEX color code.

Args: r (float): Red. g (float): Green. b (float): Blue. a (float, optional): Alpha. Defaults to 1.0.

Returns: str: The hexified rgba values.

def color_overlay_func(r: float, g: float, b: float, a: float | None = None) -> tuple:
467def color_overlay_func(
468    r: float, g: float, b: float, a: float | None = None
469) -> tuple:
470    """I could NOT come up with a better function name.
471
472    Args:
473        r (float): Red.
474        g (float): Green.
475        b (float): Blue.
476        a (float | None, optional): Alpha. Defaults to None.
477
478    Returns:
479        tuple: A brighter color if the provided one is dark,
480               and a darker one if it's darker.
481    """
482
483    # Calculate the relative luminance using the formula for sRGB
484    # https://www.w3.org/TR/WCAG20/#relativeluminancedef
485    def relative_luminance(color: float) -> Any:
486        if color <= 0.03928:
487            return color / 12.92
488        return ((color + 0.055) / 1.055) ** 2.4
489
490    luminance = (
491        0.2126 * relative_luminance(r)
492        + 0.7152 * relative_luminance(g)
493        + 0.0722 * relative_luminance(b)
494    )
495    # Set our color multiplier depending on the provided color's luminance.
496    luminant = 1.65 if luminance < 0.33 else 0.2
497    # Multiply our given numbers, making sure
498    # they don't blend in the original bg.
499    avg = (0.7 - (r + g + b / 3)) + 0.15
500    r, g, b = [max(avg, x * luminant) for x in (r, g, b)]
501    # Include our alpha and ship it!
502    return (r, g, b, a) if a is not None else (r, g, b)

I could NOT come up with a better function name.

Args: r (float): Red. g (float): Green. b (float): Blue. a (float | None, optional): Alpha. Defaults to None.

Returns: tuple: A brighter color if the provided one is dark, and a darker one if it's darker.