diff --git a/code/modules/atmospherics/machinery/portable/canister.dm b/code/modules/atmospherics/machinery/portable/canister.dm index 876f4c9d782..f386d5f9bef 100644 --- a/code/modules/atmospherics/machinery/portable/canister.dm +++ b/code/modules/atmospherics/machinery/portable/canister.dm @@ -5,8 +5,8 @@ desc = "A canister for the storage of gas." icon_state = "yellow" density = TRUE - ui_x = 420 - ui_y = 405 + ui_x = 346 + ui_y = 268 var/valve_open = FALSE var/obj/machinery/atmospherics/components/binary/passive_gate/pump diff --git a/code/modules/power/smes.dm b/code/modules/power/smes.dm index e2c6745d71c..fc253fd92aa 100644 --- a/code/modules/power/smes.dm +++ b/code/modules/power/smes.dm @@ -22,7 +22,7 @@ use_power = NO_POWER_USE circuit = /obj/item/circuitboard/machine/smes ui_x = 340 - ui_y = 440 + ui_y = 350 var/capacity = 5e6 // maximum charge var/charge = 0 // actual charge @@ -327,23 +327,21 @@ /obj/machinery/power/smes/ui_data() var/list/data = list( - "capacityPercent" = round(100*charge/capacity, 0.1), "capacity" = capacity, + "capacityPercent" = round(100*charge/capacity, 0.1), "charge" = charge, - "inputAttempt" = input_attempt, "inputting" = inputting, "inputLevel" = input_level, "inputLevel_text" = DisplayPower(input_level), "inputLevelMax" = input_level_max, - "inputAvailable" = DisplayPower(input_available), - + "inputAvailable" = input_available, "outputAttempt" = output_attempt, "outputting" = outputting, "outputLevel" = output_level, "outputLevel_text" = DisplayPower(output_level), "outputLevelMax" = output_level_max, - "outputUsed" = DisplayPower(output_used) + "outputUsed" = output_used, ) return data diff --git a/tgui/.eslintrc.yml b/tgui/.eslintrc.yml index 4bff333295d..e5ebafddcfc 100644 --- a/tgui/.eslintrc.yml +++ b/tgui/.eslintrc.yml @@ -625,7 +625,7 @@ rules: ## Prevent invalid characters from appearing in markup react/no-unescaped-entities: error ## Prevent usage of unknown DOM property (fixable) - react/no-unknown-property: error + # react/no-unknown-property: error ## Prevent usage of unsafe lifecycle methods react/no-unsafe: error ## Prevent definitions of unused prop types @@ -705,7 +705,7 @@ rules: ## Validate JSX has key prop when in array or iterator react/jsx-key: error ## Validate JSX maximum depth - react/jsx-max-depth: [error, { max: 6 }] ## Generous + react/jsx-max-depth: [error, { max: 10 }] ## Generous ## Limit maximum of props on a single line in JSX (fixable) # react/jsx-max-props-per-line: error ## Prevent usage of .bind() and arrow functions in JSX props diff --git a/tgui/README.md b/tgui/README.md index 5fd6434c1ea..a7ef35602ed 100644 --- a/tgui/README.md +++ b/tgui/README.md @@ -15,7 +15,7 @@ People come to tgui from different backgrounds and with different learning styles. Whether you prefer a more theoretical or a practical approach, we hope you’ll find this section helpful. -### Practical tutorial +### Practical Tutorial If you are completely new to frontend and prefer to **learn by doing**, start with our [practical tutorial](docs/tutorial-and-examples.md). @@ -123,7 +123,26 @@ Note that in Windows, you have to go through Advanced System Settings, System Properties and then open Environment Variables window to do the same thing. You may need to reboot after this. -## Project structure +## Developer Tools + +When developing with `tgui-dev-server`, you will have access to certain +development only features. + +**Debug Logs.** +When running server via `bin/tgui --dev --debug`, server will print debug +logs and time spent on rendering. Use this information to optimize your +code, and try to keep re-renders below 16ms. + +**Kitchen Sink.** +Press `Ctrl+Alt+=` to open the KitchenSink interface. This interface is a +playground to test various tgui components. + +**Layout Debugger.** +Press `Ctrl+Alt+-` to toggle the *layout debugger*. It will show outlines of +all tgui elements, which makes it easy to understand how everything comes +together, and can reveal certain layout bugs which are not normally visible. + +## Project Structure - `/packages` - Each folder here represents a self-contained Node module. - `/packages/common` - Helper functions @@ -138,17 +157,17 @@ interfaces, otherwise they simply won't load. window elements, like the titlebar, buttons, resize handlers. Calls `routes.js` to decide which component to render. - `/packages/tgui/styles/main.scss` - CSS entry point. -- `/packages/tgui/styles/atomic.scss` - Atomic CSS classes. +- `/packages/tgui/styles/atomic` - Atomic CSS classes. These are very simple, tiny, reusable CSS classes which you can use and combine to change appearance of your elements. Keep them small. -- `/packages/tgui/styles/components.scss` - CSS classes which are used +- `/packages/tgui/styles/components` - CSS classes which are used in UI components, and most of the stylesheets referenced here are located in `/packages/tgui/components`. These stylesheets closely follow the [BEM](https://en.bem.info/methodology/) methodology. - `/packages/tgui/styles/functions.scss` - Useful SASS functions. Stuff like `lighten`, `darken`, `luminance` are defined here. -## Component reference +## Component Reference > Notice: This documentation might be out of date, so always check the source > code to see the most up-to-date information. @@ -162,17 +181,14 @@ it is used a lot in this framework. There are a few important semantics you need to know about: -- `content` prop is a synonym to a `children` prop. +- Some elements support a `content` prop, which is a synonym to a +`children` prop. - `content` is better used when your element is a self-closing tag (like ``), and when content is long and complex. This is - a native React prop (unlike `content`), and contains all elements you - defined between the opening and the closing tag of an element. - - You should never use both on a same element. + to React, and is only available on these components: `Button`, `Tooltip`. - You should never use `children` explicitly as a prop on an element. + Instead open a full tag, and place children or text inside the tag. - Inferno supports both camelcase (`onClick`) and lowercase (`onclick`) event names. - Camel case names are what's called "synthetic" events, and are the @@ -183,8 +199,8 @@ event names. - Lower case names are native browser events and should be used sparingly, for example when you need an explicit IE8 support. **DO NOT** use lowercase event handlers unless you really know what you are doing. - - [Button](#button) component straight up does not support lowercase event - handlers. Use the camel case `onClick` instead. + - [Button](#button) component does not support lowercase `onclick` event. + Use the camel case `onClick` instead. ### `AnimatedNumber` @@ -430,6 +446,8 @@ Props: Dims surrounding area to emphasize content placed inside. +Content is automatically centered inside the dimmer. + Props: - See inherited props: [Box](#box) @@ -498,6 +516,8 @@ Props: (1 unit - 0.5em). Does not directly relate to a flex css property (adds a modifier class under the hood), and only integer numbers are supported. +- `inline: boolean` - Makes flexbox container inline, with similar behavior +to an `inline` property on a `Box`. - `direction: string` - This establishes the main-axis, thus defining the direction flex items are placed in the flex container. - `row` (default) - left to right. @@ -549,14 +569,22 @@ item should take up. This number is unit-less and is relative to other siblings. - `shrink: number` - This defines the ability for a flex item to shrink if necessary. Inverse of `grow`. -- `basis: string` - This defines the default size of an element before the -remaining space is distributed. It can be a length (e.g. `20%`, `5rem`, etc.), +- `basis: string` - This defines the default size of an element before any +flex-related calculations are done. Has to be a length (e.g. `20%`, `5rem`), an `auto` or `content` keyword. + - **Important:** IE11 flex is buggy, and auto width/height calculations + can sometimes end up in a circular dependency. This usually happens, when + working with tables inside flex (they have wacky internal widths and such). + Setting basis to `0` breaks the loop and fixes all of the problems. - `align: string` - This allows the default alignment (or the one specified by align-items) to be overridden for individual flex items. See: [Flex](#flex). ### `Grid` +> **Deprecated:** This component is no longer recommended due to the variety +> of bugs that come with table-based layouts. +> We recommend using [Flex](#flex) instead. + Helps you to divide horizontal space into two or more equal sections. It is essentially a single-row `Table`, but with some extra features. @@ -565,10 +593,14 @@ Example: ```jsx -
+
+ Hello world! +
-
+
+ Hello world! +
``` @@ -627,6 +659,44 @@ when this happens. Useful for things like chat inputs. the text by either unfocusing the input box, or by pressing the Enter key. - `onInput: (e, value) => void` - An event, which fires on every keypress. +### `Knob` + +A radial control, which allows dialing in precise values by dragging it +up and down. + +Single click opens an input box to manually type in a number. + +Props: + +- See inherited props: [Box](#box) +- `animated: boolean` - Animates the value if it was changed externally. +- `bipolar: boolean` - Knob can be bipolar or unipolar. +- `size: number` - Relative size of the knob. `1` is normal size, `2` is two +times bigger. Fractional numbers are supported. +- `color: string` - Color of the outer ring around the knob. +- `value: number` - Value itself, controls the position of the cursor. +- `unit: string` - Unit to display to the right of value. +- `minValue: number` - Lowest possible value. +- `maxValue: number` - Highest possible value. +- `fillValue: number` - If set, this value will be used to set the fill +percentage of the outer ring independently of the main value. +- `ranges: { color: [from, to] }` - Applies a `color` to the outer ring around +the knob based on whether the value lands in the range between `from` and `to`. +See an example of this prop in [ProgressBar](#progressbar). +- `step: number` (default: 1) - Adjust value by this amount when +dragging the input. +- `stepPixelSize: number` (default: 1) - Screen distance mouse needs +to travel to adjust value by one `step`. +- `format: value => value` - Format value using this function before +displaying it. +- `suppressFlicker: number` - A number in milliseconds, for which the input +will hold off from updating while events propagate through the backend. +Default is about 250ms, increase it if you still see flickering. +- `onChange: (e, value) => void` - An event, which fires when you release +the input, or successfully enter a number. +- `onDrag: (e, value) => void` - An event, which fires about every 500ms +when you drag the input up and down, on release and on manual editing. + ### `LabeledList` LabeledList is a continuous, vertical list of text and other content, where @@ -795,6 +865,41 @@ means deeper level of nesting. Must be an integer number. - `buttons: any` - Buttons to render aside the section title. - `content/children: any` - Content of this section. +### `Slider` + +A horizontal, [ProgressBar](#progressbar)-like control, which allows dialing +in precise values by dragging it left and right. + +Single click opens an input box to manually type in a number. + +Props: + +- See inherited props: [Box](#box) +- `animated: boolean` - Animates the value if it was changed externally. +- `color: string` - Color of the slider. +- `value: number` - Value itself, controls the position of the cursor. +- `unit: string` - Unit to display to the right of value. +- `minValue: number` - Lowest possible value. +- `maxValue: number` - Highest possible value. +- `fillValue: number` - If set, this value will be used to set the fill +percentage of the progress bar filler independently of the main value. +- `ranges: { color: [from, to] }` - Applies a `color` to the slider +based on whether the value lands in the range between `from` and `to`. +See an example of this prop in [ProgressBar](#progressbar). +- `step: number` (default: 1) - Adjust value by this amount when +dragging the input. +- `stepPixelSize: number` (default: 1) - Screen distance mouse needs +to travel to adjust value by one `step`. +- `format: value => value` - Format value using this function before +displaying it. +- `suppressFlicker: number` - A number in milliseconds, for which the input +will hold off from updating while events propagate through the backend. +Default is about 250ms, increase it if you still see flickering. +- `onChange: (e, value) => void` - An event, which fires when you release +the input, or successfully enter a number. +- `onDrag: (e, value) => void` - An event, which fires about every 500ms +when you drag the input up and down, on release and on manual editing. + ### `Table` A straight forward mapping to a standard html table, which is slightly diff --git a/tgui/packages/common/math.js b/tgui/packages/common/math.js index a33b9aa214c..f2918c5c111 100644 --- a/tgui/packages/common/math.js +++ b/tgui/packages/common/math.js @@ -1,10 +1,22 @@ /** * Limits a number to the range between 'min' and 'max'. */ -export const clamp = (value, min = 0, max = 1) => { +export const clamp = (value, min, max) => { return Math.max(min, Math.min(value, max)); }; +/** + * Limits a number between 0 and 1. + */ +export const clamp01 = value => clamp(value, 0, 1); + +/** + * Scales a number to fit into the range between min and max. + */ +export const scale = (value, min, max) => { + return (value - min) / (max - min); +}; + /** * Returns a rounded number. * TODO: Replace this native rounding function with a more robust one. @@ -15,5 +27,31 @@ export const round = value => Math.round(value); * Returns a string representing a number in fixed point notation. */ export const toFixed = (value, fractionDigits = 0) => { - return Number(value).toFixed(fractionDigits); + return Number(value).toFixed(Math.max(fractionDigits, 0)); +}; + +/** + * Checks whether a value is within the provided range. + * + * Range is an array of two numbers, for example: [0, 15]. + */ +export const inRange = (value, range) => { + return range + && value >= range[0] + && value <= range[1]; +}; + +/** + * Walks over the object with ranges, comparing value against every range, + * and returns the key of the first matching range. + * + * Range is an array of two numbers, for example: [0, 15]. + */ +export const keyOfMatchingRange = (value, ranges) => { + for (let rangeName of Object.keys(ranges)) { + const range = ranges[rangeName]; + if (inRange(value, range)) { + return rangeName; + } + } }; diff --git a/tgui/packages/tgui/components/Box.js b/tgui/packages/tgui/components/Box.js index b0f56e67e85..4433e10107c 100644 --- a/tgui/packages/tgui/components/Box.js +++ b/tgui/packages/tgui/components/Box.js @@ -123,43 +123,53 @@ export const computeBoxProps = props => { } } // Concatenate styles - Object.assign(computedStyles, props.style); let style = ''; for (let attrName of Object.keys(computedStyles)) { const attrValue = computedStyles[attrName]; style += attrName + ':' + attrValue + ';'; } + if (props.style) { + for (let attrName of Object.keys(props.style)) { + const attrValue = props.style[attrName]; + style += attrName + ':' + attrValue + ';'; + } + } if (style.length > 0) { computedProps.style = style; } return computedProps; }; +export const computeBoxClassName = props => { + const color = props.textColor || props.color; + const backgroundColor = props.backgroundColor; + return classes([ + isColorClass(color) && 'color-' + color, + isColorClass(backgroundColor) && 'color-bg-' + backgroundColor, + ]); +}; + export const Box = props => { const { as = 'div', className, - content, children, ...rest } = props; - const color = props.textColor || props.color; - const backgroundColor = props.backgroundColor; // Render props if (typeof children === 'function') { return children(computeBoxProps(props)); } + const computedClassName = typeof className === 'string' + ? className + ' ' + computeBoxClassName(rest) + : computeBoxClassName(rest); const computedProps = computeBoxProps(rest); // Render a wrapper element return createVNode( VNodeFlags.HtmlElement, as, - classes([ - className, - isColorClass(color) && 'color-' + color, - isColorClass(backgroundColor) && 'color-bg-' + backgroundColor, - ]), - content || children, + computedClassName, + children, ChildFlags.UnknownChildren, computedProps); }; diff --git a/tgui/packages/tgui/components/Button.js b/tgui/packages/tgui/components/Button.js index 8d67c15d748..3b7c3a3776e 100644 --- a/tgui/packages/tgui/components/Button.js +++ b/tgui/packages/tgui/components/Button.js @@ -41,7 +41,7 @@ export const Button = props => { // IE8: Use a lowercase "onclick" because synthetic events are fucked. // IE8: Use an "unselectable" prop because "user-select" doesn't work. return ( - { ])} color={content ? null : 'transparent'} backgroundColor={color} - content={content || '.'} - {...rest} /> + {...rest}> + {content || '.'} + ); }; diff --git a/tgui/packages/tgui/components/DraggableControl.js b/tgui/packages/tgui/components/DraggableControl.js new file mode 100644 index 00000000000..c540401b379 --- /dev/null +++ b/tgui/packages/tgui/components/DraggableControl.js @@ -0,0 +1,274 @@ +import { clamp } from 'common/math'; +import { pureComponentHooks } from 'common/react'; +import { Component, createRef } from 'inferno'; +import { AnimatedNumber } from './AnimatedNumber'; + +/** + * Reduces screen offset to a single number based on the matrix provided. + */ +const getScalarScreenOffset = (e, matrix) => { + return e.screenX * matrix[0] + e.screenY * matrix[1]; +}; + +export class DraggableControl extends Component { + constructor(props) { + super(props); + this.inputRef = createRef(); + this.state = { + value: props.value, + dragging: false, + editing: false, + internalValue: null, + origin: null, + suppressingFlicker: false, + }; + + // Suppresses flickering while the value propagates through the backend + this.flickerTimer = null; + this.suppressFlicker = () => { + const { suppressFlicker } = this.props; + if (suppressFlicker > 0) { + this.setState({ + suppressingFlicker: true, + }); + clearTimeout(this.flickerTimer); + this.flickerTimer = setTimeout(() => this.setState({ + suppressingFlicker: false, + }), suppressFlicker); + } + }; + + this.handleDragStart = e => { + const { + value, + dragMatrix, + } = this.props; + const { editing } = this.state; + if (editing) { + return; + } + document.body.style['pointer-events'] = 'none'; + this.ref = e.target; + this.setState({ + dragging: false, + origin: getScalarScreenOffset(e, dragMatrix), + value, + internalValue: value, + }); + this.timer = setTimeout(() => { + this.setState({ + dragging: true, + }); + }, 250); + this.dragInterval = setInterval(() => { + const { dragging, value } = this.state; + const { onDrag } = this.props; + if (dragging && onDrag) { + onDrag(e, value); + } + }, 500); + document.addEventListener('mousemove', this.handleDragMove); + document.addEventListener('mouseup', this.handleDragEnd); + }; + + this.handleDragMove = e => { + const { + minValue, + maxValue, + step, + stepPixelSize, + dragMatrix, + } = this.props; + this.setState(prevState => { + const state = { ...prevState }; + const offset = getScalarScreenOffset(e, dragMatrix) - state.origin; + if (prevState.dragging) { + const stepOffset = Number.isFinite(minValue) + ? minValue % step + : 0; + // Translate mouse movement to value + // Give it some headroom (by increasing clamp range by 1 step) + state.internalValue = clamp( + state.internalValue + + offset * step / stepPixelSize, + minValue - step, + maxValue + step); + // Clamp the final value + state.value = clamp( + state.internalValue + - state.internalValue % step + + stepOffset, + minValue, + maxValue); + state.origin = getScalarScreenOffset(e, dragMatrix); + } + else if (Math.abs(offset) > 4) { + state.dragging = true; + } + return state; + }); + }; + + this.handleDragEnd = e => { + const { + onChange, + onDrag, + } = this.props; + const { + dragging, + value, + internalValue, + } = this.state; + document.body.style['pointer-events'] = 'auto'; + clearTimeout(this.timer); + clearInterval(this.dragInterval); + this.setState({ + dragging: false, + editing: !dragging, + origin: null, + }); + document.removeEventListener('mousemove', this.handleDragMove); + document.removeEventListener('mouseup', this.handleDragEnd); + if (dragging) { + this.suppressFlicker(); + if (onChange) { + onChange(e, value); + } + if (onDrag) { + onDrag(e, value); + } + } + else if (this.inputRef) { + const input = this.inputRef.current; + input.value = internalValue; + // IE8: Dies when trying to focus a hidden element + // (Error: Object does not support this action) + try { + input.focus(); + input.select(); + } + catch {} + } + }; + } + + render() { + const { + dragging, + editing, + value: intermediateValue, + suppressingFlicker, + } = this.state; + const { + animated, + value, + unit, + minValue, + maxValue, + format, + onChange, + onDrag, + children, + // Input props + height, + lineHeight, + fontSize, + } = this.props; + let displayValue = value; + if (dragging || suppressingFlicker) { + displayValue = intermediateValue; + } + // Setup a display element + // Shows a formatted number based on what we are currently doing + // with the draggable surface. + const renderDisplayElement = value => ( + value + (unit ? ' ' + unit : '') + ); + const displayElement = ( + animated && !dragging && !suppressingFlicker && ( + + {renderDisplayElement} + + ) || ( + renderDisplayElement(format + ? format(displayValue) + : displayValue) + ) + ); + // Setup an input element + // Handles direct input via the keyboard + const inputElement = ( + { + if (!editing) { + return; + } + const value = clamp(e.target.value, minValue, maxValue); + this.setState({ + editing: false, + value, + }); + this.suppressFlicker(); + if (onChange) { + onChange(e, value); + } + if (onDrag) { + onDrag(e, value); + } + }} + onKeyDown={e => { + if (e.keyCode === 13) { + const value = clamp(e.target.value, minValue, maxValue); + this.setState({ + editing: false, + value, + }); + this.suppressFlicker(); + if (onChange) { + onChange(e, value); + } + if (onDrag) { + onDrag(e, value); + } + return; + } + if (e.keyCode === 27) { + this.setState({ + editing: false, + }); + return; + } + }} /> + ); + // Return a part of the state for higher-level components to use. + return children({ + dragging, + editing, + value, + displayValue, + displayElement, + inputElement, + handleDragStart: this.handleDragStart, + }); + } +} + +DraggableControl.defaultHooks = pureComponentHooks; +DraggableControl.defaultProps = { + minValue: -Infinity, + maxValue: +Infinity, + step: 1, + stepPixelSize: 1, + suppressFlicker: 50, + dragMatrix: [1, 0], +}; diff --git a/tgui/packages/tgui/components/Flex.js b/tgui/packages/tgui/components/Flex.js index 4b5cb71115c..420f39d079b 100644 --- a/tgui/packages/tgui/components/Flex.js +++ b/tgui/packages/tgui/components/Flex.js @@ -8,12 +8,14 @@ export const computeFlexProps = props => { wrap, align, justify, + inline, spacing = 0, ...rest } = props; return { className: classes([ 'Flex', + inline && 'Flex--inline', spacing > 0 && 'Flex--spacing--' + spacing, className, ]), diff --git a/tgui/packages/tgui/components/Knob.js b/tgui/packages/tgui/components/Knob.js new file mode 100644 index 00000000000..c13548aa489 --- /dev/null +++ b/tgui/packages/tgui/components/Knob.js @@ -0,0 +1,128 @@ +import { keyOfMatchingRange, scale } from 'common/math'; +import { classes } from 'common/react'; +import { computeBoxClassName, computeBoxProps } from './Box'; +import { DraggableControl } from './DraggableControl'; + +export const Knob = props => { + const { + // Draggable props (passthrough) + animated, + format, + maxValue, + minValue, + onChange, + onDrag, + step, + stepPixelSize, + suppressFlicker, + unit, + value, + // Own props + className, + style, + fillValue, + color, + ranges = {}, + size, + bipolar, + children, + ...rest + } = props; + return ( + + {control => { + const { + dragging, + editing, + value, + displayValue, + displayElement, + inputElement, + handleDragStart, + } = control; + const scaledFillValue = scale( + fillValue ?? displayValue, + minValue, + maxValue); + const scaledDisplayValue = scale( + displayValue, + minValue, + maxValue); + const effectiveColor = color + || keyOfMatchingRange(fillValue ?? value, ranges) + || 'default'; + const rotation = (scaledDisplayValue - 0.5) * 270; + return ( +
+
+
+
+
+
+ {dragging && ( +
+ {displayElement} +
+ )} + + + + + + + {inputElement} +
+ ); + }} + + ); +}; diff --git a/tgui/packages/tgui/components/NumberInput.js b/tgui/packages/tgui/components/NumberInput.js index 52f6a878652..c9f9846e2f3 100644 --- a/tgui/packages/tgui/components/NumberInput.js +++ b/tgui/packages/tgui/components/NumberInput.js @@ -77,13 +77,15 @@ export class NumberInput extends Component { // Give it some headroom (by increasing clamp range by 1 step) state.internalValue = clamp( state.internalValue + offset * step / stepPixelSize, - minValue - step, maxValue + step); + minValue - step, + maxValue + step); // Clamp the final value state.value = clamp( state.internalValue - state.internalValue % step + stepOffset, - minValue, maxValue); + minValue, + maxValue); state.origin = e.screenY; } else if (Math.abs(offset) > 4) { diff --git a/tgui/packages/tgui/components/ProgressBar.js b/tgui/packages/tgui/components/ProgressBar.js index e58bfa38693..db3ada7023a 100644 --- a/tgui/packages/tgui/components/ProgressBar.js +++ b/tgui/packages/tgui/components/ProgressBar.js @@ -1,47 +1,41 @@ +import { clamp, keyOfMatchingRange, toFixed } from 'common/math'; import { classes, pureComponentHooks } from 'common/react'; -import { clamp, toFixed } from 'common/math'; +import { computeBoxClassName, computeBoxProps } from './Box'; export const ProgressBar = props => { const { + className, value, minValue = 0, maxValue = 1, + color, ranges = {}, - content, children, + ...rest } = props; const scaledValue = (value - minValue) / (maxValue - minValue); - const hasContent = content !== undefined || children !== undefined; - let { color } = props; - // Cycle through ranges in key order to determine progressbar color. - if (!color) { - for (let rangeName of Object.keys(ranges)) { - const range = ranges[rangeName]; - if (range && value >= range[0] && value <= range[1]) { - color = rangeName; - break; - } - } - } - // Default color - if (!color) { - color = 'default'; - } + const hasContent = children !== undefined; + const effectiveColor = color + || keyOfMatchingRange(value, ranges) + || 'default'; return (
+ 'ProgressBar--color--' + effectiveColor, + className, + computeBoxClassName(rest), + ])} + {...computeBoxProps(rest)}>
- {hasContent && content} - {hasContent && children} - {!hasContent && toFixed(scaledValue * 100) + '%'} + {hasContent + ? children + : toFixed(scaledValue * 100) + '%'}
); diff --git a/tgui/packages/tgui/components/Slider.js b/tgui/packages/tgui/components/Slider.js new file mode 100644 index 00000000000..c8967dcecf6 --- /dev/null +++ b/tgui/packages/tgui/components/Slider.js @@ -0,0 +1,122 @@ +import { clamp01, keyOfMatchingRange, scale } from 'common/math'; +import { classes } from 'common/react'; +import { computeBoxClassName, computeBoxProps } from './Box'; +import { DraggableControl } from './DraggableControl'; + +export const Slider = props => { + const { + // Draggable props (passthrough) + animated, + format, + maxValue, + minValue, + onChange, + onDrag, + step, + stepPixelSize, + suppressFlicker, + unit, + value, + // Own props + className, + fillValue, + color, + ranges = {}, + children, + ...rest + } = props; + const hasContent = children !== undefined; + return ( + + {control => { + const { + dragging, + editing, + value, + displayValue, + displayElement, + inputElement, + handleDragStart, + } = control; + const hasFillValue = fillValue !== undefined + && fillValue !== null; + const scaledValue = scale( + value, + minValue, + maxValue); + const scaledFillValue = scale( + fillValue ?? displayValue, + minValue, + maxValue); + const scaledDisplayValue = scale( + displayValue, + minValue, + maxValue); + const effectiveColor = color + || keyOfMatchingRange(fillValue ?? value, ranges) + || 'default'; + return ( +
+
+
+
+
+
+ {dragging && ( +
+ {displayElement} +
+ )} +
+
+ {hasContent + ? children + : displayElement} +
+ {inputElement} +
+ ); + }} + + ); +}; diff --git a/tgui/packages/tgui/components/Table.js b/tgui/packages/tgui/components/Table.js index 53295ecbc23..159048cc146 100644 --- a/tgui/packages/tgui/components/Table.js +++ b/tgui/packages/tgui/components/Table.js @@ -1,55 +1,66 @@ import { classes, pureComponentHooks } from 'common/react'; -import { Box } from './Box'; +import { Box, computeBoxClassName, computeBoxProps } from './Box'; export const Table = props => { - const { collapsing, className, content, children, ...rest } = props; + const { + className, + collapsing, + children, + ...rest + } = props; return ( - + {...computeBoxProps(rest)}> - {content} {children} - + ); }; Table.defaultHooks = pureComponentHooks; export const TableRow = props => { - const { className, header, ...rest } = props; + const { + className, + header, + ...rest + } = props; return ( - + {...computeBoxProps(rest)} /> ); }; TableRow.defaultHooks = pureComponentHooks; export const TableCell = props => { - const { className, collapsing, header, ...rest } = props; + const { + className, + collapsing, + header, + ...rest + } = props; return ( - + {...computeBoxProps(rest)} /> ); }; diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js index 618c3609fe6..0c38f8a812c 100644 --- a/tgui/packages/tgui/components/index.js +++ b/tgui/packages/tgui/components/index.js @@ -13,11 +13,13 @@ export { Flex } from './Flex'; export { Grid } from './Grid'; export { Icon } from './Icon'; export { Input } from './Input'; +export { Knob } from './Knob'; export { LabeledList } from './LabeledList'; export { NoticeBox } from './NoticeBox'; export { NumberInput } from './NumberInput'; export { ProgressBar } from './ProgressBar'; export { Section } from './Section'; +export { Slider } from './Slider'; export { Table } from './Table'; export { Tabs } from './Tabs'; export { TitleBar } from './TitleBar'; diff --git a/tgui/packages/tgui/format.js b/tgui/packages/tgui/format.js new file mode 100644 index 00000000000..6b0558b8f3a --- /dev/null +++ b/tgui/packages/tgui/format.js @@ -0,0 +1,56 @@ +import { clamp, toFixed } from 'common/math'; + +const SI_SYMBOLS = [ + 'f', // femto + 'p', // pico + 'n', // nano + 'μ', // micro + 'm', // milli + // NOTE: This is a space for a reason. When we right align si numbers, + // in monospace mode, we want to units and numbers stay in their respective + // columns. If rendering in HTML mode, this space will collapse into + // a single space anyway. + ' ', + 'k', // kilo + 'M', // mega + 'G', // giga + 'T', // tera + 'P', // peta + 'E', // exa + 'Z', // zetta + 'Y', // yotta +]; + +const SI_BASE_INDEX = SI_SYMBOLS.indexOf(' '); + + +/** + * Formats a number to a human readable form, by reducing it to SI units. + * TODO: This is quite a shit code and shit math, needs optimization. + */ +const formatSiUnit = (value, minBase1000 = -SI_BASE_INDEX, unit = '') => { + const realBase10 = Math.floor(Math.log10(value)); + const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10)); + const realBase1000 = Math.floor(realBase10 / 3); + const base1000 = Math.floor(base10 / 3); + const symbolIndex = clamp( + SI_BASE_INDEX + base1000, + 0, + SI_SYMBOLS.length); + const symbol = SI_SYMBOLS[symbolIndex]; + const scaledNumber = value / Math.pow(1000, base1000); + const scaledPrecision = realBase1000 > minBase1000 + ? (2 + base1000 * 3 - base10) + : 0; + // TODO: Make numbers bigger than precision value show + // up to 2 decimal numbers. + const finalString = ( + toFixed(scaledNumber, scaledPrecision) + + ' ' + symbol + unit + ); + return finalString.trim(); +}; + +export const formatPower = (value, minBase1000 = 0) => { + return formatSiUnit(value, minBase1000, 'W'); +}; diff --git a/tgui/packages/tgui/hotkeys.js b/tgui/packages/tgui/hotkeys.js index 08625aa64d7..b2e4650053a 100644 --- a/tgui/packages/tgui/hotkeys.js +++ b/tgui/packages/tgui/hotkeys.js @@ -251,6 +251,13 @@ export const hotKeyReducer = (state, action) => { showKitchenSink: !state.showKitchenSink, }; } + // Toggle layout debugger + if (ctrlKey && altKey && keyCode === KEY_MINUS) { + return { + ...state, + debugLayout: !state.debugLayout, + }; + } return state; } return state; diff --git a/tgui/packages/tgui/interfaces/Achievements.js b/tgui/packages/tgui/interfaces/Achievements.js index 6e2ad7a5993..1ae7d8970c3 100644 --- a/tgui/packages/tgui/interfaces/Achievements.js +++ b/tgui/packages/tgui/interfaces/Achievements.js @@ -16,9 +16,9 @@ export const Achievement = props => {

{name}

{desc} - + + {value ? 'Unlocked' : 'Locked'} + ); @@ -39,9 +39,9 @@ export const Score = props => {

{name}

{desc} - 0 ? 'good' : 'bad'} - content={value > 0 ? `Earned ${value} times` : 'Locked'} /> + 0 ? 'good' : 'bad'}> + {value > 0 ? `Earned ${value} times` : 'Locked'} + ); diff --git a/tgui/packages/tgui/interfaces/BlackmarketUplink.js b/tgui/packages/tgui/interfaces/BlackmarketUplink.js index cac46988120..c887c2e7e15 100644 --- a/tgui/packages/tgui/interfaces/BlackmarketUplink.js +++ b/tgui/packages/tgui/interfaces/BlackmarketUplink.js @@ -105,7 +105,7 @@ export const BlackmarketUplink = props => {
- -
- - - - kPa - - - -
-
act('eject')} /> )}> - {!!data.hasHoldingTank && ( + {!!hasHoldingTank && ( - {data.holdingTank.name} + {holdingTank.name} - kPa + kPa )} - {!data.hasHoldingTank && ( + {!hasHoldingTank && ( No Holding Tank diff --git a/tgui/packages/tgui/interfaces/Canvas.js b/tgui/packages/tgui/interfaces/Canvas.js index 9571d94e225..04b5ec23789 100644 --- a/tgui/packages/tgui/interfaces/Canvas.js +++ b/tgui/packages/tgui/interfaces/Canvas.js @@ -1,9 +1,6 @@ -import { Fragment } from 'inferno'; +import { Component, createRef } from 'inferno'; import { useBackend } from '../backend'; import { Box, Button } from '../components'; -import { Component, createRef } from 'inferno'; -import { pureComponentHooks } from 'common/react'; - class PaintCanvas extends Component { constructor(props) { @@ -45,8 +42,7 @@ class PaintCanvas extends Component { clickwrapper(event) { const x_size = this.props.value.length; - if (!x_size) - { + if (!x_size) { return; } const y_size = this.props.value[0].length; @@ -78,6 +74,7 @@ class PaintCanvas extends Component { ); } } + export const Canvas = props => { const { act, data } = useBackend(props); return ( @@ -86,11 +83,13 @@ export const Canvas = props => { value={data.grid} onCanvasClick={(x, y) => act("paint", { x, y })} /> - {!data.finalized - && act("finalize")} - content="Finalize" />} + {!data.finalized && ( + act("finalize")} + content="Finalize" /> + )} {data.name} - ); + + ); }; diff --git a/tgui/packages/tgui/interfaces/Cargo.js b/tgui/packages/tgui/interfaces/Cargo.js index 6df2c13955a..5e58463d211 100644 --- a/tgui/packages/tgui/interfaces/Cargo.js +++ b/tgui/packages/tgui/interfaces/Cargo.js @@ -60,7 +60,9 @@ export const Cargo = props => { disabled={!(data.away && data.docked)} onClick={() => act(ref, 'loan')} /> ) : ( - Loaned to Centcom + + Loaned to Centcom + )} ) : ''} @@ -158,7 +160,8 @@ const Catalog = props => { )} -
@@ -79,7 +80,8 @@ export const ChemDispenser = props => { )}> {recipes.map(recipe => ( - - )} /> + + + K diff --git a/tgui/packages/tgui/interfaces/DecalPainter.js b/tgui/packages/tgui/interfaces/DecalPainter.js index 7709d41f495..3f9307667c6 100644 --- a/tgui/packages/tgui/interfaces/DecalPainter.js +++ b/tgui/packages/tgui/interfaces/DecalPainter.js @@ -1,6 +1,6 @@ import { Fragment } from 'inferno'; import { useBackend } from '../backend'; -import { AnimatedNumber, Box, Button, LabeledList, ProgressBar, Section, Tabs } from '../components'; +import { Button, Section } from '../components'; export const DecalPainter = props => { const { act, data } = useBackend(props); @@ -10,17 +10,15 @@ export const DecalPainter = props => { return (
- {decal_list.map(decal => { - return ( -
{color_list.map(color => { @@ -28,35 +26,35 @@ export const DecalPainter = props => {
- {dir_list.map(dir => { + {dir_list.map(dir => { return (
); diff --git a/tgui/packages/tgui/interfaces/DnaVault.js b/tgui/packages/tgui/interfaces/DnaVault.js index 76fc5da4a3f..a708194ef08 100644 --- a/tgui/packages/tgui/interfaces/DnaVault.js +++ b/tgui/packages/tgui/interfaces/DnaVault.js @@ -22,18 +22,21 @@ export const DnaVault = props => { + value={dna / dna_max}> + {dna + ' / ' + dna_max + ' Samples'} + + value={plants / plants_max}> + {plants + ' / ' + plants_max + ' Samples'} + + value={animals / animals}> + {animals + ' / ' + animals_max + ' Samples'} +
diff --git a/tgui/packages/tgui/interfaces/Gateway.js b/tgui/packages/tgui/interfaces/Gateway.js index e884cb5a1af..776dbb25ffe 100644 --- a/tgui/packages/tgui/interfaces/Gateway.js +++ b/tgui/packages/tgui/interfaces/Gateway.js @@ -22,8 +22,7 @@ export const Gateway = props => { ); } - if (current_target) - { + if (current_target) { return (
@@ -37,12 +36,15 @@ export const Gateway = props => { } if (!destinations.length) { - return (
No gateway nodes detected.
); + return ( +
+ No gateway nodes detected. +
+ ); } - const GatewayDest = dest => { - if (dest.availible) - { + const renderGatewayDest = dest => { + if (dest.availible) { return (
{ textAlign="center"> -
); - } - else - { - return ( -
- {dest.reason} - {!!dest.timeout && ()} -
); +
+ ); } + return ( +
+ + {dest.reason} + + {!!dest.timeout && ( + + Calibrating... + + )} +
+ ); }; return ( - {!gateway_status && (Gateway Unpowered)} - {destinations.map(GatewayDest)} - ); + {!gateway_status && ( + + Gateway Unpowered + + )} + {destinations.map(renderGatewayDest)} + + ); }; diff --git a/tgui/packages/tgui/interfaces/KitchenSink.js b/tgui/packages/tgui/interfaces/KitchenSink.js index d7455009faf..e450171feb8 100644 --- a/tgui/packages/tgui/interfaces/KitchenSink.js +++ b/tgui/packages/tgui/interfaces/KitchenSink.js @@ -1,6 +1,7 @@ import { Component } from 'inferno'; import { useBackend } from '../backend'; -import { BlockQuote, Box, Button, ByondUi, Collapsible, Input, LabeledList, NumberInput, ProgressBar, Section, Tabs, Tooltip } from '../components'; +import { BlockQuote, Box, Button, ByondUi, Collapsible, Input, LabeledList, NumberInput, ProgressBar, Section, Tabs, Tooltip, Slider, Icon, Knob } from '../components'; +import { DraggableControl } from '../components/DraggableControl'; const COLORS_ARBITRARY = [ 'red', @@ -47,7 +48,7 @@ const PAGES = [ component: () => KitchenSinkTooltip, }, { - title: 'Input', + title: 'Input / Slider', component: () => KitchenSinkInput, }, { @@ -123,8 +124,9 @@ const KitchenSinkButton = props => { + color={color}> + {color} + ))} @@ -134,14 +136,30 @@ const KitchenSinkButton = props => { const KitchenSinkBox = props => { return ( - - - - - - - - + + bold + + + italic + + + opacity 0.5 + + + opacity 0.25 + + + m: 2 + + + left + + + center + + + right + ); }; @@ -166,8 +184,9 @@ class KitchenSinkProgressBar extends Component { }} minValue={-1} maxValue={1} - value={progress} - content={`value: ${Number(progress).toFixed(1)}`} /> + value={progress}> + Value: {Number(progress).toFixed(1)} + }> - {data.capacityPercent >= 100 - ? 'Fully Charged' - : data.inputting - ? 'Charging' - : 'Not Charging'} + {capacityPercent >= 100 && 'Fully Charged' + || inputting && 'Charging' + || 'Not Charging'} - - - - }> - {data.outputting + {outputting ? 'Sending' - : data.charge > 0 + : charge > 0 ? 'Not Sending' : 'No Charge'} - - - -