Source code for pygame_menu.widgets.widget.colorinput

"""
pygame-menu
https://github.com/ppizarror/pygame-menu

COLOR INPUT
Color input class, Widget created in top of TextInput that provides a textbox
for entering and previewing colors in RGB and HEX format.
"""

from __future__ import annotations

__all__ = [
    # Main class
    "ColorInput",
    "ColorInputManager",
    # Constants
    "COLORINPUT_TYPE_HEX",
    "COLORINPUT_TYPE_RGB",
    "COLORINPUT_HEX_FORMAT_LOWER",
    "COLORINPUT_HEX_FORMAT_NONE",
    "COLORINPUT_HEX_FORMAT_UPPER",
    # Type
    "ColorInputColorType",
    "ColorInputHexFormatType",
]

import math
from abc import ABC
from typing import TYPE_CHECKING, Any

import pygame

from pygame_menu._types import (
    CallbackType,
    EventVectorType,
    NumberInstance,
    NumberType,
    Tuple3IntType,
)
from pygame_menu.locals import INPUT_TEXT
from pygame_menu.utils import check_key_pressed_valid, make_surface
from pygame_menu.widgets.core.widget import AbstractWidgetManager, Widget
from pygame_menu.widgets.widget.textinput import TextInput

if TYPE_CHECKING:
    from collections.abc import Callable

    import pygame_menu

# Input modes
COLORINPUT_TYPE_HEX = "hex"
COLORINPUT_TYPE_RGB = "rgb"

# Apply format to hex color string
COLORINPUT_HEX_FORMAT_LOWER = "lower"
COLORINPUT_HEX_FORMAT_NONE = "none"
COLORINPUT_HEX_FORMAT_UPPER = "upper"

# Custom typing
ColorInputColorType = str
ColorInputHexFormatType = str


[docs] class ColorInput(TextInput): """ Color input widget. The callbacks receive the current value and all unknown keyword arguments, where ``current_color=widget.get_value()``: .. code-block:: python onchange(current_color, **kwargs) onreturn(current_color, **kwargs) .. note:: This widget cannot select text as :py:class:`pygame_menu.widgets.TextInput` does. Also, copy and paste is disabled. .. note:: ColorInput accepts the same transformations as :py:class:`pygame_menu.widgets.TextInput`. :param title: Color input title :param colorinput_id: ID of the text input :param color_type: Type of color input :param cursor_switch_ms: Interval of cursor switch between off and on status. First status is ``off`` :param dynamic_width: If ``True`` the widget width changes if the previsualization color box is active or not :param hex_format: Hex format string mode :param input_separator: Divisor between RGB channels :param input_underline: Character drawn under each number input :param input_underline_vmargin: Vertical margin of underline in px :param cursor_color: Color of cursor :param onchange: Function when changing the values of the color text :param onreturn: Function when pressing return (apply) on the color text input :param onselect: Function when selecting the widget :param prev_margin: Horizontal margin between the previsualization and the input text in px :param prev_width_factor: Width of the previsualization box in terms of the height of the widget :param repeat_keys_initial_ms: Time in milliseconds before keys are repeated when held :param repeat_keys_interval_ms: Interval between key press repetition when held :param repeat_mouse_interval_ms: Interval between mouse events when held :param kwargs: Optional keyword arguments """ _auto_separator_pos: list[int] _color_type: str _dynamic_width: bool _hex_format: str _last_g: int _last_r: int _prev_margin: int _previsualization_surface: pygame.Surface | None _separator: str def __init__( self, title: Any, colorinput_id: str = "", color_type: ColorInputColorType = COLORINPUT_TYPE_RGB, cursor_color: Tuple3IntType = (0, 0, 0), cursor_switch_ms: NumberType = 500, dynamic_width: bool = True, hex_format: ColorInputHexFormatType = COLORINPUT_HEX_FORMAT_NONE, input_separator: str = ",", input_underline: str = "_", input_underline_vmargin: int = 0, onchange: CallbackType = None, onreturn: CallbackType = None, onselect: CallbackType = None, prev_margin: int = 10, prev_width_factor: NumberType = 3, repeat_keys_initial_ms: NumberType = 450, repeat_keys_interval_ms: NumberType = 80, repeat_mouse_interval_ms: NumberType = 100, *args, **kwargs, ) -> None: assert isinstance(color_type, str) assert isinstance(colorinput_id, str) assert isinstance(dynamic_width, bool) assert isinstance(hex_format, str) assert isinstance(input_separator, str) assert isinstance(input_underline, str) assert isinstance(prev_margin, int) assert isinstance(prev_width_factor, NumberInstance) assert len(input_separator) == 1, "input_separator must be a single char" assert len(input_separator) != 0, "input_separator cannot be empty" assert prev_width_factor > 0, ( "previsualization width factor must be greater than zero" ) assert input_separator not in ( "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ), "input_separator cannot be a number" assert color_type in (COLORINPUT_TYPE_HEX, COLORINPUT_TYPE_RGB), ( f'color type must be "{COLORINPUT_TYPE_HEX}" or "{COLORINPUT_TYPE_RGB}"' ) assert hex_format in ( COLORINPUT_HEX_FORMAT_NONE, COLORINPUT_HEX_FORMAT_LOWER, COLORINPUT_HEX_FORMAT_UPPER, ), 'invalid hex format mode, it must be "none", "lower" or "upper"' maxchar: int = 0 valid_chars: list[str] | None = None self._color_type = color_type.lower() if self._color_type == COLORINPUT_TYPE_RGB: maxchar = 11 # RRR,GGG,BBB valid_chars = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", input_separator, ] elif self._color_type == COLORINPUT_TYPE_HEX: maxchar = 7 # #XXYYZZ valid_chars = [ "a", "A", "b", "B", "c", "C", "d", "D", "e", "E", "f", "F", "#", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ] # noinspection PyArgumentEqualDefault super().__init__( alt_x_enabled=False, apply_widget_update_callback=False, copy_paste_enable=False, cursor_color=cursor_color, cursor_switch_ms=cursor_switch_ms, cursor_selection_enable=False, history=0, input_type=INPUT_TEXT, input_underline=input_underline, input_underline_vmargin=input_underline_vmargin, maxchar=maxchar, maxwidth=0, onchange=onchange, onreturn=onreturn, onselect=onselect, password=False, repeat_keys_initial_ms=repeat_keys_initial_ms, repeat_keys_interval_ms=repeat_keys_interval_ms, repeat_mouse_interval_ms=repeat_mouse_interval_ms, text_ellipsis="", textinput_id=colorinput_id, title=title, valid_chars=valid_chars, *args, **kwargs, ) # Store inner variables self._auto_separator_pos = [] # This stores indexes of auto separator added self._dynamic_width = dynamic_width self._hex_format = hex_format self._separator = input_separator # Previsualization surface, if -1 does not show self._last_b = -1 self._last_g = -1 self._last_r = -1 self._prev_margin = prev_margin self._prev_width_factor = prev_width_factor self._previsualization_surface = None def _apply_font(self) -> None: super()._apply_font() # Compute the size of the underline if self._input_underline != "": max_width: int = 0 # Max expected width if self._color_type == COLORINPUT_TYPE_RGB: max_width = self._font_render_string( f"255{self._separator}255{self._separator}255" ).get_width() else: for i in ("a", "b", "c", "d", "e", "f"): max_width = max( max_width, self._font_render_string(f"#{i * 6}").get_width() ) char = math.ceil(max_width / self._input_underline_size) for i in range(10): # Find the best guess for fw = self._font_render_string( self._input_underline * int(char) ).get_width() char += 1 if fw >= max_width: break self._input_underline_len = char
[docs] def clear(self) -> None: super().clear() self._previsualization_surface = None self._auto_separator_pos.clear() if self._color_type == COLORINPUT_TYPE_HEX: super().set_value("#") self.change()
[docs] def set_value(self, color: str | Tuple3IntType | None) -> None: """ Set the color value. :param color: A string if the type is HEX, or a (r, g, b) tuple if RGB """ if color is None: color = "" format_color: str = "" if self._color_type == COLORINPUT_TYPE_RGB: if color == "": super().set_value("") return assert isinstance(color, tuple), ( "color in rgb format must be a tuple in (r,g,b) format" ) assert len(color) == 3, "tuple must contain only 3 colors, R,G,B" r, g, b = color assert isinstance(r, int), "red color must be an integer" assert isinstance(g, int), "blue color must be an integer" assert isinstance(b, int), "green color must be an integer" assert 0 <= r <= 255, "red color must be between 0 and 255" assert 0 <= g <= 255, "blue color must be between 0 and 255" assert 0 <= b <= 255, "green color must be between 0 and 255" format_color = f"{r}{self._separator}{g}{self._separator}{b}" self._auto_separator_pos = [0, 1] elif self._color_type == COLORINPUT_TYPE_HEX: text: str = str(color).strip() if text == "" or text == "#": format_color = "#" else: # Remove all invalid chars valid_text = "" for ch in text: if ch in self._valid_chars: valid_text += ch text = valid_text # Check if the color is valid count_hash = 0 for ch in text: if ch == "#": count_hash += 1 if count_hash == 1: assert text[0] == "#", 'color format must be "#RRGGBB"' if count_hash == 0: text = "#" + text assert len(text) == 7, ( 'invalid color, only formats "#RRGGBB" or "RRGGBB" are allowed' ) format_color = text super().set_value(format_color) self._format_hex()
[docs] def value_changed(self) -> bool: default = self._default_value if self._color_type == COLORINPUT_TYPE_HEX and "#" not in default: default = "#" + default return self.get_value(as_string=True) != default
[docs] def get_value(self, as_string: bool = False) -> str | float | int | Tuple3IntType: """ Return the color value as a tuple or red blue and green channels. .. note:: If the data is invalid the widget returns ``(-1, -1, -1)``. :param as_string: If ``True`` returns the widget value as plain text :return: Color tuple as (R, G, B) or color string """ if as_string: return self._input_string elif self._color_type == COLORINPUT_TYPE_RGB: color = self._input_string.split(self._separator) if len(color) == 3 and color[0] != "" and color[1] != "" and color[2] != "": r, g, b = int(color[0]), int(color[1]), int(color[2]) if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= g <= 255: return r, g, b elif self._color_type == COLORINPUT_TYPE_HEX: if len(self._input_string) == 7: color = self._input_string[1:] color = tuple(int(color[i: i + 2], 16) for i in (0, 2, 4)) return color[0], color[1], color[2] return -1, -1, -1
[docs] def is_valid(self) -> bool: """ Return ``True`` if the current value of the input is a valid color or not. :return: ``True`` if valid """ r, g, b = self.get_value() return not (r == -1 or g == -1 or b == -1)
def _draw(self, surface: pygame.Surface) -> None: super()._draw(surface) # This calls _render() # Draw previsualization box if self._previsualization_surface is not None: posx: float = ( self._rect.x + self._rect.width - self._prev_width_factor * self._rect.height + self._rect.height / 10 ) posy: int = self._rect.y surface.blit(self._previsualization_surface, (int(posx), int(posy))) def _render(self) -> bool | None: render_text = super()._render() # Maybe TextInput did not render, so this has to be changed self._rect.width, self._rect.height = self._surface.get_size() if not self._dynamic_width or ( self._dynamic_width and self._previsualization_surface is not None ): self._rect.width += ( self._prev_width_factor * self._rect.height + self._prev_margin ) # Render the previsualization box r, g, b = self.get_value() if not self.is_valid(): # Remove previsualization if invalid color self._previsualization_surface = None return render_text # If previsualization surface is None or the color changed elif ( self._last_r != r or self._last_b != b or self._last_g != g or self._previsualization_surface is None ): width = self._prev_width_factor * self._rect.height if width == 0 or self._rect.height == 0: self._previsualization_surface = None else: self._previsualization_surface = make_surface(width, self._rect.height) self._previsualization_surface.fill((r, g, b)) self._last_r = r self._last_g = g self._last_b = b if self._dynamic_width: self._rect.width += ( self._prev_width_factor * self._rect.height + self._prev_margin ) return render_text def _format_hex(self) -> None: """ Apply hex format. """ if ( self._color_type != COLORINPUT_TYPE_HEX or self._hex_format == COLORINPUT_HEX_FORMAT_NONE ): return elif self._hex_format == COLORINPUT_HEX_FORMAT_LOWER: self._input_string = self._input_string.lower() elif self._hex_format == COLORINPUT_HEX_FORMAT_UPPER: self._input_string = self._input_string.upper()
[docs] def update(self, events: EventVectorType) -> bool: self.apply_update_callbacks(events) if self.readonly or not self.is_visible(): self._readonly_check_mouseover(events) return False input_str = self._input_string cursor_pos = self._cursor_position disable_remove_separator = True key = "" # Pressed key if self._color_type == COLORINPUT_TYPE_RGB: for event in events: # User writes if event.type == pygame.KEYDOWN and self._keyboard_enabled: # Check if any key is pressed if ( self._ignores_keyboard_nonphysical() and not check_key_pressed_valid(event) ): continue elif ( disable_remove_separator and len(input_str) > 0 and len(input_str) > cursor_pos and ( f"{self._separator}{self._separator}" not in input_str or input_str[cursor_pos] == self._separator and len(input_str) == cursor_pos + 1 ) ): # Backspace button, delete text from right if self._ctrl.back(event, self): if ( len(input_str) >= 1 and input_str[cursor_pos - 1] == self._separator ): return True # Delete button, delete text from left elif self._ctrl.delete(event, self): if input_str[cursor_pos] == self._separator: return True # Verify only on user key input, the rest of events are checked # by TextInput on super call key = str(event.unicode) if key in self._valid_chars: new_string = ( self._input_string[: self._cursor_position] + key + self._input_string[self._cursor_position:] ) # Cannot be separator at first if not input_str and key == self._separator: return False elif len(input_str) > 1: # Check separators if key == self._separator: # If more than 2 separators total_separator = 0 for ch in input_str: if ch == self._separator: total_separator += 1 if total_separator >= 2: return False # Check the number between the current separators, # this number must be between 0-255 if key != self._separator: pos_before = 0 pos_after = 0 for i in range(cursor_pos): if ( new_string[cursor_pos - i - 1] == self._separator ): pos_before = cursor_pos - i break for i in range(len(new_string) - cursor_pos): if new_string[cursor_pos + i] == self._separator: pos_after = cursor_pos + i break if pos_after == 0: pos_after = len(new_string) num = new_string[pos_before:pos_after].replace(",", "") if num == "": num = "0" if int(num) > 255: # Number exceeds 25X return False # User adds 0 at left, example: 12 -> 012 elif num != str(int(num)) and key == "0": return False elif len(num) > 3: # Number like 0XXX return False elif self._color_type == COLORINPUT_TYPE_HEX: self._format_hex() for event in events: # User writes if event.type == pygame.KEYDOWN and self._keyboard_enabled: # Check if any key is pressed if ( self._ignores_keyboard_nonphysical() and not check_key_pressed_valid(event) ): continue # Backspace button, delete text from right elif self._ctrl.back(event, self): if cursor_pos == 1: return True # Delete button, delete text from left elif self._ctrl.delete(event, self): if cursor_pos == 0: return True # Verify only on user key input, the rest of events are checked # by TextInput on super call key = str(event.unicode) if key in self._valid_chars: if key == "#": return True elif cursor_pos == 0: return True # Update updated = super().update(events) # After if self._color_type == COLORINPUT_TYPE_RGB: total_separator = 0 for ch in input_str: if ch == self._separator: total_separator += 1 # Adds auto separator if ( key == "0" and len(self._input_string) == self._cursor_position and total_separator < 2 and ( len(self._input_string) == 1 or len(self._input_string) > 2 and self._input_string[self._cursor_position - 2] == self._separator ) ): self._push_key_input( self._separator, sounds=False ) # This calls .onchange() # Check number is valid (fix) because sometimes the user can type # too fast and avoid analysis of the text colors = self._input_string.split(self._separator) for c in colors: if len(c) > 0 and (int(c) > 255 or int(c) < 0): self._input_string = input_str self._cursor_position = cursor_pos break if len(colors) == 3: self._auto_separator_pos = [0, 1] # Add an auto separator if the number can't continue growing and the cursor # is at the end of the line if total_separator < 2 and len(self._input_string) == self._cursor_position: auto_pos = len(colors) - 1 last_num = colors[auto_pos] if ( (len(last_num) == 2 and int(last_num) > 25) or (len(last_num) == 3 and int(last_num) <= 255) ) and auto_pos not in self._auto_separator_pos: self._push_key_input( self._separator, sounds=False ) # This calls .onchange() self._auto_separator_pos.append(auto_pos) # If the user cleared all the string, reset auto separator if total_separator == 0 and ( len(self._input_string) < 2 or len(self._input_string) == 2 and int(colors[0]) <= 25 ): self._auto_separator_pos.clear() return updated
class ColorInputManager(AbstractWidgetManager, ABC): """ ColorInput manager. """ def color_input( self, title: str | Any, color_type: ColorInputColorType, color_id: str = "", default: str | Tuple3IntType = "", hex_format: ColorInputHexFormatType = COLORINPUT_HEX_FORMAT_NONE, input_separator: str = ",", input_underline: str = "_", onchange: CallbackType = None, onreturn: CallbackType = None, onselect: Callable[[bool, Widget, pygame_menu.Menu], Any] | None = None, **kwargs, ) -> pygame_menu.widgets.ColorInput: """ Add a color widget with RGB or HEX format to the Menu. Includes a preview box that renders the given color. The callbacks (if defined) receive the current value and all unknown keyword arguments, where ``current_color=widget.get_value()``: .. code-block:: python onchange(current_color, **kwargs) onreturn(current_color, **kwargs) If ``onselect`` is defined, the callback is executed as follows, where ``selected`` is a boolean representing the selected status: .. code-block:: python onselect(selected, widget, menu) kwargs (Optional) - ``align`` (str) – Widget `alignment <https://pygame-menu.readthedocs.io/en/latest/_source/themes.html#alignment>`_ - ``background_color`` (tuple, list, str, int, :py:class:`pygame.Color`, :py:class:`pygame_menu.baseimage.BaseImage`) – Color of the background. ``None`` for no-color - ``background_inflate`` (tuple, list) – Inflate background on x-axis and y-axis (x, y) in px - ``border_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Widget border color. ``None`` for no-color - ``border_inflate`` (tuple, list) – Widget border inflate on x-axis and y-axis (x, y) in px - ``border_position`` (str, tuple, list) – Widget border positioning. It can be a single position, or a tuple/list of positions. Only are accepted: north, south, east, and west. See :py:mod:`pygame_menu.locals` - ``border_width`` (int) – Border width in px. If ``0`` disables the border - ``cursor`` (int, :py:class:`pygame.cursors.Cursor`, None) – Cursor of the widget if the mouse is placed over - ``dynamic_width`` (bool) – If ``True`` the widget width changes if the pre-visualization color box is active or not - ``float`` (bool) - If ``True`` the widget don't contribute width/height to the Menu widget positioning computation, and don't add one unit to the rows - ``float_origin_position`` (bool) - If ``True`` the widget position is set to the top-left position of the Menu if the widget is floating - ``font_background_color`` (tuple, list, str, int, :py:class:`pygame.Color`, None) – Widget font background color - ``font_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Widget font color - ``font_name`` (str, :py:class:`pathlib.Path`, :py:class:`pygame.font.Font`) – Widget font path - ``font_shadow_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Font shadow color - ``font_shadow_offset`` (int) – Font shadow offset in px - ``font_shadow_position`` (str) – Font shadow position, see locals for position - ``font_shadow`` (bool) – Font shadow is enabled or disabled - ``font_size`` (int) – Font size of the widget - ``input_underline_vmargin`` (int) – Vertical margin of underline in px - ``margin`` (tuple, list) – Widget (left, bottom) margin in px - ``maxwidth_dynamically_update`` (bool) - Dynamically update maxwidth depending on char size. ``True`` by default - ``padding`` (int, float, tuple, list) – Widget padding according to CSS rules. General shape: (top, right, bottom, left) - ``previsualization_margin`` (int) – Pre-visualization left margin from text input in px. Default is ``0`` - ``previsualization_width`` (int, float) – Pre-visualization width as a factor of the height. Default is ``3`` - ``readonly_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Color of the widget if readonly mode - ``readonly_selected_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Color of the widget if readonly mode and is selected - ``repeat_keys`` (bool) - Enable key repeat. ``True`` by default - ``repeat_keys_initial_ms`` (int, float) - Time in milliseconds before keys are repeated when held in milliseconds. ``400`` by default - ``repeat_keys_interval_ms`` (int, float) - Interval between key press repetition when held in milliseconds. ``50`` by default - ``repeat_mouse_interval_ms`` (int, float) - Interval between mouse events when held in milliseconds. ``400`` by default - ``selection_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Color of the selected widget; only affects the font color - ``selection_effect`` (:py:class:`pygame_menu.widgets.core.Selection`) – Widget selection effect - ``shadow_color`` (tuple, list, str, int, :py:class:`pygame.Color`) – Color of the widget shadow - ``shadow_radius`` (int) - Border radius of the shadow - ``shadow_type`` (str) - Shadow type, it can be ``'rectangular'`` or ``'ellipse'`` - ``shadow_width`` (int) - Width of the shadow. If ``0`` the shadow is disabled - ``tab_size`` (int) – Width of a tab character .. note:: All theme-related optional kwargs use the default Menu theme if not defined. .. note:: This is applied only to the base Menu (not the currently displayed, stored in ``_current`` pointer); for such behavior apply to :py:meth:`pygame_menu.menu.Menu.get_current` object. .. warning:: Be careful with kwargs collision. Consider that all optional documented kwargs keys are removed from the object. :param title: Title of the color input :param color_type: Type of the color input :param color_id: ID of the color input :param default: Default value to display, if RGB type it must be a tuple ``(r, g, b)``, if HEX must be a string ``"#XXXXXX"`` :param hex_format: Hex format string mode :param input_separator: Divisor between RGB channels, not valid in HEX format :param input_underline: Underline character :param onchange: Callback executed when changing the values of the color text :param onreturn: Callback executed when pressing return (apply) on the color input :param onselect: Callback executed when selecting the widget :param kwargs: Optional keyword arguments :return: Widget object :rtype: :py:class:`pygame_menu.widgets.ColorInput` """ assert isinstance(default, (str, tuple)) # Filter widget attributes to avoid passing them to the callbacks attributes = self._filter_widget_attributes(kwargs) dynamic_width = kwargs.pop("dynamic_width", True) input_underline_vmargin = kwargs.pop("input_underline_vmargin", 0) prev_margin = kwargs.pop("previsualization_margin", 10) prev_width = kwargs.pop("previsualization_width", 3) widget = ColorInput( color_type=color_type, colorinput_id=color_id, cursor_color=self._theme.cursor_color, cursor_switch_ms=self._theme.cursor_switch_ms, dynamic_width=dynamic_width, hex_format=hex_format, input_separator=input_separator, input_underline=input_underline, input_underline_vmargin=input_underline_vmargin, onchange=onchange, onreturn=onreturn, onselect=onselect, prev_margin=prev_margin, prev_width_factor=prev_width, title=title, **kwargs, ) self._configure_widget(widget=widget, **attributes) self._append_widget(widget) widget.set_default_value(default) return widget