Shared tgui states, global Byond API object

This commit is contained in:
Aleksej Komarov
2020-04-19 19:38:54 +03:00
parent 25ea72f09c
commit 57674e06d6
18 changed files with 249 additions and 186 deletions
+8
View File
@@ -107,6 +107,14 @@
/datum/proc/ui_host(mob/user)
return src // Default src.
/**
* global
*
* Associative list of JSON-encoded shared states that were set by
* tgui clients.
*/
/datum/var/list/tgui_shared_states
/**
* global
*
+16 -13
View File
@@ -42,8 +42,6 @@
var/datum/tgui/master_ui
/// Children of this UI.
var/list/datum/tgui/children = list()
// TODO: Remove in favor of useGlobal
var/ui_screen = "home"
/**
* public
@@ -197,7 +195,6 @@
json_data["config"] = list(
"title" = title,
"status" = status,
"screen" = ui_screen,
"interface" = interface,
"fancy" = user.client.prefs.tgui_fancy,
"locked" = user.client.prefs.tgui_lock,
@@ -213,6 +210,10 @@
if(!isnull(static_data))
json_data["static_data"] = static_data
// Send shared states
if(src_object.tgui_shared_states)
json_data["shared"] = src_object.tgui_shared_states
// Generate the JSON.
var/json = json_encode(json_data)
// Strip #255/improper.
@@ -238,9 +239,12 @@
if("tgui:initialize")
user << output(_initial_update, "[window_id].browser:update")
initialized = TRUE
if("tgui:view")
if(params["screen"])
ui_screen = params["screen"]
if("tgui:setSharedState")
var/key = params["key"]
var/value = params["value"]
if(!src_object.tgui_shared_states)
src_object.tgui_shared_states = list()
src_object.tgui_shared_states[key] = value
SStgui.update_uis(src_object)
if("tgui:log")
// Force window to show frills on fatal errors
@@ -249,14 +253,13 @@
log_message(params["log"])
if("tgui:link")
user << link(params["url"])
if("tgui:fancy")
user.client.prefs.tgui_fancy = TRUE
if("tgui:nofrills")
user.client.prefs.tgui_fancy = FALSE
else
update_status(push = FALSE) // Update the window state.
if(src_object.ui_act(action, params, src, state)) // Call ui_act() on the src_object.
SStgui.update_uis(src_object) // Update if the object requested it.
// Update the window state.
update_status(push = FALSE)
// Call ui_act() on the src_object.
if(src_object.ui_act(action, params, src, state))
// Update if the object requested it.
SStgui.update_uis(src_object)
/**
* private
+105 -18
View File
@@ -1,6 +1,3 @@
import { act } from './byond';
import { UI_DISABLED, UI_INTERACTIVE } from './constants';
/**
* This file provides a clear separation layer between backend updates
* and what state our React app sees.
@@ -10,21 +7,23 @@ import { UI_DISABLED, UI_INTERACTIVE } from './constants';
* the response with already existing state.
*/
/**
* Creates a backend update action.
*/
import { UI_DISABLED, UI_INTERACTIVE } from './constants';
import { callByond } from './byond';
export const backendUpdate = state => ({
type: 'backendUpdate',
type: 'backend/update',
payload: state,
});
/**
* Precisely defines state changes.
*/
export const backendSetSharedState = (key, nextState) => ({
type: 'backend/setSharedState',
payload: { key, nextState },
});
export const backendReducer = (state, action) => {
const { type, payload } = action;
if (type === 'backendUpdate') {
if (type === 'backend/update') {
// Merge config
const config = {
...state.config,
@@ -36,6 +35,19 @@ export const backendReducer = (state, action) => {
...payload.static_data,
...payload.data,
};
// Merge shared states
const shared = { ...state.shared };
if (payload.shared) {
for (let key of Object.keys(payload.shared)) {
const value = payload.shared[key];
if (value === '') {
shared[key] = undefined;
}
else {
shared[key] = JSON.parse(value);
}
}
}
// Calculate our own fields
const visible = config.status !== UI_DISABLED;
const interactive = config.status === UI_INTERACTIVE;
@@ -44,11 +56,23 @@ export const backendReducer = (state, action) => {
...state,
config,
data,
shared,
visible,
interactive,
};
}
if (type === 'backend/setSharedState') {
const { key, nextState } = payload;
return {
...state,
shared: {
...state.shared,
[key]: nextState,
},
};
}
return state;
};
@@ -84,15 +108,78 @@ export const backendReducer = (state, action) => {
* }}
*/
export const useBackend = context => {
// TODO: Dispatch "act" calls as Redux actions
const { store } = context;
const state = store.getState();
const ref = state.config.ref;
const boundAct = (action, params = {}) => {
act(ref, action, params);
};
return {
...state,
act: boundAct,
const act = (action, params = {}) => {
callByond('', {
src: ref,
action,
...params,
});
};
return { ...state, act };
};
/**
* Allocates state on Redux store without sharing it with other clients.
*
* Use it when you want to have a stateful variable in your component
* that persists between renders, but will be forgotten after you close
* the UI.
*
* It is a lot more performant than `setSharedState`.
*
* @param {any} context React context.
* @param {string} key Key which uniquely identifies this state in Redux store.
* @param {any} initialState Initializes your global variable with this value.
*/
export const useLocalState = (context, key, initialState) => {
const { store } = context;
const state = store.getState();
const sharedStates = state.shared ?? {};
const sharedState = (key in sharedStates)
? sharedStates[key]
: initialState;
return [
sharedState,
nextState => {
store.dispatch(backendSetSharedState(key, nextState));
},
];
};
/**
* Allocates state on Redux store, and **shares** it with other clients
* in the game.
*
* Use it when you want to have a stateful variable in your component
* that persists not only between renders, but also gets pushed to other
* clients that observe this UI.
*
* This makes creation of observable s
*
* @param {any} context React context.
* @param {string} key Key which uniquely identifies this state in Redux store.
* @param {any} initialState Initializes your global variable with this value.
*/
export const useSharedState = (context, key, initialState) => {
const { store } = context;
const state = store.getState();
const ref = state.config.ref;
const sharedStates = state.shared ?? {};
const sharedState = (key in sharedStates)
? sharedStates[key]
: initialState;
return [
sharedState,
nextState => {
callByond('', {
src: ref,
action: 'tgui:setSharedState',
key,
value: JSON.stringify(nextState) || '',
});
},
];
};
+15 -39
View File
@@ -1,12 +1,12 @@
import { buildQueryString } from 'common/string';
// Reference a global Byond object
const { Byond } = window;
/**
* Version of Trident engine used in Internet Explorer.
* An integer number or `null` if this is not a trident engine.
*
* - IE 8 - Trident 4.0
* - IE 11 - Trident 7.0
*
* @return An integer number or 'null' if this is not a trident engine.
*/
const tridentVersion = (() => {
const groups = navigator.userAgent.match(/Trident\/(\d+).+?;/i);
@@ -27,41 +27,23 @@ export const IS_IE8 = tridentVersion !== null
&& tridentVersion <= 4;
/**
* True if browser is a BYOND browser.
* Makes a BYOND call.
*
* If path is empty, this will trigger a Topic call.
* You can reference a specific object by setting the "src" parameter.
*
* See: https://secure.byond.com/docs/ref/skinparams.html
*/
// We're currently just checking, whether we're running on a localhost
// with a path similar to a BYOND cache. I couldn't find a better/faster
// non-invasive method of doing this.
export const IS_BYOND = tridentVersion !== null
&& location.hostname === '127.0.0.1'
&& location.pathname.startsWith('/tmp');
/**
* Helper to generate a BYOND href given 'params' as an object
* (with an optional 'url' for eg winset).
*/
const createByondUrl = (path, params = {}) => {
return 'byond://' + path + '?' + buildQueryString(params);
};
export const callByond = (path, params = {}) => {
// Abort BYOND calls when we're running in a normal browser
if (!IS_BYOND) {
return;
}
location.href = createByondUrl(path, params);
Byond.call(path, params);
};
/**
* A high-level abstraction of BYJAX. Makes a call to BYOND and returns
* A high-level abstraction of BYOND calls. Makes a BYOND call and returns
* a promise, which (if endpoint has a callback parameter) resolves
* with the return value of that call.
*/
export const callByondAsync = (path, params = {}) => {
// Abort BYOND calls when we're running in a normal browser
if (!IS_BYOND) {
return new Promise(() => {});
}
// Create a callback array if it doesn't exist yet
window.__callbacks__ = window.__callbacks__ || [];
// Create a Promise and push its resolve function into callback array
@@ -71,7 +53,7 @@ export const callByondAsync = (path, params = {}) => {
window.__callbacks__.push(resolve);
});
// Call BYOND client
callByond(path, {
Byond.call(path, {
...params,
callback: `__callbacks__[${callbackIndex}]`,
});
@@ -79,18 +61,12 @@ export const callByondAsync = (path, params = {}) => {
};
/**
* Literally types a command on the client.
* Runs a BYOND skin command
*
* See: https://secure.byond.com/docs/ref/skinparams.html
*/
export const runCommand = command => callByond('winset', { command });
/**
* Helper to make a BYOND ui_act() call on the UI 'src' given an 'action'
* and optional 'params'.
*/
export const act = (src, action, params = {}) => {
return callByond('', { src, action, ...params });
};
/**
* Calls 'winget' on a BYOND skin element, retrieving value by the 'key'.
*/
+2 -5
View File
@@ -11,15 +11,12 @@ import { setupHotReloading } from 'tgui-dev-server/link/client';
import { backendUpdate } from './backend';
import { IS_IE8 } from './byond';
import { setupDrag } from './drag';
import { createLogger } from './logging';
import { logger } from './logging';
import { createStore, StoreProvider } from './store';
const enteredBundleAt = Date.now();
const logger = createLogger();
const store = createStore();
let reactRoot;
let initialRender = true;
const renderLayout = () => {
@@ -117,7 +114,7 @@ const setupApp = () => {
// Subscribe for bankend updates
window.update = stateJson => {
// NOTE: stateJson can be an object only if called manually from console.
// This is useful for debugging tgui in proper browsers, like Chrome.
// This is useful for debugging tgui in external browsers, like Chrome.
const state = typeof stateJson === 'string'
? parseStateJson(stateJson)
: stateJson;
+10 -19
View File
@@ -1,7 +1,7 @@
import { toFixed } from 'common/math';
import { decodeHtmlEntities } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, LabeledList, NumberInput, Section } from '../components';
import { getGasLabel } from '../constants';
import { Window } from '../layouts';
@@ -114,19 +114,17 @@ const AIR_ALARM_ROUTES = {
};
const AirAlarmControl = (props, context) => {
const { act, config } = useBackend(context);
const route = AIR_ALARM_ROUTES[config.screen] || AIR_ALARM_ROUTES.home;
const [screen, setScreen] = useLocalState(context, 'screen');
const route = AIR_ALARM_ROUTES[screen] || AIR_ALARM_ROUTES.home;
const Component = route.component();
return (
<Section
title={route.title}
buttons={config.screen !== 'home' && (
buttons={screen && (
<Button
icon="arrow-left"
content="Back"
onClick={() => act('tgui:view', {
screen: 'home',
})} />
onClick={() => setScreen()} />
)}>
<Component />
</Section>
@@ -139,6 +137,7 @@ const AirAlarmControl = (props, context) => {
const AirAlarmControlHome = (props, context) => {
const { act, data } = useBackend(context);
const [screen, setScreen] = useLocalState(context, 'screen');
const {
mode,
atmos_alarm,
@@ -166,30 +165,22 @@ const AirAlarmControlHome = (props, context) => {
<Button
icon="sign-out-alt"
content="Vent Controls"
onClick={() => act('tgui:view', {
screen: 'vents',
})} />
onClick={() => setScreen('vents')} />
<Box mt={1} />
<Button
icon="filter"
content="Scrubber Controls"
onClick={() => act('tgui:view', {
screen: 'scrubbers',
})} />
onClick={() => setScreen('scrubbers')} />
<Box mt={1} />
<Button
icon="cog"
content="Operating Mode"
onClick={() => act('tgui:view', {
screen: 'modes',
})} />
onClick={() => setScreen('modes')} />
<Box mt={1} />
<Button
icon="chart-bar"
content="Alarm Thresholds"
onClick={() => act('tgui:view', {
screen: 'thresholds',
})} />
onClick={() => setScreen('thresholds')} />
</Fragment>
);
};
@@ -3,10 +3,9 @@ import { flow } from 'common/fp';
import { classes } from 'common/react';
import { createSearch } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Button, ByondUi, Input, Section } from '../components';
import { refocusLayout, Window } from '../layouts';
import { useGlobal } from '../store';
/**
* Returns previous and next camera names relative to the currently
@@ -95,7 +94,7 @@ export const CameraConsoleContent = (props, context) => {
const [
searchText,
setSearchText,
] = useGlobal(context, 'searchText', '');
] = useLocalState(context, 'searchText', '');
const { activeCamera } = data;
const cameras = selectCameras(data.cameras, searchText);
return (
+3 -4
View File
@@ -1,13 +1,12 @@
import { toArray } from 'common/collections';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useSharedState } from '../backend';
import { AnimatedNumber, Box, Button, Divider, Flex, LabeledList, Section, Table } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
export const Cargo = (props, context) => {
const { act, data } = useBackend(context);
const [tab, setTab] = useGlobal(context, 'tab', 'catalog');
const [tab, setTab] = useSharedState(context, 'tab', 'catalog');
const {
requestonly,
} = data;
@@ -125,7 +124,7 @@ export const CargoCatalog = (props, context) => {
const [
activeSupplyName,
setActiveSupplyName,
] = useGlobal(context, 'supply', supplies[0]?.name);
] = useSharedState(context, 'supply', supplies[0]?.name);
const activeSupply = supplies.find(supply => {
return supply.name === activeSupplyName;
});
@@ -1,9 +1,9 @@
import { AnimatedNumber, Section, LabeledList, Button, Box } from "../components";
import { Fragment } from "inferno";
import { InterfaceLockNoticeBox } from "./common/InterfaceLockNoticeBox";
import { CargoCatalog } from "./Cargo";
import { useBackend } from "../backend";
import { Window } from "../layouts";
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { AnimatedNumber, Box, Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
import { CargoCatalog } from './Cargo';
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
export const CargoExpress = (props, context) => {
const { act, data } = useBackend(context);
+3 -4
View File
@@ -1,8 +1,7 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Button, Flex, Input, Section } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
export const ChemFilterPane = (props, context) => {
const { act } = useBackend(context);
@@ -48,8 +47,8 @@ export const ChemFilter = (props, context) => {
left = [],
right = [],
} = data;
const [leftName, setLeftName] = useGlobal(context, 'leftName', '');
const [rightName, setRightName] = useGlobal(context, 'rightName', '');
const [leftName, setLeftName] = useLocalState(context, 'leftName', '');
const [rightName, setRightName] = useLocalState(context, 'rightName', '');
return (
<Window resizable>
<Window.Content scrollable>
+5 -6
View File
@@ -1,8 +1,7 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useSharedState } from '../backend';
import { AnimatedNumber, Box, Button, ColorBox, LabeledList, NumberInput, Section, Table } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
export const ChemMaster = (props, context) => {
const { data } = useBackend(context);
@@ -221,19 +220,19 @@ const PackagingControls = (props, context) => {
const [
pillAmount,
setPillAmount,
] = useGlobal(context, 'pillAmount', 1);
] = useSharedState(context, 'pillAmount', 1);
const [
patchAmount,
setPatchAmount,
] = useGlobal(context, 'patchAmount', 1);
] = useSharedState(context, 'patchAmount', 1);
const [
bottleAmount,
setBottleAmount,
] = useGlobal(context, 'bottleAmount', 1);
] = useSharedState(context, 'bottleAmount', 1);
const [
packAmount,
setPackAmount,
] = useGlobal(context, 'packAmount', 1);
] = useSharedState(context, 'packAmount', 1);
const {
condi,
chosenPillStyle,
@@ -1,20 +1,19 @@
import { map } from 'common/collections';
import { classes } from 'common/react';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Input, LabeledList, NumberInput, Section } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
export const ChemReactionChamber = (props, context) => {
const { act, data } = useBackend(context);
const [
reagentName,
setReagentName,
] = useGlobal(context, 'reagentName', '');
] = useLocalState(context, 'reagentName', '');
const [
reagentQuantity,
setReagentQuantity,
] = useGlobal(context, 'reagentQuantity', 1);
] = useLocalState(context, 'reagentQuantity', 1);
const emptying = data.emptying;
const reagents = data.reagents || [];
return (
+3 -4
View File
@@ -1,9 +1,8 @@
import { Component } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { BlockQuote, Box, Button, ByondUi, Collapsible, Icon, Input, Knob, LabeledList, NumberInput, ProgressBar, Section, Slider, Tabs, Tooltip } from '../components';
import { DraggableControl } from '../components/DraggableControl';
import { Window } from '../layouts';
import { useGlobal } from '../store';
const COLORS_ARBITRARY = [
'red',
@@ -72,7 +71,7 @@ const PAGES = [
];
export const KitchenSink = (props, context) => {
const [theme] = useGlobal(context, 'kitchenSinkTheme');
const [theme] = useLocalState(context, 'kitchenSinkTheme');
return (
<Window
theme={theme}
@@ -470,7 +469,7 @@ const KitchenSinkByondUi = (props, context) => {
};
const KitchenSinkThemes = (props, context) => {
const [theme, setTheme] = useGlobal(context, 'kitchenSinkTheme');
const [theme, setTheme] = useLocalState(context, 'kitchenSinkTheme');
return (
<Box>
<LabeledList>
@@ -1,9 +1,8 @@
import { createSearch, decodeHtmlEntities } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Input, Section, Table, Tabs } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
const MAX_SEARCH_RESULTS = 25;
@@ -17,11 +16,11 @@ export const MalfunctionModulePicker = (props, context) => {
const [
hoveredItem,
setHoveredItem,
] = useGlobal(context, 'hoveredItem', {});
] = useLocalState(context, 'hoveredItem', {});
const [
searchText,
setSearchText,
] = useGlobal(context, 'searchText', '');
] = useLocalState(context, 'searchText', '');
const testSearch = createSearch(searchText, item => {
return item.name + item.desc;
});
+3 -4
View File
@@ -1,9 +1,8 @@
import { createSearch, decodeHtmlEntities } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Input, Section, Table, Tabs } from '../components';
import { Window } from '../layouts';
import { useGlobal } from '../store';
const MAX_SEARCH_RESULTS = 25;
@@ -18,11 +17,11 @@ export const Uplink = (props, context) => {
const [
hoveredItem,
setHoveredItem,
] = useGlobal(context, 'hoveredItem', {});
] = useLocalState(context, 'hoveredItem', {});
const [
searchText,
setSearchText,
] = useGlobal(context, 'searchText', '');
] = useLocalState(context, 'searchText', '');
const testSearch = createSearch(searchText, item => {
return item.name + item.desc;
});
+4 -2
View File
@@ -1,5 +1,5 @@
import { sendLogEntry } from 'tgui-dev-server/link/client';
import { act } from './byond';
import { callByond } from './byond';
const LEVEL_DEBUG = 0;
const LEVEL_LOG = 1;
@@ -27,7 +27,9 @@ const log = (level, ns, ...args) => {
.filter(value => value)
.join(' ')
+ '\nUser Agent: ' + navigator.userAgent;
act(window.__ref__, 'tgui:log', {
callByond({
src: window.__ref__,
action: 'tgui:log',
log: logEntry,
});
}
+58 -6
View File
@@ -19,6 +19,53 @@ window.__ref__ = document
if (window.__ref__ === '[' + 'tgui:ref' + ']') {
window.__ref__ = null;
}
// BYOND API object
window.Byond = (function () {
var Byond = {};
// Utility functions
var hasOwn = Object.prototype.hasOwnProperty;
// Basic checks to detect whether this page runs in BYOND
var isByond = !!navigator.userAgent.match(/Trident\/(\d+).+?;/i)
&& location.hostname === '127.0.0.1'
&& location.pathname.indexOf('/tmp') === 0;
// Makes a BYOND call.
// See: https://secure.byond.com/docs/ref/skinparams.html
Byond.call = function (path, params) {
// Not running in BYOND, abort.
if (!isByond) {
return;
}
// Build the URL
var url = (path || '') + '?';
var i = 0;
if (params) {
for (var key in params) {
if (hasOwn.call(params, key)) {
if (i++ > 0) {
url += '&';
}
var value = params[key];
if (value === null || value === undefined) {
value = '';
}
url += encodeURIComponent(key)
+ '=' + encodeURIComponent(value)
}
}
}
// Perform a standard call via location.href
if (url.length < 2048) {
location.href = 'byond://' + url;
return;
}
// Send an HTTP request to DreamSeeker's HTTP server.
// Allows sending much bigger payloads.
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
};
return Byond;
})();
// Global error handling
window.onerror = function (msg, url, line, col, error) {
// Proper stacktrace
@@ -44,10 +91,13 @@ window.onerror = function (msg, url, line, col, error) {
errorStack.textContent = stack;
}
}
// Send to the backend
location.href = 'byond://?src=' + window.__ref__
+ '&action=tgui:log&fatal=1'
+ '&log=' + encodeURIComponent(stack);
// Send logs to the server
Byond.call('', {
src: window.__ref__,
action: 'tgui:log',
fatal: '1',
log: stack,
});
// Short-circuit further updates
window.__updateQueue__ = [];
window.update = function () {};
@@ -59,8 +109,10 @@ window.__updateQueue__ = [];
window.update = function (stateJson) {
window.__updateQueue__.push(stateJson);
};
location.href = 'byond://?src=' + window.__ref__
+ '&action=tgui:initialize';
Byond.call('', {
src: window.__ref__,
action: 'tgui:initialize',
});
</script>
<!-- Styles -->
-45
View File
@@ -11,7 +11,6 @@ export const createStore = () => {
// Global state reducers
backendReducer,
hotKeyReducer,
globalStateReducer,
]);
const middleware = [
// loggingMiddleware,
@@ -34,47 +33,3 @@ export class StoreProvider extends Component {
export const useDispatch = context => {
return context.store.dispatch;
};
/**
* Allocates a global variable on Redux store.
*
* Great when you want to store some UI state globally, without having to
* modify DM object you're working with to have that var and export it via
* ui_data.
*
* @param {any} context React context.
* @param {string} key Key which uniquely identifies this state in Redux store.
* @param {any} initialState Initializes your global variable with this value.
*/
export const useGlobal = (context, key, initialState) => {
const { store } = context;
const globalObj = store.getState().global ?? {};
const state = (key in globalObj)
? globalObj[key]
: initialState;
const setState = nextState => {
store.dispatch({
type: 'setGlobal',
payload: { key, nextState },
});
};
return [state, setState];
};
/**
* Reducer, which handles actions coming from useGlobal.
*/
const globalStateReducer = (state, action) => {
const { type, payload } = action;
if (type === 'setGlobal') {
const { key, nextState } = payload;
return {
...state,
global: {
...state.global,
[key]: nextState,
},
};
}
return state;
};