tgui-next: Radio, ORM, Solar Tracker (#47537)

* Radio interface

* Ore redemption machine interface

* Remove candystripe because it looks like shit

* Solar tracker interface

* Add more channels, add full color support to Box

* Cleanup README

* conversion guide draft 1

* Update documentation

* Implement an Input component, update component reference
This commit is contained in:
Aleksej Komarov
2019-11-05 02:26:57 -08:00
committed by Rob Bailey
parent 44b76a4112
commit 8cf0a9db2c
30 changed files with 1434 additions and 153 deletions
@@ -111,7 +111,14 @@
. = ..()
ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
if(!ui)
ui = new(user, src, ui_key, "radio", name, 370, 220 + channels.len * 22, master_ui, state)
var/ui_width = 360
var/ui_height = 106
if(subspace_transmission)
if (channels.len > 0)
ui_height += 6 + channels.len * 21
else
ui_height += 24
ui = new(user, src, ui_key, "radio", name, ui_width, ui_height, master_ui, state)
ui.open()
/obj/item/radio/ui_data(mob/user)
@@ -237,6 +237,7 @@
data["disconnected"] = "mineral withdrawal is on hold"
data["diskDesigns"] = list()
data["hasDisk"] = FALSE
if(inserted_disk)
data["hasDisk"] = TRUE
if(inserted_disk.blueprints.len)
+41 -35
View File
@@ -12,8 +12,6 @@
active_power_usage = 0
max_integrity = 150
integrity_failure = 0.33
ui_x = 500
ui_y = 400
var/id = 0
var/obscured = 0
@@ -347,7 +345,7 @@
datum/tgui/master_ui = null, datum/ui_state/state = GLOB.default_state)
ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open)
if(!ui)
ui = new(user, src, ui_key, "solar_control", name, ui_x, ui_y, master_ui, state)
ui = new(user, src, ui_key, "solar_control", name, 380, 230, master_ui, state)
ui.open()
/obj/machinery/power/solar_control/ui_data()
@@ -368,39 +366,47 @@
/obj/machinery/power/solar_control/ui_act(action, params)
if(..())
return
switch(action)
if("angle")
var/adjust = text2num(params["adjust"])
if(adjust)
currentdir = CLAMP((360 + adjust + currentdir) % 360, 0, 359)
targetdir = currentdir
set_panels(currentdir)
. = TRUE
if("rate")
var/adjust = text2num(params["adjust"])
if(adjust)
trackrate = CLAMP(trackrate + adjust, -7200, 7200)
if(trackrate)
nexttime = world.time + 36000 / abs(trackrate)
. = TRUE
if("tracking")
var/mode = text2num(params["mode"])
track = mode
if(mode == 2 && connected_tracker)
connected_tracker.set_angle(SSsun.angle)
set_panels(currentdir)
else if(mode == 1)
targetdir = currentdir
if(trackrate)
nexttime = world.time + 36000 / abs(trackrate)
set_panels(targetdir)
. = TRUE
if("refresh")
search_for_connected()
if(connected_tracker && track == 2)
connected_tracker.set_angle(SSsun.angle)
if(action == "angle")
var/adjust = text2num(params["adjust"])
var/value = text2num(params["value"])
if(adjust)
value = currentdir + adjust
if(value != null)
currentdir = CLAMP((360 + value) % 360, 0, 359)
targetdir = currentdir
set_panels(currentdir)
. = TRUE
return TRUE
return FALSE
if(action == "rate")
var/adjust = text2num(params["adjust"])
var/value = text2num(params["value"])
if(adjust)
value = trackrate + adjust
if(value != null)
trackrate = CLAMP(value, -7200, 7200)
if(trackrate)
nexttime = world.time + 36000 / abs(trackrate)
return TRUE
return FALSE
if(action == "tracking")
var/mode = text2num(params["mode"])
track = mode
if(mode == 2 && connected_tracker)
connected_tracker.set_angle(SSsun.angle)
set_panels(currentdir)
else if(mode == 1)
targetdir = currentdir
if(trackrate)
nexttime = world.time + 36000 / abs(trackrate)
set_panels(targetdir)
return TRUE
if(action == "refresh")
search_for_connected()
if(connected_tracker && track == 2)
connected_tracker.set_angle(SSsun.angle)
set_panels(currentdir)
return TRUE
return FALSE
/obj/machinery/power/solar_control/attackby(obj/item/I, mob/user, params)
if(I.tool_behaviour == TOOL_SCREWDRIVER)
+1 -1
View File
@@ -234,7 +234,7 @@ rules:
## Enforce boolean attributes notation in JSX (fixable)
react/jsx-boolean-value: error
## Enforce or disallow spaces inside of curly braces in JSX attributes and expressions.
react/jsx-child-element-spacing: error
# react/jsx-child-element-spacing: error
## Validate closing bracket location in JSX (fixable)
## NOTE: Too fucking annoying because all styles are viable
# react/jsx-closing-bracket-location: [error, {
+210 -37
View File
@@ -1,37 +1,38 @@
# tgui-next
# tgui
## Introduction
tgui is a robust user interface framework of /tg/station. It is rendered
completely in the browser, based on JSON data sent from the server.
This data flow is always unidirectional, and the only way to make changes
to the game state is to dispatch actions which are processed on the server,
in a similar method to native BYOND Topic(). Once the action is processed,
an updated JSON is sent.
tgui is a robust user interface framework of /tg/station.
tgui is very different from most UIs you will encounter in BYOND programming,
and is heavily reliant of Javascript and web technologies as opposed to DM.
However, if you are familiar with NanoUI (a library which can be found on almost
tgui is very different from most UIs you will encounter in BYOND programming.
It is heavily reliant on Javascript and web technologies as opposed to DM.
If you are familiar with NanoUI (a library which can be found on almost
every other SS13 codebase), tgui should be fairly easy to pick up.
tgui is a fork of an older tgui (based on Ractive), which is a fork of NanoUI.
The server-side code (DM) is similar and derived from NanoUI, while the
clientside is a wholly new project with no code in common.
## Learn tgui
To get a clearer picture how to create a completely new interface from scratch,
please refer to this [tutorial document](docs/tutorial-and-examples.md).
If you don't know how tgui backend works, or have very little knowledge about
both frontend and backend, or simply want a step by step instruction,
we recommend you first read the document linked above.
People come to tgui from different backgrounds and with different
learning styles. Whether you prefer a more theoretical or a practical
approach, we hope youll find this section helpful.
### 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).
### Guides
This project uses **Inferno** - a very fast UI rendering engine with a similar
API to React. If you are new to Inferno or React, take your time to read
these documents:
API to React. Take your time to read these guides:
- [React guide](https://reactjs.org/docs/hello-world.html)
- [Inferno documentation](https://infernojs.org/docs/guides/components) -
highlights differences with React.
If you were already familiar with an older, Ractive-based tgui, and want
to translate concepts between old and new tgui, read this
[interface conversion guide](docs/converting-old-tgui-interfaces.md).
## Pre-requisites
You will need these programs to start developing in tgui:
@@ -51,12 +52,19 @@ You will need these programs to start developing in tgui:
**For MSys2, Git Bash, WSL, Linux or macOS users:**
First and foremost, run `bin/tgui --install-git-hooks` to install merge
drivers which will help you resolve conflicts when rebasing your branches.
First and foremost, change your directory to `tgui-next`.
Run `bin/tgui --install-git-hooks` (optional) to install merge drivers
which will assist you in conflict resolution when rebasing your branches.
Run one of the following:
- `bin/tgui` - build the project in production mode.
- `bin/tgui --dev` - launch a development server, with live log
collection, byond cache reloading and hot module replacement.
- `bin/tgui --dev` - launch a development server.
- tgui development server provides you with incremental compilation,
hot module replacement and logging facilities in all running instances
of tgui. In short, this means that you will instantly see changes in the
game as you code it. Very useful, highly recommended.
- `bin/tgui --dev --reload` - reload byond cache once.
- `bin/tgui --dev --debug` - run server with debug logging enabled.
- `bin/tgui --dev --no-hot` - disable hot module replacement (helps when
@@ -74,20 +82,19 @@ If you haven't opened the console already, you can do that by holding
Shift and right clicking on the `tgui-next` folder, then pressing
either `Open command window here` or `Open PowerShell window here`.
Run `yarn install`, then:
Run `yarn install` to install npm dependencies, then one of the following:
- `yarn run build` - build the project in production mode.
- `yarn run watch` - launch a development server, with live log
collection, byond cache reloading and hot module replacement.
- `yarn run watch` - launch a development server.
- `yarn run lint` - show problems with the code.
- `yarn run lint --fix` - auto-fix problems with the code.
- `yarn run analyze` - run a bundle analyzer.
We also got some batch files in store, which are very simple to use:
We also got some batch files in store, for those who don't like fiddling
with the console:
- `bin/tgui-build.bat` - build the project in production mode.
- `bin/tgui-dev-server.bat` - launch a development server, with live log
collection, cache reloading and hot module replacement.
- `bin/tgui-dev-server.bat` - launch a development server.
> Remember to always run a full build before submitting a PR. It creates
> a compressed javascript bundle which is then referenced from DM code.
@@ -170,6 +177,19 @@ animate starting from `0`, and if omitted, it will not play an initial
animation.
- `format: function` - Output formatter.
- Example: `value => Math.round(value)`.
- `children: function` - Pull a raw number to animate more complex things
deeper in the DOM tree.
- Example: `value => <Icon rotation={value} />`
### `BlockQuote`
Just a block quote, just like this example in markdown:
> Here's an example of a block quote.
Props:
- See inherited props: [Box](#box)
### `Box`
@@ -241,10 +261,15 @@ all available horizontal space.
- `relative` - Relative positioning.
- `absolute` - Absolute positioning.
- `fixed` - Fixed positioning.
- `top: number` - Vertical position of a positioned element.
- `bottom: number` - Vertical position of a positioned element.
- `left: number` - Horizontal position of a positioned element.
- `right: number` - Horizontal position of a positioned element.
- `color: string` - An alias to `textColor`.
- `textColor: string` - Sets text color.
- `#ffffff` - Hex format
- `rgba(255, 255, 255, 1)` - RGB format
- `purple` - Applies an atomic `color-<name>` class to the element.
See `styles/color-map.scss`.
- `backgroundColor: string` - Sets background color.
- `#ffffff` - Hex format
- `rgba(255, 255, 255, 1)` - RGB format
### `Button`
@@ -253,7 +278,7 @@ Buttons allow users to take actions, and make choices, with a single click.
Props:
- See inherited props: [Box](#box)
- `fluid: boolean` - Tells the button to fill all available horizontal space.
- `fluid: boolean` - Fill all available horizontal space.
- `icon: string` - Adds an icon to the button.
- `color: string` - Button color, as defined in `variables.scss`.
- There is also a special color `transparent` - makes the button
@@ -359,6 +384,36 @@ remaining space is distributed. It can be a length (e.g. `20%`, `5rem`, etc.),
an `auto` or `content` keyword.
- `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`
Helps you to divide horizontal space into two or more equal sections.
It is essentially a single-row `Table`, but with some extra features.
Example:
```jsx
<Grid>
<Grid.Column>
<Section title="Section 1" content="Hello world!" />
</Grid.Column>
<Grid.Column size={2}>
<Section title="Section 2" content="Hello world!" />
</Grid.Column>
</Grid>
```
Props:
- See inherited props: [Table](#table)
### `Grid.Column`
Props:
- See inherited props: [Table.Cell](#table-cell)
- `size: number` (default: 1) - Size of the column relative to other columns.
### `Icon`
Renders one of the FontAwesome icons of your choice.
@@ -378,6 +433,25 @@ Props:
- `name: string` - Icon name.
- `size: number` - Icon size. `1` is normal size, `2` is two times bigger.
Fractional numbers are supported.
- `rotation: number` - Icon rotation, in degrees.
- `spin: boolean` - Whether an icon should be spinning. Good for load
indicators.
### `Input`
A basic text input, which allow users to enter text into a UI.
> Input does not support custom font size and height due to the way
> it's implemented in CSS. Eventually, this needs to be fixed.
Props:
- See inherited props: [Box](#box)
- `value: string` - Value of an input.
- `fluid: boolean` - Fill all available horizontal space.
- `onChange: (e, value) => void` - An event, which fires when you commit
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.
### `LabeledList`
@@ -440,6 +514,42 @@ Props:
- `size: number` - Size of the divider.
### `NoticeBox`
A notice box, which warns you about something very important.
Props:
- See inherited props: [Box](#box)
### `NumberInput`
A fancy, interactive number input, which you can either drag up and down
to fine tune the value, or single click it to manually type a number.
Props:
- `animated: boolean` - Animates the value if it was changed externally.
- `fluid: boolean` - Fill all available horizontal space.
- `value: number` - Value itself.
- `unit: string` - Unit to display to the right of value.
- `minValue: number` - Lowest possible value.
- `maxValue: number` - Highest possible value.
- `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`.
- `width: string|number` - Width of the element, in `Box` units or pixels.
- `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.
### `ProgressBar`
Progress indicators inform users about the status of ongoing processes.
@@ -448,8 +558,27 @@ Progress indicators inform users about the status of ongoing processes.
<ProgressBar value={0.6} />
```
- `value: number` - Current progress as a floating point number,
from 0 to 1. Determines how filled the bar is.
Usage of `ranges` prop:
```jsx
<ProgressBar
ranges={{
good: [0.5, Infinity],
average: [0.25, 0.5],
bad: [-Infinity, 0.25],
}}
value={0.6} />
```
Props:
- `value: number` - Current progress as a floating point number between
`minValue` (default: 0) and `maxValue` (default: 1). Determines the
percentage and how filled the bar is.
- `minValue: number` - Lowest possible value.
- `maxValue: number` - Highest possible value.
- `ranges: { color: [from, to] }` - Applies a `color` to the progress bar
based on whether the value lands in the range between `from` and `to`.
- `color: string` - Color of the progress bar.
- `content/children: any` - Content to render inside the progress bar.
@@ -489,6 +618,50 @@ 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.
### `Table`
A straight forward mapping to a standard html table, which is slightly
simplified (does not need a `<tbody>` tag) and with sane default styles
(e.g. table width is 100% by default).
Example:
```jsx
<Table>
<Table.Row>
<Table.Cell bold>
Hello world!
</Table.Cell>
<Table.Cell collapsing color="label">
Label
</Table.Cell>
</Table.Row>
</Table>
```
Props:
- See inherited props: [Box](#box)
- `collapsing: boolean` - Collapses table to the smallest possible size.
### `Table.Row`
A straight forward mapping to `<tr>` element.
Props:
- See inherited props: [Box](#box)
### `Table.Cell`
A straight forward mapping to `<td>` element.
Props:
- See inherited props: [Box](#box)
- `collapsing: boolean` - Collapses table cell to the smallest possible size,
and stops any text inside from wrapping.
### `Tabs`
Tabs make it easy to explore and switch between different views.
@@ -0,0 +1,322 @@
# Converting old tgui interfaces to tgui-next
This guide is going to assume you already know roughly how tgui-next works, how to make new uis, etc. It's mostly aimed at helping translate concepts between tgui and tgui-next, and clarify some confusing parts of the transition.
## Backend
Backend in almost every case does not require any changes. In particularly heavy ui cases, something to be aware of is the new `ui_static_data()` proc. This proc allows you to split some data sent to the interface off into data that will only be sent on ui initialize and when manually updated by elsewhere in the code. Useful for things like cargo where you have a very large set of mostly identical code.
Keep in mind that for uis where *all* data doesn't need to be live updating, you can just toggle off autoupdate for the ui instead of messing with static data.
## Frontend
The very first thing to note is the name of the `ract` file containing the old interface. Whatever the name is (minus the extension) is going to be what the route key is going to be.
One thing I like to do before starting work on a conversion is screenshot what the old interface looks like so I have something to reference to make sure that the styling can line up as well.
## General syntax changes
Ractive has a fairly different templating syntax from React.
### `data`
You likely already know that React data inserts look like this
```jsx
{data.example_data}
```
Ractive looks very similar, the only real difference is that React uses one paranthesis instead of two.
```ractive
{{data.example_data}}
```
However, you may occasionally come across data inserts that instead of referencing the `data` var or things contained within it instead reference `adata`. `adata` was short for animated data, and was used for smooth number animations in interfaces. instead of having a seperate data structure for this. tgui-next instead uses a component, which is `AnimatedNumber`.
`AnimatedNumber` is used like this
```jsx
<AnimatedNumber value={data.example_data}/>
```
Make sure you don't forget to import it.
### Conditionals
Ractive conditionals look very different from React conditionals.
A ractive `if` (only render if result of expression is true) looks like this
```ractive
{{#if data.condition}}
<span>Example Render</span>
{{/if}}
```
The equivalent React would be
```jsx
{!!data.condition && (
<Fragment>Example Render</Fragment>
)}
```
This might look a bit intimidating compared to the reactive part but it's not as complicated as it seems:
1. A new jsx context is opened with `{}`
2. jsx contexts like this always render whatever the return value is, so we can use `&&` to return a value we want. `&&` returns the last true value (or not "falsey" because this is js).
3. jsx tags are never "falsey", so a conditioned paired with a jsx tag will mean the condition being true will continue on and return the tag. `()` is just used to contain the tag
4. The `!!` is not a special operator, it is a literal double negation. This is because most `false` values coming from byond are going to actually be `0`, which would be rendered if the condition is false. Negating `0` returns `true`, negating `true` returns `false`, which isn't rendered.
5. `Fragment` is actually a true "dead tag". It's similar to `span` in that it just contains things without providing functionality, but it's unwrapped before the final render and children of it are injected into its parent. In a case where you only need to render text without any styling, it's probably better to just return a string literal (`"Example Render"`), but this was just to illustrate that you can put any tag in this expression.
You don't really need to know all this to understand how to use it, but I find it helps with understanding when things go wrong.
Ractive conditionals can have an `else` as well
```ractive
{{#if data.condition}}
value
{{else}}
other value
{{/if}}
```
Similarly to the previous example, just add a `||` operator to handle the
"falsy" condition:
```jsx
{!!data.condition && (
<Fragment>value</Fragment>
) || (
<Fragment>other value</Fragment>
)}
```
There's also our good old friend - the ternary:
```jsx
{data.condition ? 'value' : 'other value'}
```
Keep in mind you can also use tags here like the conditional example,
and you can mix string literals, values, and tags as well.
```jsx
{data.is_robot ? (
<Button content="Robot Button"/>
) : 'Not a robot'}
```
### Loops
Ractive has loops for iterating over data and inserting something for each
member of an array or object
```
{{#each data.list_of_foo}}
foo {{number}} is here.
{{/each}}
```
This didn't care whether the data was an array or an object, and members of each entry of the loop were "unwrapped" so to say. `{{number}}` in that example is referring to the `{{number}}` value on the entry of the list for that iterate.
The React equivalent to this is going to be `map`.
_AN IMPORTANT DISTINCTION HERE IS THAT NOW WE CARE WHETHER THIS IS AN OBJECT OR AN ARRAY BEING ACTED ON._
Objects are represented by `{}`, arrays by `[]`
"How can I tell?" you may ask. It's fairly simple, associated lists on the byond side are going to be turned into objects when they get json converted, normal lists are going to be turned into arrays.
`list("bla", "blo")` would become `["bla", "blo"]` and `list("foo" = 1, "bar" = 2)` would become `{"foo": 1, "bar": 2}`
First things first, above the `return` of the function you're making the interface in, you're going to want to add something like this
```jsx
const things = data.things || [];
```
This ensures that you'll never be reading a null entry by mistake. Substitute `{}` for objects as appropriate.
If it's an array, you'll want to do this in the template
```jsx
{things.map(thing => (
<Fragment>Thing {thing.number} is here!</Fragment>
))}
```
`map` is a function that calls a passed function (a lambda) on each entry, and returns the value. You should already know that returned tags and values (except `false`) get rendered, so that's how it's rendering each time.
A lambda is what's known as an anonymous function, it's a function that doesn't have a name that's only used for a specific usage. `map` wants a function that has one parameter, so we define one parameter then use `=>` to say the parameter has to do with the following block.
`parameter => ()` is just a shorthand for `parameter => {return();}`
This is quite a bit higher concept than ractive's each statements, so feel free to look around and ~~copy paste~~ learn from how other interfaces use this.
Now for objects, there's a genuinely pretty gross syntax here. We apoligize, it's related to ie8 compatibility nonsense.
```jsx
{map((value, key) => {
return (
<Fragment>Key is {key}, value is {value}</Fragment>
);
})(fooObject)}
```
Again, sorry for this syntax. `fooObject` would be the object being iterated on, value would be the value of the iterated entry on the list, and key would be the key. the naming of value and key isn't important here, but knowing that it goes `value`, `key` in that order is important.
It is sometimes better to preemptively convert an object to array before
the big return statement, like this:
```jsx
const fooArray = map((value, key) => {
return { key, value };
})(fooObject);
```
Or if you just want to discard all keys, this will also work nicely:
```jsx
const fooArray = toArray(fooObject);
```
Also occasionally you'd see an else:
```
{{#each data.potentially_empty_list}}
Thing "{{name}}" is in this list!
{{else}}
None found!
{{/each}}
```
This would iterate using the first contents each time, or display the second option if the list was empty.
To do a similar thing in JSX, just check if array is empty like this:
```jsx
{fooArray.length === 0 && 'fooArray is empty.'}
{fooArray.map(foo => (
<Fragment>Foo is {foo}</Fragment>
))}
```
### Extra Stuff
I'll put some extra stuff here when I think of it.
## Components
This will be a reference of tgui components and the tgui-next equivalent.
### `ui-display`
Equivalent of `<ui-display>` is `<Section>`
```
<ui-display title="Status">
Contents
</ui-display>
```
becomes
```jsx
<Section title="Status">
Contents
</Section>
```
A feature sometimes used is if `ui-display` has the `button` property, it will contain a `partial` command. This becomes the `buttons` property on `Section`:
```
<ui-display title="Status" button>
{{#partial button}}
<ui-button /> // lots more button bullshit here
{{/partial}}
Contents
</ui-display>
```
becomes
```jsx
<Section
title="Status"
buttons={(
<Button />
)}>
Contents
</Section>
```
### `ui-section`
Very important to note `ui-section` is NOT the equivalent of `Section`
`<ui-section>` does not have a direct equivalent, but the closest equivalent is `<LabeledList>`
```
<ui-section label="power">
No Power
</ui-section>
<ui-section label="connection">
No Connection
</ui-section>
```
becomes
```jsx
<LabeledList>
<LabeledList.Item label="power">
No Power
</LabeledList.Item>
<LabeledList.Item label="connection">
No Connection
</LabeledList.Item>
</LabeledList>
```
Important to note that `LabeledList.Item` has `buttons` as well.
Also good to know that if you need the contents of a `LabeledList.Item` to be colored, you can just set the `color` prop on it instead of putting a `span` inside it.
### `ui-notice`
`<ui-notice>` has a direct equivalent in `<NoticeBox>`
```
<ui-notice>
Notice stuff!
</ui-notice>
```
becomes
```jsx
<NoticeBox>
Notice stuff!
</NoticeBox>
```
### `ui-button`
The equivalent of `ui-button` is `Button` but it works quite a bit differently.
```
<ui-button
state='{{data.condition ? "disabled" : null}}'
action="ui_action"
params={param: value}>
Click
</ui-button>
```
becomes
```
<Button
content="Click"
disabled={data.condition}
onClick={() => act(ref, "ui_action", {param: value})}/>
```
+46 -37
View File
@@ -1,22 +1,25 @@
# TGUI Backend Documentation
# Tutorial and Examples
## Main concepts
Basic tgui backend code consists of defining a few procs. In these procs
you will handle a request to open or update a UI (typically by updating a UI
if it exists or setting up and opening it if it does not), a request for data,
in which you build a list to be passed as JSON to the UI, and an action
handler, which handles any user input.
Basic tgui backend code consists of the following vars and procs:
- The atom, which UI corresponds to in the game world, is in most cases
known as the `src_object`.
- Frontend data is built in `ui_data` proc, which munges whatever complex
data your `src_object` has into a list.
- The action/topic handler, `ui_act`, is what recieves input from the user
and acts on it.
- The request/update proc, `ui_interact` is where you open your UI and set
options like title, size, autoupdate, theme, and more.
- Finally, `ui_state` (set in `ui_interact`) dictates under what conditions
```
ui_interact(mob/user, ui_key, datum/tgui/ui, force_open,
datum/tgui/master_ui, datum/ui_state/state)
ui_data(mob/user)
ui_act(action, params)
```
- `src_object` - The atom, which UI corresponds to in the game world.
- `ui_interact` - The proc where you will handle a request to open an
interface. Typically, you would update an existing UI (if it exists),
or set up a new instance of UI by calling the `SStgui` subsystem.
- `ui_data` - In this proc you munges whatever complex data your `src_object`
has into an associative list, which will then be sent to UI as a JSON string.
- `ui_act` - This proc receives user actions and reacts to them by changing
the state of the game.
- `ui_state` (set in `ui_interact`) - This var dictates under what conditions
a UI may be interacted with. This may be the standard checks that check if
you are in range and conscious, or more.
@@ -115,20 +118,14 @@ This object contains a few special values:
- `config` is always the same and is part of core tgui
(it will be explained later),
- `data` is the data returned from `ui_data`
- `adata` is the same, but with certain values (numbers at this time)
interpolated in order to allow animation.
```jsx
import { Section, LabeledList } from '../components';
const SampleInterface = props => {
// Extract state from props
const { state } = props;
// Extract config and data from the state
const { config, data } = state;
// Extract window reference (will be used later for dispatching actions)
const { ref } = config;
// Return the Virtual DOM
return (
<Section title="Health status">
<LabeledList>
@@ -145,11 +142,10 @@ const SampleInterface = props => {
```
This syntax can be very confusing at first, but it is very important to
realize that this is just a natural extension of javascript. This syntax
simply creates a Virtual DOM object, which you can treat as any other
object in javascript. Here are some control flow examples:
realize that this is just a natural extension of javascript. Here's a few
examples of this syntax:
Returning different elements based on a condition:
Return a different element based on a condition:
```jsx
if (condition) {
@@ -158,7 +154,7 @@ if (condition) {
return <Bar />;
```
Conditionally rendering a element inside of another element:
Conditionally render a element inside of another element:
```jsx
<Box>
@@ -168,7 +164,7 @@ Conditionally rendering a element inside of another element:
</Box>
```
Looping over the array to make element for each item:
Looping over the array to make an element for each item:
```jsx
<LabeledList>
@@ -180,6 +176,24 @@ Looping over the array to make element for each item:
</LabeledList>
```
### Routing table
Once you finished creating your interface, you need to add a route entry to
the large `ROUTES` object, otherwise tgui won't know when and how to render
your interface. Key of this `ROUTES` object corresponds to the interface
name you use in DM code.
```js
import { SampleInterface } from './interfaces/SampleInterface';
const ROUTES = {
sample_interface: {
component: () => SampleInterface,
scrollable: true,
},
};
```
## Copypasta
We all do it, even the best of us. If you just want to make a tgui **fast**,
@@ -196,17 +210,16 @@ upon code review):
/obj/copypasta/ui_data(mob/user)
var/list/data = list()
data["var"] = var
return data
/obj/copypasta/ui_act(action, params)
if(..())
return
switch(action)
if("copypasta")
var/newvar = params["var"]
var = Clamp(newvar, min_val, max_val) // Just a demo of proper input sanitation.
. = TRUE
if(action == "copypasta")
var/newvar = params["var"]
// A demo of proper input sanitation.
var = CLAMP(newvar, min_val, max_val)
return TRUE
update_icon() // Not applicable to all objects.
```
@@ -216,13 +229,9 @@ And the template:
import { Section, LabeledList } from '../components';
const SampleInterface = props => {
// Extract state from props
const { state } = props;
// Extract config and data from the state
const { config, data } = state;
// Extract window reference (will be used later for dispatching actions)
const { ref } = config;
// Return the UI
return (
<Section title="Section name">
<LabeledList>
@@ -63,6 +63,9 @@ export const retrace = stack => {
.map(frame => {
// Stringify the frame
const { file, methodName, lineNumber } = frame;
if (!file) {
return ` at ${methodName}`;
}
const compactPath = file
.replace(/^webpack:\/\/\/?/, './')
.replace(/.*node_modules\//, '');
@@ -0,0 +1,15 @@
import { Box } from './Box';
export const BlockQuote = props => {
const { style, ...rest } = props;
return (
<Box
style={{
'color': 'rgba(255, 255, 255, 0.5)',
'border-left': '2px solid rgba(255, 255, 255, 0.5)',
'padding-left': '6px',
...style,
}}
{...rest} />
);
};
+16 -2
View File
@@ -16,6 +16,10 @@ export const unit = value => {
}
};
const isColorCode = str => typeof str === 'string' && (
str.startsWith('#') || str.startsWith('rgb')
);
const mapRawPropTo = attrName => (style, value) => {
if (!isFalsy(value)) {
style[attrName] = value;
@@ -42,6 +46,12 @@ const mapDirectionalUnitPropTo = (attrName, dirs) => (style, value) => {
}
};
const mapColorPropTo = attrName => (style, value) => {
if (isColorCode(value)) {
style[attrName] = value;
}
};
const styleMapperByPropName = {
// Direct mapping
position: mapRawPropTo('position'),
@@ -67,6 +77,10 @@ const styleMapperByPropName = {
mb: mapUnitPropTo('margin-bottom'),
ml: mapUnitPropTo('margin-left'),
mr: mapUnitPropTo('margin-right'),
// Color props
color: mapColorPropTo('color'),
textColor: mapColorPropTo('color'),
backgroundColor: mapColorPropTo('background-color'),
};
export const computeBoxProps = props => {
@@ -103,11 +117,11 @@ export const Box = props => {
const {
as = 'div',
className,
color,
content,
children,
...rest
} = props;
const color = props.textColor || props.color;
// Render props
if (typeof children === 'function') {
return children(computeBoxProps(props));
@@ -119,7 +133,7 @@ export const Box = props => {
as,
classes([
className,
color && 'color-' + color,
color && !isColorCode(color) && 'color-' + color,
]),
content || children,
ChildFlags.UnknownChildren,
+6 -5
View File
@@ -51,18 +51,19 @@ export const Button = props => {
tabIndex={!disabled && '0'}
unselectable={tridentVersion <= 4}
onclick={e => {
if (disabled || !onClick) {
return;
}
refocusLayout();
onClick(e);
if (!disabled && onClick) {
onClick(e);
}
}}
onKeyDown={e => {
const keyCode = window.event ? e.which : e.keyCode;
// Simulate a click when pressing space or enter.
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
e.preventDefault();
onClick(e);
if (!disabled && onClick) {
onClick(e);
}
return;
}
// Refocus layout on pressing escape.
@@ -5,16 +5,16 @@
rgba(0, 0, 0, 1),
rgba(255, 255, 255, 1));
transition: color, background-color 50ms;
transition: color 50ms, background-color 50ms;
background-color: $color;
color: $text-color;
&:hover {
transition: color, background-color 0ms;
transition: color 0ms, background-color 0ms;
}
&:focus {
transition: color, background-color 100ms;
transition: color 100ms, background-color 100ms;
}
&:hover,
+9 -3
View File
@@ -14,12 +14,18 @@ export const Grid = props => {
Grid.defaultHooks = pureComponentHooks;
export const GridItem = props => {
export const GridColumn = props => {
const { size = 1, style, ...rest } = props;
return (
<Table.Cell {...props} />
<Table.Cell
style={{
width: size + '%',
...style,
}}
{...rest} />
);
};
Grid.defaultHooks = pureComponentHooks;
Grid.Item = GridItem;
Grid.Column = GridColumn;
+109
View File
@@ -0,0 +1,109 @@
import { classes, pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
/* eslint-disable react/destructuring-assignment */
export class Input extends Component {
constructor() {
super();
this.inputRef = createRef();
this.state = {
editing: false,
};
}
componentDidMount() {
const nextValue = this.props.value;
const input = this.inputRef.current;
if (input) {
input.value = nextValue;
}
}
componentDidUpdate(prevProps, prevState) {
const { editing } = this.state;
const prevValue = prevProps.value;
const nextValue = this.props.value;
const input = this.inputRef.current;
if (input && !editing && prevValue !== nextValue) {
input.value = nextValue;
}
}
setEditing(editing) {
this.setState({ editing });
}
render() {
const { props } = this;
// Input only props
const {
onInput,
onChange,
value,
...boxProps
} = props;
// Box props
const {
className,
fluid,
...rest
} = boxProps;
return (
<Box
className={classes([
'Input',
fluid && 'Input--fluid',
className,
])}
{...rest}>
<div className="Input__baseline">
.
</div>
<input
ref={this.inputRef}
type="text"
className="Input__input"
onInput={e => {
this.setEditing(true);
if (onInput) {
onInput(e, e.target.value);
}
}}
onFocus={e => {
this.setEditing(true);
}}
onBlur={e => {
const { editing } = this.state;
if (editing) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
}
}}
onKeyDown={e => {
if (e.keyCode === 13) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
if (onInput) {
onInput(e, e.target.value);
}
e.target.blur();
return;
}
if (e.keyCode === 27) {
this.setEditing(false);
e.target.value = props.value;
e.target.blur();
return;
}
}} />
</Box>
);
}
}
Input.defaultHooks = pureComponentHooks;
@@ -0,0 +1,46 @@
.Input {
position: relative;
display: inline-block;
width: 120px;
border: 1px solid $input-base-color;
border: 1px solid rgba($input-base-color, 0.75);
border-radius: 2px;
color: #fff;
background-color: #000;
background-color: rgba(0, 0, 0, 0.75);
padding: 0 4px;
margin-right: 2px;
line-height: 17px;
overflow: visible;
}
.Input--fluid {
display: block;
width: auto;
}
.Input__baseline {
display: inline-block;
color: transparent;
}
.Input__input {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border: 0;
outline: 0;
width: 100%;
font-size: 12px;
line-height: 17px;
height: 17px;
margin: 0;
padding: 0 6px;
font-family: Verdana, sans-serif;
background-color: transparent;
color: #fff;
color: inherit;
}
@@ -1,5 +1,5 @@
import { clamp } from 'common/math';
import { pureComponentHooks } from 'common/react';
import { classes, pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { tridentVersion } from '../byond';
import { AnimatedNumber } from './AnimatedNumber';
@@ -20,13 +20,15 @@ export class NumberInput extends Component {
};
// 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,
});
setTimeout(() => this.setState({
clearTimeout(this.flickerTimer);
this.flickerTimer = setTimeout(() => this.setState({
suppressingFlicker: false,
}), suppressFlicker);
}
@@ -68,6 +70,9 @@ export class NumberInput extends Component {
const state = { ...prevState };
const offset = state.origin - e.screenY;
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(
@@ -75,7 +80,9 @@ export class NumberInput extends Component {
minValue - step, maxValue + step);
// Clamp the final value
state.value = clamp(
state.internalValue - state.internalValue % step,
state.internalValue
- state.internalValue % step
+ stepOffset,
minValue, maxValue);
state.origin = e.screenY;
}
@@ -125,6 +132,8 @@ export class NumberInput extends Component {
suppressingFlicker,
} = this.state;
const {
className,
fluid,
animated,
value,
unit,
@@ -157,7 +166,11 @@ export class NumberInput extends Component {
));
return (
<Box
className="NumberInput"
className={classes([
'NumberInput',
fluid && 'NumberInput--fluid',
className,
])}
minWidth={width}
onMouseDown={this.handleDragStart}>
<div className="NumberInput__barContainer">
@@ -172,7 +185,7 @@ export class NumberInput extends Component {
{contentElement}
<input
ref={this.inputRef}
className="NumberInput__editable"
className="NumberInput__input"
style={{
display: !editing ? 'none' : undefined,
}}
@@ -1,5 +1,3 @@
$input-base-color: #88bfff;
.NumberInput {
position: relative;
display: inline-block;
@@ -16,6 +14,10 @@ $input-base-color: #88bfff;
overflow: visible;
}
.NumberInput--fluid {
display: block;
}
.NumberInput__content {
margin-left: 6px;
}
@@ -37,7 +39,7 @@ $input-base-color: #88bfff;
background-color: $input-base-color;
}
.NumberInput__editable {
.NumberInput__input {
display: block;
position: absolute;
top: 0;
@@ -49,6 +51,7 @@ $input-base-color: #88bfff;
width: 100%;
font-size: 12px;
line-height: 17px;
height: 17px;
margin: 0;
padding: 0 6px;
font-family: Verdana, sans-serif;
@@ -2,8 +2,27 @@ import { classes, pureComponentHooks } from 'common/react';
import { clamp, toFixed } from 'common/math';
export const ProgressBar = props => {
const { value, content, color, children } = props;
const {
value,
minValue = 0,
maxValue = 1,
ranges = {},
content,
children,
} = props;
let { color } = props;
const scaledValue = (value - minValue) / (maxValue - minValue);
const hasContent = content !== undefined || children !== undefined;
if (!color) {
// Cycle through ranges in key order to determine progressbar color.
for (let rangeName of Object.keys(ranges)) {
const range = ranges[rangeName];
if (range && value >= range[0] && value <= range[1]) {
color = rangeName;
break;
}
}
}
return (
<div
className={classes([
@@ -13,12 +32,12 @@ export const ProgressBar = props => {
<div
className="ProgressBar__fill"
style={{
'width': (clamp(value, 0, 1) * 100) + '%',
'width': (clamp(scaledValue, 0, 1) * 100) + '%',
}} />
<div className="ProgressBar__content">
{hasContent && content}
{hasContent && children}
{!hasContent && toFixed(value * 100) + '%'}
{!hasContent && toFixed(scaledValue * 100) + '%'}
</div>
</div>
);
@@ -13,7 +13,7 @@
top: 0;
left: 0;
bottom: 0;
transition: background-color, width 500ms;
transition: background-color 500ms, width 500ms;
background-color: $bar-color-normal;
}
@@ -9,12 +9,12 @@
.TitleBar__clickable {
color: rgba(255, 255, 255, 0.5);
background-color: darken($titlebar-color-background, 0%);
transition: color, background-color 250ms;
transition: color 250ms, background-color 250ms;
&:hover {
color: rgba(255, 255, 255, 1.0);
background-color: #c00;
transition: color, background-color 0ms;
transition: color 0ms, background-color 0ms;
}
}
@@ -1,9 +1,11 @@
export { AnimatedNumber } from './AnimatedNumber';
export { BlockQuote } from './BlockQuote';
export { Box } from './Box';
export { Button } from './Button';
export { Flex } from './Flex';
export { Grid } from './Grid';
export { Icon } from './Icon';
export { Input } from './Input';
export { LabeledList } from './LabeledList';
export { NoticeBox } from './NoticeBox';
export { NumberInput } from './NumberInput';
@@ -1,5 +1,8 @@
import { Fragment, Component } from 'inferno';
import { Section, Tabs, Box, Button, Flex, ProgressBar, Tooltip } from '../components';
import { Component, Fragment } from 'inferno';
import {
Box, Button, Flex, Input, LabeledList, NumberInput,
ProgressBar, Section, Tabs, Tooltip,
} from '../components';
const COLORS_ARBITRARY = [
'black',
@@ -44,6 +47,7 @@ export const KitchenSink = props => {
<KitchenSinkProgress />
<KitchenSinkTabs />
<KitchenSinkTooltips />
<KitchenSinkInputs />
</Fragment>
);
};
@@ -116,7 +120,16 @@ class KitchenSinkProgress extends Component {
const { progress } = this.state;
return (
<Section title="Progress">
<ProgressBar value={progress} />
<ProgressBar
ranges={{
good: [0.5, Infinity],
bad: [-Infinity, 0.1],
average: [0, 0.5],
}}
minValue={-1}
maxValue={1}
value={progress}
content={`value: ${Number(progress).toFixed(1)}`} />
<Button
content="-0.1"
onClick={() => this.setState(prevState => ({
@@ -187,6 +200,62 @@ const KitchenSinkTooltips = props => {
);
};
class KitchenSinkInputs extends Component {
constructor() {
super();
this.state = {
number: 0,
text: 'Sample text',
};
}
render() {
const { number, text } = this.state;
return (
<Section title="Inputs">
<LabeledList>
<LabeledList.Item label="NumberInput">
<NumberInput
animated
width={10}
step={1}
stepPixelSize={5}
value={number}
minValue={-100}
maxValue={100}
onChange={(e, value) => this.setState({
number: value,
})} />
<NumberInput
animated
width={10}
step={1}
stepPixelSize={5}
value={number}
minValue={-100}
maxValue={100}
onDrag={(e, value) => this.setState({
number: value,
})} />
</LabeledList.Item>
<LabeledList.Item label="Input">
<Input
value={text}
onChange={(e, value) => this.setState({
text: value,
})} />
<Input
value={text}
onInput={(e, value) => this.setState({
text: value,
})} />
</LabeledList.Item>
</LabeledList>
</Section>
);
}
}
const BoxOfSampleText = props => {
return (
<Box {...props}>
@@ -0,0 +1,146 @@
import { toTitleCase } from 'common/string';
import { Component, Fragment } from 'inferno';
import { act } from '../byond';
import { BlockQuote, Box, Button, NumberInput, Section, Table } from '../components';
export const OreRedemptionMachine = props => {
const { state } = props;
const { config, data } = state;
const { ref } = config;
const {
unclaimedPoints,
materials,
alloys,
diskDesigns,
hasDisk,
} = data;
return (
<Fragment>
<Section>
<BlockQuote mb={1}>
This machine only accepts ore.<br />
Gibtonite and Slag are not accepted.
</BlockQuote>
<Box>
<Box inline color="label" mr={1}>
Unclaimed points:
</Box>
{unclaimedPoints}
<Button
ml={2}
content="Claim"
disabled={unclaimedPoints === 0}
onClick={() => act(ref, 'Claim')} />
</Box>
</Section>
<Section>
{hasDisk && (
<Fragment>
<Box mb={1}>
<Button
icon="eject"
content="Eject design disk"
onClick={() => act(ref, 'diskEject')} />
</Box>
<Table>
{diskDesigns.map(design => (
<Table.Row key={design.index}>
<Table.Cell>
File {design.index}: {design.name}
</Table.Cell>
<Table.Cell collapsing>
<Button
disabled={!design.canupload}
content="Upload"
onClick={() => act(ref, 'diskUpload', {
design: design.index,
})} />
</Table.Cell>
</Table.Row>
))}
</Table>
</Fragment>
) || (
<Button
icon="save"
content="Insert design disk"
onClick={() => act(ref, 'diskInsert')} />
)}
</Section>
<Section title="Materials">
<Table>
{materials.map(material => (
<MaterialRow
key={material.id}
material={material}
onRelease={amount => act(ref, 'Release', {
id: material.id,
sheets: amount,
})} />
))}
</Table>
</Section>
<Section title="Alloys">
<Table>
{alloys.map(material => (
<MaterialRow
key={material.id}
material={material}
onRelease={amount => act(ref, 'Release', {
id: material.id,
sheets: amount,
})} />
))}
</Table>
</Section>
</Fragment>
);
};
class MaterialRow extends Component {
constructor() {
super();
this.state = {
amount: 1,
};
}
render() {
const { amount } = this.state;
const { material, onRelease } = this.props;
const amountAvailable = Math.floor(material.amount);
return (
<Table.Row>
<Table.Cell>
{toTitleCase(material.name).replace('Alloy', '')}
</Table.Cell>
<Table.Cell collapsing textAlign="right">
<Box mr={2} color="label" inline>
{material.value && material.value + ' cr'}
</Box>
</Table.Cell>
<Table.Cell collapsing textAlign="right">
<Box mr={2} color="label" inline>
{amountAvailable} sheets
</Box>
</Table.Cell>
<Table.Cell collapsing>
<NumberInput
width="32px"
step={1}
stepPixelSize={5}
minValue={1}
maxValue={50}
value={amount}
onChange={(e, value) => this.setState({
amount: value,
})} />
<Button
disabled={amountAvailable < 1}
content="Release"
onClick={() => onRelease(amount)} />
</Table.Cell>
</Table.Row>
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import { map } from 'common/fp';
import { toFixed } from 'common/math';
import { act } from '../byond';
import { Box, Button, LabeledList, NumberInput, Section } from '../components';
const CHANNELS = [
{
name: 'Syndicate',
freq: 1213,
color: '#a52a2a',
},
{
name: 'Red Team',
freq: 1215,
color: '#ff4444',
},
{
name: 'Blue Team',
freq: 1217,
color: '#3434fd',
},
{
name: 'CentCom',
freq: 1337,
color: '#2681a5',
},
{
name: 'Supply',
freq: 1347,
color: '#b88646',
},
{
name: 'Service',
freq: 1349,
color: '#6ca729',
},
{
name: 'Science',
freq: 1351,
color: '#c68cfa',
},
{
name: 'Command',
freq: 1353,
color: '#5177ff',
},
{
name: 'Medical',
freq: 1355,
color: '#57b8f0',
},
{
name: 'Engineering',
freq: 1357,
color: '#f37746',
},
{
name: 'Security',
freq: 1359,
color: '#dd3535',
},
{
name: 'AI Private',
freq: 1447,
color: '#d65d95',
},
{
name: 'Common',
freq: 1459,
color: '#1ecc43',
},
];
export const Radio = props => {
const { state } = props;
const { config, data } = state;
const { ref } = config;
const {
freqlock,
frequency,
minFrequency,
maxFrequency,
listening,
broadcasting,
command,
useCommand,
subspace,
subspaceSwitchable,
} = data;
const tunedChannel = CHANNELS
.find(channel => channel.freq === frequency);
const channels = map((value, key) => ({
name: key,
status: !!value,
}))(data.channels);
return (
<Section>
<LabeledList>
<LabeledList.Item label="Frequency">
{freqlock && (
<Box inline color="light-gray">
{toFixed(frequency / 10, 1) + ' kHz'}
</Box>
) || (
<NumberInput
animate
unit="kHz"
step={0.2}
stepPixelSize={10}
minValue={minFrequency / 10}
maxValue={maxFrequency / 10}
value={frequency / 10}
format={value => toFixed(value, 1)}
onDrag={(e, value) => act(ref, 'frequency', {
adjust: (value - frequency / 10),
})} />
)}
{tunedChannel && (
<Box inline color={tunedChannel.color} ml={2}>
[{tunedChannel.name}]
</Box>
)}
</LabeledList.Item>
<LabeledList.Item label="Audio">
<Button
textAlign="center"
width="37px"
icon={listening ? 'volume-up' : 'volume-mute'}
selected={listening}
onClick={() => act(ref, 'listen')} />
<Button
textAlign="center"
width="37px"
icon={broadcasting ? 'microphone' : 'microphone-slash'}
selected={broadcasting}
onClick={() => act(ref, 'broadcast')} />
{!!command && (
<Button
ml={1}
icon="bullhorn"
selected={useCommand}
content={`High volume ${useCommand ? 'ON' : 'OFF'}`}
onClick={() => act(ref, 'command')} />
)}
{!!subspaceSwitchable && (
<Button
ml={1}
icon="bullhorn"
selected={subspace}
content={`Subspace Tx ${subspace ? 'ON' : 'OFF'}`}
onClick={() => act(ref, 'subspace')} />
)}
</LabeledList.Item>
{!!subspace && (
<LabeledList.Item label="Channels">
{channels.length === 0 && (
<Box inline color="bad">
No encryption keys installed.
</Box>
)}
{channels.map(channel => (
<Box key={channel.name}>
<Button
icon={channel.status ? 'check-square-o' : 'square-o'}
selected={channel.status}
content={channel.name}
onClick={() => act(ref, 'channel', {
channel: channel.name,
})} />
</Box>
))}
</LabeledList.Item>
)}
</LabeledList>
</Section>
);
};
@@ -0,0 +1,119 @@
import { toFixed } from 'common/math';
import { Fragment } from 'inferno';
import { act } from '../byond';
import { Box, Button, Grid, LabeledList, NumberInput, ProgressBar, Section } from '../components';
export const SolarControl = props => {
const { state } = props;
const { config, data } = state;
const { ref } = config;
const {
generated,
angle,
tracking_state,
tracking_rate,
connected_panels,
connected_tracker,
} = data;
return (
<Fragment>
<Section
title="Status"
buttons={(
<Button
icon="sync"
content="Scan for new hardware"
onClick={() => act(ref, 'refresh')} />
)}>
<Grid>
<Grid.Column>
<LabeledList>
<LabeledList.Item
label="Solar tracker"
color={connected_tracker ? 'good' : 'bad'}>
{connected_tracker ? 'OK' : 'N/A'}
</LabeledList.Item>
<LabeledList.Item
label="Solar panels"
color={connected_panels > 0 ? 'good' : 'bad'}>
{connected_panels}
</LabeledList.Item>
</LabeledList>
</Grid.Column>
<Grid.Column size={1.5}>
<LabeledList>
<LabeledList.Item label="Power output">
<ProgressBar
ranges={{
good: [60000, Infinity],
average: [30000, 60000],
bad: [-Infinity, 30000],
}}
minValue={0}
maxValue={90000}
value={generated}
content={generated + ' W'} />
</LabeledList.Item>
</LabeledList>
</Grid.Column>
</Grid>
</Section>
<Section title="Controls">
<LabeledList>
<LabeledList.Item label="Tracking">
<Button
icon="times"
content="Off"
selected={tracking_state === 0}
onClick={() => act(ref, 'tracking', { mode: 0 })} />
<Button
icon="clock-o"
content="Timed"
selected={tracking_state === 1}
onClick={() => act(ref, 'tracking', { mode: 1 })} />
<Button
icon="sync"
content="Auto"
selected={tracking_state === 2}
disabled={!connected_tracker}
onClick={() => act(ref, 'tracking', { mode: 2 })} />
</LabeledList.Item>
<LabeledList.Item label="Angle">
{(tracking_state === 0 || tracking_state === 1) && (
<NumberInput
width="52px"
unit="°"
step={1}
stepPixelSize={2}
minValue={-360}
maxValue={+720}
value={angle}
format={angle => Math.round(360 + angle) % 360}
onDrag={(e, value) => act(ref, 'angle', { value })} />
)}
{tracking_state === 1 && (
<NumberInput
width="80px"
unit="°/h"
step={5}
stepPixelSize={2}
minValue={-7200}
maxValue={7200}
value={tracking_rate}
format={rate => {
const sign = Math.sign(rate) > 0 ? '+' : '-';
return sign + toFixed(Math.abs(rate));
}}
onDrag={(e, value) => act(ref, 'rate', { value })} />
)}
{tracking_state === 2 && (
<Box inline color="label" mt="3px">
{angle + ' °'} (auto)
</Box>
)}
</LabeledList.Item>
</LabeledList>
</Section>
</Fragment>
);
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26 -9
View File
@@ -21,26 +21,28 @@ import { ChemDispenser } from './interfaces/ChemDispenser';
import { ChemFilter } from './interfaces/ChemFilter';
import { ChemHeater } from './interfaces/ChemHeater';
import { ChemMaster } from './interfaces/ChemMaster';
import { ChemPress } from './interfaces/ChemPress';
import { ChemSplitter } from './interfaces/ChemSplitter';
import { ChemSynthesizer } from './interfaces/ChemSynthesizer';
import { CodexGigas } from './interfaces/CodexGigas';
import { Crayon } from './interfaces/Crayon';
import { CrewConsole } from './interfaces/CrewConsole';
import { Cryo } from './interfaces/Cryo';
import { DisposalUnit } from './interfaces/DisposalUnit';
import { KitchenSink } from './interfaces/KitchenSink';
import { LanguageMenu } from './interfaces/LanguageMenu';
import { Mint } from './interfaces/Mint';
import { OperatingComputer } from './interfaces/OperatingComputer';
import { OreRedemptionMachine } from './interfaces/OreRedemptionMachine';
import { PersonalCrafting } from './interfaces/PersonalCrafting';
import { PortableGenerator } from './interfaces/PortableGenerator';
import { Radio } from './interfaces/Radio';
import { ShuttleManipulator } from './interfaces/ShuttleManipulator';
import { SmartVend } from './interfaces/SmartVend';
import { SMES } from './interfaces/SMES';
import { SolarControl } from './interfaces/SolarControl';
import { ThermoMachine } from './interfaces/ThermoMachine';
import { VaultController } from './interfaces/VaultController';
import { Wires } from './interfaces/Wires';
import { ChemSynthesizer } from './interfaces/ChemSynthesizer';
import { ChemPress } from './interfaces/ChemPress';
const ROUTES = {
achievements: {
@@ -179,6 +181,10 @@ const ROUTES = {
component: () => Mint,
scrollable: false,
},
ore_redemption_machine: {
component: () => OreRedemptionMachine,
scrollable: true,
},
operating_computer: {
component: () => OperatingComputer,
scrollable: true,
@@ -191,6 +197,10 @@ const ROUTES = {
component: () => PortableGenerator,
scrollable: false,
},
radio: {
component: () => Radio,
scrollable: false,
},
shuttle_manipulator: {
component: () => ShuttleManipulator,
scrollable: true,
@@ -203,6 +213,10 @@ const ROUTES = {
component: () => SMES,
scrollable: false,
},
solar_control: {
component: () => SolarControl,
scrollable: false,
},
thermomachine: {
component: () => ThermoMachine,
scrollable: false,
@@ -218,12 +232,15 @@ const ROUTES = {
};
export const getRoute = state => {
// Show a kitchen sink
if (state.showKitchenSink) {
return {
component: () => KitchenSink,
scrollable: true,
};
if (process.env.NODE_ENV !== 'production') {
// Show a kitchen sink
if (state.showKitchenSink) {
const { KitchenSink } = require('./interfaces/KitchenSink');
return {
component: () => KitchenSink,
scrollable: true,
};
}
}
// Refer to the routing table
return ROUTES[state.config && state.config.interface];
@@ -1,6 +1,7 @@
// Components
@import '../components/Button.scss';
@import '../components/Flex.scss';
@import '../components/Input.scss';
@import '../components/LabeledList.scss';
@import '../components/Layout.scss';
@import '../components/NoticeBox.scss';
@@ -118,3 +118,6 @@ $color-damage-oxy: #3498db;
$color-damage-toxin: #2ecc71;
$color-damage-burn: #e67e22;
$color-damage-brute: #e74c3c;
// Inputs
$input-base-color: #88bfff;