diff --git a/package-lock.json b/package-lock.json
index 8f13508..9dcf849 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,6 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@reduxjs/toolkit": "^2.2.6",
"ace-builds": "^1.35.1",
"electron-debug": "^3.2.0",
"electron-log": "^4.4.8",
@@ -18,7 +17,6 @@
"react": "^18.2.0",
"react-ace": "^12.0.0",
"react-dom": "^18.2.0",
- "react-redux": "^9.1.2",
"react-router-dom": "^6.16.0"
},
"devDependencies": {
@@ -2033,29 +2031,6 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"dev": true
},
- "node_modules/@reduxjs/toolkit": {
- "version": "2.2.6",
- "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.6.tgz",
- "integrity": "sha512-kH0r495c5z1t0g796eDQAkYbEQ3a1OLYN9o8jQQVZyKyw367pfRGS+qZLkHYvFHiUUdafpoSlQ2QYObIApjPWA==",
- "dependencies": {
- "immer": "^10.0.3",
- "redux": "^5.0.1",
- "redux-thunk": "^3.1.0",
- "reselect": "^5.1.0"
- },
- "peerDependencies": {
- "react": "^16.9.0 || ^17.0.0 || ^18",
- "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
- },
- "peerDependenciesMeta": {
- "react": {
- "optional": true
- },
- "react-redux": {
- "optional": true
- }
- }
- },
"node_modules/@remix-run/router": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz",
@@ -2653,7 +2628,7 @@
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
- "devOptional": true
+ "dev": true
},
"node_modules/@types/qs": {
"version": "6.9.7",
@@ -2671,7 +2646,7 @@
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
- "devOptional": true,
+ "dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2772,11 +2747,6 @@
"integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==",
"dev": true
},
- "node_modules/@types/use-sync-external-store": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
- "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
- },
"node_modules/@types/verror": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.6.tgz",
@@ -5799,7 +5769,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==",
- "devOptional": true
+ "dev": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -9282,15 +9252,6 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
- "node_modules/immer": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
- "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/immer"
- }
- },
"node_modules/immutable": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.4.tgz",
@@ -13699,28 +13660,6 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
- "node_modules/react-redux": {
- "version": "9.1.2",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz",
- "integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==",
- "dependencies": {
- "@types/use-sync-external-store": "^0.0.3",
- "use-sync-external-store": "^1.0.0"
- },
- "peerDependencies": {
- "@types/react": "^18.2.25",
- "react": "^18.0",
- "redux": "^5.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "redux": {
- "optional": true
- }
- }
- },
"node_modules/react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@@ -13861,19 +13800,6 @@
"node": ">=8"
}
},
- "node_modules/redux": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
- "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
- },
- "node_modules/redux-thunk": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
- "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
- "peerDependencies": {
- "redux": "^5.0.0"
- }
- },
"node_modules/reflect.getprototypeof": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
@@ -13969,11 +13895,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
},
- "node_modules/reselect": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
- "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
- },
"node_modules/resolve": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -16266,14 +16187,6 @@
"requires-port": "^1.0.0"
}
},
- "node_modules/use-sync-external-store": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
- "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
- }
- },
"node_modules/utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
diff --git a/package.json b/package.json
index 916f8b9..d30d63f 100644
--- a/package.json
+++ b/package.json
@@ -100,7 +100,6 @@
}
},
"dependencies": {
- "@reduxjs/toolkit": "^2.2.6",
"ace-builds": "^1.35.1",
"electron-debug": "^3.2.0",
"electron-log": "^4.4.8",
@@ -109,7 +108,6 @@
"react": "^18.2.0",
"react-ace": "^12.0.0",
"react-dom": "^18.2.0",
- "react-redux": "^9.1.2",
"react-router-dom": "^6.16.0"
},
"devDependencies": {
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index 6a1de2a..09feb6d 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -1,8 +1,12 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
+import { config as aceConfig } from 'ace-builds';
import App from '../renderer/App';
describe('App', () => {
+ beforeEach(() => {
+ aceConfig.set('basePath', 'node_modules/ace-builds/src-noconflict');
+ });
it('should render', () => {
expect(render()).toBeTruthy();
});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 8d56b6f..4998314 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -8,7 +8,10 @@ import {
useLayoutEffect,
} from 'react';
import Topbar from './Topbar';
-import Editor, { EditorContentStatus, KeyboardControlsStatus } from './Editor';
+import Editor, {
+ EditorContentStatus,
+ KeyboardControlsStatus,
+} from './editor/Editor';
import DeviceInfo from './DeviceInfo';
import AppConsole from './AppConsole';
import type AppConsoleMessage from '../common/AppConsoleMessage'; // No crypto package on the renderer
diff --git a/src/renderer/editor/ApiLink.css b/src/renderer/editor/ApiLink.css
new file mode 100644
index 0000000..8e63aaa
--- /dev/null
+++ b/src/renderer/editor/ApiLink.css
@@ -0,0 +1,8 @@
+.ApiLink {
+ color: blue;
+ text-decoration: underline;
+ background-color: transparent;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
diff --git a/src/renderer/editor/ApiLink.tsx b/src/renderer/editor/ApiLink.tsx
new file mode 100644
index 0000000..371e9f3
--- /dev/null
+++ b/src/renderer/editor/ApiLink.tsx
@@ -0,0 +1,36 @@
+import './ApiLink.css';
+
+/**
+ * Links to a section of the student API documentation, opening the help window or just jumping to
+ * the appropriate section if the window is already open. The single text node child of this
+ * component is used as the link text.
+ * @param props
+ * @param props.dest - the section of the docs to jump to. TODO: figure out format
+ * @param props.code - whether to display the link text in a monospaced font.
+ */
+export default function ApiLink({
+ dest,
+ code = false,
+ children,
+}: {
+ dest: string;
+ code?: boolean;
+ children: string;
+}) {
+ const text = `(${children})[${dest}]`;
+ // Placeholder
+ return (
+
+ );
+}
+/**
+ * Default props for ApiLink.
+ */
+ApiLink.defaultProps = {
+ /**
+ * By default, link text is displayed in the inherited font.
+ */
+ code: false,
+};
diff --git a/src/renderer/Editor.css b/src/renderer/editor/Editor.css
similarity index 100%
rename from src/renderer/Editor.css
rename to src/renderer/editor/Editor.css
diff --git a/src/renderer/Editor.tsx b/src/renderer/editor/Editor.tsx
similarity index 86%
rename from src/renderer/Editor.tsx
rename to src/renderer/editor/Editor.tsx
index 427c786..977ac24 100644
--- a/src/renderer/Editor.tsx
+++ b/src/renderer/editor/Editor.tsx
@@ -1,22 +1,12 @@
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import AceEditor from 'react-ace';
-import 'ace-builds/src-noconflict/mode-python';
-import uploadSvg from '../../assets/upload.svg';
-import downloadSvg from '../../assets/download.svg';
-import openSvg from '../../assets/open.svg';
-import saveSvg from '../../assets/save.svg';
-import saveAsSvg from '../../assets/save-as.svg';
-import newFileSvg from '../../assets/new-file.svg';
-import pieSvg from '../../assets/pie.svg';
-import consoleSvg from '../../assets/console.svg';
-import consoleClearSvg from '../../assets/console-clear.svg';
-import zoomInSvg from '../../assets/zoom-in.svg';
-import zoomOutSvg from '../../assets/zoom-out.svg';
-import startRobot from '../../assets/start-robot.svg';
-import stopRobot from '../../assets/stop-robot.svg';
-import keyboardKeySvg from '../../assets/keyboard-key.svg';
-import themeSvg from '../../assets/theme.svg';
+import addEditorAutocomplete from './addEditorAutocomplete';
+import addEditorTooltips from './addEditorTooltips';
+import 'ace-builds/src-noconflict/mode-python';
+import 'ace-builds/src-noconflict/snippets/python';
+import 'ace-builds/src-noconflict/ext-language_tools';
+import 'ace-builds/src-noconflict/ext-searchbox';
import 'ace-builds/src-noconflict/theme-chrome';
import 'ace-builds/src-noconflict/theme-clouds';
import 'ace-builds/src-noconflict/theme-dawn';
@@ -26,6 +16,22 @@ import 'ace-builds/src-noconflict/theme-tomorrow_night';
import 'ace-builds/src-noconflict/theme-clouds_midnight';
import 'ace-builds/src-noconflict/theme-ambiance';
+import uploadSvg from '../../../assets/upload.svg';
+import downloadSvg from '../../../assets/download.svg';
+import openSvg from '../../../assets/open.svg';
+import saveSvg from '../../../assets/save.svg';
+import saveAsSvg from '../../../assets/save-as.svg';
+import newFileSvg from '../../../assets/new-file.svg';
+import pieSvg from '../../../assets/pie.svg';
+import consoleSvg from '../../../assets/console.svg';
+import consoleClearSvg from '../../../assets/console-clear.svg';
+import zoomInSvg from '../../../assets/zoom-in.svg';
+import zoomOutSvg from '../../../assets/zoom-out.svg';
+import startRobot from '../../../assets/start-robot.svg';
+import stopRobot from '../../../assets/stop-robot.svg';
+import keyboardKeySvg from '../../../assets/keyboard-key.svg';
+import themeSvg from '../../../assets/theme.svg';
+
import './Editor.css';
/**
@@ -162,10 +168,18 @@ export default function Editor({
}) {
const [opmode, setOpmode] = useState('auto');
const [fontSize, setFontSize] = useState(12);
+ const editorRef = useRef(null as AceEditor | null);
const zoomEditor = (increase: boolean) => {
setFontSize((old) => old + (increase ? 1 : -1));
};
+ useEffect(() => {
+ if (editorRef.current !== null) {
+ const { editor } = editorRef.current;
+ addEditorAutocomplete(editor);
+ addEditorTooltips(editor);
+ }
+ }, [editorRef]);
const [theme, setTheme] = useState('dawn'); // Default theme
const handleThemeChange = (newTheme: string) => {
@@ -278,7 +292,9 @@ export default function Editor({
name="Editor-toolbar-opmode"
>
{Object.entries(ACE_THEMES).map(([themeKey, themeName]) => (
-
+
))}
@@ -325,7 +341,7 @@ export default function Editor({
- Keyboard input sent to robot — disable to edit code
+ Keyboard input sent to robot -- disable to edit code
diff --git a/src/renderer/editor/HighlightedCode.css b/src/renderer/editor/HighlightedCode.css
new file mode 100644
index 0000000..f6b4242
--- /dev/null
+++ b/src/renderer/editor/HighlightedCode.css
@@ -0,0 +1,12 @@
+.HighlightedCode-editor .ace_scroller {
+ width: 100%;
+}
+.HighlightedCode-editor .ace_cursor {
+ opacity: 0;
+}
+.HighlightedCode-editor .ace_marker-layer {
+ display: none;
+}
+.HighlightedCode-editor .ace_print-margin {
+ display: none;
+}
diff --git a/src/renderer/editor/HighlightedCode.tsx b/src/renderer/editor/HighlightedCode.tsx
new file mode 100644
index 0000000..afff260
--- /dev/null
+++ b/src/renderer/editor/HighlightedCode.tsx
@@ -0,0 +1,57 @@
+import AceEditor from 'react-ace';
+import 'ace-builds/src-noconflict/mode-python';
+import './HighlightedCode.css';
+
+/**
+ * Uses a read-only AceEditor to display a code block with Python syntax highlighting. The only
+ * valid children of this component is text containing the code to be displayed. Common indentation
+ * is removed from each line of code before display. Only spaces are considered indentation; tabs,
+ * half-width spaces, and nonbreaking spaces are treated as content.
+ * @param props
+ * @param props.indent - number of spaces of indentation to prepend to each line, after common
+ * indentation is removed.
+ */
+export default function HighlightedCode({
+ children,
+ indent = 0,
+}: {
+ children: string;
+ indent?: number;
+}) {
+ const lines = children.split('\n');
+ if (lines.length && !lines[0].trim()) {
+ lines.shift();
+ }
+ if (lines.length && !lines[lines.length - 1].trim()) {
+ lines.pop();
+ }
+ // Remove common indent
+ const minIndent = Math.min(
+ ...lines
+ .filter((line) => line.trim().length)
+ .map((line) => line.match(/^ */)![0].length),
+ );
+ const formatted = lines
+ .map((line) => ' '.repeat(indent) + line.slice(minIndent))
+ .join('\n');
+ return (
+
+ );
+}
+/**
+ * Default props for HighlightedCode.
+ */
+HighlightedCode.defaultProps = {
+ /**
+ * No indentation is added to code lines after stripping common indentation by default.
+ */
+ indent: 0,
+};
diff --git a/src/renderer/editor/addEditorAutocomplete.ts b/src/renderer/editor/addEditorAutocomplete.ts
new file mode 100644
index 0000000..560a01a
--- /dev/null
+++ b/src/renderer/editor/addEditorAutocomplete.ts
@@ -0,0 +1,186 @@
+import { Ace, require as acequire } from 'ace-builds';
+import robotKeyNumberMap from '../robotKeyNumberMap';
+import readApiCall from './readApiCall';
+
+const { TokenIterator } = acequire('ace/token_iterator');
+
+/**
+ * The base score to give to our added completions. Set at this value to override Ace's default
+ * keyword and 'local' variable completions.
+ */
+const COMP_SCORE = 200;
+
+/**
+ * Completer for PiE globals.
+ */
+const globalCompleter = {
+ getCompletions: (
+ _editor: Ace.Editor,
+ _session: Ace.EditSession,
+ _pos: Ace.Point,
+ _prefix: string,
+ callback: Ace.CompleterCallback,
+ ) => {
+ callback(
+ null,
+ ['Robot', 'Keyboard', 'Gamepad'].map((value) => ({
+ value,
+ meta: 'PiE API',
+ score: COMP_SCORE - 10,
+ })),
+ );
+ },
+};
+
+/**
+ * Creates a completer that only shows its completions when the tokens before the caret
+ * match one of the given strings.
+ * @param ctx - the string to match the tokens before the caret against. Be advised that this is
+ * only designed to support function calls; parenthese used outside function calls, braces, spaces
+ * between identifiers, and any kind of punctuation other than periods will never be matched.
+ * @param completions - the array of completions to show if the tokens around the caret are
+ * matched with one of the lastToken strings.
+ * @return The created completer.
+ */
+function makeContextCompleter(ctx: string, completions: string[]) {
+ return {
+ getCompletions: (
+ _editor: Ace.Editor,
+ session: Ace.EditSession,
+ pos: Ace.Point,
+ _prefix: string,
+ callback: Ace.CompleterCallback,
+ ) => {
+ const maxLength =
+ ctx.length +
+ Math.max(...completions.map((completion) => completion.length));
+ const buf = readApiCall(session, pos, maxLength);
+ const isContext = buf.startsWith(ctx);
+ callback(
+ null,
+ isContext
+ ? completions
+ .filter(
+ (completion) =>
+ (ctx + completion).startsWith(buf.trim()) &&
+ ctx + completion !== buf.trim(),
+ )
+ .map((caption) => {
+ return {
+ caption,
+ // FIXME
+ // Completion is multiple tokens (e.g. Gamepad.get_value completions): slice is needed
+ // to remove already-typed string from completion.
+ // Context is single token: unsliced is needed or else existing text is replaced with
+ // sliced completion, effectively deleting the already-typed bit.
+ // value: caption.slice(buf.length - ctx.length),
+ value: caption,
+ meta: 'PiE API',
+ score: COMP_SCORE,
+ };
+ })
+ : [],
+ );
+ },
+ onInsert: (_editor: Ace.Editor, _completion: Ace.Completion) => {
+ // Adding something over here could maybe fix sliced completion?
+ },
+ };
+}
+
+/**
+ * Wraps a completer so it does not trigger if the current or preceeding token contains a dot.
+ * @param completer - the completer to wrap.
+ * @return The wrapped completer.
+ */
+function adaptGlobalCompleter(completer: Ace.Completer) {
+ return {
+ getCompletions: (
+ editor: Ace.Editor,
+ session: Ace.EditSession,
+ pos: Ace.Point,
+ prefix: string,
+ callback: Ace.CompleterCallback,
+ ) => {
+ const iter = new TokenIterator(session, pos.row, pos.column);
+ const curTokenDot =
+ iter.getCurrentToken() !== undefined &&
+ iter.getCurrentToken().value &&
+ iter.getCurrentToken().value.includes('.');
+ const prevTokenDot =
+ iter.stepBackward() !== null &&
+ iter.getCurrentToken().value &&
+ iter.getCurrentToken().value.includes('.');
+ if (!curTokenDot && !prevTokenDot) {
+ completer.getCompletions(editor, session, pos, prefix, callback);
+ }
+ },
+ };
+}
+
+/**
+ * Adds PiE API completers to the given editor.
+ * @param editor - the editor to modify
+ */
+export default function addEditorAutocomplete(editor: Ace.Editor) {
+ editor.commands.addCommand({
+ name: 'dotAutoComplete',
+ bindKey: {
+ win: '.',
+ mac: '.',
+ },
+ exec: () => {
+ editor.insert('.');
+ editor.execCommand('startAutocomplete');
+ },
+ });
+ editor.commands.addCommand({
+ name: 'parenAutoComplete',
+ bindKey: {
+ win: '(',
+ mac: '(',
+ },
+ exec: () => {
+ editor.insert('(');
+ editor.execCommand('startAutocomplete');
+ },
+ });
+ editor.completers = [
+ adaptGlobalCompleter(globalCompleter),
+ makeContextCompleter('Robot.', [
+ 'get_value',
+ 'set_value',
+ 'start_pos',
+ 'sleep',
+ 'log',
+ 'is_running',
+ 'run',
+ ]),
+ makeContextCompleter('Gamepad.', ['available', 'get_value']),
+ makeContextCompleter('Gamepad.get_value(', [
+ '"button_a"',
+ '"button_b"',
+ '"button_x"',
+ '"button_y"',
+ '"l_bumper"',
+ '"r_bumper"',
+ '"l_trigger"',
+ '"r_trigger"',
+ '"button_back"',
+ '"button_start"',
+ '"l_stick"',
+ '"r_stick"',
+ '"dpad_up"',
+ '"dpad_down"',
+ '"dpad_left"',
+ '"dpad_right"',
+ '"button_xbox"',
+ ]),
+ makeContextCompleter('Keyboard.', ['available', 'get_value']),
+ makeContextCompleter(
+ 'Keyboard.get_value(',
+ Object.keys(robotKeyNumberMap).map((c) => `"${c}"`),
+ ),
+ ...(editor.completers || []).map(adaptGlobalCompleter),
+ ];
+}
diff --git a/src/renderer/editor/addEditorTooltips.tsx b/src/renderer/editor/addEditorTooltips.tsx
new file mode 100644
index 0000000..e9f02ba
--- /dev/null
+++ b/src/renderer/editor/addEditorTooltips.tsx
@@ -0,0 +1,78 @@
+import { Ace, require as acequire } from 'ace-builds';
+import { ReactNode } from 'react';
+import { createRoot } from 'react-dom/client';
+import ApiLink from './ApiLink';
+import HighlightedCode from './HighlightedCode';
+import readApiCall from './readApiCall';
+
+const { HoverTooltip } = acequire('ace/tooltip');
+
+const apiHelpComponents: {
+ [matchText: string]: () => ReactNode;
+} = {
+ 'Robot.get_value': () => (
+
+ The get_value function returns the current value of a
+ specified param on a device with the specified{' '}
+ device_id.
+
+ Parameters:
+
+
+ device_id: the ID that
+ specifies which PiE device will be read
+
+
+ param: identifies which
+ parameter on the specified PiE device will be read. Possible param
+ values depend on the specified device. Find a list of params for each
+ type of device on the{' '}
+ lowcar devices page.
+
+
+ The function is useful for checking the current state of devices. For
+ example, getting the current state of a limit switch using its{' '}
+ device_id and the param "switch0" will
+ return True when pressed down and False if not.
+ {`
+ # First segment of code ran in the teleop process
+ limit_switch = "//INSERT SWITCH ID HERE//"
+
+ def teleop_setup():
+ print("Tele-operated mode has started!")
+
+ def teleop_main():
+ # Example code for getting the value of a limit switch
+ # First parameter is the limit switch's id
+ # Second parameter tells which switch to get the value from
+ # In this case the method will return True or False depending on if the switch is pressed down or not
+
+ Robot.get_value(limit_switch, switch0)
+ `}
+
+ ),
+ Robot: () =>
Documentation for Robot object.
,
+};
+
+/**
+ * Configures hover tooltips for PiE API stuff in the given editor.
+ * @param editor - the editor to modify
+ */
+export default function addEditorTooltips(editor: Ace.Editor) {
+ const tooltip = new HoverTooltip();
+ const node = document.createElement('div');
+ const root = createRoot(node);
+ // Check just past longest match in case the very next character
+ const maxMatchTextLength =
+ Math.max(...Object.keys(apiHelpComponents).map((s) => s.length)) + 1;
+ tooltip.setDataProvider((event: any, _editor: Ace.Editor) => {
+ const pos: Ace.Position = event.getDocumentPosition();
+ const range = editor.session.getWordRange(pos.row, pos.column);
+ const result = readApiCall(editor.session, range.end, maxMatchTextLength);
+ if (result in apiHelpComponents) {
+ root.render(apiHelpComponents[result]());
+ tooltip.showForRange(editor, range, node, event);
+ }
+ });
+ tooltip.addToEditor(editor);
+}
diff --git a/src/renderer/editor/readApiCall.ts b/src/renderer/editor/readApiCall.ts
new file mode 100644
index 0000000..4f01611
--- /dev/null
+++ b/src/renderer/editor/readApiCall.ts
@@ -0,0 +1,71 @@
+import { Ace, require as acequire } from 'ace-builds';
+
+const { TokenIterator } = acequire('ace/token_iterator');
+
+/**
+ * Reads a string that looks like an API call just before the given position in the editor.
+ * An "API call" may include parentheses used to call a function, periods, identifier characters,
+ * and whitespace before and after non-identifiers. Tokens the Ace tokenizer considers to be part of
+ * a string in the editor's current mode also delimit an API call. Comments and line breaks around
+ * non-identifiers are ignored, and string collections continues on the other side of the comment.
+ * Probably mode-independent, though it hasn't been tested on anything besides Python.
+ * @param session - the Ace editing session to use to read the editor contents.
+ * @param pos - the position to start reading behind.
+ * @param minLength - the minimum length in characters of text to read. A whole number of tokens
+ * will always be read, but reading is stopped after reading at least this many characters.
+ * @return The text of the read "API call", which may be an empty string if the first token before
+ * pos is not part of an API call.
+ */
+export default (
+ session: Ace.EditSession,
+ pos: Ace.Position,
+ minLength: number,
+): string => {
+ const iter = new TokenIterator(session, pos.row, pos.column);
+ let token = iter.getCurrentToken();
+ const tokenIsIdent = () =>
+ ['identifier', 'function.support'].includes(token.type);
+ const firstToken = token;
+ while (token === undefined || token.value.trim() === '') {
+ token = iter.stepBackward();
+ if (token === null) {
+ return '';
+ }
+ }
+ if (token.type === 'comment' || token.type === 'string') {
+ return '';
+ }
+ let lastWasIdentifier = tokenIsIdent();
+ let buf = token.value.trim();
+ let posInBuf;
+ if (token === firstToken) {
+ posInBuf = pos.column - iter.getCurrentTokenColumn();
+ } else {
+ posInBuf = buf.length;
+ }
+ buf = buf.slice(0, posInBuf);
+ while (buf.length < minLength) {
+ token = iter.stepBackward();
+ if (token === null) {
+ break;
+ }
+ const tokenIsWhitespace = token.value.trim() === '';
+ // The following conditions cause a token break if coming before an identifier:
+ const preIdentBreak =
+ tokenIsIdent() ||
+ token.type.startsWith('paren') ||
+ token.type === 'string' ||
+ (token.type === 'punctuation' && !token.value.includes('.'));
+ if (lastWasIdentifier && preIdentBreak) {
+ break;
+ }
+ if (token.type !== 'comment') {
+ buf = token.value.trim() + buf;
+ posInBuf += token.value.trim().length;
+ if (!tokenIsWhitespace) {
+ lastWasIdentifier = tokenIsIdent();
+ }
+ }
+ }
+ return buf;
+};
diff --git a/src/renderer/modals/ConfirmModal.tsx b/src/renderer/modals/ConfirmModal.tsx
index 50de41d..1c2ce99 100644
--- a/src/renderer/modals/ConfirmModal.tsx
+++ b/src/renderer/modals/ConfirmModal.tsx
@@ -46,8 +46,7 @@ export default function ConfirmModal({
);
}
/**
- * Default properties for ConfirmModal. Not sure why we need this if we have the default
- * deconstruction parameter but the linter cries if we leave it out.
+ * Default properties for ConfirmModal.
*/
ConfirmModal.defaultProps = {
/**
diff --git a/src/renderer/modals/Modal.tsx b/src/renderer/modals/Modal.tsx
index df52b46..77379a2 100644
--- a/src/renderer/modals/Modal.tsx
+++ b/src/renderer/modals/Modal.tsx
@@ -19,8 +19,8 @@ export default function Modal({
onClose: () => void;
isActive: boolean;
modalTitle: string;
- children: ReactNode;
className?: string;
+ children: ReactNode;
}) {
return (