diff --git a/src/ScriptEditor/ui/OptionsModal.tsx b/src/ScriptEditor/ui/OptionsModal.tsx
index 70fb9e361..1e0d622f4 100644
--- a/src/ScriptEditor/ui/OptionsModal.tsx
+++ b/src/ScriptEditor/ui/OptionsModal.tsx
@@ -9,6 +9,10 @@ import Select from "@mui/material/Select";
import Switch from "@mui/material/Switch";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
+import EditIcon from "@mui/icons-material/Edit";
+import SaveIcon from "@mui/icons-material/Save";
+
+import { ThemeEditorModal } from "./ThemeEditorModal";
interface IProps {
options: Options;
@@ -23,6 +27,7 @@ export function OptionsModal(props: IProps): React.ReactElement {
const [fontSize, setFontSize] = useState(props.options.fontSize);
const [wordWrap, setWordWrap] = useState(props.options.wordWrap);
const [vim, setVim] = useState(props.options.vim);
+ const [themeEditorOpen, setThemeEditorOpen] = useState(false);
function save(): void {
props.save({
@@ -43,6 +48,7 @@ export function OptionsModal(props: IProps): React.ReactElement {
return (
+ setThemeEditorOpen(false)} />
Theme:
+
@@ -80,7 +90,9 @@ export function OptionsModal(props: IProps): React.ReactElement {
-
+ }>
+ Save
+
);
}
diff --git a/src/ScriptEditor/ui/ScriptEditorRoot.tsx b/src/ScriptEditor/ui/ScriptEditorRoot.tsx
index 1e3d529d9..2dc20212b 100644
--- a/src/ScriptEditor/ui/ScriptEditorRoot.tsx
+++ b/src/ScriptEditor/ui/ScriptEditorRoot.tsx
@@ -25,7 +25,7 @@ import { Settings } from "../../Settings/Settings";
import { iTutorialNextStep, ITutorial, iTutorialSteps } from "../../InteractiveTutorial";
import { debounce } from "lodash";
import { saveObject } from "../../SaveObject";
-import { loadThemes } from "./themes";
+import { loadThemes, makeTheme, sanitizeTheme } from "./themes";
import { GetServer } from "../../Server/AllServers";
import Button from "@mui/material/Button";
@@ -362,6 +362,8 @@ export function Root(props: IProps): React.ReactElement {
monaco.languages.typescript.javascriptDefaults.addExtraLib(source, "netscript.d.ts");
monaco.languages.typescript.typescriptDefaults.addExtraLib(source, "netscript.d.ts");
loadThemes(monaco);
+ sanitizeTheme(Settings.EditorTheme);
+ monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
}
// When the editor is mounted
@@ -993,7 +995,11 @@ export function Root(props: IProps): React.ReactElement {
setOptionsOpen(false)}
+ onClose={() => {
+ sanitizeTheme(Settings.EditorTheme);
+ monacoRef.current?.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
+ setOptionsOpen(false);
+ }}
options={{
theme: Settings.MonacoTheme,
insertSpaces: Settings.MonacoInsertSpaces,
@@ -1002,6 +1008,8 @@ export function Root(props: IProps): React.ReactElement {
vim: Settings.MonacoVim,
}}
save={(options: Options) => {
+ sanitizeTheme(Settings.EditorTheme);
+ monacoRef.current?.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
setOptions(options);
Settings.MonacoTheme = options.theme;
Settings.MonacoInsertSpaces = options.insertSpaces;
diff --git a/src/ScriptEditor/ui/ThemeEditorModal.tsx b/src/ScriptEditor/ui/ThemeEditorModal.tsx
new file mode 100644
index 000000000..69bf2aa32
--- /dev/null
+++ b/src/ScriptEditor/ui/ThemeEditorModal.tsx
@@ -0,0 +1,276 @@
+import { History, Reply, Save } from "@mui/icons-material";
+import { Box, Button, Paper, TextField, Tooltip, Typography } from "@mui/material";
+import IconButton from "@mui/material/IconButton";
+import _ from "lodash";
+import { Color, ColorPicker } from "material-ui-color";
+import React, { useState } from "react";
+import { Settings } from "../../Settings/Settings";
+import { Modal } from "../../ui/React/Modal";
+import { OptionSwitch } from "../../ui/React/OptionSwitch";
+import { defaultMonacoTheme, IScriptEditorTheme } from "./themes";
+
+interface IProps {
+ onClose: () => void;
+ open: boolean;
+}
+
+interface IColorEditorProps {
+ label: string;
+ themePath: string;
+ color: string | undefined;
+ onColorChange: (name: string, value: string) => void;
+ defaultColor: string;
+}
+
+// Slightly tweaked version of the same function found in game options
+function ColorEditor({ label, themePath, onColorChange, color, defaultColor }: IColorEditorProps): React.ReactElement {
+ if (color === undefined) {
+ console.error(`color ${themePath} was undefined, reverting to default`);
+ color = defaultColor;
+ }
+
+ return (
+ <>
+
+
+
+ onColorChange(themePath, newColor.hex)}
+ disableAlpha
+ />
+ >
+ ),
+ endAdornment: (
+ <>
+ onColorChange(themePath, defaultColor)}>
+
+
+ >
+ ),
+ }}
+ />
+
+
+ >
+ );
+}
+
+export function ThemeEditorModal(props: IProps): React.ReactElement {
+ const setRerender = useState(false)[1];
+ function rerender(): void {
+ setRerender((o) => !o);
+ }
+
+ // Need to deep copy the object since it has nested attributes
+ const [themeCopy, setThemeCopy] = useState(JSON.parse(JSON.stringify(Settings.EditorTheme)));
+
+ function onColorChange(name: string, value: string): void {
+ setThemeCopy(_.set(themeCopy, name, value));
+ rerender();
+ }
+
+ function onThemeChange(event: React.ChangeEvent): void {
+ try {
+ const importedTheme = JSON.parse(event.target.value);
+ if (typeof importedTheme !== "object") return;
+ setThemeCopy(importedTheme);
+ } catch (err) {
+ // ignore
+ }
+ }
+
+ return (
+ {
+ setThemeCopy(Settings.EditorTheme);
+ props.onClose();
+ }}
+ >
+ Customize Editor theme
+ Hover over input boxes for more information
+
+ {
+ setThemeCopy(_.set(themeCopy, "base", val ? "vs" : "vs-dark"));
+ rerender();
+ }}
+ text="Use light theme as base"
+ tooltip={
+ <>
+ If enabled, the vs light theme will be used as the theme base, otherwise,{" "}
+ vs-dark will be used.
+ >
+ }
+ />
+
+
+ UI
+
+
+
+
+
+
+
+
+
+ Syntax
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ScriptEditor/ui/themes.ts b/src/ScriptEditor/ui/themes.ts
index 79a698727..089c2f5b9 100644
--- a/src/ScriptEditor/ui/themes.ts
+++ b/src/ScriptEditor/ui/themes.ts
@@ -1,3 +1,216 @@
+export interface IScriptEditorTheme {
+ [key: string]: any;
+ base: string;
+ inherit: boolean;
+ common: {
+ [key: string]: string;
+ accent: string;
+ bg: string;
+ fg: string;
+ };
+ syntax: {
+ [key: string]: string;
+ tag: string;
+ entity: string;
+ string: string;
+ regexp: string;
+ markup: string;
+ keyword: string;
+ comment: string;
+ constant: string;
+ error: string;
+ };
+ ui: {
+ [key: string]: any;
+ line: string;
+ panel: {
+ [key: string]: string;
+ bg: string;
+ selected: string;
+ border: string;
+ };
+ selection: {
+ [key: string]: string;
+ bg: string;
+ };
+ };
+}
+
+export const defaultMonacoTheme: IScriptEditorTheme = {
+ base: "vs-dark",
+ inherit: true,
+ common: {
+ accent: "B5CEA8",
+ bg: "1E1E1E",
+ fg: "D4D4D4",
+ },
+ syntax: {
+ tag: "569CD6",
+ entity: "569CD6",
+ string: "CE9178",
+ regexp: "646695",
+ markup: "569CD6",
+ keyword: "569CD6",
+ comment: "6A9955",
+ constant: "569CD6",
+ error: "F44747",
+ },
+ ui: {
+ line: "1E1E1E",
+ panel: {
+ bg: "252526",
+ selected: "252526",
+ border: "1E1E1E",
+ },
+ selection: {
+ bg: "ADD6FF26",
+ },
+ },
+};
+
+// Regex used for token color validation
+// https://github.com/microsoft/vscode/blob/973684056e67153952f495fce93bf50d0ec0b892/src/vs/editor/common/languages/supports/tokenization.ts#L153
+const colorRegExp = /^#?([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/;
+
+// Recursively sanitize the theme data to prevent errors
+// Invalid data will be replaced with FF0000 (bright red)
+export const sanitizeTheme = (theme: IScriptEditorTheme): void => {
+ for (const [k, v] of Object.entries(theme)) {
+ switch (k) {
+ case "base":
+ if (!["vs-dark", "vs"].includes(theme.base)) theme.base = "vs-dark";
+ continue;
+ case "inherit":
+ if (typeof theme.inherit !== "boolean") theme.inherit = true;
+ continue;
+ }
+
+ const repairBlock = (block: { [key: string]: any }): void => {
+ for (const [k, v] of Object.entries(block)) {
+ if (typeof v === "object") {
+ repairBlock(v as { [key: string]: string });
+ } else if (!v.match(colorRegExp)) block[k] = "FF0000";
+ }
+ };
+ repairBlock(v);
+ }
+};
+
+export function makeTheme(theme: IScriptEditorTheme): any {
+ const themeRules = [
+ {
+ token: "",
+ background: theme.ui.line,
+ foreground: theme.common.fg,
+ },
+ {
+ token: "identifier",
+ foreground: theme.common.accent,
+ },
+ {
+ token: "keyword",
+ foreground: theme.syntax.keyword,
+ },
+ {
+ token: "string",
+ foreground: theme.syntax.string,
+ },
+ {
+ token: "string.escape",
+ foreground: theme.syntax.regexp,
+ },
+ {
+ token: "comment",
+ foreground: theme.syntax.comment,
+ },
+ {
+ token: "constant",
+ foreground: theme.syntax.constant,
+ },
+ {
+ token: "entity",
+ foreground: theme.syntax.entity,
+ },
+ {
+ token: "type",
+ foreground: theme.syntax.tag,
+ },
+ {
+ token: "tag",
+ foreground: theme.syntax.tag,
+ },
+ {
+ token: "regexp",
+ foreground: theme.syntax.regexp,
+ },
+ {
+ token: "attribute",
+ foreground: theme.syntax.tag,
+ },
+ {
+ token: "constructor",
+ foreground: theme.syntax.markup,
+ },
+ {
+ token: "invalid",
+ foreground: theme.syntax.error,
+ },
+ {
+ token: "number",
+ foreground: theme.common.accent,
+ },
+ {
+ token: "delimiter",
+ foreground: theme.common.fg,
+ },
+ // Custom tokens
+ {
+ token: "ns",
+ foreground: theme.syntax.tag,
+ },
+ {
+ token: "netscriptfunction",
+ foreground: theme.syntax.markup,
+ },
+ {
+ token: "otherkeywords",
+ foreground: theme.syntax.keyword,
+ },
+ {
+ token: "otherkeyvars",
+ foreground: theme.common.accent,
+ },
+ {
+ token: "this",
+ foreground: theme.syntax.tag,
+ },
+ ];
+
+ const themeColors = Object.fromEntries(
+ [
+ ["editor.background", theme.common.bg],
+ ["editor.foreground", theme.common.fg],
+ ["editor.lineHighlightBackground", theme.ui.line],
+ ["editor.selectionBackground", theme.ui.selection.bg],
+
+ ["editorSuggestWidget.background", theme.ui.panel.bg],
+ ["editorSuggestWidget.border", theme.ui.panel.border],
+ ["editorSuggestWidget.selectedBackground", theme.ui.panel.selected],
+
+ ["editorHoverWidget.background", theme.ui.panel.bg],
+ ["editorHoverWidget.border", theme.ui.panel.border],
+
+ ["editorWidget.background", theme.ui.panel.bg],
+ ["editorWidget.border", theme.ui.panel.border],
+
+ ["input.background", theme.ui.panel.bg],
+ ["input.border", theme.ui.panel.border],
+ ].map(([k, v]) => [k, "#" + v]),
+ );
+
+ return { base: theme.base, inherit: theme.inherit, rules: themeRules, colors: themeColors };
+}
+
export async function loadThemes(monaco: { editor: any }): Promise {
monaco.editor.defineTheme("monokai", {
base: "vs-dark",
@@ -261,6 +474,7 @@ export async function loadThemes(monaco: { editor: any }): Promise {
foreground: "FFB86C",
fontStyle: "italic",
},
+
{
token: "netscriptfunction",
foreground: "FF79C6",
diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts
index 906c9ca2d..c8d1cfd0c 100644
--- a/src/Settings/Settings.ts
+++ b/src/Settings/Settings.ts
@@ -5,6 +5,7 @@ import { defaultStyles } from "../Themes/Styles";
import { WordWrapOptions } from "../ScriptEditor/ui/Options";
import { OverviewSettings } from "../ui/React/Overview";
import { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions";
+import { defaultMonacoTheme, IScriptEditorTheme } from "../ScriptEditor/ui/themes";
/**
* Represents the default settings the player could customize.
@@ -157,6 +158,11 @@ interface IDefaultSettings {
* If the game's sidebar is opened
*/
IsSidebarOpened: boolean;
+
+ /**
+ * Script editor theme data
+ */
+ EditorTheme: IScriptEditorTheme;
}
/**
@@ -216,6 +222,8 @@ export const defaultSettings: IDefaultSettings = {
theme: defaultTheme,
styles: defaultStyles,
overview: { x: 0, y: 0, opened: true },
+
+ EditorTheme: defaultMonacoTheme,
};
/**
@@ -262,6 +270,7 @@ export const Settings: ISettings & ISelfInitializer & ISelfLoading = {
theme: { ...defaultTheme },
styles: { ...defaultStyles },
overview: defaultSettings.overview,
+ EditorTheme: { ...defaultMonacoTheme },
init() {
Object.assign(Settings, defaultSettings);
},
@@ -273,6 +282,8 @@ export const Settings: ISettings & ISelfInitializer & ISelfLoading = {
delete save.styles;
Object.assign(Settings.overview, save.overview);
delete save.overview;
+ Object.assign(Settings.EditorTheme, save.EditorTheme);
+ delete save.EditorTheme;
Object.assign(Settings, save);
},
};
diff --git a/src/Themes/ui/ThemeEditorModal.tsx b/src/Themes/ui/ThemeEditorModal.tsx
index 602c45655..882b8d761 100644
--- a/src/Themes/ui/ThemeEditorModal.tsx
+++ b/src/Themes/ui/ThemeEditorModal.tsx
@@ -366,9 +366,9 @@ export function ThemeEditorModal(props: IProps): React.ReactElement {
sx={{ mb: 1 }}
multiline
fullWidth
- maxRows={3}
+ maxRows={10}
label={"import / export theme"}
- value={JSON.stringify(customTheme)}
+ value={JSON.stringify(customTheme, undefined, 2)}
onChange={onThemeChange}
/>
<>