From 0e792673d1ba5681af8de309422ac65b0f2032ce Mon Sep 17 00:00:00 2001 From: harryob Date: Thu, 7 Nov 2024 07:13:36 +0000 Subject: [PATCH] hair/facial hair character setup picker (#7493) video https://github.com/user-attachments/assets/36c68a58-2ecf-4847-85db-ccf519ff42d4 :cl: harryob, drathek ui: replaces the lists for hair/facial hair with images in a separate picker ui: replaces the eye color picker with a tgui color picker code: added unit test for duplicate sprite accessories (hair) /:cl: color picker modal is from https://github.com/BeeStation/BeeStation-Hornet/pull/9417 thank you --------- Co-authored-by: Drathek <76988376+Drulikar@users.noreply.github.com> --- code/modules/client/hair_picker.dm | 171 +++++ code/modules/client/preferences.dm | 131 +--- code/modules/client/preferences_savefile.dm | 14 +- .../mob/new_player/sprite_accessories/hair.dm | 8 - code/modules/tgui_input/color.dm | 219 ++++++ code/modules/unit_tests/_unit_tests.dm | 1 + .../duplicate_sprite_accessories.dm | 13 + colonialmarines.dme | 2 + tgui/packages/common/color.ts | 291 ++++++++ tgui/packages/tgui/components/Interactive.tsx | 153 ++++ tgui/packages/tgui/components/Pointer.tsx | 46 ++ tgui/packages/tgui/components/index.ts | 2 + .../tgui/interfaces/ColorPickerModal.tsx | 695 ++++++++++++++++++ tgui/packages/tgui/interfaces/HairPicker.tsx | 226 ++++++ .../tgui/styles/interfaces/ColorPicker.scss | 153 ++++ .../tgui/styles/interfaces/HairPicker.scss | 15 + tgui/packages/tgui/styles/main.scss | 2 + tgui/packages/tgui/styles/themes/crt.scss | 10 +- .../tgui/styles/themes/crt/crt-blue.scss | 4 +- 19 files changed, 2053 insertions(+), 103 deletions(-) create mode 100644 code/modules/client/hair_picker.dm create mode 100644 code/modules/tgui_input/color.dm create mode 100644 code/modules/unit_tests/duplicate_sprite_accessories.dm create mode 100644 tgui/packages/tgui/components/Interactive.tsx create mode 100644 tgui/packages/tgui/components/Pointer.tsx create mode 100644 tgui/packages/tgui/interfaces/ColorPickerModal.tsx create mode 100644 tgui/packages/tgui/interfaces/HairPicker.tsx create mode 100644 tgui/packages/tgui/styles/interfaces/ColorPicker.scss create mode 100644 tgui/packages/tgui/styles/interfaces/HairPicker.scss diff --git a/code/modules/client/hair_picker.dm b/code/modules/client/hair_picker.dm new file mode 100644 index 000000000000..c569c22297c5 --- /dev/null +++ b/code/modules/client/hair_picker.dm @@ -0,0 +1,171 @@ +/datum/hair_picker/ui_static_data(mob/user) + . = ..() + + .["hair_icon"] = /datum/sprite_accessory/hair::icon + .["facial_hair_icon"] = /datum/sprite_accessory/facial_hair::icon + + +/datum/hair_picker/ui_data(mob/user) + . = ..() + + var/datum/preferences/prefs = user.client.prefs + + .["hair_style"] = GLOB.hair_styles_list[prefs.h_style].icon_state + .["hair_color"] = rgb(prefs.r_hair, prefs.g_hair, prefs.b_hair) + + .["hair_styles"] = list() + for(var/key in GLOB.hair_styles_list) + var/datum/sprite_accessory/hair/hair = GLOB.hair_styles_list[key] + if(!hair.selectable) + continue + if(!(prefs.species in hair.species_allowed)) + continue + + .["hair_styles"] += list( + list("name" = hair.name, "icon" = hair.icon_state) + ) + + .["facial_hair_style"] = GLOB.facial_hair_styles_list[prefs.f_style].icon_state + .["facial_hair_color"] = rgb(prefs.r_facial, prefs.g_facial, prefs.b_facial) + + .["facial_hair_styles"] = list() + for(var/key in GLOB.facial_hair_styles_list) + var/datum/sprite_accessory/facial_hair/facial_hair = GLOB.facial_hair_styles_list[key] + if(!facial_hair.selectable) + continue + if(!(prefs.species in facial_hair.species_allowed)) + continue + if(facial_hair.gender != NEUTER && prefs.gender != facial_hair.gender) + continue + + .["facial_hair_styles"] += list( + list("name" = facial_hair.name, "icon" = facial_hair.icon_state) + ) + + .["gradient_available"] = !!(/datum/character_trait/hair_dye in prefs.traits) + .["gradient_style"] = prefs.grad_style + .["gradient_color"] = rgb(prefs.r_gradient, prefs.g_gradient, prefs.b_gradient) + + .["gradient_styles"] = list() + for(var/key in GLOB.hair_gradient_list) + var/datum/sprite_accessory/hair_gradient/gradient = GLOB.hair_gradient_list[key] + if(!gradient.selectable) + continue + if(!(prefs.species in gradient.species_allowed)) + continue + + .["gradient_styles"] += gradient.name + +/datum/hair_picker/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + + var/datum/preferences/prefs = ui.user.client.prefs + + switch(action) + if("hair") + var/datum/sprite_accessory/hair/hair = GLOB.hair_styles_list[params["name"]] + if(!hair) + return + + if(!hair.selectable) + return + + if(!(prefs.species in hair.species_allowed)) + return + + prefs.h_style = params["name"] + + if("hair_color") + var/param_color = params["color"] + if(!param_color) + return + + var/r = hex2num(copytext(param_color, 2, 4)) + var/g = hex2num(copytext(param_color, 4, 6)) + var/b = hex2num(copytext(param_color, 6, 8)) + + if(!isnum(r) || !isnum(g) || !isnum(b)) + return + + prefs.r_hair = clamp(r, 0, 255) + prefs.g_hair = clamp(g, 0, 255) + prefs.b_hair = clamp(b, 0, 255) + + if("facial_hair") + var/datum/sprite_accessory/facial_hair/facial_hair = GLOB.facial_hair_styles_list[params["name"]] + if(!facial_hair) + return + + if(!facial_hair.selectable) + return + + if(!(prefs.species in facial_hair.species_allowed)) + return + + if(facial_hair.gender != NEUTER && prefs.gender != facial_hair.gender) + return + + prefs.f_style = params["name"] + + if("facial_hair_color") + var/param_color = params["color"] + if(!param_color) + return + + var/r = hex2num(copytext(param_color, 2, 4)) + var/g = hex2num(copytext(param_color, 4, 6)) + var/b = hex2num(copytext(param_color, 6, 8)) + + if(!isnum(r) || !isnum(g) || !isnum(b)) + return + + prefs.r_facial = clamp(r, 0, 255) + prefs.g_facial = clamp(g, 0, 255) + prefs.b_facial = clamp(b, 0, 255) + + if("gradient") + var/datum/sprite_accessory/hair_gradient/gradient = GLOB.hair_gradient_list[params["name"]] + if(!gradient) + return + + if(!gradient.selectable) + return + + if(!(prefs.species in gradient.species_allowed)) + return + + prefs.grad_style = params["name"] + + if("gradient_color") + var/param_color = params["color"] + if(!param_color) + return + + var/r = hex2num(copytext(param_color, 2, 4)) + var/g = hex2num(copytext(param_color, 4, 6)) + var/b = hex2num(copytext(param_color, 6, 8)) + + if(!isnum(r) || !isnum(g) || !isnum(b)) + return + + prefs.r_gradient = clamp(r, 0, 255) + prefs.g_gradient = clamp(g, 0, 255) + prefs.b_gradient = clamp(b, 0, 255) + + prefs.ShowChoices(ui.user) + return TRUE + +/datum/hair_picker/tgui_interact(mob/user, datum/tgui/ui) + . = ..() + + ui = SStgui.try_update_ui(user, src, ui) + + if(!ui) + ui = new(user, src, "HairPicker", "Hair Picker") + ui.open() + ui.set_autoupdate(FALSE) + + winset(user, ui.window.id, "focus=true") + +/datum/hair_picker/ui_state(mob/user) + return GLOB.always_state diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index 72e580b59521..fdedf8dbc2e0 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -30,7 +30,8 @@ GLOBAL_LIST_INIT(bgstate_options, list( var/atom/movable/screen/rotate/alt/rotate_left var/atom/movable/screen/rotate/rotate_right - var/static/datum/body_picker/picker = new + var/static/datum/hair_picker/hair_picker = new + var/static/datum/body_picker/body_picker = new //doohickeys for savefiles var/path @@ -361,30 +362,23 @@ GLOBAL_LIST_INIT(bgstate_options, list( dat += "
" dat += "

Hair and Eyes:

" - dat += "Hair: " - dat += "[h_style]" + dat += "Hair: [h_style]" dat += " | " - dat += "" - dat += "Color " - dat += "" + dat += "" + dat += "
" + + dat += "Facial Hair: [f_style]" + dat += " | " + dat += "" dat += "
" if(/datum/character_trait/hair_dye in traits) - dat += "Hair Gradient: " - dat += "[grad_style]" + dat += "Hair Gradient: [grad_style]" dat += " | " - dat += "" - dat += "Color " - dat += "" + dat += "" dat += "
" - dat += "Facial Hair: " - dat += "[f_style]" - dat += " | " - dat += "" - dat += "Color " - dat += "" - dat += "
" + dat += "Edit Hair: Picker

" dat += "Eye: " dat += "" @@ -1531,83 +1525,13 @@ GLOBAL_LIST_INIT(bgstate_options, list( metadata = strip_html(new_metadata) if("hair") - if(species == "Human") - var/new_hair = input(user, "Choose your character's hair color:", "Character Preference", rgb(r_hair, g_hair, b_hair)) as color|null - if(new_hair) - r_hair = hex2num(copytext(new_hair, 2, 4)) - g_hair = hex2num(copytext(new_hair, 4, 6)) - b_hair = hex2num(copytext(new_hair, 6, 8)) - - if("h_style") - var/list/valid_hairstyles = list() - for(var/hairstyle in GLOB.hair_styles_list) - var/datum/sprite_accessory/sprite_accessory = GLOB.hair_styles_list[hairstyle] - if( !(species in sprite_accessory.species_allowed)) - continue - if(!sprite_accessory.selectable) - continue - - valid_hairstyles[hairstyle] = GLOB.hair_styles_list[hairstyle] - valid_hairstyles = sortList(valid_hairstyles) - - var/new_h_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in valid_hairstyles - if(new_h_style) - h_style = new_h_style - - if("grad") - if(species == "Human") - var/new_hair_grad = input(user, "Choose your character's hair gradient color:", "Character Preference", rgb(r_gradient, g_gradient, b_gradient)) as color|null - if(new_hair_grad) - r_gradient = hex2num(copytext(new_hair_grad, 2, 4)) - g_gradient = hex2num(copytext(new_hair_grad, 4, 6)) - b_gradient = hex2num(copytext(new_hair_grad, 6, 8)) - - if("grad_style") - var/list/valid_hair_gradients = list() - for(var/hair_gradient in GLOB.hair_gradient_list) - var/datum/sprite_accessory/sprite_accessory = GLOB.hair_gradient_list[hair_gradient] - if(!(species in sprite_accessory.species_allowed)) - continue - if(!sprite_accessory.selectable) - continue - valid_hair_gradients[hair_gradient] = GLOB.hair_gradient_list[hair_gradient] - valid_hair_gradients = sortList(valid_hair_gradients) - - var/new_h_gradient_style = input(user, "Choose your character's hair gradient style:", "Character Preference") as null|anything in valid_hair_gradients - if(new_h_gradient_style) - grad_style = new_h_gradient_style + hair_picker.tgui_interact(user) + return if ("body") - picker.tgui_interact(user) + body_picker.tgui_interact(user) return - if("facial") - var/new_facial = input(user, "Choose your character's facial-hair color:", "Character Preference", rgb(r_facial, g_facial, b_facial)) as color|null - if(new_facial) - r_facial = hex2num(copytext(new_facial, 2, 4)) - g_facial = hex2num(copytext(new_facial, 4, 6)) - b_facial = hex2num(copytext(new_facial, 6, 8)) - - if("f_style") - var/list/valid_facialhairstyles = list() - for(var/facialhairstyle in GLOB.facial_hair_styles_list) - var/datum/sprite_accessory/sprite_accessory = GLOB.facial_hair_styles_list[facialhairstyle] - if(gender == MALE && sprite_accessory.gender == FEMALE) - continue - if(gender == FEMALE && sprite_accessory.gender == MALE) - continue - if( !(species in sprite_accessory.species_allowed)) - continue - if(!sprite_accessory.selectable) - continue - - valid_facialhairstyles[facialhairstyle] = GLOB.facial_hair_styles_list[facialhairstyle] - valid_facialhairstyles = sortList(valid_facialhairstyles) - - var/new_f_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in valid_facialhairstyles - if(new_f_style) - f_style = new_f_style - if("underwear") var/list/underwear_options = gender == MALE ? GLOB.underwear_m : GLOB.underwear_f var/old_gender = gender @@ -1629,7 +1553,8 @@ GLOBAL_LIST_INIT(bgstate_options, list( ShowChoices(user) if("eyes") - var/new_eyes = input(user, "Choose your character's eye color:", "Character Preference", rgb(r_eyes, g_eyes, b_eyes)) as color|null + var/new_eyes = tgui_color_picker(user, "Choose your character's eye color:", "Character Preference", rgb(r_eyes, g_eyes, b_eyes)) + if(new_eyes) r_eyes = hex2num(copytext(new_eyes, 2, 4)) g_eyes = hex2num(copytext(new_eyes, 4, 6)) @@ -1793,6 +1718,11 @@ GLOBAL_LIST_INIT(bgstate_options, list( underwear = sanitize_inlist(underwear, gender == MALE ? GLOB.underwear_m : GLOB.underwear_f, initial(underwear)) undershirt = sanitize_inlist(undershirt, gender == MALE ? GLOB.undershirt_m : GLOB.undershirt_f, initial(undershirt)) + // Refresh hair picker + var/datum/tgui/picker_ui = SStgui.get_open_ui(user, hair_picker) + if(picker_ui) + picker_ui.send_update() + if("hear_adminhelps") toggles_sound ^= SOUND_ADMINHELP @@ -2002,6 +1932,14 @@ GLOBAL_LIST_INIT(bgstate_options, list( load_character() reload_cooldown = world.time + 50 + // Refresh pickers + var/datum/tgui/picker_ui = SStgui.get_open_ui(user, hair_picker) + if(picker_ui) + picker_ui.send_update() + picker_ui = SStgui.get_open_ui(user, body_picker) + if(picker_ui) + picker_ui.send_update() + if("open_load_dialog") if(!IsGuestKey(user.key)) open_load_dialog(user) @@ -2017,6 +1955,15 @@ GLOBAL_LIST_INIT(bgstate_options, list( var/mob/new_player/np = user if(istype(np)) np.new_player_panel_proc() + + // Refresh pickers + var/datum/tgui/picker_ui = SStgui.get_open_ui(user, hair_picker) + if(picker_ui) + picker_ui.send_update() + picker_ui = SStgui.get_open_ui(user, body_picker) + if(picker_ui) + picker_ui.send_update() + if("tgui_fancy") tgui_fancy = !tgui_fancy if("tgui_lock") diff --git a/code/modules/client/preferences_savefile.dm b/code/modules/client/preferences_savefile.dm index 4b69960c761f..4e8aee02034d 100644 --- a/code/modules/client/preferences_savefile.dm +++ b/code/modules/client/preferences_savefile.dm @@ -1,5 +1,5 @@ #define SAVEFILE_VERSION_MIN 8 -#define SAVEFILE_VERSION_MAX 28 +#define SAVEFILE_VERSION_MAX 29 //handles converting savefiles to new formats //MAKE SURE YOU KEEP THIS UP TO DATE! @@ -180,6 +180,18 @@ completed_tutorials += "marine_req_1" S["completed_tutorials"] << tutorial_list_to_savestring() + if(savefile_version < 29) + var/hair_style = "" + S["hair_style_name"] >> hair_style + + switch(hair_style) + if("Shoulder-length Hair Alt") + hair_style = "Long Fringe" + if("Long Hair Alt") + hair_style = "Longer Fringe" + + S["hair_style_name"] << hair_style + savefile_version = SAVEFILE_VERSION_MAX return 1 diff --git a/code/modules/mob/new_player/sprite_accessories/hair.dm b/code/modules/mob/new_player/sprite_accessories/hair.dm index ea9ea092d9ea..078a2b221794 100644 --- a/code/modules/mob/new_player/sprite_accessories/hair.dm +++ b/code/modules/mob/new_player/sprite_accessories/hair.dm @@ -27,18 +27,10 @@ name = "Shoulder-length Hair" icon_state = "hair_long_shoulder" -/datum/sprite_accessory/hair/longalt - name = "Shoulder-length Hair Alt" - icon_state = "hair_longfringe" - /datum/sprite_accessory/hair/longer name = "Long Hair" icon_state = "hair_vlong" -/datum/sprite_accessory/hair/longeralt - name = "Long Hair Alt" - icon_state = "hair_vlongfringe" - /datum/sprite_accessory/hair/longest name = "Very Long Hair" icon_state = "hair_longest" diff --git a/code/modules/tgui_input/color.dm b/code/modules/tgui_input/color.dm new file mode 100644 index 000000000000..780eba4654c6 --- /dev/null +++ b/code/modules/tgui_input/color.dm @@ -0,0 +1,219 @@ +/** + * Creates a TGUI color picker window and returns the user's response. + * + * This proc should be used to create a color picker that the caller will wait for a response from. + * Arguments: + * * user - The user to show the picker to. + * * title - The of the picker modal, shown on the top of the TGUI window. + * * timeout - The timeout of the picker, after which the modal will close and qdel itself. Set to zero for no timeout. + * * autofocus - The bool that controls if this picker should grab window focus. + */ +/proc/tgui_color_picker(mob/user, message, title, default = "#000000", timeout = 0, autofocus = TRUE) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + + var/datum/tgui_color_picker/picker = new(user, message, title, default, timeout, autofocus) + picker.tgui_interact(user) + picker.wait() + if (picker) + . = picker.choice + qdel(picker) + +/** + * Creates an asynchronous TGUI color picker window with an associated callback. + * + * This proc should be used to create a color picker that invokes a callback with the user's chosen option. + * Arguments: + * * user - The user to show the picker to. + * * title - The of the picker modal, shown on the top of the TGUI window. + * * callback - The callback to be invoked when a choice is made. + * * timeout - The timeout of the picker, after which the modal will close and qdel itself. Set to zero for no timeout. + * * autofocus - The bool that controls if this picker should grab window focus. + */ +/proc/tgui_color_picker_async(mob/user, message, title, default = "#000000", datum/callback/callback, timeout = 0, autofocus = TRUE) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + + var/datum/tgui_color_picker/async/picker = new(user, message, title, default, callback, timeout, autofocus) + picker.tgui_interact(user) + +/** + * # tgui_color_picker + * + * Datum used for instantiating and using a TGUI-controlled color picker. + */ +/datum/tgui_color_picker + /// The title of the TGUI window + var/title + /// The message to show the user + var/message + /// The default choice, used if there is an existing value + var/default + /// The color the user selected, null if no selection has been made + var/choice + /// The time at which the tgui_color_picker was created, for displaying timeout progress. + var/start_time + /// The lifespan of the tgui_color_picker, after which the window will close and delete itself. + var/timeout + /// The bool that controls if this modal should grab window focus + var/autofocus + /// Boolean field describing if the tgui_color_picker was closed by the user. + var/closed + +/datum/tgui_color_picker/New(mob/user, message, title, default, timeout, autofocus) + src.autofocus = autofocus + src.title = title + src.default = default + src.message = message + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_color_picker/Destroy(force, ...) + SStgui.close_uis(src) + . = ..() + +/** + * Waits for a user's response to the tgui_color_picker's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_color_picker/proc/wait() + while (!choice && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_color_picker/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "ColorPickerModal") + ui.open() + ui.set_autoupdate(timeout > 0) + +/datum/tgui_color_picker/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_color_picker/ui_state(mob/user) + return GLOB.always_state + +/datum/tgui_color_picker/ui_static_data(mob/user) + . = list() + .["autofocus"] = autofocus + .["title"] = title + .["default_color"] = default + .["message"] = message + .["large_buttons"] = TRUE + +/datum/tgui_color_picker/ui_data(mob/user) + . = list() + if(timeout) + .["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1) + +/datum/tgui_color_picker/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("submit") + var/raw_data = lowertext(params["entry"]) + var/hex = sanitize_hexcolor(raw_data, desired_format = 6, include_crunch = TRUE) + if (!hex) + return + set_choice(hex) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_color_picker/proc/set_choice(choice) + src.choice = choice + +/// Return `color` if it is a valid hex color, otherwise `default` +/datum/tgui_color_picker/proc/sanitize_hexcolor(color, desired_format = 3, include_crunch = FALSE, default) + var/crunch = include_crunch ? "#" : "" + if(!istext(color)) + color = "" + + var/start = 1 + (text2ascii(color, 1) == 35) + var/len = length(color) + var/char = "" + // Used for conversion between RGBA hex formats. + var/format_input_ratio = "[desired_format]:[length_char(color)-(start-1)]" + + . = "" + var/i = start + while(i <= len) + char = color[i] + i += length(char) + switch(text2ascii(char)) + if(48 to 57) //numbers 0 to 9 + . += char + if(97 to 102) //letters a to f + . += char + if(65 to 70) //letters A to F + char = lowertext(char) + . += char + else + break + switch(format_input_ratio) + if("3:8", "4:8", "3:6", "4:6") //skip next one. RRGGBB(AA) -> RGB(A) + i += length(color[i]) + if("6:4", "6:3", "8:4", "8:3") //add current char again. RGB(A) -> RRGGBB(AA) + . += char + + if(length_char(.) == desired_format) + return crunch + . + switch(format_input_ratio) //add or remove alpha channel depending on desired format. + if("3:8", "3:4", "6:4") + return crunch + copytext(., 1, desired_format+1) + if("4:6", "4:3", "8:3") + return crunch + . + ((desired_format == 4) ? "f" : "ff") + else //not a supported hex color format. + return default ? default : crunch + repeat_string(desired_format, "0") + +/// Returns `string` repeated `times` times +/datum/tgui_color_picker/proc/repeat_string(times, string="") + . = "" + for(var/i in 1 to times) + . += string + + +/** + * # async tgui_color_picker + * + * An asynchronous version of tgui_color_picker to be used with callbacks instead of waiting on user responses. + */ +/datum/tgui_color_picker/async + /// The callback to be invoked by the tgui_color_picker upon having a choice made. + var/datum/callback/callback + +/datum/tgui_color_picker/async/New(mob/user, message, title, default, callback, timeout, autofocus) + ..(user, message, title, default, timeout, autofocus) + src.callback = callback + +/datum/tgui_color_picker/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_color_picker/async/set_choice(choice) + . = ..() + if(!isnull(src.choice)) + callback?.InvokeAsync(src.choice) + +/datum/tgui_color_picker/async/wait() + return diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index 6f2b63e5b9e8..866217a5eb66 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -78,6 +78,7 @@ #include "autowiki.dm" #include "check_runtimes.dm" #include "create_and_destroy.dm" +#include "duplicate_sprite_accessories.dm" #include "emote_panels.dm" #include "missing_icons.dm" #include "resist.dm" diff --git a/code/modules/unit_tests/duplicate_sprite_accessories.dm b/code/modules/unit_tests/duplicate_sprite_accessories.dm new file mode 100644 index 000000000000..a0bb3b7162f6 --- /dev/null +++ b/code/modules/unit_tests/duplicate_sprite_accessories.dm @@ -0,0 +1,13 @@ +/datum/unit_test/duplicate_sprite_accessories/Run() + var/list/unique_icons_and_states = list() + + for(var/datum/sprite_accessory/accessory as anything in subtypesof(/datum/sprite_accessory)) + if(!length(accessory::icon_state)) + continue + + var/icon_and_state = "[accessory::icon]_[accessory::icon_state]" + + if(icon_and_state in unique_icons_and_states) + TEST_FAIL("Duplicate sprite accessory [accessory] - icon '[accessory::icon]' and icon_state '[accessory::icon_state]' already in use.") + + unique_icons_and_states += icon_and_state diff --git a/colonialmarines.dme b/colonialmarines.dme index b7f4b71cb21d..a4644f0ee77e 100644 --- a/colonialmarines.dme +++ b/colonialmarines.dme @@ -1562,6 +1562,7 @@ #include "code\modules\client\client_procs.dm" #include "code\modules\client\color_picker.dm" #include "code\modules\client\country_flags.dm" +#include "code\modules\client\hair_picker.dm" #include "code\modules\client\player_details.dm" #include "code\modules\client\preferences.dm" #include "code\modules\client\preferences_factions.dm" @@ -2413,6 +2414,7 @@ #include "code\modules\tgui\tgui-say\speech.dm" #include "code\modules\tgui\tgui-say\typing.dm" #include "code\modules\tgui_input\checkboxes.dm" +#include "code\modules\tgui_input\color.dm" #include "code\modules\tgui_input\text.dm" #include "code\modules\tgui_panel\audio.dm" #include "code\modules\tgui_panel\external.dm" diff --git a/tgui/packages/common/color.ts b/tgui/packages/common/color.ts index 943b52a71fae..144237305fdb 100644 --- a/tgui/packages/common/color.ts +++ b/tgui/packages/common/color.ts @@ -92,3 +92,294 @@ export class Color { return this.lerp(colors[index], colors[index + 1], ratio); } } + +/* + * MIT License + * https://github.com/omgovich/react-colorful/ + * + * Copyright (c) 2020 Vlad Shilov + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const round = ( + number: number, + digits = 0, + base = Math.pow(10, digits), +): number => { + return Math.round(base * number) / base; +}; + +export interface RgbColor { + r: number; + g: number; + b: number; +} + +export interface RgbaColor extends RgbColor { + a: number; +} + +export interface HslColor { + h: number; + s: number; + l: number; +} + +export interface HslaColor extends HslColor { + a: number; +} + +export interface HsvColor { + h: number; + s: number; + v: number; +} + +export interface HsvaColor extends HsvColor { + a: number; +} + +export type ObjectColor = + | RgbColor + | HslColor + | HsvColor + | RgbaColor + | HslaColor + | HsvaColor; + +export type AnyColor = string | ObjectColor; + +/** + * Valid CSS units. + * https://developer.mozilla.org/en-US/docs/Web/CSS/angle + */ +const angleUnits: Record = { + grad: 360 / 400, + turn: 360, + rad: 360 / (Math.PI * 2), +}; + +export const hexToHsva = (hex: string): HsvaColor => rgbaToHsva(hexToRgba(hex)); + +export const hexToRgba = (hex: string): RgbaColor => { + if (hex[0] === '#') hex = hex.substring(1); + + if (hex.length < 6) { + return { + r: parseInt(hex[0] + hex[0], 16), + g: parseInt(hex[1] + hex[1], 16), + b: parseInt(hex[2] + hex[2], 16), + a: hex.length === 4 ? round(parseInt(hex[3] + hex[3], 16) / 255, 2) : 1, + }; + } + + return { + r: parseInt(hex.substring(0, 2), 16), + g: parseInt(hex.substring(2, 4), 16), + b: parseInt(hex.substring(4, 6), 16), + a: hex.length === 8 ? round(parseInt(hex.substring(6, 8), 16) / 255, 2) : 1, + }; +}; + +export const parseHue = (value: string, unit = 'deg'): number => { + return Number(value) * (angleUnits[unit] || 1); +}; + +export const hslaStringToHsva = (hslString: string): HsvaColor => { + const matcher = + /hsla?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i; + const match = matcher.exec(hslString); + + if (!match) return { h: 0, s: 0, v: 0, a: 1 }; + + return hslaToHsva({ + h: parseHue(match[1], match[2]), + s: Number(match[3]), + l: Number(match[4]), + a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1), + }); +}; + +export const hslStringToHsva = hslaStringToHsva; + +export const hslaToHsva = ({ h, s, l, a }: HslaColor): HsvaColor => { + s *= (l < 50 ? l : 100 - l) / 100; + + return { + h: h, + s: s > 0 ? ((2 * s) / (l + s)) * 100 : 0, + v: l + s, + a, + }; +}; + +export const hsvaToHex = (hsva: HsvaColor): string => + rgbaToHex(hsvaToRgba(hsva)); + +export const hsvaToHsla = ({ h, s, v, a }: HsvaColor): HslaColor => { + const hh = ((200 - s) * v) / 100; + + return { + h: round(h), + s: round( + hh > 0 && hh < 200 + ? ((s * v) / 100 / (hh <= 100 ? hh : 200 - hh)) * 100 + : 0, + ), + l: round(hh / 2), + a: round(a, 2), + }; +}; + +export const hsvaToHslString = (hsva: HsvaColor): string => { + const { h, s, l } = hsvaToHsla(hsva); + return `hsl(${h}, ${s}%, ${l}%)`; +}; + +export const hsvaToHsvString = (hsva: HsvaColor): string => { + const { h, s, v } = roundHsva(hsva); + return `hsv(${h}, ${s}%, ${v}%)`; +}; + +export const hsvaToHsvaString = (hsva: HsvaColor): string => { + const { h, s, v, a } = roundHsva(hsva); + return `hsva(${h}, ${s}%, ${v}%, ${a})`; +}; + +export const hsvaToHslaString = (hsva: HsvaColor): string => { + const { h, s, l, a } = hsvaToHsla(hsva); + return `hsla(${h}, ${s}%, ${l}%, ${a})`; +}; + +export const hsvaToRgba = ({ h, s, v, a }: HsvaColor): RgbaColor => { + h = (h / 360) * 6; + s = s / 100; + v = v / 100; + + const hh = Math.floor(h), + b = v * (1 - s), + c = v * (1 - (h - hh) * s), + d = v * (1 - (1 - h + hh) * s), + module = hh % 6; + + return { + r: [v, c, b, b, d, v][module] * 255, + g: [d, v, v, c, b, b][module] * 255, + b: [b, b, d, v, v, c][module] * 255, + a: round(a, 2), + }; +}; + +export const hsvaToRgbString = (hsva: HsvaColor): string => { + const { r, g, b } = hsvaToRgba(hsva); + return `rgb(${round(r)}, ${round(g)}, ${round(b)})`; +}; + +export const hsvaToRgbaString = (hsva: HsvaColor): string => { + const { r, g, b, a } = hsvaToRgba(hsva); + return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${round(a, 2)})`; +}; + +export const hsvaStringToHsva = (hsvString: string): HsvaColor => { + const matcher = + /hsva?\(?\s*(-?\d*\.?\d+)(deg|rad|grad|turn)?[,\s]+(-?\d*\.?\d+)%?[,\s]+(-?\d*\.?\d+)%?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i; + const match = matcher.exec(hsvString); + + if (!match) return { h: 0, s: 0, v: 0, a: 1 }; + + return roundHsva({ + h: parseHue(match[1], match[2]), + s: Number(match[3]), + v: Number(match[4]), + a: match[5] === undefined ? 1 : Number(match[5]) / (match[6] ? 100 : 1), + }); +}; + +export const hsvStringToHsva = hsvaStringToHsva; + +export const rgbaStringToHsva = (rgbaString: string): HsvaColor => { + const matcher = + /rgba?\(?\s*(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?[,\s]+(-?\d*\.?\d+)(%)?,?\s*[/\s]*(-?\d*\.?\d+)?(%)?\s*\)?/i; + const match = matcher.exec(rgbaString); + + if (!match) return { h: 0, s: 0, v: 0, a: 1 }; + + return rgbaToHsva({ + r: Number(match[1]) / (match[2] ? 100 / 255 : 1), + g: Number(match[3]) / (match[4] ? 100 / 255 : 1), + b: Number(match[5]) / (match[6] ? 100 / 255 : 1), + a: match[7] === undefined ? 1 : Number(match[7]) / (match[8] ? 100 : 1), + }); +}; + +export const rgbStringToHsva = rgbaStringToHsva; + +const format = (number: number) => { + const hex = number.toString(16); + return hex.length < 2 ? '0' + hex : hex; +}; + +export const rgbaToHex = ({ r, g, b, a }: RgbaColor): string => { + const alphaHex = a < 1 ? format(round(a * 255)) : ''; + return ( + '#' + format(round(r)) + format(round(g)) + format(round(b)) + alphaHex + ); +}; + +export const rgbaToHsva = ({ r, g, b, a }: RgbaColor): HsvaColor => { + const max = Math.max(r, g, b); + const delta = max - Math.min(r, g, b); + + // prettier-ignore + const hh = delta + ? max === r + ? (g - b) / delta + : max === g + ? 2 + (b - r) / delta + : 4 + (r - g) / delta + : 0; + + return { + h: 60 * (hh < 0 ? hh + 6 : hh), + s: max ? (delta / max) * 100 : 0, + v: (max / 255) * 100, + a, + }; +}; + +export const roundHsva = (hsva: HsvaColor): HsvaColor => ({ + h: round(hsva.h), + s: round(hsva.s), + + v: round(hsva.v), + a: round(hsva.a, 2), +}); + +export const rgbaToRgb = ({ r, g, b }: RgbaColor): RgbColor => ({ r, g, b }); + +export const hslaToHsl = ({ h, s, l }: HslaColor): HslColor => ({ h, s, l }); + +export const hsvaToHsv = (hsva: HsvaColor): HsvColor => { + const { h, s, v } = roundHsva(hsva); + return { h, s, v }; +}; + +const hexMatcher = /^#?([0-9A-F]{3,8})$/i; + +export const validHex = (value: string, alpha?: boolean): boolean => { + const match = hexMatcher.exec(value); + const length = match ? match[1].length : 0; + + return ( + length === 3 || // '#rgb' format + length === 6 || // '#rrggbb' format + (!!alpha && length === 4) || // '#rgba' format + (!!alpha && length === 8) // '#rrggbbaa' format + ); +}; diff --git a/tgui/packages/tgui/components/Interactive.tsx b/tgui/packages/tgui/components/Interactive.tsx new file mode 100644 index 000000000000..241a10623868 --- /dev/null +++ b/tgui/packages/tgui/components/Interactive.tsx @@ -0,0 +1,153 @@ +/** + * MIT License + * https://github.com/omgovich/react-colorful/ + * + * Copyright (c) 2020 Vlad Shilov + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { clamp } from 'common/math'; +import { Component, createRef, ReactNode, RefObject } from 'react'; + +export interface Interaction { + left: number; + top: number; +} + +// Finds the proper window object to fix iframe embedding issues +const getParentWindow = (node?: HTMLDivElement | null): Window => { + return (node && node.ownerDocument.defaultView) || self; +}; + +// Returns a relative position of the pointer inside the node's bounding box +const getRelativePosition = ( + node: HTMLDivElement, + event: MouseEvent | React.MouseEvent, +): Interaction => { + const rect = node.getBoundingClientRect(); + const pointer = event as React.MouseEvent; + return { + left: clamp( + (pointer.pageX - (rect.left + getParentWindow(node).pageXOffset)) / + rect.width, + 0, + 1, + ), + top: clamp( + (pointer.pageY - (rect.top + getParentWindow(node).pageYOffset)) / + rect.height, + 0, + 1, + ), + }; +}; + +export interface InteractiveProps { + readonly onMove: (interaction: Interaction) => void; + readonly onKey: (offset: Interaction) => void; + readonly children: ReactNode | ReactNode[]; + readonly style?: any; +} + +export class Interactive extends Component { + containerRef: RefObject; + props: InteractiveProps; + + constructor(props: InteractiveProps) { + super(props); + this.props = props; + this.containerRef = createRef(); + } + + handleMoveStart = (event: React.MouseEvent) => { + const el = this.containerRef?.current; + if (!el) return; + + // Prevent text selection + event.preventDefault(); + el.focus(); + this.props.onMove(getRelativePosition(el, event)); + this.toggleDocumentEvents(true); + }; + + handleMove = (event: MouseEvent) => { + // Prevent text selection + event.preventDefault(); + + // If user moves the pointer outside of the window or iframe bounds and release it there, + // `mouseup`/`touchend` won't be fired. In order to stop the picker from following the cursor + // after the user has moved the mouse/finger back to the document, we check `event.buttons` + // and `event.touches`. It allows us to detect that the user is just moving his pointer + // without pressing it down + const isDown = event.buttons > 0; + + if (isDown && this.containerRef?.current) { + this.props.onMove(getRelativePosition(this.containerRef.current, event)); + } else { + this.toggleDocumentEvents(false); + } + }; + + handleMoveEnd = () => { + this.toggleDocumentEvents(false); + }; + + handleKeyDown = (event: React.KeyboardEvent) => { + const keyCode = event.which || event.keyCode; + + // Ignore all keys except arrow ones + if (keyCode < 37 || keyCode > 40) return; + // Do not scroll page by arrow keys when document is focused on the element + event.preventDefault(); + // Send relative offset to the parent component. + // We use codes (37←, 38↑, 39→, 40↓) instead of keys ('ArrowRight', 'ArrowDown', etc) + // to reduce the size of the library + this.props.onKey({ + left: keyCode === 39 ? 0.05 : keyCode === 37 ? -0.05 : 0, + top: keyCode === 40 ? 0.05 : keyCode === 38 ? -0.05 : 0, + }); + }; + + toggleDocumentEvents(state?: boolean) { + const el = this.containerRef?.current; + const parentWindow = getParentWindow(el); + + // Add or remove additional pointer event listeners + const toggleEvent = state + ? parentWindow.addEventListener + : parentWindow.removeEventListener; + toggleEvent('mousemove', this.handleMove); + toggleEvent('mouseup', this.handleMoveEnd); + } + + componentDidMount() { + this.toggleDocumentEvents(true); + } + + componentWillUnmount() { + this.toggleDocumentEvents(false); + } + + render() { + return ( +
+ {this.props.children} +
+ ); + } +} diff --git a/tgui/packages/tgui/components/Pointer.tsx b/tgui/packages/tgui/components/Pointer.tsx new file mode 100644 index 000000000000..554b69dca510 --- /dev/null +++ b/tgui/packages/tgui/components/Pointer.tsx @@ -0,0 +1,46 @@ +/** + * MIT License + * https://github.com/omgovich/react-colorful/ + * + * Copyright (c) 2020 Vlad Shilov + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { classes } from 'common/react'; +import { ReactNode } from 'react'; + +interface PointerProps { + readonly className?: string; + readonly top?: number; + readonly left: number; + readonly color: string; +} + +export const Pointer = ({ + className, + color, + left, + top = 0.5, +}: PointerProps): ReactNode => { + const nodeClassName = classes(['react-colorful__pointer', className]); + + const style = { + top: `${top * 100}%`, + left: `${left * 100}%`, + }; + + return ( +
+
+
+ ); +}; diff --git a/tgui/packages/tgui/components/index.ts b/tgui/packages/tgui/components/index.ts index e50589f16f09..ea9a90719f30 100644 --- a/tgui/packages/tgui/components/index.ts +++ b/tgui/packages/tgui/components/index.ts @@ -26,6 +26,7 @@ export { Icon } from './Icon'; export { Image } from './Image'; export { InfinitePlane } from './InfinitePlane'; export { Input } from './Input'; +export { Interactive } from './Interactive'; export { KeyListener } from './KeyListener'; export { Knob } from './Knob'; export { LabeledControls } from './LabeledControls'; @@ -34,6 +35,7 @@ export { MenuBar } from './MenuBar'; export { Modal } from './Modal'; export { NoticeBox } from './NoticeBox'; export { NumberInput } from './NumberInput'; +export { Pointer } from './Pointer'; export { Popper } from './Popper'; export { ProgressBar } from './ProgressBar'; export { RestrictedInput } from './RestrictedInput'; diff --git a/tgui/packages/tgui/interfaces/ColorPickerModal.tsx b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx new file mode 100644 index 000000000000..18c4ca73eca5 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx @@ -0,0 +1,695 @@ +/** + * @file + * @copyright 2023 itsmeow + * @license MIT + */ + +import { + hexToHsva, + HsvaColor, + hsvaToHex, + hsvaToHslString, + hsvaToRgba, + rgbaToHsva, + validHex, +} from 'common/color'; +import { KEY } from 'common/keys'; +import { clamp } from 'common/math'; +import { classes } from 'common/react'; +import { + Component, + FocusEvent, + FormEvent, + KeyboardEvent, + ReactNode, + useState, +} from 'react'; +import { Interaction, Interactive } from 'tgui/components/Interactive'; + +import { useBackend } from '../backend'; +import { + Autofocus, + Box, + Button, + Flex, + NumberInput, + Pointer, + Section, + Stack, + Tooltip, +} from '../components'; +import { Window } from '../layouts'; +import { InputButtons } from './common/InputButtons'; +import { Loader } from './common/Loader'; + +type ColorPickerData = { + autofocus: boolean; + buttons: string[]; + message: string; + large_buttons: boolean; + swapped_buttons: boolean; + timeout: number; + title: string; + default_color: string; +}; + +export const ColorPickerModal = () => { + const { data } = useBackend(); + const { + timeout, + message, + title, + autofocus, + default_color = '#000000', + } = data; + + let [selectedColor, setSelectedColor] = useState( + hexToHsva(default_color), + ); + + return ( + + {!!timeout && } + + + {message && ( + +
+ + {message} + +
+
+ )} + +
+ {!!autofocus && } + +
+
+ + + +
+
+
+ ); +}; + +export const ColorSelector = ({ + color, + setColor, + defaultColor, + onConfirm, +}: { + readonly color: HsvaColor; + readonly setColor: (_) => void; + readonly defaultColor: string; + readonly onConfirm?: (_) => void; +}) => { + const handleChange = (params: Partial) => { + setColor((current: HsvaColor) => { + return Object.assign({}, current, params); + }); + }; + const rgb = hsvaToRgba(color); + const hexColor = hsvaToHex(color); + return ( + + + + +
+ + +
+
+ + + Current + + + Previous + +
+ + + + + + +
+
+
+ + + + + + Hex: + + + { + setColor(hexToHsva(value)); + }} + prefixed + /> + + + + + + + + H: + + + + + + handleChange({ h: v })} + max={360} + unit="°" + /> + + + + + + + S: + + + + + + handleChange({ s: v })} + unit="%" + /> + + + + + + + V: + + + + + + handleChange({ v: v })} + unit="%" + /> + + + + + + + + R: + + + + + + { + rgb.r = v; + handleChange(rgbaToHsva(rgb)); + }} + max={255} + /> + + + + + + + G: + + + + + + { + rgb.g = v; + handleChange(rgbaToHsva(rgb)); + }} + max={255} + /> + + + + + + + B: + + + + + + { + rgb.b = v; + handleChange(rgbaToHsva(rgb)); + }} + max={255} + /> + + + + {onConfirm && ( + + + + )} + + +
+ ); +}; + +const TextSetter = ({ + value, + callback, + min = 0, + max = 100, + unit, +}: { + readonly value: number; + readonly callback: any; + readonly min?: number; + readonly max?: number; + readonly unit?: string; +}) => { + return ( + + ); +}; + +/** + * MIT License + * https://github.com/omgovich/react-colorful/ + * + * Copyright (c) 2020 Vlad Shilov + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +interface HexColorInputProps + extends Omit { + /** Enables `#` prefix displaying */ + readonly prefixed?: boolean; + /** Allows `#rgba` and `#rrggbbaa` color formats */ + readonly alpha?: boolean; +} + +/** Adds "#" symbol to the beginning of the string */ +const prefix = (value: string) => '#' + value; + +export const HexColorInput = (props: HexColorInputProps): ReactNode => { + const { prefixed, alpha, color, fluid, onChange, ...rest } = props; + + /** Escapes all non-hexadecimal characters including "#" */ + const escape = (value: string) => + value.replace(/([^0-9A-F]+)/gi, '').substring(0, alpha ? 8 : 6); + + /** Validates hexadecimal strings */ + const validate = (value: string) => validHex(value, alpha); + + return ( + + ); +}; + +interface ColorInputBaseProps { + readonly fluid?: boolean; + readonly color: string; + readonly onChange: (newColor: string) => void; + /** Blocks typing invalid characters and limits string length */ + readonly escape: (value: string) => string; + /** Checks that value is valid color string */ + readonly validate: (value: string) => boolean; + /** Processes value before displaying it in the input */ + readonly format?: (value: string) => string; +} + +export class ColorInput extends Component { + props: ColorInputBaseProps; + state: { localValue: string }; + + constructor(props: ColorInputBaseProps) { + super(props); + this.props = props; + this.state = { localValue: this.props.escape(this.props.color) }; + } + + // Trigger `onChange` handler only if the input value is a valid color + handleInput = (e: FormEvent) => { + const inputValue = this.props.escape(e.currentTarget.value); + this.setState({ localValue: inputValue }); + }; + + // Take the color from props if the last typed color (in local state) is not valid + handleBlur = (e: FocusEvent) => { + if (e.currentTarget) { + if (!this.props.validate(e.currentTarget.value)) { + this.setState({ localValue: this.props.escape(this.props.color) }); // return to default; + } else { + this.props.onChange( + this.props.escape + ? this.props.escape(e.currentTarget.value) + : e.currentTarget.value, + ); + } + } + }; + + handleKeyDown = (e: KeyboardEvent) => { + if (e.key === KEY.Enter) { + e.currentTarget.blur(); + return; + } + if (e.key === KEY.Escape) { + e.currentTarget.blur(); + } + }; + + componentDidUpdate(prevProps, prevState): void { + if (prevProps.color !== this.props.color) { + // Update the local state when `color` property value is changed + this.setState({ localValue: this.props.escape(this.props.color) }); + } + } + + render() { + return ( + +
.
+ +
+ ); + } +} + +const SaturationValue = ({ hsva, onChange }) => { + const handleMove = (interaction: Interaction) => { + onChange({ + s: interaction.left * 100, + v: 100 - interaction.top * 100, + }); + }; + + const handleKey = (offset: Interaction) => { + // Saturation and brightness always fit into [0, 100] range + onChange({ + s: clamp(hsva.s + offset.left * 100, 0, 100), + v: clamp(hsva.v - offset.top * 100, 0, 100), + }); + }; + + const containerStyle = { + backgroundColor: `${hsvaToHslString({ h: hsva.h, s: 100, v: 100, a: 1 })} !important`, + }; + + return ( +
+ + + +
+ ); +}; + +const Hue = ({ + className, + hue, + onChange, +}: { + readonly className?: string; + readonly hue: number; + readonly onChange: (newHue: { h: number }) => void; +}) => { + const handleMove = (interaction: Interaction) => { + onChange({ h: 360 * interaction.left }); + }; + + const handleKey = (offset: Interaction) => { + // Hue measured in degrees of the color circle ranging from 0 to 360 + onChange({ + h: clamp(hue + offset.left * 360, 0, 360), + }); + }; + + const nodeClassName = classes(['react-colorful__hue', className]); + + return ( +
+ + + +
+ ); +}; + +const Saturation = ({ + className, + color, + onChange, +}: { + readonly className?: string; + readonly color: HsvaColor; + readonly onChange: (newSaturation: { s: number }) => void; +}) => { + const handleMove = (interaction: Interaction) => { + onChange({ s: 100 * interaction.left }); + }; + + const handleKey = (offset: Interaction) => { + // Hue measured in degrees of the color circle ranging from 0 to 100 + onChange({ + s: clamp(color.s + offset.left * 100, 0, 100), + }); + }; + + const nodeClassName = classes(['react-colorful__saturation', className]); + + return ( +
+ + + +
+ ); +}; + +const Value = ({ + className, + color, + onChange, +}: { + readonly className?: string; + readonly color: HsvaColor; + readonly onChange: (newValue: { v: number }) => void; +}) => { + const handleMove = (interaction: Interaction) => { + onChange({ v: 100 * interaction.left }); + }; + + const handleKey = (offset: Interaction) => { + onChange({ + v: clamp(color.v + offset.left * 100, 0, 100), + }); + }; + + const nodeClassName = classes(['react-colorful__value', className]); + + return ( +
+ + + +
+ ); +}; + +const RGBSlider = ({ + className, + color, + onChange, + target, +}: { + readonly className?: string; + readonly color: HsvaColor; + readonly onChange: (newValue: HsvaColor) => void; + readonly target: string; +}) => { + const rgb = hsvaToRgba(color); + + const setNewTarget = (value: number) => { + rgb[target] = value; + onChange(rgbaToHsva(rgb)); + }; + + const handleMove = (interaction: Interaction) => { + setNewTarget(255 * interaction.left); + }; + + const handleKey = (offset: Interaction) => { + setNewTarget(clamp(rgb[target] + offset.left * 255, 0, 255)); + }; + + const nodeClassName = classes([`react-colorful__${target}`, className]); + + let selected = + target === 'r' + ? `rgb(${Math.round(rgb.r)},0,0)` + : target === 'g' + ? `rgb(0,${Math.round(rgb.g)},0)` + : `rgb(0,0,${Math.round(rgb.b)})`; + + return ( +
+ + + +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/HairPicker.tsx b/tgui/packages/tgui/interfaces/HairPicker.tsx new file mode 100644 index 000000000000..932c96097454 --- /dev/null +++ b/tgui/packages/tgui/interfaces/HairPicker.tsx @@ -0,0 +1,226 @@ +import { hexToHsva, HsvaColor, hsvaToHex } from 'common/color'; +import { BooleanLike } from 'common/react'; +import { createRef, useState } from 'react'; + +import { useBackend } from '../backend'; +import { + Box, + Button, + ColorBox, + DmIcon, + Dropdown, + Modal, + Section, + Stack, + Tooltip, +} from '../components'; +import { Window } from '../layouts'; +import { ColorSelector } from './ColorPickerModal'; + +type HairPickerData = { + hair_icon: string; + hair_styles: { name: string; icon: string }[]; + hair_style: string; + hair_color: string; + + facial_hair_icon: string; + facial_hair_styles: { name: string; icon: string }[]; + facial_hair_style: string; + facial_hair_color: string; + + gradient_available: BooleanLike; + gradient_styles: string[]; + gradient_style: string; + gradient_color: string; +}; + +export const HairPicker = () => { + const { act, data } = useBackend(); + + const { + hair_icon, + hair_style, + hair_styles, + hair_color, + facial_hair_icon, + facial_hair_style, + facial_hair_styles, + facial_hair_color, + gradient_available, + gradient_style, + gradient_styles, + gradient_color, + } = data; + + const [colorPicker, setColorPicker] = useState< + 'hair' | 'facial_hair' | 'gradient' | false + >(false); + + let height = 340; + if (facial_hair_styles.length > 1) { + height = height + 310; + } + + if (gradient_available) { + height = height + 85; + } + + let defaultColor = '#000000'; + switch (colorPicker) { + case 'hair': + defaultColor = hair_color; + break; + case 'facial_hair': + defaultColor = facial_hair_color; + break; + case 'gradient': + defaultColor = gradient_color; + break; + + default: + break; + } + + return ( + + + + {!!(facial_hair_styles.length > 1) && ( + + )} + {!!gradient_available && ( +
setColorPicker('gradient')}> + + Color + + } + > + act('gradient', { name: selected })} + over + /> +
+ )} + {!!colorPicker && ( + setColorPicker(false)} + default_color={defaultColor} + /> + )} +
+
+ ); +}; + +const ColorPicker = (props: { + readonly type: 'hair' | 'facial_hair' | 'gradient'; + readonly close: () => void; + readonly default_color: string; +}) => { + const { act } = useBackend(); + + const { type, close, default_color } = props; + + const [currentColor, setCurrentColor] = useState( + hexToHsva(default_color || '#000000'), + ); + + return ( + + + + + { + close(); + act(`${type}_color`, { color: hsvaToHex(currentColor) }); + }} + /> + + + + + ); +}; + +const PickerElement = (props: { + readonly name: string; + readonly icon: string; + readonly active: string; + readonly hair: { icon: string; name: string }[]; + readonly action: 'hair' | 'facial_hair'; + readonly setColor: (_) => void; + readonly color: string; +}) => { + const { name, icon, hair, active, action, setColor, color } = props; + + const { act } = useBackend(); + + const scrollRef = createRef(); + + return ( +
setColor(action)}> + + Color + + } + ref={scrollRef} + onMouseOver={() => { + scrollRef.current?.focus(); + }} + > + + {hair.map((hair) => ( + + + act(action, { name: hair.name })} + > + + + + + ))} + +
+ ); +}; diff --git a/tgui/packages/tgui/styles/interfaces/ColorPicker.scss b/tgui/packages/tgui/styles/interfaces/ColorPicker.scss new file mode 100644 index 000000000000..99f628c35e2e --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/ColorPicker.scss @@ -0,0 +1,153 @@ +/** + * MIT License + * https://github.com/omgovich/react-colorful/ + * + * Copyright (c) 2020 Vlad Shilov + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@use '../colors.scss'; +@use '../base.scss'; + +.react-colorful { + position: relative; + display: flex; + flex-direction: column; + width: 200px; + height: 200px; + user-select: none; + cursor: default; +} + +.react-colorful__saturation_value { + position: relative; + flex-grow: 1; + border-color: transparent; /* Fixes https://github.com/omgovich/react-colorful/issues/139 */ + border-bottom: 12px solid #000; + border-radius: 8px 8px 0 0; + background-image: linear-gradient( + to top, + rgba(0, 0, 0, 255), + rgba(0, 0, 0, 0) + ), + linear-gradient(to right, rgba(255, 255, 255, 255), rgba(255, 255, 255, 0)); +} + +.react-colorful__pointer-fill, +.react-colorful__alpha-gradient { + content: ''; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: inherit; +} + +/* Improve elements rendering on light backgrounds */ +.react-colorful__alpha-gradient, +.react-colorful__saturation_value { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); +} + +.react-colorful__hue, +.react-colorful__r, +.react-colorful__g, +.react-colorful__b, +.react-colorful__alpha, +.react-colorful__saturation, +.react-colorful__value { + position: relative; + height: 24px; +} + +.react-colorful__hue { + background: linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); +} + +.react-colorful__r { + background: linear-gradient(to right, #000, #f00); +} + +.react-colorful__g { + background: linear-gradient(to right, #000, #0f0); +} + +.react-colorful__b { + background: linear-gradient(to right, #000, #00f); +} + +/* Round bottom corners of the last element: `Hue` for `ColorPicker` or `Alpha` for `AlphaColorPicker` */ +.react-colorful__last-control { + border-radius: 0 0 8px 8px; +} + +.react-colorful__interactive { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: inherit; + outline: none; + /* Don't trigger the default scrolling behavior when the event is originating from this element */ + touch-action: none; +} + +.react-colorful__pointer { + position: absolute; + z-index: 1; + box-sizing: border-box; + width: 28px; + height: 28px; + transform: translate(-50%, -50%); + background-color: #cfcfcf; + border: 2px solid #cfcfcf; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4); +} + +.react-colorful__interactive:focus .react-colorful__pointer { + transform: translate(-50%, -50%) scale(1.1); + background-color: #fff; + border-color: #fff; +} + +/* Chessboard-like pattern for alpha related elements */ +.react-colorful__alpha, +.react-colorful__alpha-pointer { + background-color: #fff; + background-image: url('data:image/svg+xml,'); +} + +.react-colorful__saturation-pointer, +.react-colorful__value-pointer, +.react-colorful__hue-pointer, +.react-colorful__r-pointer, +.react-colorful__g-pointer, +.react-colorful__b-pointer { + z-index: 1; + width: 20px; + height: 20px; +} + +/* Display the saturation value pointer over the hue one */ +.react-colorful__saturation_value-pointer { + z-index: 3; +} diff --git a/tgui/packages/tgui/styles/interfaces/HairPicker.scss b/tgui/packages/tgui/styles/interfaces/HairPicker.scss new file mode 100644 index 000000000000..716741fbf859 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/HairPicker.scss @@ -0,0 +1,15 @@ +.theme-crtblue { + .HairPicker { + .Stack--horizontal > .Picker:first-of-type { + margin-left: 6px; + } + + .Picker.Active { + outline: 4px solid #8ac8ff; + } + + .Picker img { + background: white; + } + } +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index 3803da7a9237..dbc514428cae 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -50,6 +50,7 @@ // Interfaces @include meta.load-css('./interfaces/BodyPicker.scss'); @include meta.load-css('./interfaces/Changelog.scss'); +@include meta.load-css('./interfaces/ColorPicker.scss'); @include meta.load-css('./interfaces/ListInput.scss'); @include meta.load-css('./interfaces/CasSim.scss'); @include meta.load-css('./interfaces/AlertModal.scss'); @@ -62,6 +63,7 @@ @include meta.load-css('./interfaces/DropshipWeapons.scss'); @include meta.load-css('./interfaces/ElevatorControl.scss'); @include meta.load-css('./interfaces/ExperimentConfigure.scss'); +@include meta.load-css('./interfaces/HairPicker.scss'); @include meta.load-css('./interfaces/MarkMenu.scss'); @include meta.load-css('./interfaces/MedalsViewer.scss'); @include meta.load-css('./interfaces/NavigationShuttle.scss'); diff --git a/tgui/packages/tgui/styles/themes/crt.scss b/tgui/packages/tgui/styles/themes/crt.scss index 87c0bd58ad14..71043439b4cf 100644 --- a/tgui/packages/tgui/styles/themes/crt.scss +++ b/tgui/packages/tgui/styles/themes/crt.scss @@ -4,6 +4,7 @@ $font-family: monospace; $background-radial-opacity: 0.2 !default; +$scrollbar-color-multiplier: 0.5 !default; @use '../base.scss'; @@ -74,7 +75,7 @@ $background-radial-opacity: 0.2 !default; ); @include meta.load-css( '../layouts/Layout.scss', - $with: ('scrollbar-color-multiplier': 0.5) + $with: ('scrollbar-color-multiplier': $scrollbar-color-multiplier) ); @include meta.load-css( '../components/Section.scss', @@ -213,6 +214,13 @@ $background-radial-opacity: 0.2 !default; } } + .Dropdown__control { + & > .Dropdown__selected-text, + & > .Dropdown__arrow-button { + color: rgba(0, 0, 0, 1); + } + } + hr { color: base.$color-fg; border: 1px solid base.$color-fg; diff --git a/tgui/packages/tgui/styles/themes/crt/crt-blue.scss b/tgui/packages/tgui/styles/themes/crt/crt-blue.scss index bb3a4d4adea5..3c8827bf2a83 100644 --- a/tgui/packages/tgui/styles/themes/crt/crt-blue.scss +++ b/tgui/packages/tgui/styles/themes/crt/crt-blue.scss @@ -8,7 +8,9 @@ $dark: #000011; $color-fg: $light ); -@use '../crt.scss'; +@use '../crt.scss' with ( + $scrollbar-color-multiplier: 1 +); .theme-crtblue { @extend %theme-crt;