Tgui list input hotfix + changes (#63515)

Previous issues addressed:

- You could not reselect search just by keying
- Pressing enter sometimes deselected
- Double click would deselect - which was intentional, but useless really
- Default values were not implemented in original
- Validation is required for tgui inputs but truly only one "needs" it, it costs performance otherwise

Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
This commit is contained in:
Jeremiah
2021-12-25 11:37:05 +02:00
committed by GitHub
co-authored by Mothblocks
parent 6207f14e80
commit fe9bc91693
5 changed files with 113 additions and 73 deletions
+16 -7
View File
@@ -7,9 +7,10 @@
* * 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.
* * items - The options that can be chosen by the user, each string is assigned a button on the UI.
* * default - If an option is already preselected on the UI. Current values, etc.
* * 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(mob/user, message, title = "Select", list/items, timeout = 0)
/proc/tgui_input_list(mob/user, message, title = "Select", list/items, default, timeout = 0)
if (!user)
user = usr
if(!length(items))
@@ -23,7 +24,7 @@
/// Client does NOT have tgui_input on: Returns regular input
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title) as null|anything in items
var/datum/tgui_list_input/input = new(user, message, title, items, timeout)
var/datum/tgui_list_input/input = new(user, message, title, items, default, timeout)
input.ui_interact(user)
input.wait()
if (input)
@@ -39,10 +40,11 @@
* * 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.
* * items - The options that can be chosen by the user, each string is assigned a button on the UI.
* * default - If an option is already preselected on the UI. Current values, etc.
* * 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/items, datum/callback/callback, timeout = 60 SECONDS)
/proc/tgui_input_list_async(mob/user, message, title = "Select", list/items, default, datum/callback/callback, timeout = 60 SECONDS)
if (!user)
user = usr
if(!length(items))
@@ -53,7 +55,10 @@
user = client.mob
else
return
var/datum/tgui_list_input/async/input = new(user, message, title, items, callback, timeout)
/// Client does NOT have tgui_input on: Returns regular input
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title) as null|anything in items
var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout)
input.ui_interact(user)
/**
@@ -73,6 +78,8 @@
var/list/items_map
/// The button that the user has pressed, null if no selection has been made
var/choice
/// The default button to be selected
var/default
/// 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.
@@ -80,11 +87,12 @@
/// 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/items, timeout)
/datum/tgui_list_input/New(mob/user, message, title, list/items, default, timeout)
src.title = title
src.message = message
src.items = list()
src.items_map = list()
src.default = default
var/list/repeat_items = list()
// Gets rid of illegal characters
@@ -136,6 +144,7 @@
/datum/tgui_list_input/ui_static_data(mob/user)
. = list(
"init_value" = default || items[1],
"items" = items,
"message" = message,
"preferences" = list(),
@@ -177,8 +186,8 @@
/// 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/items, callback, timeout)
..(user, message, title, items, timeout)
/datum/tgui_list_input/async/New(mob/user, message, title, list/items, default, callback, timeout)
..(user, message, title, items, default, timeout)
src.callback = callback
/datum/tgui_list_input/async/Destroy(force, ...)
+8 -5
View File
@@ -14,7 +14,7 @@
* * min_value - Specifies a minimum value. Often 0.
* * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout.
*/
/proc/tgui_input_number(mob/user, message = null, title = "Number Input", default = null, max_value = null, min_value = 0, timeout = 0)
/proc/tgui_input_number(mob/user, message, title = "Number Input", default, max_value, min_value, timeout = 0)
if (!user)
user = usr
if (!istype(user))
@@ -23,7 +23,7 @@
user = client.mob
else
return
/// Client does NOT have tgui_input on: Returns regular input
// Client does NOT have tgui_input on: Returns regular input
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title, default) as null|num
var/datum/tgui_input_number/number_input = new(user, message, title, default, max_value, min_value, timeout)
@@ -48,7 +48,7 @@
* * callback - The callback to be invoked when a choice is made.
* * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout.
*/
/proc/tgui_input_number_async(mob/user, message = null, title = "Number Input", default = null, max_value = null, min_value = 0, datum/callback/callback, timeout = 60 SECONDS)
/proc/tgui_input_number_async(mob/user, message, title = "Number Input", default, max_value, min_value, datum/callback/callback, timeout = 60 SECONDS)
if (!user)
user = usr
if (!istype(user))
@@ -57,6 +57,9 @@
user = client.mob
else
return
// Client does NOT have tgui_input on: Returns regular input
if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title, default) as null|num
var/datum/tgui_input_number/async/number_input = new(user, message, title, default, max_value, min_value, callback, timeout)
number_input.ui_interact(user)
@@ -126,10 +129,10 @@
/datum/tgui_input_number/ui_static_data(mob/user)
. = list(
"init_value" = default || 0, // Default is a reserved keyword
"max_value" = max_value,
"message" = message,
"min_value" = min_value,
"placeholder" = default, /// You cannot use default as a const
"min_value" = min_value || 0,
"preferences" = list(),
"title" = title
)
@@ -1,13 +1,14 @@
import { Loader } from './common/Loader';
import { InputButtons, Preferences, Validator } from './common/InputButtons';
import { InputButtons, Preferences } from './common/InputButtons';
import { Button, Input, Section, Stack } from '../components';
import { KEY_ENTER, KEY_DOWN, KEY_UP, KEY_ESCAPE } from '../../common/keycodes';
import { KEY_A, KEY_DOWN, KEY_ESCAPE, KEY_ENTER, KEY_UP, KEY_Z } from '../../common/keycodes';
import { Window } from '../layouts';
import { useBackend, useLocalState } from '../backend';
type ListInputData = {
items: string[];
message: string;
init_value: string;
preferences: Preferences;
timeout: number;
title: string;
@@ -15,12 +16,12 @@ type ListInputData = {
export const ListInputModal = (_, context) => {
const { act, data } = useBackend<ListInputData>(context);
const { items = [], message, preferences, timeout, title } = data;
const { items = [], message, init_value, preferences, timeout, title } = data;
const { large_buttons } = preferences;
const [selected, setSelected] = useLocalState<number | null>(
const [selected, setSelected] = useLocalState<number>(
context,
'selected',
0
items.indexOf(init_value)
);
const [searchBarVisible, setSearchBarVisible] = useLocalState<boolean>(
context,
@@ -32,56 +33,60 @@ export const ListInputModal = (_, context) => {
'searchQuery',
''
);
const [inputIsValid, setInputIsValid] = useLocalState<Validator>(
context,
'inputIsValid',
{ isValid: true, error: null }
);
// User presses up or down on keyboard
// Simulates clicking an item
const onArrowKey = (key: number) => {
const len = filteredItems.length - 1;
if (key === KEY_DOWN) {
if (selected === null || selected === len) {
onClick(0);
setSelected(0);
document!.getElementById('0')?.scrollIntoView();
} else {
onClick(selected + 1);
setSelected(selected + 1);
document!.getElementById((selected + 1).toString())?.scrollIntoView();
}
} else if (key === KEY_UP) {
if (selected === null || selected === 0) {
onClick(len);
setSelected(len);
document!.getElementById(len.toString())?.scrollIntoView();
} else {
onClick(selected - 1);
setSelected(selected - 1);
document!.getElementById((selected - 1).toString())?.scrollIntoView();
}
}
};
// User selects an item with mouse
const onClick = (index: number) => {
if (isNaN(index) || index === selected) {
setInputIsValid({ isValid: false, error: 'No selection' });
setSelected(null);
} else {
setInputIsValid({ isValid: true, error: null });
setSelected(index);
document!.getElementById(index.toString())?.focus();
if (index === selected) {
return;
}
setSelected(index);
};
// User doesn't have search bar visible & presses a key
const onLetterKey = (key: number) => {
// User presses a letter key and searchbar is visible
const onFocusSearch = () => {
setSearchBarVisible(false);
setSearchBarVisible(true);
};
// User presses a letter key with no searchbar visible
const onLetterSearch = (key: number) => {
const keyChar = String.fromCharCode(key);
const foundItem = items.find((item) => {
return item?.toLowerCase().startsWith(keyChar?.toLowerCase());
});
if (foundItem) {
setSelected(filteredItems.indexOf(foundItem));
document!.getElementById(filteredItems
.indexOf(foundItem)
.toString())?.focus();
const foundIndex = items.indexOf(foundItem);
setSelected(foundIndex);
document!.getElementById(foundIndex.toString())?.scrollIntoView();
}
};
// User types into search bar
const onSearch = (query: string) => {
if (query === searchQuery) {
return;
}
setSearchQuery(query);
setSelected(0);
document!.getElementById('0')?.scrollIntoView();
};
// User presses the search button
const onSearchBarToggle = () => {
@@ -94,6 +99,10 @@ export const ListInputModal = (_, context) => {
// Dynamically changes the window height based on the message.
const windowHeight
= 325 + Math.ceil(message?.length / 3) + (large_buttons ? 5 : 0);
// Grabs the cursor when no search bar is visible.
if (!searchBarVisible) {
setTimeout(() => document!.getElementById(selected.toString())?.focus(), 1);
}
return (
<Window title={title} width={325} height={windowHeight}>
@@ -105,9 +114,13 @@ export const ListInputModal = (_, context) => {
event.preventDefault();
onArrowKey(keyCode);
}
if (!searchBarVisible && keyCode >= 65 && keyCode <= 90) {
if (keyCode === KEY_ENTER) {
event.preventDefault();
onLetterKey(keyCode);
act('submit', { entry: filteredItems[selected] });
}
if (!searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
event.preventDefault();
onLetterSearch(keyCode);
}
if (keyCode === KEY_ESCAPE) {
event.preventDefault();
@@ -118,13 +131,15 @@ export const ListInputModal = (_, context) => {
buttons={
<Button
compact
icon="search"
color="transparent"
selected={searchBarVisible}
tooltip="Search Bar"
icon={searchBarVisible ? "search" : "font"}
selected
tooltip={searchBarVisible
? "Search Mode. Type to search or use arrow keys to select manually."
: "Hotkey Mode. Type a letter to jump to the first match. Enter to select."}
tooltipPosition="left"
onClick={() => onSearchBarToggle()}
/>
}
className="ListInput__Section"
fill
@@ -134,15 +149,21 @@ export const ListInputModal = (_, context) => {
<ListDisplay
filteredItems={filteredItems}
onClick={onClick}
isValid={inputIsValid.isValid}
onFocusSearch={onFocusSearch}
searchBarVisible={searchBarVisible}
selected={selected}
/>
</Stack.Item>
{searchBarVisible && <SearchBar onSearch={onSearch} />}
{searchBarVisible && (
<SearchBar
filteredItems={filteredItems}
onSearch={onSearch}
searchQuery={searchQuery}
selected={selected}
/>
)}
<Stack.Item pl={!large_buttons && 4} pr={!large_buttons && 4}>
<InputButtons
input={selected !== null ? filteredItems[selected] : null}
inputIsValid={inputIsValid} />
<InputButtons input={filteredItems[selected]} />
</Stack.Item>
</Stack>
</Section>
@@ -157,7 +178,8 @@ export const ListInputModal = (_, context) => {
*/
const ListDisplay = (props, context) => {
const { act } = useBackend<ListInputData>(context);
const { filteredItems, isValid, onClick, selected } = props;
const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected }
= props;
return (
<Section fill scrollable tabIndex={0}>
@@ -169,11 +191,15 @@ const ListDisplay = (props, context) => {
id={index}
key={index}
onClick={() => onClick(index)}
onDblClick={(event) => {
event.preventDefault();
act('submit', { entry: filteredItems[selected] });
}}
onKeyDown={(event) => {
const keyCode = window.event ? event.which : event.keyCode;
if (keyCode === KEY_ENTER && isValid) {
if (searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
event.preventDefault();
act('submit', { entry: filteredItems[selected] });
onFocusSearch();
}
}}
selected={index === selected}
@@ -193,16 +219,20 @@ const ListDisplay = (props, context) => {
* Renders a search bar input.
* Closing the bar defaults input to an empty string.
*/
const SearchBar = (props) => {
const { onSearch, searchQuery } = props;
const SearchBar = (props, context) => {
const { act } = useBackend<ListInputData>(context);
const { filteredItems, onSearch, searchQuery, selected } = props;
return (
<Input
autoFocus
autoSelect
fluid
onInput={(_, value) => {
onSearch(value);
onEnter={(event) => {
event.preventDefault();
act('submit', { entry: filteredItems[selected] });
}}
onInput={(_, value) => onSearch(value)}
placeholder="Search..."
value={searchQuery}
/>
@@ -9,7 +9,7 @@ type NumberInputData = {
max_value: number | null;
message: string;
min_value: number | null;
placeholder: number;
init_value: number;
preferences: Preferences;
timeout: number;
title: string;
@@ -17,17 +17,15 @@ type NumberInputData = {
export const NumberInputModal = (_, context) => {
const { act, data } = useBackend<NumberInputData>(context);
const { message, placeholder, preferences, timeout, title } = data;
const { message, init_value, preferences, timeout, title } = data;
const { large_buttons } = preferences;
const [input, setInput] = useLocalState(context, 'input', placeholder);
const [input, setInput] = useLocalState(context, 'input', init_value);
const onChange = (value: number) => {
setInput(value);
};
const onClick = (value: number) => {
setInput(value);
};
// NumberInput basically handles everything here
const defaultValidState = { isValid: true, error: null };
// Dynamically changes the window height based on the message.
const windowHeight
= 125 + Math.ceil(message?.length / 3) + (large_buttons ? 5 : 0);
@@ -54,7 +52,7 @@ export const NumberInputModal = (_, context) => {
<InputArea input={input} onClick={onClick} onChange={onChange} />
</Stack.Item>
<Stack.Item pl={!large_buttons && 4} pr={!large_buttons && 4}>
<InputButtons input={input} inputIsValid={defaultValidState} />
<InputButtons input={input} />
</Stack.Item>
</Stack>
</Section>
@@ -65,8 +63,8 @@ export const NumberInputModal = (_, context) => {
/** Gets the user input and invalidates if there's a constraint. */
const InputArea = (props, context) => {
const { act, data } = useBackend<NumberInputData>(context);
const { min_value, max_value, placeholder } = data;
const { data } = useBackend<NumberInputData>(context);
const { min_value, max_value, init_value } = data;
const { input, onClick, onChange } = props;
return (
@@ -87,7 +85,7 @@ const InputArea = (props, context) => {
maxValue={max_value}
onChange={(_, value) => onChange(value)}
onDrag={(_, value) => onChange(value)}
value={input || placeholder || 0}
value={input || init_value || 0}
/>
</Stack.Item>
<Stack.Item>
@@ -100,7 +98,7 @@ const InputArea = (props, context) => {
<Stack.Item>
<Button
icon="redo"
onClick={() => onClick(placeholder || 0)}
onClick={() => onClick(init_value || 0)}
tooltip="Reset"
/>
</Stack.Item>
@@ -7,7 +7,7 @@ type InputButtonsData = {
type InputButtonsProps = {
input: string | number | null;
inputIsValid: Validator;
inputIsValid?: Validator;
};
export type Validator = {
@@ -24,17 +24,17 @@ export const InputButtons = (props: InputButtonsProps, context) => {
const { act, data } = useBackend<InputButtonsData>(context);
const { large_buttons = false, swapped_buttons = true } = data.preferences;
const { input, inputIsValid } = props;
const { isValid, error } = inputIsValid;
const submitButton = (
<Button
color="good"
disabled={!isValid}
disabled={inputIsValid && !inputIsValid.isValid}
fluid={!!large_buttons}
height={!!large_buttons && 2}
onClick={() => act('submit', { entry: input })}
pt={large_buttons ? 0.33 : 0}
textAlign="center"
tooltip={!!large_buttons && error}
tooltip={(!!large_buttons && inputIsValid?.error) || null}
width={!large_buttons && 6}>
{large_buttons ? 'SUBMIT' : 'Submit'}
</Button>
@@ -63,9 +63,9 @@ export const InputButtons = (props: InputButtonsProps, context) => {
)}
{!large_buttons && (
<Stack.Item grow>
{!isValid && (
{inputIsValid && !inputIsValid.isValid && inputIsValid.error && (
<Box color="average" nowrap textAlign="center">
{error}
{inputIsValid.error}
</Box>
)}
</Stack.Item>