mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-07-13 09:05:11 +01:00
tgui: List Input (#56065)
Ported over from https://gitlab.com/cmdevs/colonial-warfare/-/merge_requests/613 which provides an input box to select an option from a list. Has a search bar to allow filtering for results and an onKeyDown event to replicate default behaviour from the default list input boxes that BYOND provides (where you are able to type the first letter of an element in the list and it'll jump to the first element that matches and then the next and so on) Right now, it is only applied to the holopad and "Drop Bomb" verb for administrators. Credits to bobbahbrown for the Loader element from AlertModal.js which allows for a timed input (was needed on the CM codebase for timed inputs) and for the majority of the DM code. Co-authored-by: Watermelon914 <3052169-Watermelon914@users.noreply.gitlab.com> Co-authored-by: Aleksej Komarov <stylemistake@gmail.com>
This commit is contained in:
@@ -263,7 +263,7 @@ Possible to do for anyone motivated enough:
|
||||
if(A)
|
||||
LAZYADD(callnames[A], I)
|
||||
callnames -= get_area(src)
|
||||
var/result = input(usr, "Choose an area to call", "Holocall") as null|anything in sortNames(callnames)
|
||||
var/result = tgui_input_list(usr, "Choose an area to call", "Holocall", sortNames(callnames))
|
||||
if(QDELETED(usr) || !result || outgoing_call)
|
||||
return
|
||||
if(usr.loc == loc)
|
||||
|
||||
@@ -480,7 +480,7 @@ GLOBAL_PROTECT(admin_verbs_hideable)
|
||||
set desc = "Cause an explosion of varying strength at your location."
|
||||
|
||||
var/list/choices = list("Small Bomb (1, 2, 3, 3)", "Medium Bomb (2, 3, 4, 4)", "Big Bomb (3, 5, 7, 5)", "Maxcap", "Custom Bomb")
|
||||
var/choice = input("What size explosion would you like to produce? NOTE: You can do all this rapidly and in an IC manner (using cruise missiles!) with the Config/Launch Supplypod verb. WARNING: These ignore the maxcap") as null|anything in choices
|
||||
var/choice = tgui_input_list(usr, "What size explosion would you like to produce? NOTE: You can do all this rapidly and in an IC manner (using cruise missiles!) with the Config/Launch Supplypod verb. WARNING: These ignore the maxcap", "Drop Bomb", choices)
|
||||
var/turf/epicenter = mob.loc
|
||||
|
||||
switch(choice)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Creates a TGUI input list window and returns the user's response.
|
||||
*
|
||||
* This proc should be used to create alerts that the caller will wait for a response from.
|
||||
* Arguments:
|
||||
* * user - The user to show the input box to.
|
||||
* * message - The content of the input box, shown in the body of the TGUI window.
|
||||
* * title - The title of the input box, shown on the top of the TGUI window.
|
||||
* * buttons - The options that can be chosen by the user, each string is assigned a button on the UI.
|
||||
* * timeout - The timeout of the input box, after which the input box will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_list(mob/user, message, title, list/buttons, timeout = 0)
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(buttons))
|
||||
return
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/input = new(user, message, title, buttons, timeout)
|
||||
input.ui_interact(user)
|
||||
input.wait()
|
||||
if (input)
|
||||
. = input.choice
|
||||
qdel(input)
|
||||
|
||||
/**
|
||||
* Creates an asynchronous TGUI input list window with an associated callback.
|
||||
*
|
||||
* This proc should be used to create inputs that invoke a callback with the user's chosen option.
|
||||
* Arguments:
|
||||
* * user - The user to show the input box to.
|
||||
* * message - The content of the input box, shown in the body of the TGUI window.
|
||||
* * title - The title of the input box, shown on the top of the TGUI window.
|
||||
* * buttons - The options that can be chosen by the user, each string is assigned a button on the UI.
|
||||
* * callback - The callback to be invoked when a choice is made.
|
||||
* * timeout - The timeout of the input box, after which the menu will close and qdel itself. Set to zero for no timeout.
|
||||
*/
|
||||
/proc/tgui_input_list_async(mob/user, message, title, list/buttons, datum/callback/callback, timeout = 60 SECONDS)
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(buttons))
|
||||
return
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/async/input = new(user, message, title, buttons, callback, timeout)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # tgui_list_input
|
||||
*
|
||||
* Datum used for instantiating and using a TGUI-controlled list input that prompts the user with
|
||||
* a message and shows a list of selectable options
|
||||
*/
|
||||
/datum/tgui_list_input
|
||||
/// The title of the TGUI window
|
||||
var/title
|
||||
/// The textual body of the TGUI window
|
||||
var/message
|
||||
/// The list of buttons (responses) provided on the TGUI window
|
||||
var/list/buttons
|
||||
/// Buttons (strings specifically) mapped to the actual value (e.g. a mob or a verb)
|
||||
var/list/buttons_map
|
||||
/// The button that the user has pressed, null if no selection has been made
|
||||
var/choice
|
||||
/// The time at which the tgui_list_input was created, for displaying timeout progress.
|
||||
var/start_time
|
||||
/// The lifespan of the tgui_list_input, after which the window will close and delete itself.
|
||||
var/timeout
|
||||
/// Boolean field describing if the tgui_list_input was closed by the user.
|
||||
var/closed
|
||||
|
||||
/datum/tgui_list_input/New(mob/user, message, title, list/buttons, timeout)
|
||||
src.title = title
|
||||
src.message = message
|
||||
src.buttons = list()
|
||||
src.buttons_map = list()
|
||||
|
||||
// Gets rid of illegal characters
|
||||
var/static/regex/whitelistedWords = regex(@{"([^\u0020-\u8000]+)"})
|
||||
|
||||
for(var/i in buttons)
|
||||
var/string_key = whitelistedWords.Replace("[i]", "")
|
||||
|
||||
src.buttons += string_key
|
||||
src.buttons_map[string_key] = i
|
||||
|
||||
|
||||
if (timeout)
|
||||
src.timeout = timeout
|
||||
start_time = world.time
|
||||
QDEL_IN(src, timeout)
|
||||
|
||||
/datum/tgui_list_input/Destroy(force, ...)
|
||||
SStgui.close_uis(src)
|
||||
QDEL_NULL(buttons)
|
||||
. = ..()
|
||||
|
||||
/**
|
||||
* Waits for a user's response to the tgui_list_input's prompt before returning. Returns early if
|
||||
* the window was closed by the user.
|
||||
*/
|
||||
/datum/tgui_list_input/proc/wait()
|
||||
while (!choice && !closed)
|
||||
stoplag(1)
|
||||
|
||||
/datum/tgui_list_input/ui_interact(mob/user, datum/tgui/ui)
|
||||
ui = SStgui.try_update_ui(user, src, ui)
|
||||
if(!ui)
|
||||
ui = new(user, src, "ListInput")
|
||||
ui.open()
|
||||
|
||||
/datum/tgui_list_input/ui_close(mob/user)
|
||||
. = ..()
|
||||
closed = TRUE
|
||||
|
||||
/datum/tgui_list_input/ui_state(mob/user)
|
||||
return GLOB.always_state
|
||||
|
||||
/datum/tgui_list_input/ui_static_data(mob/user)
|
||||
. = list(
|
||||
"title" = title,
|
||||
"message" = message,
|
||||
"buttons" = buttons
|
||||
)
|
||||
|
||||
/datum/tgui_list_input/ui_data(mob/user)
|
||||
. = list()
|
||||
if(timeout)
|
||||
.["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1)
|
||||
|
||||
/datum/tgui_list_input/ui_act(action, list/params)
|
||||
. = ..()
|
||||
if (.)
|
||||
return
|
||||
switch(action)
|
||||
if("choose")
|
||||
if (!(params["choice"] in buttons))
|
||||
return
|
||||
choice = buttons_map[params["choice"]]
|
||||
SStgui.close_uis(src)
|
||||
return TRUE
|
||||
if("cancel")
|
||||
SStgui.close_uis(src)
|
||||
closed = TRUE
|
||||
return TRUE
|
||||
|
||||
/**
|
||||
* # async tgui_list_input
|
||||
*
|
||||
* An asynchronous version of tgui_list_input to be used with callbacks instead of waiting on user responses.
|
||||
*/
|
||||
/datum/tgui_list_input/async
|
||||
/// The callback to be invoked by the tgui_list_input upon having a choice made.
|
||||
var/datum/callback/callback
|
||||
|
||||
/datum/tgui_list_input/async/New(mob/user, message, title, list/buttons, callback, timeout)
|
||||
..(user, title, message, buttons, timeout)
|
||||
src.callback = callback
|
||||
|
||||
/datum/tgui_list_input/async/Destroy(force, ...)
|
||||
QDEL_NULL(callback)
|
||||
. = ..()
|
||||
|
||||
/datum/tgui_list_input/async/ui_close(mob/user)
|
||||
. = ..()
|
||||
qdel(src)
|
||||
|
||||
/datum/tgui_list_input/async/ui_act(action, list/params)
|
||||
. = ..()
|
||||
if (!. || choice == null)
|
||||
return
|
||||
callback.InvokeAsync(choice)
|
||||
qdel(src)
|
||||
|
||||
/datum/tgui_list_input/async/wait()
|
||||
return
|
||||
@@ -3200,6 +3200,7 @@
|
||||
#include "code\modules\tgui\states.dm"
|
||||
#include "code\modules\tgui\tgui.dm"
|
||||
#include "code\modules\tgui\tgui_alert.dm"
|
||||
#include "code\modules\tgui\tgui_input_list.dm"
|
||||
#include "code\modules\tgui\tgui_window.dm"
|
||||
#include "code\modules\tgui\states\admin.dm"
|
||||
#include "code\modules\tgui\states\always.dm"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 watermelon914 (https://github.com/watermelon914)
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01 } from 'common/math';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Flex, Section, Input } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const ARROW_KEY_UP = 38;
|
||||
const ARROW_KEY_DOWN = 40;
|
||||
|
||||
let lastScrollTime = 0;
|
||||
|
||||
export const ListInput = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
buttons,
|
||||
timeout,
|
||||
} = data;
|
||||
|
||||
// Search
|
||||
const [showSearchBar, setShowSearchBar] = useLocalState(
|
||||
context, 'search_bar', false);
|
||||
const [displayedArray, setDisplayedArray] = useLocalState(
|
||||
context, 'displayed_array', buttons);
|
||||
|
||||
// KeyPress
|
||||
const [searchArray, setSearchArray] = useLocalState(
|
||||
context, 'search_array', []);
|
||||
const [searchIndex, setSearchIndex] = useLocalState(
|
||||
context, 'search_index', 0);
|
||||
const [lastCharCode, setLastCharCode] = useLocalState(
|
||||
context, 'last_char_code', null);
|
||||
|
||||
// Selected Button
|
||||
const [selectedButton, setSelectedButton] = useLocalState(
|
||||
context, 'selected_button', buttons[0]);
|
||||
return (
|
||||
<Window
|
||||
title={title}
|
||||
width={325}
|
||||
height={325}
|
||||
resizable
|
||||
>
|
||||
{timeout !== undefined && <Loader value={timeout} />}
|
||||
<Window.Content>
|
||||
<Flex direction="column" height="100%">
|
||||
<Flex.Item grow={1}>
|
||||
<Section
|
||||
scrollable
|
||||
className="ListInput__Section"
|
||||
width="100%"
|
||||
fill
|
||||
title={message}
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
e.preventDefault();
|
||||
if (lastScrollTime > performance.now()) {
|
||||
return;
|
||||
}
|
||||
lastScrollTime = performance.now() + 125;
|
||||
|
||||
if (e.keyCode === ARROW_KEY_UP || e.keyCode === ARROW_KEY_DOWN)
|
||||
{
|
||||
let direction = 1;
|
||||
if (e.keyCode === ARROW_KEY_UP) direction = -1;
|
||||
|
||||
let index = 0;
|
||||
for (index; index < buttons.length; index++) {
|
||||
if (buttons[index] === selectedButton) break;
|
||||
}
|
||||
index += direction;
|
||||
if (index < 0) index = buttons.length-1;
|
||||
else if (index >= buttons.length) index = 0;
|
||||
setSelectedButton(buttons[index]);
|
||||
setLastCharCode(null);
|
||||
document.getElementById(buttons[index]).focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const charCode = String.fromCharCode(e.keyCode).toLowerCase();
|
||||
if (!charCode) return;
|
||||
|
||||
let foundValue;
|
||||
if (charCode === lastCharCode && searchArray.length > 0) {
|
||||
const nextIndex = searchIndex + 1;
|
||||
|
||||
if (nextIndex < searchArray.length) {
|
||||
foundValue = searchArray[nextIndex];
|
||||
setSearchIndex(nextIndex);
|
||||
}
|
||||
else {
|
||||
foundValue = searchArray[0];
|
||||
setSearchIndex(0);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const resultArray = displayedArray.filter(value =>
|
||||
value.substring(0, 1).toLowerCase() === charCode
|
||||
);
|
||||
|
||||
if (resultArray.length > 0) {
|
||||
setSearchArray(resultArray);
|
||||
setSearchIndex(0);
|
||||
foundValue = resultArray[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (foundValue) {
|
||||
setLastCharCode(charCode);
|
||||
setSelectedButton(foundValue);
|
||||
document.getElementById(foundValue).focus();
|
||||
}
|
||||
}}
|
||||
buttons={(
|
||||
<Button
|
||||
icon="search"
|
||||
color="transparent"
|
||||
selected={showSearchBar}
|
||||
tooltip="Search Bar"
|
||||
tooltipPosition="left"
|
||||
onClick={() => {
|
||||
setShowSearchBar(!showSearchBar);
|
||||
setDisplayedArray(buttons);
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Flex wrap>
|
||||
{displayedArray.map(button => (
|
||||
<Flex.Item key={button} basis="100%">
|
||||
<Button
|
||||
color="transparent"
|
||||
content={button}
|
||||
id={button}
|
||||
width="100%"
|
||||
selected={selectedButton === button}
|
||||
onClick={() => {
|
||||
if (selectedButton === button) {
|
||||
act("choose", { choice: button });
|
||||
}
|
||||
else {
|
||||
setSelectedButton(button);
|
||||
}
|
||||
setLastCharCode(null);
|
||||
}}
|
||||
/>
|
||||
</Flex.Item>
|
||||
))}
|
||||
</Flex>
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
{showSearchBar && (
|
||||
<Flex.Item mt={1}>
|
||||
<Input
|
||||
fluid
|
||||
onInput={(e, value) => setDisplayedArray(
|
||||
buttons.filter(val =>
|
||||
val.toLowerCase().search(value.toLowerCase()) !== -1
|
||||
)
|
||||
)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
)}
|
||||
<Flex.Item mt={1}>
|
||||
<Flex textAlign="center">
|
||||
<Flex.Item grow={1} basis={0}>
|
||||
<Button
|
||||
fluid
|
||||
color="bad"
|
||||
lineHeight={2}
|
||||
content="Cancel"
|
||||
onClick={() => act("cancel")}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1} basis={0} ml={1}>
|
||||
<Button
|
||||
fluid
|
||||
color="good"
|
||||
lineHeight={2}
|
||||
content="Confirm"
|
||||
disabled={selectedButton === null}
|
||||
onClick={() => act("choose", { choice: selectedButton })}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const Loader = props => {
|
||||
const { value } = props;
|
||||
return (
|
||||
<div
|
||||
className="ListInput__Loader">
|
||||
<Box
|
||||
className="ListInput__LoaderProgress"
|
||||
style={{
|
||||
width: clamp01(value) * 100 + '%',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2020 bobbahbrown (https://github.com/bobbahbrown)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
@use '../colors.scss';
|
||||
@use '../base.scss';
|
||||
|
||||
|
||||
.ListInput__Section .Section__title{
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ListInput__Section .Section__titleText {
|
||||
font-size: base.em(12px);
|
||||
}
|
||||
|
||||
.ListInput__Loader {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.ListInput__LoaderProgress {
|
||||
position: absolute;
|
||||
transition: background-color 500ms, width 500ms;
|
||||
background-color: colors.bg(colors.$primary);
|
||||
height: 100%;
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
@include meta.load-css('./components/Tooltip.scss');
|
||||
|
||||
// Interfaces
|
||||
@include meta.load-css('./interfaces/ListInput.scss');
|
||||
@include meta.load-css('./interfaces/AlertModal.scss');
|
||||
@include meta.load-css('./interfaces/CameraConsole.scss');
|
||||
@include meta.load-css('./interfaces/NuclearBomb.scss');
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user