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

Return this popup's tag.

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

Return this popup's tag value.

@override
def on_popup_cancel(self) -> None:
402    @override
403    def on_popup_cancel(self) -> None:
404        if not self._transitioning_out:
405            bui.getsound('swish').play()
406        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:
409def hex_to_color(hex_color: str) -> tuple:
410    """Transforms an RGB / RGBA hex code into an rgb1/rgba1 tuple.
411
412    Args:
413        hex_color (str): The HEX color.
414    Raises:
415        ValueError: If the provided HEX color isn't 6 or 8 characters long.
416    Returns:
417        tuple: The color tuple divided by 255.
418    """
419    # Remove the '#' from the string if provided.
420    if hex_color.startswith('#'):
421        hex_color = hex_color.lstrip('#')
422    # Check if this has a valid length.
423    hexlength = len(hex_color)
424    if not hexlength in [6, 8]:
425        raise ValueError(f'Invalid HEX color provided: "{hex_color}"')
426
427    # Convert the hex bytes to their true byte form.
428    ar, ag, ab, aa = (
429        (int.from_bytes(bytes.fromhex(hex_color[0:2]))),
430        (int.from_bytes(bytes.fromhex(hex_color[2:4]))),
431        (int.from_bytes(bytes.fromhex(hex_color[4:6]))),
432        (
433            (int.from_bytes(bytes.fromhex(hex_color[6:8])))
434            if hexlength == 8
435            else None
436        ),
437    )
438    # Divide all numbers by 255 and return.
439    nr, ng, nb, na = (
440        x / 255 if x is not None else None for x in (ar, ag, ab, aa)
441    )
442    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:
445def color_to_hex(r: float, g: float, b: float, a: float | None = 1.0) -> str:
446    """Converts an rgb1 tuple to a HEX color code.
447
448    Args:
449        r (float): Red.
450        g (float): Green.
451        b (float): Blue.
452        a (float, optional): Alpha. Defaults to 1.0.
453
454    Returns:
455        str: The hexified rgba values.
456    """
457    # Turn our rgb1 to rgb255
458    nr, ng, nb, na = [
459        int(min(255, x * 255)) if x is not None else x for x in [r, g, b, a]
460    ]
461    # Merge all values into their HEX representation.
462    hex_code = (
463        f'#{nr:02x}{ng:02x}{nb:02x}{na:02x}'
464        if na is not None
465        else f'#{nr:02x}{ng:02x}{nb:02x}'
466    )
467    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:
470def color_overlay_func(
471    r: float, g: float, b: float, a: float | None = None
472) -> tuple:
473    """I could NOT come up with a better function name.
474
475    Args:
476        r (float): Red.
477        g (float): Green.
478        b (float): Blue.
479        a (float | None, optional): Alpha. Defaults to None.
480
481    Returns:
482        tuple: A brighter color if the provided one is dark,
483               and a darker one if it's darker.
484    """
485
486    # Calculate the relative luminance using the formula for sRGB
487    # https://www.w3.org/TR/WCAG20/#relativeluminancedef
488    def relative_luminance(color: float) -> Any:
489        if color <= 0.03928:
490            return color / 12.92
491        return ((color + 0.055) / 1.055) ** 2.4
492
493    luminance = (
494        0.2126 * relative_luminance(r)
495        + 0.7152 * relative_luminance(g)
496        + 0.0722 * relative_luminance(b)
497    )
498    # Set our color multiplier depending on the provided color's luminance.
499    luminant = 1.65 if luminance < 0.33 else 0.2
500    # Multiply our given numbers, making sure
501    # they don't blend in the original bg.
502    avg = (0.7 - (r + g + b / 3)) + 0.15
503    r, g, b = [max(avg, x * luminant) for x in (r, g, b)]
504    # Include our alpha and ship it!
505    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.