diff --git a/constants.py b/constants.py index 0a4ea3c..34ac28a 100644 --- a/constants.py +++ b/constants.py @@ -1,3 +1,7 @@ +from typing import List + +import webcolors + HEADER_LENGTH = 10 @@ -24,17 +28,118 @@ class Requests: GET_HISTORY = 'GET_HISTORY' # Send a short history of the chat to the client +class Color(): + """ + Describes a basic RGB color. + """ + + def __init__(self, name: str, hex: str) -> None: + self.name, self.hex = name, hex + self.rgb = webcolors.hex_to_rgb(hex) + + def __repr__(self) -> str: + return f'Color({self.name})' + + @property + def id(self) -> str: + return '_'.join(self.name.split()).upper() + + def relative_luminance(self) -> float: + """ + Calculates the relative luminance of a given color, according to WCAG guidelines. + + https://www.w3.org/TR/WCAG20/#relativeluminancedef + + :return: The relative luminance of this color. + """ + r, g, b = self.rgb.red / 255, self.rgb.green / 255, self.rgb.blue / 255 + r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4 + g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4 + b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + def contrast_ratio(self, other: 'Color') -> float: + """ + Calculates the relative contrast between two colors in a ratio from 1:1 to 21:1, according to WCAG guidelines. + + https://www.w3.org/TR/WCAG20/#contrast-ratiodef + + :param other: The darker color. + :return: + """ + l1 = self.relative_luminance() + l2 = other.relative_luminance() + return (l1 + 0.05) / (l2 + 0.05) + + class Colors: """ Stores hexadecimal color codes of popular colors, with names. """ - ALL_NAMES = ['Aqua', 'Azure', 'Beige', 'Black', 'Blue', 'Brown', 'Cyan', 'Dark Blue', 'Dark Cyan', 'Dark Grey', - 'Dark Green', 'Dark Khaki', 'Dark Magenta', 'Dark Olive Green', 'Dark Orange', ' Dark Orchid', 'Dark Red', 'Dark Salmon', - 'Dark Violet', 'Fuchsia', 'Gold', 'Green', 'Indigo', 'Khaki', 'Light Blue', 'Light Cyan', - 'Light Green', 'Light Grey', 'Light Pink', 'Light Yellow', 'Lime', 'Magenta', 'Maroon', 'Navy', - 'Olive', 'Orange', 'Pink', 'Purple', 'Violet', 'Red', 'Silver', 'White', 'Yellow'] + AQUA = Color("Aqua", "#00ffff") + AZURE = Color("Azure", "#f0ffff") + BEIGE = Color("Beige", "#f5f5dc") + BLACK = Color("Black", "#000000") + BLUE = Color("Blue", "#0000ff") + BROWN = Color("Brown", "#a52a2a") + CYAN = Color("Cyan", "#00ffff") + DARKBLUE = Color("Dark Blue", "#00008b") + DARKCYAN = Color("Dark Cyan", "#008b8b") + DARKGREY = Color("Dark Grey", "#a9a9a9") + DARKGREEN = Color("Dark Green", "#006400") + DARKKHAKI = Color("Dark Khaki", "#bdb76b") + DARKMAGENTA = Color("Dark Magenta", "#8b008b") + DARKOLIVEGREEN = Color("Dark Olive Green", "#556b2f") + DARKORANGE = Color("Dark Orange", "#ff8c00") + DARKORCHID = Color("Dark Orchid", "#9932cc") + DARKRED = Color("Dark Red", "#8b0000") + DARKSALMON = Color("Dark Salmon", "#e9967a") + DARKVIOLET = Color("Dark Violet", "#9400d3") + FUCHSIA = Color("Fuchsia", "#ff00ff") + GOLD = Color("Gold", "#ffd700") + GREEN = Color("Green", "#008000") + INDIGO = Color("Indigo", "#4b0082") + KHAKI = Color("Khaki", "#f0e68c") + LIGHTBLUE = Color("Light Blue", "#add8e6") + LIGHTCYAN = Color("Light Cyan", "#e0ffff") + LIGHTGREEN = Color("Light Green", "#90ee90") + LIGHTGREY = Color("Light Grey", "#d3d3d3") + LIGHTPINK = Color("Light Pink", "#ffb6c1") + LIGHTYELLOW = Color("Light Yellow", "#ffffe0") + LIME = Color("Lime", "#00ff00") + MAGENTA = Color("Magenta", "#ff00ff") + MAROON = Color("Maroon", "#800000") + NAVY = Color("Navy", "#000080") + OLIVE = Color("Olive", "#808000") + ORANGE = Color("Orange", "#ffa500") + PINK = Color("Pink", "#ffc0cb") + PURPLE = Color("Purple", "#800080") + VIOLET = Color("Violet", "#800080") + RED = Color("Red", "#ff0000") + SILVER = Color("Silver", "#c0c0c0") + WHITE = Color("White", "#ffffff") + YELLOW = Color("Yellow", "#ffff00") + ALL = [AQUA, AZURE, BEIGE, BLACK, BLUE, BROWN, CYAN, DARKBLUE, DARKCYAN, DARKGREY, DARKGREEN, DARKKHAKI, DARKMAGENTA, DARKOLIVEGREEN, DARKORANGE, DARKORCHID, DARKRED, DARKSALMON, DARKVIOLET, FUCHSIA, GOLD, GREEN, INDIGO, KHAKI, LIGHTBLUE, LIGHTCYAN, LIGHTGREEN, LIGHTGREY, LIGHTPINK, LIGHTYELLOW, LIME, MAGENTA, MAROON, - NAVY, OLIVE, ORANGE, PINK, PURPLE, VIOLET, RED, SILVER, WHITE, YELLOW, ] + NAVY, OLIVE, ORANGE, PINK, PURPLE, VIOLET, RED, SILVER, WHITE, YELLOW] + + @staticmethod + def has_contrast(ratio: float, background: Color = WHITE) -> List[Color]: + """ + Returns a list of Color objects with contrast ratios above the given minimum. + + :param ratio: The minimum contrast ratio each color must adhere to. + :param background: Defaults to White, the background (lighter) for the other contrasting colors to be compared to. + :return: A list of Color objects with contrast ratios above the given minimum. + """ + above_ratio = [] + + for color in Colors.ALL: + contrast = background.contrast_ratio(color) + if contrast >= ratio: + above_ratio.append(color) + + return above_ratio diff --git a/server/commands.py b/server/commands.py index f44cc0d..9ee1544 100644 --- a/server/commands.py +++ b/server/commands.py @@ -70,14 +70,20 @@ class CommandHandler: logger.error(f'Could not process client {self.client.nickname}\'s command request.', exc_info=e) return 'A fatal error occurred while trying to process this command.' - def reroll(self) -> str: + def reroll(self, minimum_contrast: float = 4.65) -> str: """ Randomly change the client's color to a different color. """ - newColor = random.choice(constants.Colors.ALL) - newColorName = constants.Colors.ALL_NAMES[constants.Colors.ALL.index(newColor)] + i = 0 + newColor = self.client.color + choices = constants.Colors.has_contrast(float(minimum_contrast)) + + while i < 50 and newColor == self.client.color: + newColor = random.choice(choices) + self.client.color = newColor - return f'Changed your color to {newColorName} ({newColor})' + contrast = round(constants.Colors.WHITE.contrast_ratio(newColor), 1) + return f'Changed your color to {newColor.name} ({newColor.hex}/{contrast})' def help(self, command: str) -> Optional[str]: """ diff --git a/server/handler.py b/server/handler.py index 3ab06bb..568d35f 100644 --- a/server/handler.py +++ b/server/handler.py @@ -42,7 +42,7 @@ class Client: self.conn.send(helpers.prepare_json( { 'type': constants.Types.USER_LIST, - 'users': [{'nickname': other.nickname, 'color': other.color} for other in self.all_clients] + 'users': [{'nickname': other.nickname, 'color': other.color.hex} for other in self.all_clients] } )) @@ -69,13 +69,13 @@ class Client: def send_message(self, message: str) -> None: """Sends a string message as the server to this client.""" self.conn.send(helpers.prepare_message( - nickname='Server', message=message, color=constants.Colors.BLACK + nickname='Server', message=message, color=constants.Colors.BLACK.hex )) def broadcast_message(self, message: str) -> None: """Sends a string message to all connected clients as the Server.""" prepared = helpers.prepare_message( - nickname='Server', message=message, color=constants.Colors.BLACK + nickname='Server', message=message, color=constants.Colors.BLACK.hex ) for client in self.all_clients: client.send(prepared) @@ -99,7 +99,7 @@ class Client: self.broadcast(helpers.prepare_message( nickname=self.nickname, message=data['content'], - color=self.color + color=self.color.hex )) command = data['content'].strip()