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        initial_color: Sequence[float] = (1.0, 1.0, 1.0),
 29        delegate: Any = None,
 30        scale: float | None = None,
 31        offset: tuple[float, float] = (0.0, 0.0),
 32        tag: Any = '',
 33    ):
 34        # pylint: disable=too-many-locals
 35        assert bui.app.classic is not None
 36
 37        c_raw = bui.app.classic.get_player_colors()
 38        assert len(c_raw) == 16
 39        self.colors = [c_raw[0:4], c_raw[4:8], c_raw[8:12], c_raw[12:16]]
 40
 41        uiscale = bui.app.ui_v1.uiscale
 42        if scale is None:
 43            scale = (
 44                2.3
 45                if uiscale is bui.UIScale.SMALL
 46                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 47            )
 48        self._parent = parent
 49        self._position = position
 50        self._scale = scale
 51        self._offset = offset
 52        self._delegate = delegate
 53        self._transitioning_out = False
 54        self._tag = tag
 55        self._initial_color = initial_color
 56
 57        # Create our _root_widget.
 58        super().__init__(
 59            position=position,
 60            size=(210, 240),
 61            scale=scale,
 62            focus_position=(10, 10),
 63            focus_size=(190, 220),
 64            bg_color=(0.5, 0.5, 0.5),
 65            offset=offset,
 66        )
 67        rows: list[list[bui.Widget]] = []
 68        closest_dist = 9999.0
 69        closest = (0, 0)
 70        for y in range(4):
 71            row: list[bui.Widget] = []
 72            rows.append(row)
 73            for x in range(4):
 74                color = self.colors[y][x]
 75                dist = (
 76                    abs(color[0] - initial_color[0])
 77                    + abs(color[1] - initial_color[1])
 78                    + abs(color[2] - initial_color[2])
 79                )
 80                if dist < closest_dist:
 81                    closest = (x, y)
 82                    closest_dist = dist
 83                btn = bui.buttonwidget(
 84                    parent=self.root_widget,
 85                    position=(22 + 45 * x, 185 - 45 * y),
 86                    size=(35, 40),
 87                    label='',
 88                    button_type='square',
 89                    on_activate_call=bui.WeakCall(self._select, x, y),
 90                    autoselect=True,
 91                    color=color,
 92                    extra_touch_border_scale=0.0,
 93                )
 94                row.append(btn)
 95        other_button = bui.buttonwidget(
 96            parent=self.root_widget,
 97            position=(105 - 60, 13),
 98            color=(0.7, 0.7, 0.7),
 99            text_scale=0.5,
100            textcolor=(0.8, 0.8, 0.8),
101            size=(120, 30),
102            label=bui.Lstr(
103                resource='otherText',
104                fallback_resource='coopSelectWindow.customText',
105            ),
106            autoselect=True,
107            on_activate_call=bui.WeakCall(self._select_other),
108        )
109
110        assert bui.app.classic is not None
111        if REQUIRE_PRO and 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 REQUIRE_PRO and 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()
177
178
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()
404
405
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)
440
441
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
465
466
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)
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        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()

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        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            )
colors
def get_tag(self) -> Any:
133    def get_tag(self) -> Any:
134        """Return this popup's tag."""
135        return self._tag

Return this popup's tag.

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

Return this popup's tag value.

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