diff --git a/public/FIRST_Horz_RGB.png b/public/FIRST_HorzRGB.png similarity index 100% rename from public/FIRST_Horz_RGB.png rename to public/FIRST_HorzRGB.png diff --git a/src/App.tsx b/src/App.tsx index 1dd0cfb5..8ab31e25 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,19 +23,19 @@ import * as Antd from 'antd'; import '@ant-design/v5-patch-for-react-19'; import * as Blockly from 'blockly/core'; -import {pythonGenerator} from 'blockly/python'; +import { pythonGenerator } from 'blockly/python'; import Header from './reactComponents/Header'; import * as Menu from './reactComponents/Menu'; import CodeDisplay from './reactComponents/CodeDisplay'; -import BlocklyComponent, {BlocklyComponentType} from './reactComponents/BlocklyComponent'; +import BlocklyComponent, { BlocklyComponentType } from './reactComponents/BlocklyComponent'; import ToolboxSettingsModal from './reactComponents/ToolboxSettings'; import * as Tabs from './reactComponents/Tabs'; -import {TabType } from './types/TabType'; +import { TabType } from './types/TabType'; -import {createGeneratorContext, GeneratorContext} from './editor/generator_context'; +import { createGeneratorContext, GeneratorContext } from './editor/generator_context'; import * as editor from './editor/editor'; -import {extendedPythonGenerator} from './editor/extended_python_generator'; +import { extendedPythonGenerator } from './editor/extended_python_generator'; import * as commonStorage from './storage/common_storage'; import * as clientSideStorage from './storage/client_side_storage'; @@ -46,6 +46,7 @@ import { initialize as initializePythonBlocks } from './blocks/utils/python'; import * as ChangeFramework from './blocks/utils/change_framework' import { mutatorOpenListener } from './blocks/mrc_param_container' import { TOOLBOX_UPDATE_EVENT } from './blocks/mrc_mechanism_component_holder'; +import { antdThemeFromString } from './reactComponents/ThemeModal'; /** Storage key for shown toolbox categories. */ const SHOWN_TOOLBOX_CATEGORIES_KEY = 'shownPythonToolboxCategories'; @@ -98,6 +99,7 @@ const App: React.FC = (): React.JSX.Element => { const [triggerPythonRegeneration, setTriggerPythonRegeneration] = React.useState(0); const [leftCollapsed, setLeftCollapsed] = React.useState(false); const [rightCollapsed, setRightCollapsed] = React.useState(false); + const [theme, setTheme] = React.useState('dark'); const blocksEditor = React.useRef(null); @@ -264,7 +266,7 @@ const App: React.FC = (): React.JSX.Element => { // Add event listener for toolbox updates React.useEffect(() => { window.addEventListener(TOOLBOX_UPDATE_EVENT, handleToolboxUpdateRequest); - + return () => { window.removeEventListener(TOOLBOX_UPDATE_EVENT, handleToolboxUpdateRequest); }; @@ -312,8 +314,8 @@ const App: React.FC = (): React.JSX.Element => { if (currentModule && blocklyComponent.current && generatorContext.current) { const blocklyWorkspace = blocklyComponent.current.getBlocklyWorkspace(); setGeneratedCode(extendedPythonGenerator.mrcWorkspaceToCode( - blocklyWorkspace, - generatorContext.current + blocklyWorkspace, + generatorContext.current )); } else { setGeneratedCode(''); @@ -336,26 +338,14 @@ const App: React.FC = (): React.JSX.Element => { } }, [project]); - const {Sider,Content} = Antd.Layout; + const { Sider, Content } = Antd.Layout; return ( {contextHolder} - +
{ project={project} setProject={setProject} openWPIToolboxSettings={() => setToolboxSettingsModalIsOpen(true)} + setTheme={setTheme} /> @@ -395,7 +386,10 @@ const App: React.FC = (): React.JSX.Element => { /> - + { generatedCode={generatedCode} messageApi={messageApi} setAlertErrorMessage={setAlertErrorMessage} + theme={theme} /> diff --git a/src/reactComponents/BlocklyComponent.tsx b/src/reactComponents/BlocklyComponent.tsx index 9185bd85..3ce48557 100644 --- a/src/reactComponents/BlocklyComponent.tsx +++ b/src/reactComponents/BlocklyComponent.tsx @@ -21,7 +21,8 @@ import * as React from 'react'; import * as Blockly from 'blockly/core'; import * as locale from 'blockly/msg/en'; -import * as MrcTheme from '../themes/mrc_theme_dark'; +import * as MrcDarkTheme from '../themes/mrc_theme_dark'; +import * as MrcLightTheme from '../themes/mrc_theme_light'; import {pluginInfo as HardwareConnectionsPluginInfo} from '../blocks/utils/connection_checker'; import 'blockly/blocks'; // Includes standard blocks like controls_if, logic_compare, etc. @@ -31,6 +32,11 @@ export interface BlocklyComponentType { getBlocklyWorkspace: () => Blockly.WorkspaceSvg; } +/** Interface for props passed to the BlocklyComponent. */ +export interface BlocklyComponentProps { + theme: string; +} + /** Grid spacing for the Blockly workspace. */ const GRID_SPACING = 20; @@ -68,14 +74,25 @@ const WORKSPACE_STYLE: React.CSSProperties = { * React component that renders a Blockly workspace with proper initialization, * cleanup, and resize handling. */ -const BlocklyComponent = React.forwardRef( - (_, ref): React.JSX.Element => { +const BlocklyComponent = React.forwardRef( + ({ theme }, ref): React.JSX.Element => { const blocklyDiv = React.useRef(null); const workspaceRef = React.useRef(null); + const getBlocklyTheme = (): Blockly.Theme => { + if (theme === 'dark' || theme === 'compact-dark') { + return MrcDarkTheme.theme; + } + if (theme === 'light' || theme === 'compact') { + return MrcLightTheme.theme; + } + // Default to light theme if unknown + return MrcLightTheme.theme; + }; + /** Creates the Blockly workspace configuration object. */ const createWorkspaceConfig = (): Blockly.BlocklyOptions => ({ - theme: MrcTheme.theme, + theme: getBlocklyTheme(), horizontalLayout: false, // Forces vertical layout for the workspace // Start with an empty (but not null) toolbox. It will be replaced later. toolbox: { @@ -168,6 +185,14 @@ const BlocklyComponent = React.forwardRef( return cleanupWorkspace; }, []); + // Update theme when theme prop changes + React.useEffect(() => { + if (workspaceRef.current) { + const newTheme = getBlocklyTheme(); + workspaceRef.current.setTheme(newTheme); + } + }, [theme]); + // Handle workspace resize React.useEffect(() => { return setupResizeObserver(); diff --git a/src/reactComponents/CodeDisplay.tsx b/src/reactComponents/CodeDisplay.tsx index c3062a2e..66a1ba25 100644 --- a/src/reactComponents/CodeDisplay.tsx +++ b/src/reactComponents/CodeDisplay.tsx @@ -20,11 +20,11 @@ */ import * as Antd from 'antd'; import * as React from 'react'; -import {CopyOutlined as CopyIcon} from '@ant-design/icons'; -import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'; -import {dracula} from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { CopyOutlined as CopyIcon } from '@ant-design/icons'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { dracula, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import type {MessageInstance} from 'antd/es/message/interface'; +import type { MessageInstance } from 'antd/es/message/interface'; /** Function type for setting string values. */ type StringFunction = (input: string) => void; @@ -32,25 +32,17 @@ type StringFunction = (input: string) => void; /** Props for the CodeDisplay component. */ interface CodeDisplayProps { generatedCode: string; + theme: string; messageApi: MessageInstance; setAlertErrorMessage: StringFunction; } -/** Background color for the syntax highlighter. */ -const SYNTAX_HIGHLIGHTER_BACKGROUND = '#333'; - /** Full dimensions style for the syntax highlighter. */ const FULL_SIZE_STYLE = { width: '100%', height: '100%', }; -/** Header text color. */ -const HEADER_TEXT_COLOR = '#fff'; - -/** Button text color. */ -const BUTTON_TEXT_COLOR = 'white'; - /** Success message for copy operation. */ const COPY_SUCCESS_MESSAGE = 'Copy completed successfully.'; @@ -62,37 +54,52 @@ const COPY_ERROR_MESSAGE_PREFIX = 'Could not copy code: '; * Shows generated Python code in a dark theme with a copy button. */ export default function CodeDisplay(props: CodeDisplayProps): React.JSX.Element { + const syntaxHighligherFromTheme = (theme: string) => { + switch (theme) { + case 'dark': + case 'compact-dark': + return dracula; + case 'light': + case 'compact': + return oneLight; + default: + return dracula; // Default to dracula if theme is unknown + } + } + + const { token } = Antd.theme.useToken(); + const syntaxStyle = syntaxHighligherFromTheme(props.theme); + /** Handles copying the generated code to clipboard. */ const handleCopyCode = (): void => { navigator.clipboard.writeText(props.generatedCode).then( - () => { - props.messageApi.open({ - type: 'success', - content: COPY_SUCCESS_MESSAGE, - }); - }, - (err) => { - props.setAlertErrorMessage(COPY_ERROR_MESSAGE_PREFIX + err); - } + () => { + props.messageApi.open({ + type: 'success', + content: COPY_SUCCESS_MESSAGE, + }); + }, + (err) => { + props.setAlertErrorMessage(COPY_ERROR_MESSAGE_PREFIX + err); + } ); }; /** Creates the custom style object for the syntax highlighter. */ const getSyntaxHighlighterStyle = (): React.CSSProperties => ({ - backgroundColor: SYNTAX_HIGHLIGHTER_BACKGROUND, + backgroundColor: token.colorBgContainer, ...FULL_SIZE_STYLE, }); /** Renders the header section with title and copy button. */ const renderHeader = (): React.JSX.Element => ( -

Code

+ Code } size="small" onClick={handleCopyCode} - style={{color: BUTTON_TEXT_COLOR}} />
@@ -102,7 +109,7 @@ export default function CodeDisplay(props: CodeDisplayProps): React.JSX.Element const renderCodeBlock = (): React.JSX.Element => ( {props.generatedCode} diff --git a/src/reactComponents/Header.tsx b/src/reactComponents/Header.tsx index ceb8edf1..01c706fb 100644 --- a/src/reactComponents/Header.tsx +++ b/src/reactComponents/Header.tsx @@ -49,6 +49,10 @@ const TEXT_PADDING_LEFT = 20; * and any error messages. */ export default function Header(props: HeaderProps): React.JSX.Element { + const { token } = Antd.theme.useToken(); + + const isDarkTheme = token.colorBgLayout === '#000000'; + /** Handles clearing the error message. */ const handleClearError = (): void => { props.setAlertErrorMessage(''); @@ -76,17 +80,16 @@ export default function Header(props: HeaderProps): React.JSX.Element { }; return ( - + FIRST Logo ['items'][number]; @@ -50,6 +52,7 @@ export interface MenuProps { project: commonStorage.Project | null; openWPIToolboxSettings: () => void; setProject: (project: commonStorage.Project | null) => void; + setTheme: (theme: string) => void; } /** Default selected menu keys. */ @@ -133,7 +136,7 @@ function getMenuItems(t: (key: string) => string, project: commonStorage.Project ]), getItem(t('Settings'), 'settings', , [ getItem(t('WPI toolbox'), 'wpi_toolbox'), - getItem(t('Theme') + '...', 'theme') + getItem(t('Theme') + '...', 'theme', ) ]), getItem(t('Help'), 'help', , [ getItem(t('About') + '...', 'about', @@ -156,6 +159,14 @@ export function Component(props: MenuProps): React.JSX.Element { const [moduleType, setModuleType] = React.useState(TabType.MECHANISM); const [noProjects, setNoProjects] = React.useState(false); const [aboutDialogVisible, setAboutDialogVisible] = React.useState(false); + const [themeModalOpen, setThemeModalOpen] = React.useState(false); + const [currentTheme, setCurrentTheme] = React.useState('dark'); + + + const handleThemeChange = (newTheme: string) => { + setCurrentTheme(newTheme); + props.setTheme(newTheme); + }; /** Fetches the list of modules from storage. */ const fetchListOfModules = async (): Promise => { @@ -251,6 +262,8 @@ export function Component(props: MenuProps): React.JSX.Element { setAboutDialogVisible(true); } else if (key === 'wpi_toolbox'){ props.openWPIToolboxSettings(); + } else if (key === 'theme') { + setThemeModalOpen(true); } else { // TODO: Handle other menu actions console.log(`Selected key that wasn't module: ${key}`); @@ -322,6 +335,12 @@ export function Component(props: MenuProps): React.JSX.Element { visible={aboutDialogVisible} onClose={() => setAboutDialogVisible(false)} /> + setThemeModalOpen(false)} + currentTheme={currentTheme} + onThemeChange={handleThemeChange} + /> ); } diff --git a/src/reactComponents/ThemeModal.tsx b/src/reactComponents/ThemeModal.tsx new file mode 100644 index 00000000..cec3bf6b --- /dev/null +++ b/src/reactComponents/ThemeModal.tsx @@ -0,0 +1,242 @@ +/** + * @fileoverview Theme selection modal component. + */ + +import * as React from 'react'; +import * as Antd from 'antd'; +import { + BgColorsOutlined, + CheckOutlined, + MoonOutlined, + SunOutlined, + DesktopOutlined, +} from '@ant-design/icons'; + +export interface ThemeOption { + key: string; + name: string; + icon: React.ReactNode; + description: string; +} + +export interface ThemeModalProps { + open: boolean; + onClose: () => void; + currentTheme: string; + onThemeChange: (themeKey: string) => void; +} + +const THEME_OPTIONS: ThemeOption[] = [ + { + key: 'light', + name: 'Light Theme', + icon: , + description: 'Clean and bright interface for daytime use', + }, + { + key: 'dark', + name: 'Dark Theme', + icon: , + description: 'Easy on the eyes for low-light environments', + }, + { + key: 'compact', + name: 'Compact Theme', + icon: , + description: 'More content in less space', + }, + { + key: 'compact-dark', + name: 'Compact Dark', + icon: , + description: 'Dark theme with compact layout', + }, +]; + +const ThemeModal: React.FC = ({ + open, + onClose, + currentTheme, + onThemeChange, +}) => { + const [selectedTheme, setSelectedTheme] = React.useState(currentTheme); + + React.useEffect(() => { + setSelectedTheme(currentTheme); + }, [currentTheme]); + + const handleThemeSelect = (themeKey: string) => { + setSelectedTheme(themeKey); + }; + + const handleApplyTheme = () => { + onThemeChange(selectedTheme); + onClose(); + }; + + const handleCancel = () => { + setSelectedTheme(currentTheme); + onClose(); + }; + const { token } = Antd.theme.useToken(); + + return ( + + + Theme Selection + + } + open={open} + onCancel={handleCancel} + footer={[ + + Cancel + , + + Apply Theme + , + ]} + width={600} + destroyOnHidden + > +
+ + Choose a theme that best suits your preference and working environment. + + + + {THEME_OPTIONS.map((theme) => ( + + + handleThemeSelect(theme.key)} + style={{ + border: selectedTheme === theme.key ? '2px solid #1890ff' : '1px solid #d9d9d9', + position: 'relative', + cursor: 'pointer', + }} + > + {selectedTheme === theme.key && ( +
+ +
+ )} + +
+
+ {theme.icon} +
+ + {theme.name} + +
+ + + {theme.description} + + + {/* Theme preview */} +
+
+ + Primary + +
+
+
+
+ +
+ ))} +
+ + + + +
+
+ ); +}; + +export default ThemeModal; + +export const antdThemeFromString = (theme: string): Antd.ThemeConfig => { + let compact = false; + if (theme == 'compact-dark') { + compact = true; + theme = 'dark'; + } + else if (theme == 'compact') { + compact = true; + theme = 'light'; + } + if (theme === 'dark') { + return { + algorithm: compact ? [Antd.theme.darkAlgorithm, Antd.theme.compactAlgorithm] : Antd.theme.darkAlgorithm, + components: { + Layout: { + headerBg: '#000000', + siderBg: '#000000', + triggerBg: '#000000', + }, + Menu: { + darkItemBg: '#000000', + darkSubMenuItemBg: '#000000', + } + } + } + } + else if (theme === 'light') { + return { + algorithm: compact ? [Antd.theme.defaultAlgorithm, Antd.theme.compactAlgorithm] : Antd.theme.defaultAlgorithm, + components: { + Layout: { + headerBg: '#ffffff', + siderBg: '#ffffff', + triggerBg: '#ffffff', + triggerColor: '#000000', + }, + Menu: { + darkItemBg: '#ffffff', + darkSubMenuItemBg: '#ffffff', + darkItemColor: '#000000', + darkItemSelectedColor: '#000000', + } + } + } + } + return antdThemeFromString('light'); // Default to light theme if unknown +}