From 2319b0aba4f8f05efbb077beffe3090d83461b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Sun, 6 Sep 2026 23:33:45 +0200 Subject: [PATCH 01/14] v1 --- .../src/KeyboardShortcuts/ShortcutsList.js | 216 +- .../src/KeyboardShortcuts/ShortcutsListRow.js | 111 +- .../MainFrame/Preferences/LanguageSelector.js | 79 +- .../Preferences/PreferencesDialog.js | 1796 +++++++++++------ newIDE/app/src/UI/CompactToggleField/index.js | 3 +- newIDE/app/src/UI/Dialog.js | 17 +- newIDE/app/src/UI/SettingsRow.css | 8 + newIDE/app/src/UI/SettingsRow.js | 100 + .../Preferences/PreferencesDialog.stories.js | 193 ++ .../src/stories/everything-else.stories.js | 16 - 10 files changed, 1782 insertions(+), 757 deletions(-) create mode 100644 newIDE/app/src/UI/SettingsRow.css create mode 100644 newIDE/app/src/UI/SettingsRow.js create mode 100644 newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index 32aa71fcb0f6..a559fd71736d 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -1,18 +1,13 @@ // @flow import * as React from 'react'; -import { t, Trans } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; import { type I18n } from '@lingui/core'; -import List from '@material-ui/core/List'; import Text from '../UI/Text'; +import EmptyMessage from '../UI/EmptyMessage'; import DetectShortcutDialog from './DetectShortcutDialog'; -import RaisedButton from '../UI/RaisedButton'; -import DismissableAlertMessage from '../UI/DismissableAlertMessage'; import { type ShortcutMap } from './DefaultShortcuts'; import { getShortcutDisplayName } from './index'; -import Window from '../Utils/Window'; -import defaultShortcuts, { - defaultSecondaryShortcuts, -} from '../KeyboardShortcuts/DefaultShortcuts'; +import defaultShortcuts from '../KeyboardShortcuts/DefaultShortcuts'; import ShortcutsListRow from './ShortcutsListRow'; import commandsList, { type CommandName, @@ -20,6 +15,13 @@ import commandsList, { } from '../CommandPalette/CommandsList'; import { ColumnStackLayout } from '../UI/Layout'; +const styles = { + section: { + display: 'flex', + flexDirection: 'column', + }, +}; + /** * Get shortcut string to be displayed after patching the default * shortcut with user-defined shortcut, if any. @@ -68,11 +70,103 @@ const sortCommandsIntoAreasAndGetReverseMap = ( return [areaWiseCommands, shortcutStringToCommands]; }; +/** + * Normalize a text for a search: case insensitive and ignoring spaces, so that + * "ctrl+s" matches "Ctrl + S". + */ +export const normalizeForSearch = (text: string): string => + text.toLowerCase().replace(/\s+/g, ''); + +export type ShortcutRowData = {| + commandName: CommandName, + commandDisplayText: string, + shortcutDisplayName: string, + isDefault: boolean, + /** The other commands using the same shortcut, if any. */ + clashingCommandNames: Array, +|}; + +export type ShortcutSectionData = {| + areaName: string, + title: string, + rows: Array, +|}; + +/** + * Get the shortcuts grouped by area, keeping only the commands matching the + * search text (by name or by shortcut). Sections without any match are omitted. + */ +export const getShortcutSections = ( + i18n: I18n, + userShortcutMap: ShortcutMap, + searchText: string +): Array => { + const [ + areaWiseCommands, + shortcutStringToCommands, + ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap); + const normalizedSearchText = normalizeForSearch(searchText); + + return Object.keys(areaWiseCommands) + .map(areaName => { + const rows = areaWiseCommands[areaName] + .map( + (commandName: CommandName): ShortcutRowData | null => { + // Get default and user-set shortcuts + const userShortcut = userShortcutMap[commandName]; + const defaultShortcut = defaultShortcuts[commandName] || ''; + const shortcutString = getPatchedShortcutString( + defaultShortcut, + userShortcut + ); + const shortcutDisplayName = getShortcutDisplayName(shortcutString); + const commandDisplayText = i18n._( + commandsList[commandName].displayText + ); + + const matchesSearch = + !normalizedSearchText || + normalizeForSearch(commandDisplayText).includes( + normalizedSearchText + ) || + normalizeForSearch(shortcutDisplayName).includes( + normalizedSearchText + ); + if (!matchesSearch) return null; + + // Find the other commands using the same shortcut, if any. + const clashingCommandNames = ( + shortcutStringToCommands[shortcutString] || [] + ).filter(otherCommandName => otherCommandName !== commandName); + + return { + commandName, + commandDisplayText, + shortcutDisplayName, + isDefault: shortcutString === defaultShortcut, + clashingCommandNames, + }; + } + ) + .filter(Boolean); + + return { + areaName, + title: i18n._(commandAreas[areaName]), + rows, + }; + }) + .filter(section => section.rows.length > 0); +}; + type Props = {| i18n: I18n, userShortcutMap: ShortcutMap, onEdit: (commandName: CommandName, shortcut: string) => void, - onReset: () => void, + /** Filter the displayed commands by name or by shortcut. */ + searchText?: string, + /** Only display the commands of this area, without the area title. */ + areaName?: string, |}; const ShortcutsList = (props: Props): React.Node => { @@ -81,91 +175,43 @@ const ShortcutsList = (props: Props): React.Node => { setEditedShortcut, ] = React.useState(null); - const resetAllShortcutsToDefault = () => { - const answer = Window.showConfirmDialog( - props.i18n._( - t`Are you sure you want to reset all shortcuts to their default values?` - ), - 'question' - ); - if (answer) props.onReset(); - }; - const resetShortcut = (commandName: CommandName) => { props.onEdit(commandName, defaultShortcuts[commandName]); }; - const [ - areaWiseCommands, - shortcutStringToCommands, - ] = sortCommandsIntoAreasAndGetReverseMap(props.userShortcutMap); - - const commandPaletteShortcut = getShortcutDisplayName( - props.userShortcutMap['OPEN_COMMAND_PALETTE'] || - defaultShortcuts['OPEN_COMMAND_PALETTE'] - ); - const commandPaletteSecondaryShortcut = getShortcutDisplayName( - defaultSecondaryShortcuts['OPEN_COMMAND_PALETTE'] - ); + const sections = getShortcutSections( + props.i18n, + props.userShortcutMap, + props.searchText || '' + ).filter(section => !props.areaName || section.areaName === props.areaName); return ( - - - - You can open the command palette by pressing {commandPaletteShortcut}{' '} - or {commandPaletteSecondaryShortcut}. - - - Reset all shortcuts to default} - onClick={resetAllShortcutsToDefault} - fullWidth - /> - - {Object.keys(areaWiseCommands).map(areaName => ( - - - {props.i18n._(commandAreas[areaName])} - - {areaWiseCommands[areaName].map((commandName: string) => { - // Get default and user-set shortcuts - // $FlowFixMe[incompatible-type] - const userShortcut = props.userShortcutMap[commandName]; - // $FlowFixMe[incompatible-type] - const defaultShortcut = defaultShortcuts[commandName] || ''; - const shortcutString = getPatchedShortcutString( - defaultShortcut, - userShortcut - ); - const shortcutDisplayName = getShortcutDisplayName( - shortcutString - ); - // Check if shortcut clashes with another command - const clashingCommands = shortcutStringToCommands[shortcutString]; - const hasClash = clashingCommands && clashingCommands.length > 1; - - return ( + + {sections.length > 0 ? ( + sections.map(section => ( +
+ {!props.areaName && {section.title}} +
+ {section.rows.map(row => ( setEditedShortcut(commandName)} - // $FlowFixMe[incompatible-type] - onResetShortcut={() => resetShortcut(commandName)} + key={row.commandName} + shortcutString={row.shortcutDisplayName} + commandName={row.commandName} + isDefault={row.isDefault} + clashingCommandNames={row.clashingCommandNames} + onEditShortcut={() => setEditedShortcut(row.commandName)} + onResetShortcut={() => resetShortcut(row.commandName)} /> - ); - })} - - ))} - + ))} +
+
+ )) + ) : ( + + No shortcut matches your search. + + )} {editedShortcut && ( , onEditShortcut: () => void, onResetShortcut: () => void, |}; const ShortcutsListRow = (props: Props): React.Node => { + const { isMobile } = useResponsiveWindowSize(); + + const commandDisplayText = props.i18n._( + commandsList[props.commandName].displayText + ); + const clashingCommandsDisplayText = props.clashingCommandNames + .map(clashingCommandName => + props.i18n._(commandsList[clashingCommandName].displayText) + ) + .join(', '); + return ( - - - - - {props.isClashing && ( - This shortcut clashes with another action.} - > - - - )} - No shortcut} - onClick={props.onEditShortcut} - color={props.shortcutString ? 'secondary' : 'default'} - /> - {!props.isDefault && ( - - - - )} - - - + + {props.clashingCommandNames.length > 0 && ( +
+ + This shortcut is also used by: {clashingCommandsDisplayText} + + } + > + + +
+ )} +
+ No shortcut} + onClick={props.onEditShortcut} + color={props.shortcutString ? 'secondary' : 'default'} + size={isMobile ? 'small' : 'medium'} + /> +
+
+ {!props.isDefault && ( + + + + )} +
+
); }; diff --git a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js index ddc5aa20c745..4cf9fe93eeb1 100644 --- a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js +++ b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js @@ -76,9 +76,55 @@ const renderLanguageSelectOption = localeMetadata => { ); }; -const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { +/** + * The select field listing the available languages, to be placed in a + * settings row or in a layout of your choice. + */ +export const LanguageSelectField = ({ + onLanguageChanged, +}: Props): React.Node => { const { values, setLanguage } = useContext(PreferencesContext); + return ( + { + setLanguage(value); + onLanguageChanged(value); + }} + > + + {goodProgressLocales.map(localeMetadata => + renderLanguageSelectOption(localeMetadata) + )} + {incompleteLocales.map(localeMetadata => + renderLanguageSelectOption(localeMetadata) + )} + + ); +}; + +/** + * The text inviting to contribute to the translations. + */ +export const TranslationContributionText = (): React.Node => ( + + + You can{' '} + + Window.openExternalURL('https://crowdin.com/project/gdevelop') + } + > + help translate GDevelop into your language + + . + + +); + +const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { return ( @@ -88,37 +134,10 @@ const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { - { - setLanguage(value); - onLanguageChanged(value); - }} - > - - {goodProgressLocales.map(localeMetadata => - renderLanguageSelectOption(localeMetadata) - )} - {incompleteLocales.map(localeMetadata => - renderLanguageSelectOption(localeMetadata) - )} - + - - - You can{' '} - - Window.openExternalURL('https://crowdin.com/project/gdevelop') - } - > - help translate GDevelop into your language - - . - - + ); }; diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index a7ab698b33db..b4200a954cc2 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -3,7 +3,9 @@ import { t, Trans } from '@lingui/macro'; import { type I18n } from '@lingui/core'; import React from 'react'; +import { Divider } from '@material-ui/core'; import FlatButton from '../../UI/FlatButton'; +import HelpButton from '../../UI/HelpButton'; import LocalFolderPicker from '../../UI/LocalFolderPicker'; import SelectOption from '../../UI/SelectOption'; import { CompactToggleField } from '../../UI/CompactToggleField'; @@ -15,33 +17,312 @@ import Window from '../../Utils/Window'; import optionalRequire from '../../Utils/OptionalRequire'; import PreferencesContext from './PreferencesContext'; import Text from '../../UI/Text'; -import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import EmptyMessage from '../../UI/EmptyMessage'; +import { ColumnStackLayout } from '../../UI/Layout'; import { Tabs } from '../../UI/Tabs'; -import ShortcutsList from '../../KeyboardShortcuts/ShortcutsList'; -import LanguageSelector from './LanguageSelector'; +import SettingsRow from '../../UI/SettingsRow'; +import VerticalTabButton from '../../UI/VerticalTabButton'; +import SearchBar from '../../UI/SearchBar'; +import DismissableAlertMessage from '../../UI/DismissableAlertMessage'; +import ShortcutsList, { + getShortcutSections, + normalizeForSearch, +} from '../../KeyboardShortcuts/ShortcutsList'; +import { LanguageSelectField } from './LanguageSelector'; import Link from '../../UI/Link'; +import { commandAreas } from '../../CommandPalette/CommandsList'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; import { adaptAcceleratorString } from '../../UI/AcceleratorString'; -import { getElectronAccelerator } from '../../KeyboardShortcuts'; -import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts'; +import { + getElectronAccelerator, + getShortcutDisplayName, +} from '../../KeyboardShortcuts'; +import defaultShortcuts, { + defaultSecondaryShortcuts, +} from '../../KeyboardShortcuts/DefaultShortcuts'; import AlertMessage from '../../UI/AlertMessage'; import ErrorBoundary from '../../UI/ErrorBoundary'; import CompactSelectField from '../../UI/CompactSelectField'; +import useAlertDialog from '../../UI/Alert/useAlertDialog'; +import TuneIcon from '../../UI/CustomSvgIcons/Tune'; +import BrushIcon from '../../UI/CustomSvgIcons/Brush'; +import GridIcon from '../../UI/CustomSvgIcons/Grid'; +import DesktopIcon from '../../UI/CustomSvgIcons/Desktop'; +import UpdateIcon from '../../UI/CustomSvgIcons/Update'; +import EventsIcon from '../../UI/CustomSvgIcons/Events'; +import PlayIcon from '../../UI/CustomSvgIcons/Play'; +import SceneIcon from '../../UI/CustomSvgIcons/Scene'; +import HelpIcon from '../../UI/CustomSvgIcons/Help'; +import SettingsIcon from '../../UI/CustomSvgIcons/Settings'; +import FolderIcon from '../../UI/CustomSvgIcons/Folder'; +import HammerIcon from '../../UI/CustomSvgIcons/Hammer'; +import ProjectManagerIcon from '../../UI/CustomSvgIcons/ProjectManager'; const electron = optionalRequire('electron'); +export type PreferencesSectionName = + | 'general' + | 'appearance' + | 'layouts' + | 'dialogs' + | 'updates' + | 'events-sheet' + | 'previews' + | 'scene-editor' + | 'help' + | 'other' + | 'folders' + | 'contributor'; + +export type PreferencesTabName = 'preferences' | 'shortcuts'; + +type ShortcutArea = $Keys; + +type GetIconFunction = ({ + color: string, + fontSize: 'inherit' | 'small', +}) => React.Node; + +type PreferencesSection = {| + name: PreferencesSectionName, + label: React.Node, + /** The label as a string, used by the search. */ + getSearchableLabel: (i18n: I18n) => string, + getIcon: GetIconFunction, +|}; + +const sections: Array = [ + { + name: 'general', + label: General, + getSearchableLabel: i18n => i18n._(t`General`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'appearance', + label: Appearance, + getSearchableLabel: i18n => i18n._(t`Appearance`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'layouts', + label: Layouts, + getSearchableLabel: i18n => i18n._(t`Layouts`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'dialogs', + label: Dialogs, + getSearchableLabel: i18n => i18n._(t`Dialogs`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'updates', + label: Updates, + getSearchableLabel: i18n => i18n._(t`Updates`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'events-sheet', + label: Events Sheet, + getSearchableLabel: i18n => i18n._(t`Events Sheet`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'previews', + label: Previews, + getSearchableLabel: i18n => i18n._(t`Previews`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'scene-editor', + label: Scene editor, + getSearchableLabel: i18n => i18n._(t`Scene editor`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'help', + label: Help and tutorials, + getSearchableLabel: i18n => i18n._(t`Help and tutorials`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'other', + label: Advanced, + getSearchableLabel: i18n => i18n._(t`Advanced`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + ...(electron + ? [ + { + name: 'folders', + label: Folders, + getSearchableLabel: i18n => i18n._(t`Folders`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + ] + : []), + { + name: 'contributor', + label: Contributor options, + getSearchableLabel: i18n => i18n._(t`Contributor options`), + getIcon: ({ color, fontSize }) => ( + + ), + }, +]; + +type ShortcutAreaDefinition = {| + name: ShortcutArea, + getIcon: GetIconFunction, +|}; + +/** + * The areas of the commands, displayed as the sections of the shortcuts tab. + * Their labels come from `commandAreas`. + */ +const shortcutAreas: Array = [ + { + name: 'GENERAL', + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'IDE', + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'PROJECT', + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'SCENE', + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'EVENTS', + getIcon: ({ color, fontSize }) => ( + + ), + }, +]; + +/** + * A setting displayed as a row: a translated label (also used by the search) + * and the control to change it. + */ +type SettingDefinition = {| + id: string, + label: string, + renderControl: () => React.Node, +|}; + +/** + * The content of a section: its settings, and optional content displayed + * around them when the section is opened (not when searching). + */ +type SectionContent = {| + settings: Array, + renderHeader?: () => React.Node, + renderFooter?: () => React.Node, +|}; + +const sectionsColumnWidth = 220; + +const styles = { + body: { + display: 'flex', + flex: 1, + minHeight: 0, + }, + sectionsColumn: { + width: sectionsColumnWidth, + flexShrink: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + paddingRight: 8, + }, + sectionContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + paddingLeft: 16, + }, + sectionContentOnMobile: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + }, + fullWidthControl: { + flex: 1, + minWidth: 0, + }, +}; + type Props = {| i18n: I18n, onClose: (options: {| languageDidChange: boolean |}) => void, onOpenQuickCustomizationDialog: () => void, + /** The tab displayed when the dialog opens. Defaults to the preferences. */ + initialTab?: PreferencesTabName, + /** The preferences section displayed when the dialog opens. Defaults to the general one. */ + initialSection?: PreferencesSectionName, |}; const PreferencesDialog = ({ i18n, onClose, onOpenQuickCustomizationDialog, + initialTab, + initialSection, }: Props) => { const { isMobile } = useResponsiveWindowSize(); - const [currentTab, setCurrentTab] = React.useState('preferences'); + const { showConfirmation } = useAlertDialog(); + const [ + currentSection, + setCurrentSection, + ] = React.useState(initialSection || 'general'); + const [currentTab, setCurrentTab] = React.useState( + initialTab || 'preferences' + ); + const [ + currentShortcutArea, + setCurrentShortcutArea, + ] = React.useState('GENERAL'); + const [searchText, setSearchText] = React.useState(''); const [languageDidChange, setLanguageDidChange] = React.useState( false ); @@ -94,501 +375,348 @@ const PreferencesDialog = ({ const initialUse3DEditor = React.useRef(values.use3DEditor); - return ( - Preferences} - actions={[ - Close} - primary={false} - onClick={() => onClose({ languageDidChange })} - />, - ]} - onRequestClose={() => onClose({ languageDidChange })} - open - maxWidth="sm" - fixedContent={ - Preferences }, - { value: 'shortcuts', label: Keyboard Shortcuts }, - ...(electron - ? [{ value: 'folders', label: Folders }] - : []), - ]} - // Enforce scroll on very small screens, because the tabs have long names. - variant={isMobile ? 'scrollable' : undefined} - /> - } - > - {currentTab === 'preferences' && ( - - - Language - - - { - setLanguageDidChange(true); - }} - /> - - - Appearance - - - - - - UI Theme - - - - setThemeName(value)} - > - {Object.keys(themes).map(themeName => ( - - ))} - - - - - - - Code editor Theme - - - - setCodeEditorThemeName(value)} - > - {getAllThemes().map(codeEditorTheme => ( - - ))} - - - - - - - You can contribute and{' '} - + values.userShortcutMap[commandName] != null && + values.userShortcutMap[commandName] !== + (defaultShortcuts[commandName] || '') + ); + + const resetAllShortcutsToDefault = async () => { + const answer = await showConfirmation({ + title: t`Reset all shortcuts`, + message: t`Are you sure you want to reset all shortcuts to their default values?`, + confirmButtonLabel: t`Reset all shortcuts`, + level: 'warning', + }); + if (answer) resetShortcutsToDefault(); + }; + + const commandPaletteShortcut = getShortcutDisplayName( + values.userShortcutMap['OPEN_COMMAND_PALETTE'] || + defaultShortcuts['OPEN_COMMAND_PALETTE'] + ); + const commandPaletteSecondaryShortcut = getShortcutDisplayName( + defaultSecondaryShortcuts['OPEN_COMMAND_PALETTE'] + ); + + const renderToggle = ( + checked: boolean, + onCheck: (newValue: boolean) => void + ) => ( + + ); + + const renderResetLayoutButton = (editorMosaicName: string) => ( + Reset} + onClick={() => setDefaultEditorMosaicNode(editorMosaicName, null)} + disabled={!getDefaultEditorMosaicNode(editorMosaicName)} + /> + ); + + const renderResetButton = (onClick: () => void, disabled: boolean) => ( + Reset} + onClick={onClick} + disabled={disabled} + /> + ); + + const getSectionContent = ( + sectionName: PreferencesSectionName + ): SectionContent => { + switch (sectionName) { + case 'general': + return { + settings: [ + { + id: 'language', + label: i18n._(t`Choose GDevelop language`), + renderControl: () => ( +
+ setLanguageDidChange(true)} + /> +
+ ), + }, + { + id: 'show-create-section-by-default', + label: i18n._( + t`Show the "Create" section by default when opening GDevelop` + ), + renderControl: () => + renderToggle( + values.showCreateSectionByDefault, + setShowCreateSectionByDefault + ), + }, + { + id: 'auto-open-most-recent-project', + label: i18n._( + t`Automatically re-open the project edited during last session` + ), + renderControl: () => + renderToggle( + values.autoOpenMostRecentProject, + setAutoOpenMostRecentProject + ), + }, + ], + }; + case 'appearance': + return { + settings: [ + { + id: 'ui-theme', + label: i18n._(t`UI Theme`), + renderControl: () => ( +
+ setThemeName(value)} + > + {Object.keys(themes).map(themeName => ( + + ))} + +
+ ), + }, + { + id: 'code-editor-theme', + label: i18n._(t`Code editor Theme`), + renderControl: () => ( +
+ setCodeEditorThemeName(value)} + > + {getAllThemes().map(codeEditorTheme => ( + + ))} + +
+ ), + }, + ], + renderFooter: () => ( + + + You can contribute and{' '} + + Window.openExternalURL( 'https://github.com/4ian/GDevelop/blob/master/newIDE/README-themes.md' + ) + } + > + create your own themes + + . + + + ), + }; + case 'layouts': + return { + settings: [ + { + id: 'scene-editor-layout', + label: i18n._(t`Scene Editor layout`), + renderControl: () => renderResetLayoutButton('scene-editor'), + }, + { + id: 'debugger-layout', + label: i18n._(t`Debugger layout`), + renderControl: () => renderResetLayoutButton('debugger'), + }, + { + id: 'resources-editor-layout', + label: i18n._(t`Resource Editor layout`), + renderControl: () => renderResetLayoutButton('resources-editor'), + }, + { + id: 'extension-editor-layout', + label: i18n._(t`Extension Editor layout`), + renderControl: () => + renderResetLayoutButton('events-functions-extension-editor'), + }, + ], + renderHeader: () => ( + + + Reset the position and the size of the panels of an editor to + their default values. + + + ), + }; + case 'dialogs': + return { + settings: [ + { + id: 'backdrop-click-behavior', + label: i18n._(t`Dialog backdrop click behavior`), + renderControl: () => ( +
+ + setBackdropClickBehavior(value) } - onClick={() => - Window.openExternalURL( - 'https://github.com/4ian/GDevelop/blob/master/newIDE/README-themes.md' - ) + > + + + + +
+ ), + }, + ...(electron + ? [ + { + id: 'resources-importation-behavior', + label: i18n._( + t`Importing resources outside from the project folder` + ), + renderControl: () => ( +
+ + setResourcesImporationBehavior(value) + } + > + + + + +
+ ), + }, + ] + : []), + ], + }; + case 'updates': + return { + settings: [ + { + id: 'auto-download-updates', + label: i18n._(t`Auto download and install updates (recommended)`), + renderControl: () => + renderToggle( + values.autoDownloadUpdates, + setAutoDownloadUpdates + ), + }, + { + id: 'auto-display-changelog', + label: i18n._( + t`Display What's New when a new version is launched (recommended)` + ), + renderControl: () => + renderToggle( + values.autoDisplayChangelog, + setAutoDisplayChangelog + ), + }, + ], + }; + case 'events-sheet': + return { + settings: [ + { + id: 'events-sheet-object-thumbnails', + label: i18n._(t`Display object thumbnails in Events Sheets`), + renderControl: () => + renderToggle( + values.eventsSheetShowObjectThumbnails, + setEventsSheetShowObjectThumbnails + ), + }, + { + id: 'events-sheet-assignment-operators', + label: i18n._(t`Display assignment operators in Events Sheets`), + renderControl: () => + renderToggle( + values.eventsSheetUseAssignmentOperators, + setEventsSheetUseAssignmentOperators + ), + }, + { + id: 'events-sheet-indent-scale', + label: i18n._(t`Indent Scale in Events Sheet`), + renderControl: () => ( +
+ + setEventsSheetIndentScale(parseInt(value, 10)) } > - create your own themes - - . - - - - - - Layouts - - - Reset Scene Editor layout} - onClick={() => setDefaultEditorMosaicNode('scene-editor', null)} - disabled={!getDefaultEditorMosaicNode('scene-editor')} - /> - Reset Debugger layout} - onClick={() => setDefaultEditorMosaicNode('debugger', null)} - disabled={!getDefaultEditorMosaicNode('debugger')} - /> - Reset Resource Editor layout} - onClick={() => - setDefaultEditorMosaicNode('resources-editor', null) - } - disabled={!getDefaultEditorMosaicNode('resources-editor')} - /> - Reset Extension Editor layout} - onClick={() => - setDefaultEditorMosaicNode( - 'events-functions-extension-editor', - null - ) - } - disabled={ - !getDefaultEditorMosaicNode('events-functions-extension-editor') - } - /> - - - Dialogs - - - - - - Dialog backdrop click behavior - - - - setBackdropClickBehavior(value)} - > - - - - - - - - {!!electron && ( - - - - - Importing resources outside from the project folder - - - - + + + + + + + + + +
+ ), + }, + { + id: 'events-sheet-escape-key-behavior', + label: i18n._( + t`Escape key behavior when editing an parameter inline` + ), + renderControl: () => ( +
- setResourcesImporationBehavior(value) + setEventsSheetCancelInlineParameter(value) } > - - - + + - - - )} - - - Updates - - - - - - - Events Sheet - - - - - - - - Indent Scale in Events Sheet - - - - - setEventsSheetIndentScale(parseInt(value, 10)) - } - > - - - - - - - - - - - - - - - - Escape key behavior when editing an parameter inline - - - - - - setEventsSheetCancelInlineParameter(value) - } - > - - - - - - - - - Embedded help and tutorials - - - Reset hidden embedded explanations} - onClick={() => showAllAlertMessages()} - disabled={!Object.keys(values.hiddenAlertMessages).length} - /> - Reset hidden embedded tutorials} - onClick={() => showAllTutorialHints()} - disabled={!Object.keys(values.hiddenTutorialHints).length} - /> - Reset hidden announcements} - onClick={() => showAllAnnouncements()} - disabled={!Object.keys(values.hiddenAnnouncements).length} - /> - - - - Advanced - - - - At launch - - - - - Previews - - - - - - - setPreviewCrashReportUploadLevel( - check ? 'exclude-javascript-code-events' : 'none' - ) - } - checked={values.previewCrashReportUploadLevel !== 'none'} - label={i18n._( - t`Send crash reports during previews to GDevelop` - )} - /> - - {electron && ( - <> - - - - - - - )} - - Scene editor - - - - {initialUse3DEditor.current !== values.use3DEditor && ( - - - For the 3D change to take effect, close and reopen all - currently opened scenes. - - - )} - - Other - - Reset hidden Ask AI text inputs} - onClick={() => showAllAskAiStandAloneForms()} - disabled={ - !Object.keys(values.hiddenAskAiStandAloneForms).length - } - /> - - - setDisplaySaveReminder({ activated: check })} - checked={values.displaySaveReminder.activated} - label={i18n._( - t`Display save reminder after significant changes in project` - )} - /> - - - - - Deprecated actions and conditions warning - - - +
+ ), + }, + { + id: 'deprecated-instruction-warning', + label: i18n._(t`Deprecated actions and conditions warning`), + renderControl: () => ( +
{ @@ -608,114 +736,608 @@ const PreferencesDialog = ({ label={t`Icon and [DEPRECATED] text`} /> - - - - - {!!electron && ( - - setWatchProjectFolderFilesForLocalProjects(check) - } - checked={values.watchProjectFolderFilesForLocalProjects} - label={i18n._( - t`Watch the project folder for file changes in order to refresh the resources used in the editor (images, 3D models, fonts, etc.)` - )} +
+ ), + }, + { + id: 'show-js-type-error', + label: i18n._( + t`Show type errors in JavaScript events (needs a restart)` + ), + renderControl: () => + renderToggle(values.showJsTypeError, setShowJsTypeError), + }, + ], + }; + case 'previews': + return { + settings: [ + { + id: 'autosave-on-preview', + label: i18n._(t`Auto-save project on preview`), + renderControl: () => + renderToggle(values.autosaveOnPreview, setAutosaveOnPreview), + }, + { + id: 'fetch-player-token-for-preview', + label: i18n._(t`Automatically log in as a player in preview`), + renderControl: () => + renderToggle( + values.fetchPlayerTokenForPreviewAutomatically, + setFetchPlayerTokenForPreviewAutomatically + ), + }, + { + id: 'open-diagnostic-report-automatically', + label: i18n._( + t`Automatically open the diagnostic report at preview` + ), + renderControl: () => + renderToggle( + values.openDiagnosticReportAutomatically, + setOpenDiagnosticReportAutomatically + ), + }, + { + id: 'block-preview-on-diagnostic-errors', + label: i18n._( + t`Block preview and export when diagnostic errors are found` + ), + renderControl: () => + renderToggle( + values.blockPreviewAndExportOnDiagnosticErrors, + setBlockPreviewAndExportOnDiagnosticErrors + ), + }, + { + id: 'preview-crash-reports', + label: i18n._(t`Send crash reports during previews to GDevelop`), + renderControl: () => + renderToggle( + values.previewCrashReportUploadLevel !== 'none', + check => + setPreviewCrashReportUploadLevel( + check ? 'exclude-javascript-code-events' : 'none' + ) + ), + }, + { + id: 'take-screenshot-on-preview', + label: i18n._( + t`Automatically take a screenshot in game previews` + ), + renderControl: () => + renderToggle( + values.takeScreenshotOnPreview, + setTakeScreenshotOnPreview + ), + }, + ...(electron + ? [ + { + id: 'hide-menu-bar-in-preview', + label: i18n._(t`Hide the menu bar in the preview window`), + renderControl: () => + renderToggle( + values.isMenuBarHiddenInPreview, + setIsMenuBarHiddenInPreview + ), + }, + { + id: 'always-on-top-in-preview', + label: i18n._( + t`Always display the preview window on top of the editor` + ), + renderControl: () => + renderToggle( + values.isAlwaysOnTopInPreview, + setIsAlwaysOnTopInPreview + ), + }, + { + id: 'use-shortcut-to-close-preview-window', + label: i18n._( + t`Enable "Close project" shortcut (${adaptAcceleratorString( + getElectronAccelerator( + values.userShortcutMap['CLOSE_PROJECT'] || + defaultShortcuts['CLOSE_PROJECT'] + ) + )}) to close preview window` + ), + renderControl: () => + renderToggle( + values.useShortcutToClosePreviewWindow, + setUseShortcutToClosePreviewWindow + ), + }, + ] + : []), + ], + }; + case 'scene-editor': + return { + settings: [ + { + id: 'show-basic-profiling-counters', + label: i18n._(t`Display profiling information in scene editor`), + renderControl: () => + renderToggle( + values.showBasicProfilingCounters, + setShowBasicProfilingCounters + ), + }, + { + id: 'use-3d-editor', + label: i18n._(t`Show objects in 3D in the scene editor`), + renderControl: () => + renderToggle(values.use3DEditor, setUse3DEditor), + }, + ], + renderFooter: () => + initialUse3DEditor.current !== values.use3DEditor ? ( + + + For the 3D change to take effect, close and reopen all + currently opened scenes. + + + ) : null, + }; + case 'help': + return { + settings: [ + { + id: 'hidden-alert-messages', + label: i18n._(t`Hidden embedded explanations`), + renderControl: () => + renderResetButton( + showAllAlertMessages, + !Object.keys(values.hiddenAlertMessages).length + ), + }, + { + id: 'hidden-tutorial-hints', + label: i18n._(t`Hidden embedded tutorials`), + renderControl: () => + renderResetButton( + showAllTutorialHints, + !Object.keys(values.hiddenTutorialHints).length + ), + }, + { + id: 'hidden-announcements', + label: i18n._(t`Hidden announcements`), + renderControl: () => + renderResetButton( + showAllAnnouncements, + !Object.keys(values.hiddenAnnouncements).length + ), + }, + { + id: 'hidden-ask-ai-forms', + label: i18n._(t`Hidden Ask AI text inputs`), + renderControl: () => + renderResetButton( + showAllAskAiStandAloneForms, + !Object.keys(values.hiddenAskAiStandAloneForms).length + ), + }, + ], + }; + case 'other': + return { + settings: [ + { + id: 'show-ask-ai-button-in-title-bar', + label: i18n._(t`Show "Ask AI" button in the title bar`), + renderControl: () => + renderToggle( + values.showAiAskButtonInTitleBar, + setShowAiAskButtonInTitleBar + ), + }, + { + id: 'automatically-use-credits-for-ai-requests', + label: i18n._( + t`Automatically use GDevelop credits for AI requests when run out of AI credits` + ), + renderControl: () => + renderToggle( + values.automaticallyUseCreditsForAiRequests, + setAutomaticallyUseCreditsForAiRequests + ), + }, + { + id: 'display-save-reminder', + label: i18n._( + t`Display save reminder after significant changes in project` + ), + renderControl: () => + renderToggle(values.displaySaveReminder.activated, check => + setDisplaySaveReminder({ activated: check }) + ), + }, + { + id: 'show-experimental-extensions', + label: i18n._( + t`Show experimental extensions in the list of extensions` + ), + renderControl: () => + renderToggle( + values.showExperimentalExtensions, + setShowExperimentalExtensions + ), + }, + { + id: 'use-background-serializer-for-saving', + label: i18n._( + t`Use experimental background serializer for saving projects` + ), + renderControl: () => + renderToggle( + values.useBackgroundSerializerForSaving, + setUseBackgroundSerializerForSaving + ), + }, + ...(electron + ? [ + { + id: 'watch-project-folder-files', + label: i18n._( + t`Watch the project folder for file changes in order to refresh the resources used in the editor (images, 3D models, fonts, etc.)` + ), + renderControl: () => + renderToggle( + values.watchProjectFolderFilesForLocalProjects, + check => + setWatchProjectFolderFilesForLocalProjects(check) + ), + }, + ] + : []), + ...(electron && values.disableNpmScriptConfirmation + ? [ + { + id: 'npm-script-confirmation', + label: i18n._(t`npm script security warning`), + renderControl: () => ( + Re-enable} + onClick={() => setDisableNpmScriptConfirmation(false)} + /> + ), + }, + ] + : []), + ], + }; + case 'folders': + return { + settings: [ + { + id: 'new-projects-default-folder', + label: i18n._(t`Default folder for new projects`), + renderControl: () => ( + - )} - {!!electron && values.disableNpmScriptConfirmation && ( - - Re-enable npm script security warning} - onClick={() => setDisableNpmScriptConfirmation(false)} - /> - - )} -
- - - - Contributor options - - - - - - {Window.isDev() && ( - <> - - - Developer options - - - + renderToggle( + values.showInAppTutorialDeveloperMode, + setShowInAppTutorialDeveloperMode + ), + }, + ...(Window.isDev() + ? [ + { + id: 'use-gdjs-development-watcher', + label: i18n._( t`Watch changes in game engine (GDJS) sources and auto import them (dev only)` - )} - /> - Open quick customization} - /> - - - - )} + ), + renderControl: () => + renderToggle( + values.useGDJSDevelopmentWatcher, + setUseGDJSDevelopmentWatcher + ), + }, + { + id: 'quick-customization', + label: i18n._(t`Quick customization (dev only)`), + renderControl: () => ( + Open} + /> + ), + }, + ] + : []), + ], + }; + default: + return { settings: [] }; + } + }; + + const renderSettingsRows = (settings: Array) => ( + + {settings.map(setting => ( + + {setting.renderControl()} + + ))} + + ); + + const renderCurrentSection = () => { + if (currentTab === 'shortcuts') { + return ( + + + {i18n._(commandAreas[currentShortcutArea])} + + + + You can open the command palette by pressing{' '} + {commandPaletteShortcut} or {commandPaletteSecondaryShortcut}. + + + - )} - {currentTab === 'shortcuts' && ( - - + ); + } + + const section = + sections.find(section => section.name === currentSection) || sections[0]; + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + + return ( + + + {section.label} + + {renderHeader && renderHeader()} + {settings.length > 0 && renderSettingsRows(settings)} + {renderFooter && renderFooter()} + + ); + }; + + /** + * Display the settings of all the sections matching the search, grouped by + * section. A section whose name matches shows all its settings. + */ + const renderSearchResults = () => { + const normalizedSearchText = normalizeForSearch(searchText); + + const hasMatchingShortcuts = + getShortcutSections(i18n, values.userShortcutMap, searchText).length > 0; + const matchingSections = sections + .map(section => { + const { settings } = getSectionContent(section.name); + const isSectionNameMatching = normalizeForSearch( + section.getSearchableLabel(i18n) + ).includes(normalizedSearchText); + const matchingSettings = isSectionNameMatching + ? settings + : settings.filter(setting => + normalizeForSearch(setting.label).includes(normalizedSearchText) + ); + return matchingSettings.length > 0 + ? { section, settings: matchingSettings } + : null; + }) + .filter(Boolean); + + if (matchingSections.length === 0 && !hasMatchingShortcuts) { + return ( + + No setting matches your search. + + ); + } + + return ( + + {matchingSections.map(({ section, settings }) => ( + + {section.label} + {renderSettingsRows(settings)} + + ))} + {hasMatchingShortcuts && ( + + + Keyboard Shortcuts + - - )} - {electron && currentTab === 'folders' && ( + )} +
+ ); + }; + + const onSelectTab = (tabName: PreferencesTabName) => { + setSearchText(''); + setCurrentTab(tabName); + }; + + const onSelectSection = (sectionName: PreferencesSectionName) => { + setSearchText(''); + setCurrentSection(sectionName); + }; + + const onSelectShortcutArea = (areaName: ShortcutArea) => { + setSearchText(''); + setCurrentShortcutArea(areaName); + }; + + // The entries of the sections list, on the left of the content: the + // preferences sections or the shortcut areas, depending on the current tab. + const sectionListEntries = + currentTab === 'preferences' + ? sections.map(section => ({ + key: section.name, + label: section.label, + getIcon: section.getIcon, + isActive: !isSearching && currentSection === section.name, + onSelect: () => onSelectSection(section.name), + })) + : shortcutAreas.map(area => ({ + key: area.name, + label: i18n._(commandAreas[area.name]), + getIcon: area.getIcon, + isActive: !isSearching && currentShortcutArea === area.name, + onSelect: () => onSelectShortcutArea(area.name), + })); + const activeSectionListEntry = sectionListEntries.find( + entry => entry.isActive + ); + const onSelectSectionListEntry = (key: string) => { + const entry = sectionListEntries.find(entry => entry.key === key); + if (entry) entry.onSelect(); + }; + + return ( + Preferences} + id="preferences-dialog" + actions={[ + Close} + primary={false} + onClick={() => onClose({ languageDidChange })} + />, + ]} + secondaryActions={[ + , + !isMobile ? ( + Help translate GDevelop} + onClick={() => + Window.openExternalURL('https://crowdin.com/project/gdevelop') + } + /> + ) : null, + currentTab === 'shortcuts' && !isSearching ? ( + Reset all shortcuts} + onClick={resetAllShortcutsToDefault} + disabled={!hasCustomizedShortcuts} + /> + ) : null, + ]} + onRequestClose={() => onClose({ languageDidChange })} + open + aspectRatio="16:9" + flexColumnBody + actionsFullWidthOnMobile + fixedContent={ - Preferences }, + { value: 'shortcuts', label: Keyboard Shortcuts }, + ]} + /> + {isMobile && ( + // On mobile, the sections column is replaced by scrollable tabs. + ({ + value: entry.key, + label: entry.label, + }))} + variant="scrollable" + /> + )} + - )} + } + > +
+ {!isMobile && ( + <> +
+ + {sectionListEntries.map(entry => ( + + ))} + +
+ + + )} +
+ {isSearching ? ( + + {renderSearchResults()} + + ) : ( + renderCurrentSection() + )} +
+
); }; diff --git a/newIDE/app/src/UI/CompactToggleField/index.js b/newIDE/app/src/UI/CompactToggleField/index.js index 97e18bf0709b..0f50d277f41e 100644 --- a/newIDE/app/src/UI/CompactToggleField/index.js +++ b/newIDE/app/src/UI/CompactToggleField/index.js @@ -16,6 +16,7 @@ const styles = { }, }; type Props = {| + /** The label displayed next to the toggle. Empty to display the toggle alone. */ label: string, markdownDescription?: ?string, id?: string, @@ -88,7 +89,7 @@ export const CompactToggleField = (props: Props): React.MixedElement => { - {props.hideTooltip ? ( + {!props.label ? null : props.hideTooltip ? ( label ) : ( ({ backgroundColor: gdevelopTheme.dialog.backgroundColor, minHeight: paperMinHeight, + ...(hasForcedAspectRatio ? styles.aspectRatio16by9Paper : {}), ...getAvoidSoftKeyboardStyle(softKeyboardBottomOffset), }), [ gdevelopTheme.dialog.backgroundColor, paperMinHeight, + hasForcedAspectRatio, softKeyboardBottomOffset, ] ); @@ -444,7 +457,9 @@ const DialogWithoutWindowSizeProvider = ({ style: paperStyle, }} maxWidth={ - maxWidth !== undefined + hasForcedAspectRatio + ? false + : maxWidth !== undefined ? maxWidth : getDefaultMaxWidthFromSize(windowSize) } diff --git a/newIDE/app/src/UI/SettingsRow.css b/newIDE/app/src/UI/SettingsRow.css new file mode 100644 index 000000000000..b68d98efca5d --- /dev/null +++ b/newIDE/app/src/UI/SettingsRow.css @@ -0,0 +1,8 @@ +/* Alternating ("zebra") row colors, like in the instances list (see + * Theme/Global/Table.css), instead of separators between the rows. */ +.settings-row:nth-child(odd) { + background-color: var(--table-row-even-background-color); +} +.settings-row:nth-child(even) { + background-color: var(--table-row-odd-background-color); +} diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js new file mode 100644 index 000000000000..08e4a39c98ce --- /dev/null +++ b/newIDE/app/src/UI/SettingsRow.js @@ -0,0 +1,100 @@ +// @flow +import * as React from 'react'; +import Text from './Text'; +import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; +import './SettingsRow.css'; + +// Fixed width of the control column, shared by all rows so that the controls +// (toggles, select fields, buttons, shortcuts...) are aligned like in a table. +// On mobile, the control takes the full width, on its own line. +export const settingsRowControlColumnWidth = 320; + +const styles = { + row: { + display: 'flex', + alignItems: 'center', + paddingTop: 6, + paddingBottom: 6, + paddingLeft: 8, + paddingRight: 8, + borderRadius: 4, + }, + // On mobile, the control is displayed on its own line, below the label. + rowOnMobile: { + flexDirection: 'column', + alignItems: 'stretch', + }, + labelColumn: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + paddingRight: 8, + }, + controlColumn: { + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + minWidth: 0, + }, + controlColumnOnMobile: { + width: '100%', + paddingTop: 4, + }, +}; + +type Props = {| + id?: string, + /** The name of the setting, displayed on the left. */ + label: React.Node, + /** An optional explanation, displayed below the label. */ + description?: React.Node, + /** The control (toggle, select field, button...), aligned on the right. */ + children?: React.Node, +|}; + +/** + * A row of a settings list: a label on the left and a control aligned on the + * right, in a fixed width column shared by all the rows, like in a table. + * Rows must be direct siblings so that their background colors alternate. + */ +const SettingsRow = ({ + id, + label, + description, + children, +}: Props): React.Node => { + const { isMobile } = useResponsiveWindowSize(); + + return ( +
+
+ {label} + {description && ( + + {description} + + )} +
+
+ {children} +
+
+ ); +}; + +export default SettingsRow; diff --git a/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js new file mode 100644 index 000000000000..a7bb9ece7793 --- /dev/null +++ b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js @@ -0,0 +1,193 @@ +// @flow +import * as React from 'react'; +import { I18n } from '@lingui/react'; +import { action } from '@storybook/addon-actions'; + +import paperDecorator from '../../PaperDecorator'; +import alertDecorator from '../../AlertDecorator'; +import PreferencesDialog, { + type PreferencesSectionName, + type PreferencesTabName, +} from '../../../MainFrame/Preferences/PreferencesDialog'; +import PreferencesContext, { + initialPreferences, + type Preferences, +} from '../../../MainFrame/Preferences/PreferencesContext'; +import { type ShortcutMap } from '../../../KeyboardShortcuts/DefaultShortcuts'; +import defaultShortcuts from '../../../KeyboardShortcuts/DefaultShortcuts'; +import { type CommandName } from '../../../CommandPalette/CommandsList'; + +export default { + title: 'Preferences/PreferencesDialog', + component: PreferencesDialog, + decorators: [paperDecorator, alertDecorator], +}; + +/** + * A preferences provider holding the values in a local state, so that the + * toggles, select fields and shortcuts react to the changes made in the story, + * without persisting anything. + */ +const StatefulPreferencesProvider = ({ + initialUserShortcutMap, + children, +}: {| + initialUserShortcutMap: ShortcutMap, + children: React.Node, +|}) => { + const [values, setValues] = React.useState({ + ...initialPreferences.values, + userShortcutMap: initialUserShortcutMap, + }); + + const setValue = (name: string, value: any) => { + action('Set preference')(name, value); + setValues(previousValues => ({ ...previousValues, [name]: value })); + }; + + const setShortcutForCommand = ( + commandName: CommandName, + shortcut: string + ) => { + action('Set shortcut')(commandName, shortcut); + setValues(previousValues => { + const userShortcutMap = { ...previousValues.userShortcutMap }; + if (shortcut === (defaultShortcuts[commandName] || '')) { + // Same behavior as the real provider: a shortcut set back to its + // default value is removed from the user map. + delete userShortcutMap[commandName]; + } else { + userShortcutMap[commandName] = shortcut; + } + return { ...previousValues, userShortcutMap }; + }); + }; + + const preferences: Preferences = { + // $FlowFixMe[incompatible-type] - only the setters used by the dialog are overridden. + ...initialPreferences, + // $FlowFixMe[incompatible-type] + values, + setThemeName: value => setValue('themeName', value), + setCodeEditorThemeName: value => setValue('codeEditorThemeName', value), + setAutoDownloadUpdates: value => setValue('autoDownloadUpdates', value), + setAutoDisplayChangelog: value => setValue('autoDisplayChangelog', value), + setEventsSheetShowObjectThumbnails: value => + setValue('eventsSheetShowObjectThumbnails', value), + setEventsSheetUseAssignmentOperators: value => + setValue('eventsSheetUseAssignmentOperators', value), + setEventsSheetIndentScale: value => + setValue('eventsSheetIndentScale', value), + setEventsSheetCancelInlineParameter: value => + setValue('eventsSheetCancelInlineParameter', value), + setAutosaveOnPreview: value => setValue('autosaveOnPreview', value), + setAutoOpenMostRecentProject: value => + setValue('autoOpenMostRecentProject', value), + setShowCreateSectionByDefault: value => + setValue('showCreateSectionByDefault', value), + setBackdropClickBehavior: value => setValue('backdropClickBehavior', value), + setShowExperimentalExtensions: value => + setValue('showExperimentalExtensions', value), + setShowInAppTutorialDeveloperMode: value => + setValue('showInAppTutorialDeveloperMode', value), + setOpenDiagnosticReportAutomatically: value => + setValue('openDiagnosticReportAutomatically', value), + setBlockPreviewAndExportOnDiagnosticErrors: value => + setValue('blockPreviewAndExportOnDiagnosticErrors', value), + setShowDeprecatedInstructionWarning: value => + setValue('showDeprecatedInstructionWarning', value), + setUse3DEditor: value => setValue('use3DEditor', value), + setShowBasicProfilingCounters: value => + setValue('showBasicProfilingCounters', value), + setDisplaySaveReminder: value => setValue('displaySaveReminder', value), + setFetchPlayerTokenForPreviewAutomatically: value => + setValue('fetchPlayerTokenForPreviewAutomatically', value), + setPreviewCrashReportUploadLevel: value => + setValue('previewCrashReportUploadLevel', value), + setTakeScreenshotOnPreview: value => + setValue('takeScreenshotOnPreview', value), + setShowAiAskButtonInTitleBar: value => + setValue('showAiAskButtonInTitleBar', value), + setAutomaticallyUseCreditsForAiRequests: value => + setValue('automaticallyUseCreditsForAiRequests', value), + setUseBackgroundSerializerForSaving: value => + setValue('useBackgroundSerializerForSaving', value), + setShowJsTypeError: value => setValue('showJsTypeError', value), + setShortcutForCommand, + resetShortcutsToDefault: () => { + action('Reset all shortcuts')(); + setValues(previousValues => ({ ...previousValues, userShortcutMap: {} })); + }, + }; + + return ( + + {children} + + ); +}; + +const PreferencesDialogStory = ({ + initialTab, + initialSection, + initialUserShortcutMap = {}, +}: {| + initialTab?: PreferencesTabName, + initialSection?: PreferencesSectionName, + initialUserShortcutMap?: ShortcutMap, +|}) => ( + + + {({ i18n }) => ( + + )} + + +); + +const customizedUserShortcutMap: ShortcutMap = { + // Modified shortcut: displays the reset button. + SAVE_PROJECT: 'CmdOrCtrl+Shift+KeyS', + // Removed shortcut: displays "No shortcut" and the reset button. + LAUNCH_NEW_PREVIEW: '', + // Clashing shortcuts: both display a warning naming the other command. + OPEN_OBJECTS_PANEL: 'KeyG', + // Shortcut given to a command without a default one. + OPEN_PROJECT_PROPERTIES: 'Alt+KeyP', +}; + +export const Default = (): React.Node => ; + +export const EventsSheetSection = (): React.Node => ( + +); + +export const KeyboardShortcutsSection = (): React.Node => ( + +); + +export const Mobile = (): React.Node => ; +Mobile.parameters = { + viewport: { defaultViewport: 'mobile1' }, +}; + +export const MobileKeyboardShortcutsSection = (): React.Node => ( + +); +MobileKeyboardShortcutsSection.parameters = { + viewport: { defaultViewport: 'mobile1' }, +}; diff --git a/newIDE/app/src/stories/everything-else.stories.js b/newIDE/app/src/stories/everything-else.stories.js index 65fc284c3f5c..c526d20894a0 100644 --- a/newIDE/app/src/stories/everything-else.stories.js +++ b/newIDE/app/src/stories/everything-else.stories.js @@ -105,7 +105,6 @@ import { } from '../UI/Accordion'; import ProjectPropertiesDialog from '../ProjectManager/ProjectPropertiesDialog'; import { LoadingScreenEditor } from '../ProjectManager/LoadingScreenEditor'; -import PreferencesDialog from '../MainFrame/Preferences/PreferencesDialog'; import { ExtensionsAccordion, ExamplesAccordion, @@ -2416,18 +2415,3 @@ storiesOf('ProjectPropertiesDialog/LoadingScreenEditor', module) } /> )); - -// $FlowFixMe[invalid-export] -storiesOf('PreferencesDialog', module).add('default', () => ( - - {({ i18n }) => ( - - )} - -)); From b82bab5759a4c4e14af999e641cb17e65498f046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 7 Sep 2026 00:22:50 +0200 Subject: [PATCH 02/14] v2 --- GDJS/Runtime/InGameEditor/InGameEditor.tsx | 324 ++++++++++--- newIDE/app/src/CommandPalette/CommandsList.js | 166 ++++++- .../src/EmbeddedGame/InGameEditorSettings.js | 21 + .../src/InstancesEditor/TileSetVisualizer.js | 256 +++++------ .../src/KeyboardShortcuts/DefaultShortcuts.js | 47 ++ .../src/KeyboardShortcuts/ShortcutsList.js | 76 ++- .../src/KeyboardShortcuts/ShortcutsListRow.js | 39 +- newIDE/app/src/KeyboardShortcuts/index.js | 16 +- .../Preferences/PreferencesContext.js | 9 +- .../Preferences/PreferencesDialog.js | 431 +++++++++++++----- .../Preferences/PreferencesProvider.js | 23 +- newIDE/app/src/UI/Dialog.js | 17 +- newIDE/app/src/UI/SettingsRow.js | 17 +- 13 files changed, 1053 insertions(+), 389 deletions(-) diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index 3cc38a72e105..edb5d7cbaeeb 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -337,15 +337,7 @@ namespace gdjs { const RIGHT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 2); const LEFT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(91, 1); const RIGHT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(93, 2); - const W_KEY = 87; - const A_KEY = 65; const C_KEY = 67; - const S_KEY = 83; - const D_KEY = 68; - const Q_KEY = 81; - const E_KEY = 69; - const F_KEY = 70; - const O_KEY = 79; const V_KEY = 86; const X_KEY = 88; const Y_KEY = 89; @@ -353,11 +345,176 @@ namespace gdjs { const ESC_KEY = 27; const EQUAL_KEY = 187; const MINUS_KEY = 189; - const KEY_DIGIT_1 = 49; - const KEY_DIGIT_2 = 50; - const KEY_DIGIT_3 = 51; const ROTATION_SNAP_DEGREES = 45; + /** + * The key codes of the keys that can be used in a shortcut, by the name of + * the key in the IDE shortcuts (the `code` of the keyboard events). + */ + const shortcutKeyCodes: { [code: string]: integer } = { + Tab: 9, + Space: 32, + Comma: 188, + Equal: 187, + Minus: 189, + NumpadAdd: 107, + NumpadSubtract: 109, + }; + for (let letterIndex = 0; letterIndex < 26; letterIndex++) { + shortcutKeyCodes['Key' + String.fromCharCode(65 + letterIndex)] = + 65 + letterIndex; + } + for (let digit = 0; digit <= 9; digit++) { + shortcutKeyCodes['Digit' + digit] = 48 + digit; + } + for (let functionKeyIndex = 1; functionKeyIndex <= 12; functionKeyIndex++) { + shortcutKeyCodes['F' + functionKeyIndex] = 111 + functionKeyIndex; + } + + /** + * A shortcut parsed from the IDE format (for example "CmdOrCtrl+Shift+KeyW"): + * the key code of the key and the modifiers that must be pressed with it. + */ + type ParsedShortcut = { + keyCode: integer; + ctrlOrCmd: boolean; + shift: boolean; + alt: boolean; + }; + + const parseShortcut = (shortcutString: string): ParsedShortcut | null => { + if (!shortcutString) return null; + + const parsedShortcut: ParsedShortcut = { + keyCode: 0, + ctrlOrCmd: false, + shift: false, + alt: false, + }; + for (const shortcutPart of shortcutString.split('+')) { + if (shortcutPart === 'CmdOrCtrl') parsedShortcut.ctrlOrCmd = true; + else if (shortcutPart === 'Shift') parsedShortcut.shift = true; + else if (shortcutPart === 'Alt') parsedShortcut.alt = true; + else if (shortcutKeyCodes[shortcutPart] !== undefined) + parsedShortcut.keyCode = shortcutKeyCodes[shortcutPart]; + else return null; + } + return parsedShortcut.keyCode ? parsedShortcut : null; + }; + + /** + * The default shortcuts of the in-game editor, used when the IDE did not + * send its own (customized) shortcuts. Must be kept in sync with the + * defaults of the IDE (`DefaultShortcuts.js`). + */ + const defaultInGameEditorShortcuts: { [commandName: string]: string } = { + IN_GAME_EDITOR_TRANSLATE_MODE: 'Digit1', + IN_GAME_EDITOR_ROTATE_MODE: 'Digit2', + IN_GAME_EDITOR_SCALE_MODE: 'Digit3', + IN_GAME_EDITOR_FOCUS_ON_SELECTION: 'KeyF', + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyW', + IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: 'KeyS', + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyA', + IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: 'KeyD', + IN_GAME_EDITOR_MOVE_CAMERA_UP: 'KeyE', + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyQ', + IN_GAME_EDITOR_ORBIT_CAMERA: 'KeyO', + }; + + /** + * The keyboard shortcuts of the in-game editor, customizable from the IDE. + * The keys of the shortcuts are checked with the input manager of the game. + */ + class InGameEditorShortcuts { + private _parsedShortcuts: { [commandName: string]: ParsedShortcut } = {}; + + constructor() { + this.update({}); + } + + /** + * Set the shortcuts sent by the IDE. The commands that are not listed keep + * their default shortcut. + */ + update(shortcuts: { [commandName: string]: string }): void { + const shortcutsWithDefaults = { + ...defaultInGameEditorShortcuts, + ...shortcuts, + }; + this._parsedShortcuts = {}; + for (const commandName in shortcutsWithDefaults) { + const parsedShortcut = parseShortcut( + shortcutsWithDefaults[commandName] + ); + if (parsedShortcut) this._parsedShortcuts[commandName] = parsedShortcut; + } + } + + private _areModifiersMatching( + inputManager: gdjs.InputManager, + parsedShortcut: ParsedShortcut, + ignoreShift: boolean + ): boolean { + return ( + isControlOrCmdPressed(inputManager) === parsedShortcut.ctrlOrCmd && + isAltPressed(inputManager) === parsedShortcut.alt && + (ignoreShift || isShiftPressed(inputManager) === parsedShortcut.shift) + ); + } + + /** + * Check if the shortcut of a command is pressed (held down). + * `ignoreShift` allows Shift to be used as an extra modifier (for example + * to move the camera faster) without preventing the shortcut to match. + */ + isPressed( + inputManager: gdjs.InputManager, + commandName: string, + { ignoreShift }: { ignoreShift: boolean } = { ignoreShift: false } + ): boolean { + const parsedShortcut = this._parsedShortcuts[commandName]; + if (!parsedShortcut) return false; + + return ( + inputManager.isKeyPressed(parsedShortcut.keyCode) && + this._areModifiersMatching(inputManager, parsedShortcut, ignoreShift) + ); + } + + /** + * Check if the shortcut of a command was just pressed during this frame. + */ + wasJustPressed( + inputManager: gdjs.InputManager, + commandName: string + ): boolean { + const parsedShortcut = this._parsedShortcuts[commandName]; + if (!parsedShortcut) return false; + + return ( + inputManager.wasKeyJustPressed(parsedShortcut.keyCode) && + this._areModifiersMatching(inputManager, parsedShortcut, false) + ); + } + + /** + * Check if a key, with the modifiers currently pressed, is the shortcut of + * a command: such a key must not be forwarded to the IDE. + */ + isShortcutKey(inputManager: gdjs.InputManager, keyCode: integer): boolean { + for (const commandName in this._parsedShortcuts) { + const parsedShortcut = this._parsedShortcuts[commandName]; + if ( + parsedShortcut.keyCode === keyCode && + this._areModifiersMatching(inputManager, parsedShortcut, false) + ) { + return true; + } + } + return false; + } + } + const exceptionallyGetKeyCodeFromLocationAwareKeyCode = ( locationAwareKeyCode: number ): number => { @@ -395,6 +552,12 @@ namespace gdjs { toolbarSeparatorColor: string; textColorPrimary: string; }; + /** + * The shortcuts handled by the in-game editor, by command name, in the + * format of the IDE shortcuts (for example "Shift+KeyW"). A command that + * is not listed keeps its default shortcut, an empty string removes it. + */ + shortcuts?: { [commandName: string]: string }; }; const defaultInGameEditorSettings: InGameEditorSettings = { @@ -405,6 +568,7 @@ namespace gdjs { toolbarSeparatorColor: 'black', textColorPrimary: 'black', }, + shortcuts: {}, }; let hasWindowFocus = true; @@ -570,23 +734,26 @@ namespace gdjs { return true; }; - const freeCameraKeys = [ - LEFT_KEY, - RIGHT_KEY, - UP_KEY, - DOWN_KEY, - W_KEY, - S_KEY, - A_KEY, - D_KEY, - Q_KEY, - E_KEY, + const arrowKeys = [LEFT_KEY, RIGHT_KEY, UP_KEY, DOWN_KEY]; + const moveCameraCommandNames = [ + 'IN_GAME_EDITOR_MOVE_CAMERA_FORWARD', + 'IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD', + 'IN_GAME_EDITOR_MOVE_CAMERA_LEFT', + 'IN_GAME_EDITOR_MOVE_CAMERA_RIGHT', + 'IN_GAME_EDITOR_MOVE_CAMERA_UP', + 'IN_GAME_EDITOR_MOVE_CAMERA_DOWN', ]; - const shouldSwitchToFreeCamera = (inputManager: gdjs.InputManager) => - !isControlOrCmdPressed(inputManager) && - !isAltPressed(inputManager) && - !isShiftPressed(inputManager) && - freeCameraKeys.some((key) => inputManager.isKeyPressed(key)); + const shouldSwitchToFreeCamera = ( + inputManager: gdjs.InputManager, + shortcuts: InGameEditorShortcuts + ) => + (!isControlOrCmdPressed(inputManager) && + !isAltPressed(inputManager) && + !isShiftPressed(inputManager) && + arrowKeys.some((key) => inputManager.isKeyPressed(key))) || + moveCameraCommandNames.some((commandName) => + shortcuts.isPressed(inputManager, commandName, { ignoreShift: true }) + ); const snap = (value: float, size: float, offset: float) => size ? offset + size * Math.round((value - offset) / size) : value; @@ -961,6 +1128,7 @@ namespace gdjs { private _instancesEditorSettings: InstancesEditorSettings | null = null; private _toolbar: Toolbar; private _inGameEditorSettings: InGameEditorSettings; + private _shortcuts: InGameEditorShortcuts = new InGameEditorShortcuts(); constructor( game: RuntimeGame, @@ -1032,7 +1200,16 @@ namespace gdjs { } } + /** + * The keyboard shortcuts of the in-game editor, as customized in the IDE. + */ + getShortcuts(): InGameEditorShortcuts { + return this._shortcuts; + } + private _applyInGameEditorSettings() { + this._shortcuts.update(this._inGameEditorSettings.shortcuts || {}); + if (typeof document === 'undefined') return; const rootElement = document.documentElement; @@ -1707,13 +1884,19 @@ namespace gdjs { if (!currentScene) return; const selectedObject = this._selection.getLastSelectedObject(); - if (inputManager.isKeyPressed(F_KEY) && selectedObject) { + if ( + selectedObject && + this._shortcuts.isPressed( + inputManager, + 'IN_GAME_EDITOR_FOCUS_ON_SELECTION' + ) + ) { this._focusOnSelection(); } if ( !this._getEditorCamera().isFreeCamera() && - shouldSwitchToFreeCamera(inputManager) + shouldSwitchToFreeCamera(inputManager, this._shortcuts) ) { this._getEditorCamera().switchToFreeCamera(); } @@ -3150,6 +3333,9 @@ namespace gdjs { const keyCode = exceptionallyGetKeyCodeFromLocationAwareKeyCode(locationAwareKeyCode); + // A shortcut of the in-game editor is handled here, not by the IDE. + if (this._shortcuts.isShortcutKey(inputManager, keyCode)) continue; + const debuggerClient = this._runtimeGame._debuggerClient; if (debuggerClient) { debuggerClient.sendKeyboardShortcut({ @@ -3658,11 +3844,26 @@ namespace gdjs { private _handleTransformControlsMode() { const inputManager = this._runtimeGame.getInputManager(); - if (inputManager.wasKeyJustPressed(KEY_DIGIT_1)) { + if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_TRANSLATE_MODE' + ) + ) { this._setTransformControlsMode('translate'); - } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_2)) { + } else if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_ROTATE_MODE' + ) + ) { this._setTransformControlsMode('rotate'); - } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_3)) { + } else if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_SCALE_MODE' + ) + ) { this._setTransformControlsMode('scale'); } } @@ -4403,7 +4604,10 @@ namespace gdjs { } // With touches, 3 touches will orbit around the point "in front of the camera". if ( - (touchCount === 3 || inputManager.isKeyPressed(O_KEY)) && + (touchCount === 3 || + this.editor + .getShortcuts() + .isPressed(inputManager, 'IN_GAME_EDITOR_ORBIT_CAMERA')) && this.isFreeCamera() ) { const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. @@ -4989,7 +5193,9 @@ namespace gdjs { } // Movement with the keyboard: - // Either arrow keys (move in the camera plane) or WASD ("FPS move" + Q/E for up/down). + // Either arrow keys (move in the camera plane) or the customizable + // shortcuts (by default WASD for a "FPS move" + Q/E for up/down). + // Shift makes the camera move faster. const moveSpeed = isShiftPressed(inputManager) ? 48 : 6; if ( @@ -5008,29 +5214,37 @@ namespace gdjs { if (inputManager.isKeyPressed(DOWN_KEY)) { moveCameraByVector(up, -moveSpeed); } - // Forward/back - if (inputManager.isKeyPressed(W_KEY)) { - moveCameraByVector(forward, moveSpeed); - } - if (inputManager.isKeyPressed(S_KEY)) { - moveCameraByVector(forward, -moveSpeed); - } + } - // Left/right (strafe) - if (inputManager.isKeyPressed(A_KEY)) { - moveCameraByVector(right, -moveSpeed); - } - if (inputManager.isKeyPressed(D_KEY)) { - moveCameraByVector(right, moveSpeed); - } + const shortcuts = this._editorCamera.editor.getShortcuts(); + const isMoveCameraShortcutPressed = (commandName: string) => + shortcuts.isPressed(inputManager, commandName, { + ignoreShift: true, + }); + // Forward/back + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_FORWARD')) { + moveCameraByVector(forward, moveSpeed); + } + if ( + isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD') + ) { + moveCameraByVector(forward, -moveSpeed); + } - // Up/down - if (inputManager.isKeyPressed(Q_KEY)) { - moveCameraByVector(up, -moveSpeed); - } - if (inputManager.isKeyPressed(E_KEY)) { - moveCameraByVector(up, moveSpeed); - } + // Left/right (strafe) + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_LEFT')) { + moveCameraByVector(right, -moveSpeed); + } + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_RIGHT')) { + moveCameraByVector(right, moveSpeed); + } + + // Up/down + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_DOWN')) { + moveCameraByVector(up, -moveSpeed); + } + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_UP')) { + moveCameraByVector(up, moveSpeed); } // Movement with keyboard: zoom in/out. diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index c56fd7297a02..daa5186d3a55 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -75,14 +75,35 @@ export type CommandName = | 'OPEN_EXTENSION_SETTINGS' | 'OPEN_PROFILE' | 'OPEN_MEMORY_TRACKER_REGISTRY' - | 'INSTALL_CLI_IN_PATH'; + | 'INSTALL_CLI_IN_PATH' + | 'TILEMAP_FREEHAND_BRUSH' + | 'TILEMAP_RECTANGLE_PAINT' + | 'TILEMAP_FILL_BUCKET' + | 'TILEMAP_TILE_PICKER' + | 'TILEMAP_ERASE' + | 'TILEMAP_FLIP_HORIZONTALLY' + | 'TILEMAP_FLIP_VERTICALLY' + | 'IN_GAME_EDITOR_TRANSLATE_MODE' + | 'IN_GAME_EDITOR_ROTATE_MODE' + | 'IN_GAME_EDITOR_SCALE_MODE' + | 'IN_GAME_EDITOR_FOCUS_ON_SELECTION' + | 'IN_GAME_EDITOR_MOVE_CAMERA_FORWARD' + | 'IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD' + | 'IN_GAME_EDITOR_MOVE_CAMERA_LEFT' + | 'IN_GAME_EDITOR_MOVE_CAMERA_RIGHT' + | 'IN_GAME_EDITOR_MOVE_CAMERA_UP' + | 'IN_GAME_EDITOR_MOVE_CAMERA_DOWN' + | 'IN_GAME_EDITOR_ORBIT_CAMERA'; export const commandAreas = { GENERAL: (t`General`: any), - IDE: (t`IDE`: any), PROJECT: (t`Project`: any), SCENE: (t`Scene`: any), + SCENE_3D: (t`3D Editor`: any), + TILEMAP: (t`Tilemap tools`: any), EVENTS: (t`Events`: any), + // Only displayed when the IDE runs in development mode. + DEVELOPER: (t`Developer`: any), }; type CommandArea = $Keys; @@ -93,6 +114,12 @@ type CommandMetadata = {| noShortcut?: boolean, // If true, command won't show up in shortcuts list ghost?: boolean, // If true, command won't show up in palette handledByElectron?: boolean, // If true, command shortcut is handled by Electron in desktop app + /** + * If true, the shortcut is handled by the in-game editor (the game running + * in the embedded preview), which has the keyboard focus when it's used. + * The IDE ignores these shortcuts, and they can't clash with the IDE ones. + */ + handledByInGameEditor?: boolean, |}; const commandsList: { [CommandName]: CommandMetadata } = { @@ -103,12 +130,12 @@ const commandsList: { [CommandName]: CommandMetadata } = { handledByElectron: true, }, OPEN_PROJECT_MANAGER: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open project manager`, handledByElectron: true, }, OPEN_PROFILE: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open My Profile`, }, LAUNCH_NEW_PREVIEW: { area: 'PROJECT', displayText: t`Launch new preview` }, @@ -132,7 +159,7 @@ const commandsList: { [CommandName]: CommandMetadata } = { area: 'PROJECT', displayText: t`Show diagnostic report`, }, - OPEN_HOME_PAGE: { area: 'IDE', displayText: t`Show Home` }, + OPEN_HOME_PAGE: { area: 'GENERAL', displayText: t`Show Home` }, CREATE_NEW_PROJECT: { area: 'GENERAL', displayText: t`Create a new project`, @@ -182,16 +209,16 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open recent project...`, }, OPEN_COMMAND_PALETTE: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open command palette`, ghost: true, }, RESTART_IN_GAME_EDITOR: { - area: 'IDE', + area: 'SCENE_3D', displayText: t`Restart 3D editor`, }, INSTALL_CLI_IN_PATH: { - area: 'IDE', + area: 'GENERAL', displayText: t`Install GDevelop CLI in PATH`, noShortcut: true, }, @@ -227,23 +254,23 @@ const commandsList: { [CommandName]: CommandMetadata } = { noShortcut: true, }, OPEN_GLOBAL_SEARCH: { - area: 'IDE', + area: 'GENERAL', displayText: t`Global search (search in project)`, }, // Tab-opening commands - OPEN_LAYOUT: { area: 'IDE', displayText: t`Open scene...` }, + OPEN_LAYOUT: { area: 'GENERAL', displayText: t`Open scene...` }, OPEN_EXTERNAL_EVENTS: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open external events...`, }, OPEN_EXTERNAL_LAYOUT: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open external layout...`, }, - OPEN_EXTENSION: { area: 'IDE', displayText: t`Open extension...` }, + OPEN_EXTENSION: { area: 'GENERAL', displayText: t`Open extension...` }, OPEN_GAMEPLAY_TEST: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open gameplay test...`, }, RUN_GAMEPLAY_TEST: { @@ -388,11 +415,120 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open extension settings`, }, + // Tile map painting tools (shown when painting a tile map in the scene editor) + TILEMAP_FREEHAND_BRUSH: { + area: 'TILEMAP', + displayText: t`Freehand brush`, + }, + TILEMAP_RECTANGLE_PAINT: { + area: 'TILEMAP', + displayText: t`Rectangle paint`, + }, + TILEMAP_FILL_BUCKET: { + area: 'TILEMAP', + displayText: t`Fill bucket`, + }, + TILEMAP_TILE_PICKER: { + area: 'TILEMAP', + displayText: t`Tile picker`, + }, + TILEMAP_ERASE: { + area: 'TILEMAP', + displayText: t`Erase`, + }, + TILEMAP_FLIP_HORIZONTALLY: { + area: 'TILEMAP', + displayText: t`Flip horizontally`, + }, + TILEMAP_FLIP_VERTICALLY: { + area: 'TILEMAP', + displayText: t`Flip vertically`, + }, + + // In-game (3D) editor commands. Their shortcuts are handled by the game + // running in the embedded preview: the IDE only sends them the shortcuts. + IN_GAME_EDITOR_TRANSLATE_MODE: { + area: 'SCENE_3D', + displayText: t`Move tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_ROTATE_MODE: { + area: 'SCENE_3D', + displayText: t`Rotate tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_SCALE_MODE: { + area: 'SCENE_3D', + displayText: t`Scale tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_FOCUS_ON_SELECTION: { + area: 'SCENE_3D', + displayText: t`Focus the camera on the selection`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: { + area: 'SCENE_3D', + displayText: t`Move the camera forward`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: { + area: 'SCENE_3D', + displayText: t`Move the camera backward`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: { + area: 'SCENE_3D', + displayText: t`Move the camera to the left`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: { + area: 'SCENE_3D', + displayText: t`Move the camera to the right`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_UP: { + area: 'SCENE_3D', + displayText: t`Move the camera up`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: { + area: 'SCENE_3D', + displayText: t`Move the camera down`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_ORBIT_CAMERA: { + area: 'SCENE_3D', + displayText: t`Orbit the camera around the point in front of it`, + ghost: true, + handledByInGameEditor: true, + }, + // Debug commands OPEN_MEMORY_TRACKER_REGISTRY: { - area: 'IDE', + area: 'DEVELOPER', displayText: t`Open memory tracker registry`, }, }; +/** + * The names of the commands whose shortcuts are handled by the in-game editor. + * The shortcuts of these commands are sent to the in-game editor (see + * `InGameEditorSettings`) instead of being handled by the IDE. + */ +export const getCommandNamesHandledByInGameEditor = (): Array => + Object.keys(commandsList).filter( + commandName => !!commandsList[commandName].handledByInGameEditor + ); + export default commandsList; diff --git a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js index f217cc38c361..62217d3bb3d4 100644 --- a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js +++ b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js @@ -1,6 +1,8 @@ // @flow import * as React from 'react'; import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; +import { useShortcutMap } from '../KeyboardShortcuts'; +import { getCommandNamesHandledByInGameEditor } from '../CommandPalette/CommandsList'; export type InGameEditorSettings = { theme: { @@ -10,6 +12,12 @@ export type InGameEditorSettings = { toolbarSeparatorColor: string, textColorPrimary: string, }, + /** + * The shortcuts handled by the in-game editor, by command name, in the same + * format as the IDE shortcuts (for example "Shift+KeyW"). An empty string + * means that the command has no shortcut. + */ + shortcuts: { [string]: string }, }; /** @@ -25,6 +33,17 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { const toolbarSeparatorColor = gdevelopTheme.toolbar.separatorColor; const textColorPrimary = gdevelopTheme.text.color.primary; + // The shortcut map is a new object at each render: the shortcuts handled by + // the in-game editor are serialized so that the settings only change when + // one of these shortcuts really changed. + const shortcutMap = useShortcutMap(); + const inGameEditorShortcutsJson = JSON.stringify( + getCommandNamesHandledByInGameEditor().reduce((shortcuts, commandName) => { + shortcuts[commandName] = shortcutMap[commandName] || ''; + return shortcuts; + }, {}) + ); + const inGameEditorSettings = React.useMemo( () => ({ theme: { @@ -34,6 +53,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarSeparatorColor, textColorPrimary, }, + shortcuts: JSON.parse(inGameEditorShortcutsJson), }), [ iconButtonSelectedBackgroundColor, @@ -41,6 +61,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarBackgroundColor, toolbarSeparatorColor, textColorPrimary, + inGameEditorShortcutsJson, ] ); diff --git a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js index 3f3b05aa304e..34e9db4741a9 100644 --- a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js +++ b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js @@ -20,6 +20,7 @@ import { useLongTouch, type ClientCoordinates } from '../Utils/UseLongTouch'; import Text from '../UI/Text'; import EmptyMessage from '../UI/EmptyMessage'; import { isTileSetBadlyConfigured } from '../Utils/TileMap'; +import { useCommand } from '../CommandPalette/CommandHooks'; const styles = { tilesetAndTooltipsContainer: { @@ -781,6 +782,117 @@ const TileSetVisualizer = ({ const isAtlasImageSet = !!atlasResourceName; + // The painting tools, used both by the toolbar buttons and by the keyboard + // shortcuts (commands). Selecting the current tool again deselects it. + const defaultPaintingCoordinates: TileMapCoordinates[] = lastPaintingSelection + ? lastPaintingSelection.coordinates + : [{ x: 0, y: 0 }, { x: 0, y: 0 }]; + const isToolSelected = (kind: string): boolean => + !!tileMapTileSelection && tileMapTileSelection.kind === kind; + + const toggleFreehandBrush = () => { + if (isToolSelected('freehand')) onSelectTileMapTile(null); + else + onSelectTileMapTile({ + kind: 'freehand', + coordinates: defaultPaintingCoordinates, + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + }); + }; + const toggleRectanglePaint = () => { + if (isToolSelected('rectangle')) onSelectTileMapTile(null); + else + onSelectTileMapTile( + lastSelection && lastSelection.kind === 'rectangle' + ? lastSelection + : { + kind: 'rectangle', + coordinates: defaultPaintingCoordinates, + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + } + ); + }; + const toggleFillBucket = () => { + if (isToolSelected('floodfill')) onSelectTileMapTile(null); + else + onSelectTileMapTile({ + kind: 'floodfill', + coordinates: defaultPaintingCoordinates, + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + }); + }; + const toggleTilePicker = () => { + if (isToolSelected('picker')) { + onSelectTileMapTile(null); + previousToolRef.current = null; + } else { + // Store the current selection before switching to picker + previousToolRef.current = tileMapTileSelection; + onSelectTileMapTile({ kind: 'picker' }); + } + }; + const toggleErase = () => { + if (isToolSelected('erase')) onSelectTileMapTile(null); + else onSelectTileMapTile({ kind: 'erase' }); + }; + const canFlip = + !!tileMapTileSelection && + tileMapTileSelection.kind !== 'erase' && + tileMapTileSelection.kind !== 'picker'; + const toggleFlipHorizontally = () => { + const newShouldFlipHorizontally = !shouldFlipHorizontally; + setShouldFlipHorizontally(newShouldFlipHorizontally); + if (tileMapPaintingSelection) { + const selection: TileMapTileSelection = { + kind: (tileMapPaintingSelection.kind: any), + coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), + flipHorizontally: newShouldFlipHorizontally, + flipVertically: tileMapPaintingSelection.flipVertically, + }; + onSelectTileMapTile(selection); + } + }; + const toggleFlipVertically = () => { + const newShouldFlipVertically = !shouldFlipVertically; + setShouldFlipVertically(newShouldFlipVertically); + if (tileMapPaintingSelection) { + const selection: TileMapTileSelection = { + kind: (tileMapPaintingSelection.kind: any), + coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), + flipHorizontally: tileMapPaintingSelection.flipHorizontally, + flipVertically: newShouldFlipVertically, + }; + onSelectTileMapTile(selection); + } + }; + + // Keyboard shortcuts of the painting tools, only when the toolbar is shown. + const arePaintingCommandsEnabled = showPaintingToolbar && isAtlasImageSet; + useCommand('TILEMAP_FREEHAND_BRUSH', arePaintingCommandsEnabled, { + handler: toggleFreehandBrush, + }); + useCommand('TILEMAP_RECTANGLE_PAINT', arePaintingCommandsEnabled, { + handler: toggleRectanglePaint, + }); + useCommand('TILEMAP_FILL_BUCKET', arePaintingCommandsEnabled, { + handler: toggleFillBucket, + }); + useCommand('TILEMAP_TILE_PICKER', arePaintingCommandsEnabled, { + handler: toggleTilePicker, + }); + useCommand('TILEMAP_ERASE', showPaintingToolbar, { + handler: toggleErase, + }); + useCommand('TILEMAP_FLIP_HORIZONTALLY', showPaintingToolbar && canFlip, { + handler: toggleFlipHorizontally, + }); + useCommand('TILEMAP_FLIP_VERTICALLY', showPaintingToolbar && canFlip, { + handler: toggleFlipVertically, + }); + return ( {showPaintingToolbar && ( @@ -791,26 +903,8 @@ const TileSetVisualizer = ({ id="freehandBrush" size="small" tooltip={t`Freehand brush`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'freehand' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'freehand' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'freehand', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - }} + selected={isToolSelected('freehand')} + onClick={toggleFreehandBrush} disabled={!isAtlasImageSet} > @@ -819,30 +913,8 @@ const TileSetVisualizer = ({ id="rectanglePaint" size="small" tooltip={t`Rectangle paint`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'rectangle' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'rectangle' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile( - lastSelection && lastSelection.kind === 'rectangle' - ? lastSelection - : { - kind: 'rectangle', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - } - ); - }} + selected={isToolSelected('rectangle')} + onClick={toggleRectanglePaint} disabled={!isAtlasImageSet} > @@ -851,26 +923,8 @@ const TileSetVisualizer = ({ id="fillBucket" size="small" tooltip={t`Fill bucket`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'floodfill' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'floodfill' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'floodfill', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - }} + selected={isToolSelected('floodfill')} + onClick={toggleFillBucket} disabled={!isAtlasImageSet} > @@ -879,23 +933,8 @@ const TileSetVisualizer = ({ id="tilePicker" size="small" tooltip={t`Tile picker`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'picker' - ) { - onSelectTileMapTile(null); - previousToolRef.current = null; - } else { - // Store the current selection before switching to picker - previousToolRef.current = tileMapTileSelection; - onSelectTileMapTile({ kind: 'picker' }); - } - }} + selected={isToolSelected('picker')} + onClick={toggleTilePicker} disabled={!isAtlasImageSet} > @@ -904,18 +943,8 @@ const TileSetVisualizer = ({ id="eraseBrush" size="small" tooltip={t`Erase`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'erase' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'erase' - ) - onSelectTileMapTile(null); - else onSelectTileMapTile({ kind: 'erase' }); - }} + selected={isToolSelected('erase')} + onClick={toggleErase} > @@ -926,24 +955,8 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Horizontal flip`} selected={shouldFlipHorizontally} - disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind === 'erase' || - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - const newShouldFlipHorizontally = !shouldFlipHorizontally; - setShouldFlipHorizontally(newShouldFlipHorizontally); - if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { - kind: (tileMapPaintingSelection.kind: any), - coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: newShouldFlipHorizontally, - flipVertically: tileMapPaintingSelection.flipVertically, - }; - onSelectTileMapTile(selection); - } - }} + disabled={!canFlip} + onClick={toggleFlipHorizontally} > @@ -952,25 +965,8 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Vertical flip`} selected={shouldFlipVertically} - disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind === 'erase' || - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - const newShouldFlipVertically = !shouldFlipVertically; - setShouldFlipVertically(newShouldFlipVertically); - if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { - kind: (tileMapPaintingSelection.kind: any), - coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: - tileMapPaintingSelection.flipHorizontally, - flipVertically: newShouldFlipVertically, - }; - onSelectTileMapTile(selection); - } - }} + disabled={!canFlip} + onClick={toggleFlipVertically} > diff --git a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js index 99b11b2f35b2..c3174b5ec939 100644 --- a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js +++ b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js @@ -61,6 +61,31 @@ const defaultShortcuts: ShortcutMap = { CHOOSE_AND_ADD_EVENT: 'Shift+KeyW', MOVE_EVENTS_IN_NEW_GROUP: 'CmdOrCtrl+KeyG', OPEN_EXTENSION_SETTINGS: '', + + // Tile map painting tools. Inspired by Godot and Aseprite, but the letters + // E, P, G, I and V are already used by the scene editor commands above, so + // these tools use Shift with their first letter. + TILEMAP_FREEHAND_BRUSH: 'KeyB', + TILEMAP_RECTANGLE_PAINT: 'KeyR', + TILEMAP_FILL_BUCKET: 'Shift+KeyF', + TILEMAP_TILE_PICKER: 'Shift+KeyP', + TILEMAP_ERASE: 'Shift+KeyE', + TILEMAP_FLIP_HORIZONTALLY: 'Shift+KeyH', + TILEMAP_FLIP_VERTICALLY: 'Shift+KeyY', + + // In-game (3D) editor. These shortcuts are only active when the game + // preview has the focus, so they can reuse letters of the IDE commands. + IN_GAME_EDITOR_TRANSLATE_MODE: 'Digit1', + IN_GAME_EDITOR_ROTATE_MODE: 'Digit2', + IN_GAME_EDITOR_SCALE_MODE: 'Digit3', + IN_GAME_EDITOR_FOCUS_ON_SELECTION: 'KeyF', + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyW', + IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: 'KeyS', + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyA', + IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: 'KeyD', + IN_GAME_EDITOR_MOVE_CAMERA_UP: 'KeyE', + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyQ', + IN_GAME_EDITOR_ORBIT_CAMERA: 'KeyO', }; /** @@ -72,4 +97,26 @@ export const defaultSecondaryShortcuts: ShortcutMap = { OPEN_COMMAND_PALETTE: 'CmdOrCtrl+KeyK', }; +export type KeyboardLayout = 'qwerty' | 'azerty'; + +/** + * On AZERTY keyboards, the keys used to move the camera of the 3D editor are + * ZQSD (and A to go down) instead of WASD (and Q to go down). + */ +const azertyDefaultShortcutOverrides: ShortcutMap = { + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyZ', + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyQ', + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyA', +}; + +/** + * The default shortcuts for the given keyboard layout. + */ +export const getDefaultShortcuts = ( + keyboardLayout: KeyboardLayout +): ShortcutMap => + keyboardLayout === 'azerty' + ? { ...defaultShortcuts, ...azertyDefaultShortcutOverrides } + : defaultShortcuts; + export default defaultShortcuts; diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index a559fd71736d..1ffa94aeb0fa 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -5,21 +5,31 @@ import { type I18n } from '@lingui/core'; import Text from '../UI/Text'; import EmptyMessage from '../UI/EmptyMessage'; import DetectShortcutDialog from './DetectShortcutDialog'; -import { type ShortcutMap } from './DefaultShortcuts'; +import { + type ShortcutMap, + type KeyboardLayout, + getDefaultShortcuts, +} from './DefaultShortcuts'; import { getShortcutDisplayName } from './index'; -import defaultShortcuts from '../KeyboardShortcuts/DefaultShortcuts'; import ShortcutsListRow from './ShortcutsListRow'; import commandsList, { type CommandName, commandAreas, } from '../CommandPalette/CommandsList'; import { ColumnStackLayout } from '../UI/Layout'; +import Window from '../Utils/Window'; const styles = { section: { display: 'flex', flexDirection: 'column', }, + // An area with its title, separated from the previous one. + areaSection: { + display: 'flex', + flexDirection: 'column', + marginTop: 10, + }, }; /** @@ -35,15 +45,31 @@ const getPatchedShortcutString = ( return userShortcut == null ? defaultShortcut : userShortcut; }; +/** + * The shortcuts of the commands handled by the in-game editor are only active + * when the game preview has the focus, while the other ones are active in the + * IDE: two commands can only clash if they are in the same context. + */ +const getShortcutContextKey = ( + commandName: CommandName, + shortcutString: string +): string => + (commandsList[commandName].handledByInGameEditor + ? 'in-game-editor:' + : 'ide:') + shortcutString; + /** * Sorts all commands into an object keyed by area name, and also creates a - * reverse mapping from shortcut string to list of commands with that shortcut. + * reverse mapping from shortcut (in its context, see `getShortcutContextKey`) + * to list of commands with that shortcut. */ const sortCommandsIntoAreasAndGetReverseMap = ( - userShortcutMap: ShortcutMap + userShortcutMap: ShortcutMap, + keyboardLayout: KeyboardLayout ) => { + const defaultShortcuts = getDefaultShortcuts(keyboardLayout); const areaWiseCommands = {}; - const shortcutStringToCommands: { [string]: Array } = {}; + const shortcutContextKeyToCommands: { [string]: Array } = {}; Object.keys(commandsList) .filter(name => !commandsList[name].noShortcut) .forEach(name => { @@ -62,12 +88,13 @@ const sortCommandsIntoAreasAndGetReverseMap = ( userShortcut ); if (shortcutString === '') return; - shortcutStringToCommands[shortcutString] = ( - shortcutStringToCommands[shortcutString] || [] + const shortcutContextKey = getShortcutContextKey(name, shortcutString); + shortcutContextKeyToCommands[shortcutContextKey] = ( + shortcutContextKeyToCommands[shortcutContextKey] || [] ).concat(name); }); - return [areaWiseCommands, shortcutStringToCommands]; + return [areaWiseCommands, shortcutContextKeyToCommands]; }; /** @@ -99,15 +126,21 @@ export type ShortcutSectionData = {| export const getShortcutSections = ( i18n: I18n, userShortcutMap: ShortcutMap, + keyboardLayout: KeyboardLayout, searchText: string ): Array => { + const defaultShortcuts = getDefaultShortcuts(keyboardLayout); const [ areaWiseCommands, shortcutStringToCommands, - ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap); + ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap, keyboardLayout); const normalizedSearchText = normalizeForSearch(searchText); - return Object.keys(areaWiseCommands) + // The areas are displayed in the order of `commandAreas`, which is also the + // order of the areas list in the preferences dialog. + return Object.keys(commandAreas) + .filter(areaName => !!areaWiseCommands[areaName]) + .filter(areaName => areaName !== 'DEVELOPER' || Window.isDev()) .map(areaName => { const rows = areaWiseCommands[areaName] .map( @@ -162,11 +195,17 @@ export const getShortcutSections = ( type Props = {| i18n: I18n, userShortcutMap: ShortcutMap, + /** The keyboard layout, which changes some default shortcuts. */ + keyboardLayout: KeyboardLayout, onEdit: (commandName: CommandName, shortcut: string) => void, /** Filter the displayed commands by name or by shortcut. */ searchText?: string, /** Only display the commands of this area, without the area title. */ areaName?: string, + /** Give an id to the element of each area, to be able to scroll to it. */ + getSectionElementId?: (areaName: string) => string, + /** Render content displayed at the top of an area, below its title. */ + renderAreaHeader?: (areaName: string) => React.Node, |}; const ShortcutsList = (props: Props): React.Node => { @@ -176,12 +215,16 @@ const ShortcutsList = (props: Props): React.Node => { ] = React.useState(null); const resetShortcut = (commandName: CommandName) => { - props.onEdit(commandName, defaultShortcuts[commandName]); + props.onEdit( + commandName, + getDefaultShortcuts(props.keyboardLayout)[commandName] + ); }; const sections = getShortcutSections( props.i18n, props.userShortcutMap, + props.keyboardLayout, props.searchText || '' ).filter(section => !props.areaName || section.areaName === props.areaName); @@ -189,8 +232,17 @@ const ShortcutsList = (props: Props): React.Node => { {sections.length > 0 ? ( sections.map(section => ( -
+
{!props.areaName && {section.title}} + {props.renderAreaHeader && props.renderAreaHeader(section.areaName)}
{section.rows.map(row => ( { - const { isMobile } = useResponsiveWindowSize(); - const commandDisplayText = props.i18n._( commandsList[props.commandName].displayText ); @@ -84,19 +76,16 @@ const ShortcutsListRow = (props: Props): React.Node => { label={props.shortcutString || No shortcut} onClick={props.onEditShortcut} color={props.shortcutString ? 'secondary' : 'default'} - size={isMobile ? 'small' : 'medium'} />
-
- {!props.isDefault && ( - - - - )} +
+ + +
); diff --git a/newIDE/app/src/KeyboardShortcuts/index.js b/newIDE/app/src/KeyboardShortcuts/index.js index acd5495ebe77..27a18f1fda55 100644 --- a/newIDE/app/src/KeyboardShortcuts/index.js +++ b/newIDE/app/src/KeyboardShortcuts/index.js @@ -6,9 +6,10 @@ import reservedShortcuts from './ReservedShortcuts'; import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import commandsList, { type CommandName } from '../CommandPalette/CommandsList'; import isUserTyping from './IsUserTyping'; -import defaultShortcuts, { +import { defaultSecondaryShortcuts, type ShortcutMap, + getDefaultShortcuts, } from './DefaultShortcuts'; import { type PreviewDebuggerServer } from '../ExportAndShare/PreviewLauncher.flow'; import optionalRequire from '../Utils/OptionalRequire'; @@ -240,7 +241,10 @@ export const getShortcutMetadataFromEvent = ( export const useShortcutMap = (): ShortcutMap => { const preferences = React.useContext(PreferencesContext); const userShortcutMap = preferences.values.userShortcutMap; - return { ...defaultShortcuts, ...userShortcutMap }; + return { + ...getDefaultShortcuts(preferences.values.keyboardLayout), + ...userShortcutMap, + }; }; type UseKeyboardShortcutsProps = {| @@ -270,10 +274,14 @@ export const useKeyboardShortcuts = ({ const shortcutData = getShortcutMetadataFromEvent(e); if (!shortcutData.isValid) return; - // Get corresponding command, if it exists + // Get corresponding command, if it exists. The commands handled by + // the in-game editor are ignored: their shortcuts are only active + // when the game preview has the focus, and are handled by the game. const commandName = Object.keys(shortcutMap).find( - name => shortcutMap[name] === shortcutData.shortcutString + name => + !commandsList[name].handledByInGameEditor && + shortcutMap[name] === shortcutData.shortcutString ) || Object.keys(defaultSecondaryShortcuts).find( name => diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index d22f9513cc82..edda6d59abdf 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -7,7 +7,10 @@ import type { } from '../../ResourcesList/ResourceSource'; import { type EditorMosaicNode } from '../../UI/EditorMosaic'; import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; -import { type ShortcutMap } from '../../KeyboardShortcuts/DefaultShortcuts'; +import { + type ShortcutMap, + type KeyboardLayout, +} from '../../KeyboardShortcuts/DefaultShortcuts'; import { type CommandName } from '../../CommandPalette/CommandsList'; import { type EditorTabsPersistedState } from '../EditorTabs/EditorTabsHandler'; import { type GamesDashboardOrderBy } from '../../GameDashboard/GamesList'; @@ -225,6 +228,7 @@ export type PreferencesValues = {| resourcesImporationBehavior: ResourceImportationBehavior, eventsSheetCancelInlineParameter: 'cancel' | 'apply', showExperimentalExtensions: boolean, + keyboardLayout: KeyboardLayout, showCreateSectionByDefault: boolean, showInAppTutorialDeveloperMode: boolean, showDeprecatedInstructionWarning: @@ -332,6 +336,7 @@ export type Preferences = {| setIsAlwaysOnTopInPreview: (enabled: boolean) => void, setEventsSheetCancelInlineParameter: (value: string) => void, setShowExperimentalExtensions: (enabled: boolean) => void, + setKeyboardLayout: (keyboardLayout: KeyboardLayout) => void, setShowCreateSectionByDefault: (enabled: boolean) => void, setShowInAppTutorialDeveloperMode: (enabled: boolean) => void, setOpenDiagnosticReportAutomatically: (enabled: boolean) => void, @@ -432,6 +437,7 @@ export const initialPreferences = { resourcesImporationBehavior: 'ask', eventsSheetCancelInlineParameter: 'apply', showExperimentalExtensions: false, + keyboardLayout: 'qwerty', showCreateSectionByDefault: false, showInAppTutorialDeveloperMode: false, openDiagnosticReportAutomatically: true, @@ -512,6 +518,7 @@ export const initialPreferences = { setIsAlwaysOnTopInPreview: () => {}, setEventsSheetCancelInlineParameter: () => {}, setShowExperimentalExtensions: () => {}, + setKeyboardLayout: () => {}, setShowCreateSectionByDefault: (enabled: boolean) => {}, setShowInAppTutorialDeveloperMode: (enabled: boolean) => {}, setShowDeprecatedInstructionWarning: ( diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index b4200a954cc2..a0a1a354c8cd 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -10,7 +10,8 @@ import LocalFolderPicker from '../../UI/LocalFolderPicker'; import SelectOption from '../../UI/SelectOption'; import { CompactToggleField } from '../../UI/CompactToggleField'; import Dialog from '../../UI/Dialog'; -import { Column, Line } from '../../UI/Grid'; +import { Column, Line, marginsSize } from '../../UI/Grid'; +import { homepageDesktopMenuBarWidth } from '../EditorContainers/HomePage/HomePageMenuBar'; import { themes } from '../../UI/Theme/ThemeRegistry'; import { getAllThemes } from '../../CodeEditor/Theme'; import Window from '../../Utils/Window'; @@ -39,6 +40,7 @@ import { } from '../../KeyboardShortcuts'; import defaultShortcuts, { defaultSecondaryShortcuts, + getDefaultShortcuts, } from '../../KeyboardShortcuts/DefaultShortcuts'; import AlertMessage from '../../UI/AlertMessage'; import ErrorBoundary from '../../UI/ErrorBoundary'; @@ -56,7 +58,11 @@ import HelpIcon from '../../UI/CustomSvgIcons/Help'; import SettingsIcon from '../../UI/CustomSvgIcons/Settings'; import FolderIcon from '../../UI/CustomSvgIcons/Folder'; import HammerIcon from '../../UI/CustomSvgIcons/Hammer'; +import SparkleIcon from '../../UI/CustomSvgIcons/Sparkle'; +import DebugIcon from '../../UI/CustomSvgIcons/Debug'; import ProjectManagerIcon from '../../UI/CustomSvgIcons/ProjectManager'; +import Object3dIcon from '../../UI/CustomSvgIcons/Object3d'; +import Grid2dIcon from '../../UI/CustomSvgIcons/Grid2d'; const electron = optionalRequire('electron'); export type PreferencesSectionName = @@ -71,7 +77,9 @@ export type PreferencesSectionName = | 'help' | 'other' | 'folders' - | 'contributor'; + | 'experimental' + | 'contributor' + | 'developer'; export type PreferencesTabName = 'preferences' | 'shortcuts'; @@ -99,6 +107,22 @@ const sections: Array = [ ), }, + { + name: 'updates', + label: Updates, + getSearchableLabel: i18n => i18n._(t`Updates`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'help', + label: Help and tutorials, + getSearchableLabel: i18n => i18n._(t`Help and tutorials`), + getIcon: ({ color, fontSize }) => ( + + ), + }, { name: 'appearance', label: Appearance, @@ -123,14 +147,6 @@ const sections: Array = [ ), }, - { - name: 'updates', - label: Updates, - getSearchableLabel: i18n => i18n._(t`Updates`), - getIcon: ({ color, fontSize }) => ( - - ), - }, { name: 'events-sheet', label: Events Sheet, @@ -141,8 +157,8 @@ const sections: Array = [ }, { name: 'previews', - label: Previews, - getSearchableLabel: i18n => i18n._(t`Previews`), + label: Previews & Saves, + getSearchableLabel: i18n => i18n._(t`Previews & Saves`), getIcon: ({ color, fontSize }) => ( ), @@ -155,22 +171,6 @@ const sections: Array = [ ), }, - { - name: 'help', - label: Help and tutorials, - getSearchableLabel: i18n => i18n._(t`Help and tutorials`), - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'other', - label: Advanced, - getSearchableLabel: i18n => i18n._(t`Advanced`), - getIcon: ({ color, fontSize }) => ( - - ), - }, ...(electron ? [ { @@ -191,6 +191,35 @@ const sections: Array = [ ), }, + { + name: 'experimental', + label: Experimental, + getSearchableLabel: i18n => i18n._(t`Experimental`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'other', + label: Advanced, + getSearchableLabel: i18n => i18n._(t`Advanced`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + // Settings for the developers of GDevelop, in development mode only. + ...(Window.isDev() + ? [ + { + name: 'developer', + label: Developer, + getSearchableLabel: i18n => i18n._(t`Developer`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + ] + : []), ]; type ShortcutAreaDefinition = {| @@ -209,12 +238,6 @@ const shortcutAreas: Array = [ ), }, - { - name: 'IDE', - getIcon: ({ color, fontSize }) => ( - - ), - }, { name: 'PROJECT', getIcon: ({ color, fontSize }) => ( @@ -227,12 +250,34 @@ const shortcutAreas: Array = [ ), }, + { + name: 'SCENE_3D', + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'TILEMAP', + getIcon: ({ color, fontSize }) => ( + + ), + }, { name: 'EVENTS', getIcon: ({ color, fontSize }) => ( ), }, + ...(Window.isDev() + ? [ + { + name: 'DEVELOPER', + getIcon: ({ color, fontSize }) => ( + + ), + }, + ] + : []), ]; /** @@ -255,7 +300,14 @@ type SectionContent = {| renderFooter?: () => React.Node, |}; -const sectionsColumnWidth = 220; +const getSectionElementId = (sectionName: string): string => + `preferences-section-content-${sectionName}`; +const getShortcutAreaElementId = (areaName: string): string => + `preferences-shortcuts-area-${areaName}`; + +// A section is considered displayed at the top of the content when its top is +// at most this number of pixels below the top of the content. +const scrollSpyTolerance = 8; const styles = { body: { @@ -263,13 +315,17 @@ const styles = { flex: 1, minHeight: 0, }, + // A section with its title, separated from the previous one. + section: { + marginTop: 10, + }, sectionsColumn: { - width: sectionsColumnWidth, + width: homepageDesktopMenuBarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflowY: 'auto', - paddingRight: 8, + paddingRight: marginsSize, }, sectionContent: { flex: 1, @@ -277,7 +333,7 @@ const styles = { display: 'flex', flexDirection: 'column', overflowY: 'auto', - paddingLeft: 16, + paddingLeft: marginsSize * 2, }, sectionContentOnMobile: { flex: 1, @@ -352,6 +408,7 @@ const PreferencesDialog = ({ setIsAlwaysOnTopInPreview, setEventsSheetCancelInlineParameter, setShowExperimentalExtensions, + setKeyboardLayout, setShowInAppTutorialDeveloperMode, setOpenDiagnosticReportAutomatically, setBlockPreviewAndExportOnDiagnosticErrors, @@ -377,11 +434,99 @@ const PreferencesDialog = ({ const isSearching = searchText.trim() !== ''; + // All the sections of a tab are displayed one after another: the sections + // list on the left scrolls to the chosen section, and highlights the section + // displayed at the top of the content while scrolling. + const sectionContentRef = React.useRef(null); + // The element to scroll to once rendered: an element id, or the empty + // string to scroll to the top of the content. + const pendingScrollElementIdRef = React.useRef( + initialSection && (initialTab || 'preferences') === 'preferences' + ? getSectionElementId(initialSection) + : null + ); + + const scrollToElement = (elementId: string) => { + const container = sectionContentRef.current; + const element = document.getElementById(elementId); + if (!container || !element) return; + + container.scrollTop += + element.getBoundingClientRect().top - + container.getBoundingClientRect().top; + }; + + React.useEffect(() => { + // Scroll once the content to scroll to is rendered (for example after + // the search has been cleared or the tab has been changed). + const pendingScrollElementId = pendingScrollElementIdRef.current; + if (pendingScrollElementId == null) return; + pendingScrollElementIdRef.current = null; + + if (pendingScrollElementId === '') { + if (sectionContentRef.current) sectionContentRef.current.scrollTop = 0; + } else { + scrollToElement(pendingScrollElementId); + } + }); + + /** + * Find the entry (section or shortcut area) displayed at the top of the + * content, or the last one if the content is scrolled to the bottom. + */ + const getEntryKeyAtTop = ( + entryKeys: Array, + getElementId: string => string + ): ?string => { + const container = sectionContentRef.current; + if (!container || entryKeys.length === 0) return null; + + const isScrolledToBottom = + container.scrollTop + container.clientHeight >= + container.scrollHeight - 1; + if (isScrolledToBottom) return entryKeys[entryKeys.length - 1]; + + const containerTop = container.getBoundingClientRect().top; + let entryKeyAtTop = entryKeys[0]; + entryKeys.forEach(entryKey => { + const element = document.getElementById(getElementId(entryKey)); + if ( + element && + element.getBoundingClientRect().top - containerTop <= scrollSpyTolerance + ) { + entryKeyAtTop = entryKey; + } + }); + return entryKeyAtTop; + }; + + const onContentScroll = () => { + if (isSearching) return; + + if (currentTab === 'preferences') { + const sectionNameAtTop = getEntryKeyAtTop( + sections.map(section => section.name), + getSectionElementId + ); + if (sectionNameAtTop && sectionNameAtTop !== currentSection) { + setCurrentSection((sectionNameAtTop: any)); + } + } else { + const areaNameAtTop = getEntryKeyAtTop( + shortcutAreas.map(area => area.name), + getShortcutAreaElementId + ); + if (areaNameAtTop && areaNameAtTop !== currentShortcutArea) { + setCurrentShortcutArea((areaNameAtTop: any)); + } + } + }; + const hasCustomizedShortcuts = Object.keys(values.userShortcutMap).some( commandName => values.userShortcutMap[commandName] != null && values.userShortcutMap[commandName] !== - (defaultShortcuts[commandName] || '') + (getDefaultShortcuts(values.keyboardLayout)[commandName] || '') ); const resetAllShortcutsToDefault = async () => { @@ -957,17 +1102,6 @@ const PreferencesDialog = ({ setDisplaySaveReminder({ activated: check }) ), }, - { - id: 'show-experimental-extensions', - label: i18n._( - t`Show experimental extensions in the list of extensions` - ), - renderControl: () => - renderToggle( - values.showExperimentalExtensions, - setShowExperimentalExtensions - ), - }, { id: 'use-background-serializer-for-saving', label: i18n._( @@ -1028,6 +1162,48 @@ const PreferencesDialog = ({ }, ], }; + case 'experimental': + return { + settings: [ + { + id: 'show-experimental-extensions', + label: i18n._( + t`Show experimental extensions in the list of extensions` + ), + renderControl: () => + renderToggle( + values.showExperimentalExtensions, + setShowExperimentalExtensions + ), + }, + ], + }; + case 'developer': + return { + settings: [ + { + id: 'use-gdjs-development-watcher', + label: i18n._( + t`Watch changes in game engine (GDJS) sources and auto import them` + ), + renderControl: () => + renderToggle( + values.useGDJSDevelopmentWatcher, + setUseGDJSDevelopmentWatcher + ), + }, + { + id: 'quick-customization', + label: i18n._(t`Quick customization`), + renderControl: () => ( + Open} + /> + ), + }, + ], + }; case 'contributor': return { settings: [ @@ -1042,31 +1218,6 @@ const PreferencesDialog = ({ setShowInAppTutorialDeveloperMode ), }, - ...(Window.isDev() - ? [ - { - id: 'use-gdjs-development-watcher', - label: i18n._( - t`Watch changes in game engine (GDJS) sources and auto import them (dev only)` - ), - renderControl: () => - renderToggle( - values.useGDJSDevelopmentWatcher, - setUseGDJSDevelopmentWatcher - ), - }, - { - id: 'quick-customization', - label: i18n._(t`Quick customization (dev only)`), - renderControl: () => ( - Open} - /> - ), - }, - ] - : []), ], }; default: @@ -1088,46 +1239,85 @@ const PreferencesDialog = ({ ); - const renderCurrentSection = () => { - if (currentTab === 'shortcuts') { - return ( + const renderSection = (section: PreferencesSection) => { + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + + return ( +
- {i18n._(commandAreas[currentShortcutArea])} + {section.label} + {renderHeader && renderHeader()} + {settings.length > 0 && renderSettingsRows(settings)} + {renderFooter && renderFooter()} + +
+ ); + }; + + /** + * Display all the sections of the current tab, one after another. The + * sections list on the left (not displayed on mobile) scrolls to a section. + */ + /** + * The keyboard layout (QWERTY or AZERTY) is chosen at the top of the 3D + * editor area, as it only changes the default keys moving its camera. The + * options are explicit enough to be displayed without a label. + */ + const renderShortcutAreaHeader = (areaName: string) => { + if (areaName !== 'SCENE_3D') return null; + return ( + +
+ setKeyboardLayout((value: any))} + > + + + +
+
+ ); + }; + + const renderCurrentTabContent = () => { + if (currentTab === 'shortcuts') { + return ( + - You can open the command palette by pressing{' '} + The command palette is a search bar listing all the commands of + the editor: type the name of a command to run it, without having + to remember its shortcut. Open it by pressing{' '} {commandPaletteShortcut} or {commandPaletteSecondaryShortcut}. ); } - const section = - sections.find(section => section.name === currentSection) || sections[0]; - const { settings, renderHeader, renderFooter } = getSectionContent( - section.name - ); - return ( - - {section.label} - - {renderHeader && renderHeader()} - {settings.length > 0 && renderSettingsRows(settings)} - {renderFooter && renderFooter()} + {sections.map(renderSection)} ); }; @@ -1140,7 +1330,12 @@ const PreferencesDialog = ({ const normalizedSearchText = normalizeForSearch(searchText); const hasMatchingShortcuts = - getShortcutSections(i18n, values.userShortcutMap, searchText).length > 0; + getShortcutSections( + i18n, + values.userShortcutMap, + values.keyboardLayout, + searchText + ).length > 0; const matchingSections = sections .map(section => { const { settings } = getSectionContent(section.name); @@ -1182,6 +1377,7 @@ const PreferencesDialog = ({ @@ -1194,16 +1390,22 @@ const PreferencesDialog = ({ const onSelectTab = (tabName: PreferencesTabName) => { setSearchText(''); setCurrentTab(tabName); + // Start from the top of the new tab. + if (tabName === 'preferences') setCurrentSection(sections[0].name); + else setCurrentShortcutArea(shortcutAreas[0].name); + pendingScrollElementIdRef.current = ''; }; const onSelectSection = (sectionName: PreferencesSectionName) => { setSearchText(''); setCurrentSection(sectionName); + pendingScrollElementIdRef.current = getSectionElementId(sectionName); }; const onSelectShortcutArea = (areaName: ShortcutArea) => { setSearchText(''); setCurrentShortcutArea(areaName); + pendingScrollElementIdRef.current = getShortcutAreaElementId(areaName); }; // The entries of the sections list, on the left of the content: the @@ -1224,13 +1426,6 @@ const PreferencesDialog = ({ isActive: !isSearching && currentShortcutArea === area.name, onSelect: () => onSelectShortcutArea(area.name), })); - const activeSectionListEntry = sectionListEntries.find( - entry => entry.isActive - ); - const onSelectSectionListEntry = (key: string) => { - const entry = sectionListEntries.find(entry => entry.key === key); - if (entry) entry.onSelect(); - }; return ( , ]} secondaryActions={[ - , - !isMobile ? ( + Help for keyboard shortcuts + ) : ( + Help for preferences + ) + } + />, + currentTab === 'preferences' && !isMobile ? ( Help translate GDevelop} @@ -1266,7 +1471,7 @@ const PreferencesDialog = ({ ]} onRequestClose={() => onClose({ languageDidChange })} open - aspectRatio="16:9" + fullHeight flexColumnBody actionsFullWidthOnMobile fixedContent={ @@ -1279,20 +1484,6 @@ const PreferencesDialog = ({ { value: 'shortcuts', label: Keyboard Shortcuts }, ]} /> - {isMobile && ( - // On mobile, the sections column is replaced by scrollable tabs. - ({ - value: entry.key, - label: entry.label, - }))} - variant="scrollable" - /> - )} )}
) : ( - renderCurrentSection() + renderCurrentTabContent() )}
diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index a89d0adb745c..dbb98d49f423 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -22,7 +22,10 @@ import type { } from '../../ResourcesList/ResourceSource'; import { type EditorMosaicNode } from '../../UI/EditorMosaic'; import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; -import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts'; +import { + getDefaultShortcuts, + type KeyboardLayout, +} from '../../KeyboardShortcuts/DefaultShortcuts'; import { type CommandName } from '../../CommandPalette/CommandsList'; import { getBrowserLanguageOrLocale, @@ -132,6 +135,7 @@ export const getInitialPreferences = (): { showDeprecatedInstructionWarning: string, showEffectParameterNames: boolean, showExperimentalExtensions: boolean, + keyboardLayout: KeyboardLayout, showInAppTutorialDeveloperMode: boolean, takeScreenshotOnPreview: boolean, gameplayTestFramePosition: {| left: number, bottom: number |} | null, @@ -310,6 +314,8 @@ export default class PreferencesProvider extends React.Component { this ): any), // $FlowFixMe[method-unbinding] + setKeyboardLayout: (this._setKeyboardLayout.bind(this): any), + // $FlowFixMe[method-unbinding] setShowCreateSectionByDefault: (this._setShowCreateSectionByDefault.bind( this ): any), @@ -706,6 +712,18 @@ export default class PreferencesProvider extends React.Component { ); } + _setKeyboardLayout(keyboardLayout: KeyboardLayout) { + this.setState( + state => ({ + values: { + ...state.values, + keyboardLayout, + }, + }), + () => this._persistValuesToLocalStorage(this.state) + ); + } + _setShowInAppTutorialDeveloperMode(showInAppTutorialDeveloperMode: boolean) { this.setState( state => ({ @@ -1121,7 +1139,8 @@ export default class PreferencesProvider extends React.Component { } _setShortcutForCommand(commandName: CommandName, shortcutString: string) { - const defaultShortcut = defaultShortcuts[commandName] || ''; + const defaultShortcut = + getDefaultShortcuts(this.state.values.keyboardLayout)[commandName] || ''; const setToDefault = defaultShortcut === shortcutString; const updatedShortcutMap = { ...this.state.values.userShortcutMap }; diff --git a/newIDE/app/src/UI/Dialog.js b/newIDE/app/src/UI/Dialog.js index 7907381f8dbe..fa887832c289 100644 --- a/newIDE/app/src/UI/Dialog.js +++ b/newIDE/app/src/UI/Dialog.js @@ -128,12 +128,6 @@ const styles = { minHeightForFullHeightModal: 'calc(100% - 64px)', minHeightForSmallHeightModal: 'min(100% - 64px, 350px)', minHeightForLargeHeightModal: 'min(100% - 64px, 800px)', - // Material UI dialogs have a 32px margin on each side of the paper. - aspectRatio16by9Paper: { - width: 'min(calc(100vw - 64px), calc((100vh - 64px) * 16 / 9))', - height: 'min(calc(100vh - 64px), calc((100vw - 64px) * 9 / 16))', - maxWidth: 'none', - }, topBackground: { position: 'absolute', top: 0, @@ -250,9 +244,6 @@ type DialogProps = {| maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | false, minHeight?: 'sm' | 'lg', fullHeight?: boolean, - // Force the dialog to keep a given aspect ratio, as large as the window allows. - // Ignored when the dialog is displayed fullscreen (typically on mobile). - aspectRatio?: '16:9', fullscreen?: 'never-even-on-mobile' | 'always-even-on-desktop', noPadding?: boolean, actionsFullWidthOnMobile?: boolean, @@ -280,7 +271,6 @@ const DialogWithoutWindowSizeProvider = ({ flexColumnBody, flexBody, fullHeight, - aspectRatio, id, cannotBeDismissed, exceptionallyStillAllowRenderingInstancesEditors, @@ -410,18 +400,15 @@ const DialogWithoutWindowSizeProvider = ({ : minHeight === 'sm' ? styles.minHeightForSmallHeightModal : undefined; - const hasForcedAspectRatio = aspectRatio === '16:9' && !isFullScreen; const paperStyle = React.useMemo( () => ({ backgroundColor: gdevelopTheme.dialog.backgroundColor, minHeight: paperMinHeight, - ...(hasForcedAspectRatio ? styles.aspectRatio16by9Paper : {}), ...getAvoidSoftKeyboardStyle(softKeyboardBottomOffset), }), [ gdevelopTheme.dialog.backgroundColor, paperMinHeight, - hasForcedAspectRatio, softKeyboardBottomOffset, ] ); @@ -457,9 +444,7 @@ const DialogWithoutWindowSizeProvider = ({ style: paperStyle, }} maxWidth={ - hasForcedAspectRatio - ? false - : maxWidth !== undefined + maxWidth !== undefined ? maxWidth : getDefaultMaxWidthFromSize(windowSize) } diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index 08e4a39c98ce..bb182dd9b049 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -1,23 +1,20 @@ // @flow import * as React from 'react'; import Text from './Text'; +import { marginsSize } from './Grid'; import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; import './SettingsRow.css'; -// Fixed width of the control column, shared by all rows so that the controls +// Width of the control column, shared by all rows so that the controls // (toggles, select fields, buttons, shortcuts...) are aligned like in a table. // On mobile, the control takes the full width, on its own line. -export const settingsRowControlColumnWidth = 320; +const controlColumnWidth = '40%'; const styles = { row: { display: 'flex', alignItems: 'center', - paddingTop: 6, - paddingBottom: 6, - paddingLeft: 8, - paddingRight: 8, - borderRadius: 4, + padding: `${marginsSize / 2}px ${marginsSize}px`, }, // On mobile, the control is displayed on its own line, below the label. rowOnMobile: { @@ -29,7 +26,7 @@ const styles = { minWidth: 0, display: 'flex', flexDirection: 'column', - paddingRight: 8, + paddingRight: marginsSize, }, controlColumn: { flexShrink: 0, @@ -40,7 +37,7 @@ const styles = { }, controlColumnOnMobile: { width: '100%', - paddingTop: 4, + paddingTop: marginsSize / 2, }, }; @@ -87,7 +84,7 @@ const SettingsRow = ({ ? { ...styles.controlColumn, ...styles.controlColumnOnMobile } : { ...styles.controlColumn, - width: settingsRowControlColumnWidth, + width: controlColumnWidth, } } > From aeffc5cd3d628303d77b9e933f7efbaa0305a1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 7 Sep 2026 00:46:25 +0200 Subject: [PATCH 03/14] V3 --- .../src/EmbeddedGame/InGameEditorSettings.js | 16 +- .../src/KeyboardShortcuts/DefaultShortcuts.js | 22 -- .../src/KeyboardShortcuts/ShortcutsList.js | 125 +++++------ newIDE/app/src/KeyboardShortcuts/index.js | 8 +- .../Preferences/PreferencesContext.js | 9 +- .../Preferences/PreferencesDialog.js | 208 +++++++++++------- .../Preferences/PreferencesProvider.js | 23 +- .../Preferences/PreferencesDialog.stories.js | 15 +- 8 files changed, 219 insertions(+), 207 deletions(-) diff --git a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js index 62217d3bb3d4..fdf5157dd035 100644 --- a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js +++ b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js @@ -2,7 +2,10 @@ import * as React from 'react'; import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; import { useShortcutMap } from '../KeyboardShortcuts'; -import { getCommandNamesHandledByInGameEditor } from '../CommandPalette/CommandsList'; +import { + getCommandNamesHandledByInGameEditor, + type CommandName, +} from '../CommandPalette/CommandsList'; export type InGameEditorSettings = { theme: { @@ -38,10 +41,13 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { // one of these shortcuts really changed. const shortcutMap = useShortcutMap(); const inGameEditorShortcutsJson = JSON.stringify( - getCommandNamesHandledByInGameEditor().reduce((shortcuts, commandName) => { - shortcuts[commandName] = shortcutMap[commandName] || ''; - return shortcuts; - }, {}) + getCommandNamesHandledByInGameEditor().reduce( + (shortcuts: { [CommandName]: string }, commandName) => { + shortcuts[commandName] = shortcutMap[commandName] || ''; + return shortcuts; + }, + {} + ) ); const inGameEditorSettings = React.useMemo( diff --git a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js index c3174b5ec939..a6e5404a41ae 100644 --- a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js +++ b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js @@ -97,26 +97,4 @@ export const defaultSecondaryShortcuts: ShortcutMap = { OPEN_COMMAND_PALETTE: 'CmdOrCtrl+KeyK', }; -export type KeyboardLayout = 'qwerty' | 'azerty'; - -/** - * On AZERTY keyboards, the keys used to move the camera of the 3D editor are - * ZQSD (and A to go down) instead of WASD (and Q to go down). - */ -const azertyDefaultShortcutOverrides: ShortcutMap = { - IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyZ', - IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyQ', - IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyA', -}; - -/** - * The default shortcuts for the given keyboard layout. - */ -export const getDefaultShortcuts = ( - keyboardLayout: KeyboardLayout -): ShortcutMap => - keyboardLayout === 'azerty' - ? { ...defaultShortcuts, ...azertyDefaultShortcutOverrides } - : defaultShortcuts; - export default defaultShortcuts; diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index 1ffa94aeb0fa..72b1fae5d67e 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -5,11 +5,7 @@ import { type I18n } from '@lingui/core'; import Text from '../UI/Text'; import EmptyMessage from '../UI/EmptyMessage'; import DetectShortcutDialog from './DetectShortcutDialog'; -import { - type ShortcutMap, - type KeyboardLayout, - getDefaultShortcuts, -} from './DefaultShortcuts'; +import defaultShortcuts, { type ShortcutMap } from './DefaultShortcuts'; import { getShortcutDisplayName } from './index'; import ShortcutsListRow from './ShortcutsListRow'; import commandsList, { @@ -64,20 +60,16 @@ const getShortcutContextKey = ( * to list of commands with that shortcut. */ const sortCommandsIntoAreasAndGetReverseMap = ( - userShortcutMap: ShortcutMap, - keyboardLayout: KeyboardLayout + userShortcutMap: ShortcutMap ) => { - const defaultShortcuts = getDefaultShortcuts(keyboardLayout); - const areaWiseCommands = {}; + const areaWiseCommands: { [string]: Array } = {}; const shortcutContextKeyToCommands: { [string]: Array } = {}; Object.keys(commandsList) .filter(name => !commandsList[name].noShortcut) .forEach(name => { // Sort commands by area const areaName = commandsList[name].area; - // $FlowFixMe[prop-missing] if (!areaWiseCommands[areaName]) areaWiseCommands[areaName] = []; - // $FlowFixMe[prop-missing] areaWiseCommands[areaName].push(name); // Add to shortcut-command mapping @@ -126,14 +118,12 @@ export type ShortcutSectionData = {| export const getShortcutSections = ( i18n: I18n, userShortcutMap: ShortcutMap, - keyboardLayout: KeyboardLayout, searchText: string ): Array => { - const defaultShortcuts = getDefaultShortcuts(keyboardLayout); const [ areaWiseCommands, - shortcutStringToCommands, - ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap, keyboardLayout); + shortcutContextKeyToCommands, + ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap); const normalizedSearchText = normalizeForSearch(searchText); // The areas are displayed in the order of `commandAreas`, which is also the @@ -141,62 +131,66 @@ export const getShortcutSections = ( return Object.keys(commandAreas) .filter(areaName => !!areaWiseCommands[areaName]) .filter(areaName => areaName !== 'DEVELOPER' || Window.isDev()) - .map(areaName => { - const rows = areaWiseCommands[areaName] - .map( - (commandName: CommandName): ShortcutRowData | null => { - // Get default and user-set shortcuts - const userShortcut = userShortcutMap[commandName]; - const defaultShortcut = defaultShortcuts[commandName] || ''; - const shortcutString = getPatchedShortcutString( - defaultShortcut, - userShortcut - ); - const shortcutDisplayName = getShortcutDisplayName(shortcutString); - const commandDisplayText = i18n._( - commandsList[commandName].displayText - ); - - const matchesSearch = - !normalizedSearchText || - normalizeForSearch(commandDisplayText).includes( - normalizedSearchText - ) || - normalizeForSearch(shortcutDisplayName).includes( - normalizedSearchText + .map( + (areaName): ShortcutSectionData => { + const rows: Array = areaWiseCommands[areaName] + .map( + (commandName: CommandName): ShortcutRowData | null => { + // Get default and user-set shortcuts + const userShortcut = userShortcutMap[commandName]; + const defaultShortcut = defaultShortcuts[commandName] || ''; + const shortcutString = getPatchedShortcutString( + defaultShortcut, + userShortcut + ); + const shortcutDisplayName = getShortcutDisplayName( + shortcutString ); - if (!matchesSearch) return null; + const commandDisplayText = i18n._( + commandsList[commandName].displayText + ); + + const matchesSearch = + !normalizedSearchText || + normalizeForSearch(commandDisplayText).includes( + normalizedSearchText + ) || + normalizeForSearch(shortcutDisplayName).includes( + normalizedSearchText + ); + if (!matchesSearch) return null; - // Find the other commands using the same shortcut, if any. - const clashingCommandNames = ( - shortcutStringToCommands[shortcutString] || [] - ).filter(otherCommandName => otherCommandName !== commandName); + // Find the other commands using the same shortcut, if any. + const clashingCommandNames = ( + shortcutContextKeyToCommands[ + getShortcutContextKey(commandName, shortcutString) + ] || [] + ).filter(otherCommandName => otherCommandName !== commandName); - return { - commandName, - commandDisplayText, - shortcutDisplayName, - isDefault: shortcutString === defaultShortcut, - clashingCommandNames, - }; - } - ) - .filter(Boolean); + return { + commandName, + commandDisplayText, + shortcutDisplayName, + isDefault: shortcutString === defaultShortcut, + clashingCommandNames, + }; + } + ) + .filter(Boolean); - return { - areaName, - title: i18n._(commandAreas[areaName]), - rows, - }; - }) + return { + areaName, + title: i18n._(commandAreas[areaName]), + rows, + }; + } + ) .filter(section => section.rows.length > 0); }; type Props = {| i18n: I18n, userShortcutMap: ShortcutMap, - /** The keyboard layout, which changes some default shortcuts. */ - keyboardLayout: KeyboardLayout, onEdit: (commandName: CommandName, shortcut: string) => void, /** Filter the displayed commands by name or by shortcut. */ searchText?: string, @@ -204,8 +198,6 @@ type Props = {| areaName?: string, /** Give an id to the element of each area, to be able to scroll to it. */ getSectionElementId?: (areaName: string) => string, - /** Render content displayed at the top of an area, below its title. */ - renderAreaHeader?: (areaName: string) => React.Node, |}; const ShortcutsList = (props: Props): React.Node => { @@ -215,16 +207,12 @@ const ShortcutsList = (props: Props): React.Node => { ] = React.useState(null); const resetShortcut = (commandName: CommandName) => { - props.onEdit( - commandName, - getDefaultShortcuts(props.keyboardLayout)[commandName] - ); + props.onEdit(commandName, defaultShortcuts[commandName]); }; const sections = getShortcutSections( props.i18n, props.userShortcutMap, - props.keyboardLayout, props.searchText || '' ).filter(section => !props.areaName || section.areaName === props.areaName); @@ -242,7 +230,6 @@ const ShortcutsList = (props: Props): React.Node => { style={props.areaName ? styles.section : styles.areaSection} > {!props.areaName && {section.title}} - {props.renderAreaHeader && props.renderAreaHeader(section.areaName)}
{section.rows.map(row => ( { const preferences = React.useContext(PreferencesContext); const userShortcutMap = preferences.values.userShortcutMap; - return { - ...getDefaultShortcuts(preferences.values.keyboardLayout), - ...userShortcutMap, - }; + return { ...defaultShortcuts, ...userShortcutMap }; }; type UseKeyboardShortcutsProps = {| diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index edda6d59abdf..d22f9513cc82 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -7,10 +7,7 @@ import type { } from '../../ResourcesList/ResourceSource'; import { type EditorMosaicNode } from '../../UI/EditorMosaic'; import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; -import { - type ShortcutMap, - type KeyboardLayout, -} from '../../KeyboardShortcuts/DefaultShortcuts'; +import { type ShortcutMap } from '../../KeyboardShortcuts/DefaultShortcuts'; import { type CommandName } from '../../CommandPalette/CommandsList'; import { type EditorTabsPersistedState } from '../EditorTabs/EditorTabsHandler'; import { type GamesDashboardOrderBy } from '../../GameDashboard/GamesList'; @@ -228,7 +225,6 @@ export type PreferencesValues = {| resourcesImporationBehavior: ResourceImportationBehavior, eventsSheetCancelInlineParameter: 'cancel' | 'apply', showExperimentalExtensions: boolean, - keyboardLayout: KeyboardLayout, showCreateSectionByDefault: boolean, showInAppTutorialDeveloperMode: boolean, showDeprecatedInstructionWarning: @@ -336,7 +332,6 @@ export type Preferences = {| setIsAlwaysOnTopInPreview: (enabled: boolean) => void, setEventsSheetCancelInlineParameter: (value: string) => void, setShowExperimentalExtensions: (enabled: boolean) => void, - setKeyboardLayout: (keyboardLayout: KeyboardLayout) => void, setShowCreateSectionByDefault: (enabled: boolean) => void, setShowInAppTutorialDeveloperMode: (enabled: boolean) => void, setOpenDiagnosticReportAutomatically: (enabled: boolean) => void, @@ -437,7 +432,6 @@ export const initialPreferences = { resourcesImporationBehavior: 'ask', eventsSheetCancelInlineParameter: 'apply', showExperimentalExtensions: false, - keyboardLayout: 'qwerty', showCreateSectionByDefault: false, showInAppTutorialDeveloperMode: false, openDiagnosticReportAutomatically: true, @@ -518,7 +512,6 @@ export const initialPreferences = { setIsAlwaysOnTopInPreview: () => {}, setEventsSheetCancelInlineParameter: () => {}, setShowExperimentalExtensions: () => {}, - setKeyboardLayout: () => {}, setShowCreateSectionByDefault: (enabled: boolean) => {}, setShowInAppTutorialDeveloperMode: (enabled: boolean) => {}, setShowDeprecatedInstructionWarning: ( diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index a0a1a354c8cd..d5ef1e7c9905 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -16,7 +16,9 @@ import { themes } from '../../UI/Theme/ThemeRegistry'; import { getAllThemes } from '../../CodeEditor/Theme'; import Window from '../../Utils/Window'; import optionalRequire from '../../Utils/OptionalRequire'; -import PreferencesContext from './PreferencesContext'; +import PreferencesContext, { + type EditorMosaicName, +} from './PreferencesContext'; import Text from '../../UI/Text'; import EmptyMessage from '../../UI/EmptyMessage'; import { ColumnStackLayout } from '../../UI/Layout'; @@ -40,7 +42,6 @@ import { } from '../../KeyboardShortcuts'; import defaultShortcuts, { defaultSecondaryShortcuts, - getDefaultShortcuts, } from '../../KeyboardShortcuts/DefaultShortcuts'; import AlertMessage from '../../UI/AlertMessage'; import ErrorBoundary from '../../UI/ErrorBoundary'; @@ -59,6 +60,8 @@ import SettingsIcon from '../../UI/CustomSvgIcons/Settings'; import FolderIcon from '../../UI/CustomSvgIcons/Folder'; import HammerIcon from '../../UI/CustomSvgIcons/Hammer'; import SparkleIcon from '../../UI/CustomSvgIcons/Sparkle'; +import RobotFaceIcon from '../../UI/CustomSvgIcons/RobotFace'; +import ExtensionIcon from '../../UI/CustomSvgIcons/Extension'; import DebugIcon from '../../UI/CustomSvgIcons/Debug'; import ProjectManagerIcon from '../../UI/CustomSvgIcons/ProjectManager'; import Object3dIcon from '../../UI/CustomSvgIcons/Object3d'; @@ -79,6 +82,8 @@ export type PreferencesSectionName = | 'folders' | 'experimental' | 'contributor' + | 'ask-ai' + | 'extensions' | 'developer'; export type PreferencesTabName = 'preferences' | 'shortcuts'; @@ -171,18 +176,30 @@ const sections: Array = [ ), }, - ...(electron - ? [ - { - name: 'folders', - label: Folders, - getSearchableLabel: i18n => i18n._(t`Folders`), - getIcon: ({ color, fontSize }) => ( - - ), - }, - ] - : []), + { + name: 'ask-ai', + label: Ask AI, + getSearchableLabel: i18n => i18n._(t`Ask AI`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'extensions', + label: Extensions, + getSearchableLabel: i18n => i18n._(t`Extensions`), + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'folders', + label: Folders, + getSearchableLabel: i18n => i18n._(t`Folders`), + getIcon: ({ color, fontSize }) => ( + + ), + }, { name: 'contributor', label: Contributor options, @@ -305,9 +322,14 @@ const getSectionElementId = (sectionName: string): string => const getShortcutAreaElementId = (areaName: string): string => `preferences-shortcuts-area-${areaName}`; -// A section is considered displayed at the top of the content when its top is -// at most this number of pixels below the top of the content. -const scrollSpyTolerance = 8; +// When scrolling to a section, its title is placed this number of pixels +// below the top of the content, so that it does not stick to the edge. +const sectionScrollOffset = 16; +// A section is considered displayed at the top of the content (and highlighted +// in the sections list) when its top is at most this number of pixels below the +// top of the content. Larger than the scroll offset, so that a section that has +// just been scrolled to is the highlighted one. +const scrollSpyTolerance = sectionScrollOffset + 8; const styles = { body: { @@ -356,6 +378,12 @@ type Props = {| initialTab?: PreferencesTabName, /** The preferences section displayed when the dialog opens. Defaults to the general one. */ initialSection?: PreferencesSectionName, + /** + * Display the settings of the desktop version (folders, Electron specific + * options...). Defaults to whether the dialog runs in the desktop version: + * only useful to force it, for example in Storybook. + */ + isDesktop?: boolean, |}; const PreferencesDialog = ({ @@ -364,6 +392,7 @@ const PreferencesDialog = ({ onOpenQuickCustomizationDialog, initialTab, initialSection, + isDesktop = !!electron, }: Props) => { const { isMobile } = useResponsiveWindowSize(); const { showConfirmation } = useAlertDialog(); @@ -408,7 +437,6 @@ const PreferencesDialog = ({ setIsAlwaysOnTopInPreview, setEventsSheetCancelInlineParameter, setShowExperimentalExtensions, - setKeyboardLayout, setShowInAppTutorialDeveloperMode, setOpenDiagnosticReportAutomatically, setBlockPreviewAndExportOnDiagnosticErrors, @@ -446,14 +474,36 @@ const PreferencesDialog = ({ : null ); + // A scroll made by the dialog itself (to the section chosen in the list) + // must not change the highlighted entry: near the bottom of the content, the + // chosen section can't reach the top, and the scroll spy would highlight the + // last entry instead of the chosen one. + const isProgrammaticScrollRef = React.useRef(false); + const setProgrammaticScrollTop = ( + container: HTMLDivElement, + scrollTop: number + ) => { + if (container.scrollTop === scrollTop) return; + isProgrammaticScrollRef.current = true; + container.scrollTop = scrollTop; + // In case no scroll event is fired (already at the bottom, for example). + setTimeout(() => { + isProgrammaticScrollRef.current = false; + }, 100); + }; + const scrollToElement = (elementId: string) => { const container = sectionContentRef.current; const element = document.getElementById(elementId); if (!container || !element) return; - container.scrollTop += - element.getBoundingClientRect().top - - container.getBoundingClientRect().top; + setProgrammaticScrollTop( + container, + container.scrollTop + + element.getBoundingClientRect().top - + container.getBoundingClientRect().top - + sectionScrollOffset + ); }; React.useEffect(() => { @@ -464,7 +514,8 @@ const PreferencesDialog = ({ pendingScrollElementIdRef.current = null; if (pendingScrollElementId === '') { - if (sectionContentRef.current) sectionContentRef.current.scrollTop = 0; + if (sectionContentRef.current) + setProgrammaticScrollTop(sectionContentRef.current, 0); } else { scrollToElement(pendingScrollElementId); } @@ -501,11 +552,15 @@ const PreferencesDialog = ({ }; const onContentScroll = () => { + if (isProgrammaticScrollRef.current) { + isProgrammaticScrollRef.current = false; + return; + } if (isSearching) return; if (currentTab === 'preferences') { const sectionNameAtTop = getEntryKeyAtTop( - sections.map(section => section.name), + getVisibleSections().map(section => section.name), getSectionElementId ); if (sectionNameAtTop && sectionNameAtTop !== currentSection) { @@ -526,7 +581,7 @@ const PreferencesDialog = ({ commandName => values.userShortcutMap[commandName] != null && values.userShortcutMap[commandName] !== - (getDefaultShortcuts(values.keyboardLayout)[commandName] || '') + (defaultShortcuts[commandName] || '') ); const resetAllShortcutsToDefault = async () => { @@ -559,7 +614,7 @@ const PreferencesDialog = ({ /> ); - const renderResetLayoutButton = (editorMosaicName: string) => ( + const renderResetLayoutButton = (editorMosaicName: EditorMosaicName) => ( Reset} onClick={() => setDefaultEditorMosaicNode(editorMosaicName, null)} @@ -737,7 +792,7 @@ const PreferencesDialog = ({
), }, - ...(electron + ...(isDesktop ? [ { id: 'resources-importation-behavior', @@ -957,7 +1012,7 @@ const PreferencesDialog = ({ setTakeScreenshotOnPreview ), }, - ...(electron + ...(isDesktop ? [ { id: 'hide-menu-bar-in-preview', @@ -1058,18 +1113,9 @@ const PreferencesDialog = ({ !Object.keys(values.hiddenAnnouncements).length ), }, - { - id: 'hidden-ask-ai-forms', - label: i18n._(t`Hidden Ask AI text inputs`), - renderControl: () => - renderResetButton( - showAllAskAiStandAloneForms, - !Object.keys(values.hiddenAskAiStandAloneForms).length - ), - }, ], }; - case 'other': + case 'ask-ai': return { settings: [ { @@ -1092,6 +1138,20 @@ const PreferencesDialog = ({ setAutomaticallyUseCreditsForAiRequests ), }, + { + id: 'hidden-ask-ai-forms', + label: i18n._(t`Hidden Ask AI text inputs`), + renderControl: () => + renderResetButton( + showAllAskAiStandAloneForms, + !Object.keys(values.hiddenAskAiStandAloneForms).length + ), + }, + ], + }; + case 'other': + return { + settings: [ { id: 'display-save-reminder', label: i18n._( @@ -1113,7 +1173,7 @@ const PreferencesDialog = ({ setUseBackgroundSerializerForSaving ), }, - ...(electron + ...(isDesktop ? [ { id: 'watch-project-folder-files', @@ -1129,7 +1189,7 @@ const PreferencesDialog = ({ }, ] : []), - ...(electron && values.disableNpmScriptConfirmation + ...(isDesktop && values.disableNpmScriptConfirmation ? [ { id: 'npm-script-confirmation', @@ -1146,6 +1206,19 @@ const PreferencesDialog = ({ ], }; case 'folders': + if (!isDesktop) { + return { + settings: [], + renderHeader: () => ( + + + The folders can only be configured in the desktop version of + GDevelop. + + + ), + }; + } return { settings: [ { @@ -1162,7 +1235,7 @@ const PreferencesDialog = ({ }, ], }; - case 'experimental': + case 'extensions': return { settings: [ { @@ -1178,6 +1251,9 @@ const PreferencesDialog = ({ }, ], }; + case 'experimental': + // No experimental setting for now: the section is hidden while empty. + return { settings: [] }; case 'developer': return { settings: [ @@ -1225,6 +1301,19 @@ const PreferencesDialog = ({ } }; + /** + * The sections having something to display. A section without any setting + * (for example when its settings are only available on some platforms) is + * hidden from the sections list and the content. + */ + const getVisibleSections = (): Array => + sections.filter(section => { + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + return settings.length > 0 || !!renderHeader || !!renderFooter; + }); + const renderSettingsRows = (settings: Array) => ( {settings.map(setting => ( @@ -1266,28 +1355,6 @@ const PreferencesDialog = ({ * Display all the sections of the current tab, one after another. The * sections list on the left (not displayed on mobile) scrolls to a section. */ - /** - * The keyboard layout (QWERTY or AZERTY) is chosen at the top of the 3D - * editor area, as it only changes the default keys moving its camera. The - * options are explicit enough to be displayed without a label. - */ - const renderShortcutAreaHeader = (areaName: string) => { - if (areaName !== 'SCENE_3D') return null; - return ( - -
- setKeyboardLayout((value: any))} - > - - - -
-
- ); - }; - const renderCurrentTabContent = () => { if (currentTab === 'shortcuts') { return ( @@ -1306,10 +1373,8 @@ const PreferencesDialog = ({ ); @@ -1317,7 +1382,7 @@ const PreferencesDialog = ({ return ( - {sections.map(renderSection)} + {getVisibleSections().map(renderSection)} ); }; @@ -1330,12 +1395,7 @@ const PreferencesDialog = ({ const normalizedSearchText = normalizeForSearch(searchText); const hasMatchingShortcuts = - getShortcutSections( - i18n, - values.userShortcutMap, - values.keyboardLayout, - searchText - ).length > 0; + getShortcutSections(i18n, values.userShortcutMap, searchText).length > 0; const matchingSections = sections .map(section => { const { settings } = getSectionContent(section.name); @@ -1377,7 +1437,6 @@ const PreferencesDialog = ({ @@ -1391,7 +1450,8 @@ const PreferencesDialog = ({ setSearchText(''); setCurrentTab(tabName); // Start from the top of the new tab. - if (tabName === 'preferences') setCurrentSection(sections[0].name); + if (tabName === 'preferences') + setCurrentSection(getVisibleSections()[0].name); else setCurrentShortcutArea(shortcutAreas[0].name); pendingScrollElementIdRef.current = ''; }; @@ -1412,7 +1472,7 @@ const PreferencesDialog = ({ // preferences sections or the shortcut areas, depending on the current tab. const sectionListEntries = currentTab === 'preferences' - ? sections.map(section => ({ + ? getVisibleSections().map(section => ({ key: section.name, label: section.label, getIcon: section.getIcon, diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index dbb98d49f423..a89d0adb745c 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -22,10 +22,7 @@ import type { } from '../../ResourcesList/ResourceSource'; import { type EditorMosaicNode } from '../../UI/EditorMosaic'; import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; -import { - getDefaultShortcuts, - type KeyboardLayout, -} from '../../KeyboardShortcuts/DefaultShortcuts'; +import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts'; import { type CommandName } from '../../CommandPalette/CommandsList'; import { getBrowserLanguageOrLocale, @@ -135,7 +132,6 @@ export const getInitialPreferences = (): { showDeprecatedInstructionWarning: string, showEffectParameterNames: boolean, showExperimentalExtensions: boolean, - keyboardLayout: KeyboardLayout, showInAppTutorialDeveloperMode: boolean, takeScreenshotOnPreview: boolean, gameplayTestFramePosition: {| left: number, bottom: number |} | null, @@ -314,8 +310,6 @@ export default class PreferencesProvider extends React.Component { this ): any), // $FlowFixMe[method-unbinding] - setKeyboardLayout: (this._setKeyboardLayout.bind(this): any), - // $FlowFixMe[method-unbinding] setShowCreateSectionByDefault: (this._setShowCreateSectionByDefault.bind( this ): any), @@ -712,18 +706,6 @@ export default class PreferencesProvider extends React.Component { ); } - _setKeyboardLayout(keyboardLayout: KeyboardLayout) { - this.setState( - state => ({ - values: { - ...state.values, - keyboardLayout, - }, - }), - () => this._persistValuesToLocalStorage(this.state) - ); - } - _setShowInAppTutorialDeveloperMode(showInAppTutorialDeveloperMode: boolean) { this.setState( state => ({ @@ -1139,8 +1121,7 @@ export default class PreferencesProvider extends React.Component { } _setShortcutForCommand(commandName: CommandName, shortcutString: string) { - const defaultShortcut = - getDefaultShortcuts(this.state.values.keyboardLayout)[commandName] || ''; + const defaultShortcut = defaultShortcuts[commandName] || ''; const setToDefault = defaultShortcut === shortcutString; const updatedShortcutMap = { ...this.state.values.userShortcutMap }; diff --git a/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js index a7bb9ece7793..2b332c733140 100644 --- a/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js +++ b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js @@ -42,6 +42,7 @@ const StatefulPreferencesProvider = ({ const setValue = (name: string, value: any) => { action('Set preference')(name, value); + // $FlowFixMe[incompatible-type] - the name is the one of a preference. setValues(previousValues => ({ ...previousValues, [name]: value })); }; @@ -63,8 +64,8 @@ const StatefulPreferencesProvider = ({ }); }; + // $FlowFixMe[incompatible-type] - only the setters used by the dialog are overridden. const preferences: Preferences = { - // $FlowFixMe[incompatible-type] - only the setters used by the dialog are overridden. ...initialPreferences, // $FlowFixMe[incompatible-type] values, @@ -131,10 +132,13 @@ const PreferencesDialogStory = ({ initialTab, initialSection, initialUserShortcutMap = {}, + isDesktop = false, }: {| initialTab?: PreferencesTabName, initialSection?: PreferencesSectionName, initialUserShortcutMap?: ShortcutMap, + /** Display the settings of the desktop version. */ + isDesktop?: boolean, |}) => ( @@ -143,6 +147,7 @@ const PreferencesDialogStory = ({ i18n={i18n} initialTab={initialTab} initialSection={initialSection} + isDesktop={isDesktop} onClose={action('onClose')} onOpenQuickCustomizationDialog={action( 'onOpenQuickCustomizationDialog' @@ -164,7 +169,13 @@ const customizedUserShortcutMap: ShortcutMap = { OPEN_PROJECT_PROPERTIES: 'Alt+KeyP', }; -export const Default = (): React.Node => ; +export const Web = (): React.Node => ; + +export const Desktop = (): React.Node => ; + +export const DesktopFoldersSection = (): React.Node => ( + +); export const EventsSheetSection = (): React.Node => ( From 534617d8b8b5d6bced20621559de872151d2e418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 7 Sep 2026 01:15:04 +0200 Subject: [PATCH 04/14] Fable review & Flow --- GDJS/Runtime/InGameEditor/InGameEditor.tsx | 57 +--- newIDE/app/src/CommandPalette/CommandsList.js | 18 +- .../src/EmbeddedGame/InGameEditorSettings.js | 29 +- .../src/InstancesEditor/TileSetVisualizer.js | 150 +++++----- .../src/KeyboardShortcuts/ShortcutsList.js | 32 +- newIDE/app/src/KeyboardShortcuts/index.js | 16 +- .../Preferences/PreferencesDialog.js | 273 +++++++++--------- newIDE/app/src/UI/CompactToggleField/index.js | 6 + newIDE/app/src/UI/SettingsRow.js | 52 +++- 9 files changed, 340 insertions(+), 293 deletions(-) diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index edb5d7cbaeeb..494699aae72b 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -357,8 +357,9 @@ namespace gdjs { Comma: 188, Equal: 187, Minus: 189, - NumpadAdd: 107, - NumpadSubtract: 109, + // The input manager stores the numpad keys with their location. + NumpadAdd: gdjs.InputManager.getLocationAwareKeyCode(107, 3), + NumpadSubtract: gdjs.InputManager.getLocationAwareKeyCode(109, 3), }; for (let letterIndex = 0; letterIndex < 26; letterIndex++) { shortcutKeyCodes['Key' + String.fromCharCode(65 + letterIndex)] = @@ -403,49 +404,21 @@ namespace gdjs { }; /** - * The default shortcuts of the in-game editor, used when the IDE did not - * send its own (customized) shortcuts. Must be kept in sync with the - * defaults of the IDE (`DefaultShortcuts.js`). - */ - const defaultInGameEditorShortcuts: { [commandName: string]: string } = { - IN_GAME_EDITOR_TRANSLATE_MODE: 'Digit1', - IN_GAME_EDITOR_ROTATE_MODE: 'Digit2', - IN_GAME_EDITOR_SCALE_MODE: 'Digit3', - IN_GAME_EDITOR_FOCUS_ON_SELECTION: 'KeyF', - IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyW', - IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: 'KeyS', - IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyA', - IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: 'KeyD', - IN_GAME_EDITOR_MOVE_CAMERA_UP: 'KeyE', - IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyQ', - IN_GAME_EDITOR_ORBIT_CAMERA: 'KeyO', - }; - - /** - * The keyboard shortcuts of the in-game editor, customizable from the IDE. + * The keyboard shortcuts of the in-game editor, sent by the IDE (which owns + * their default values, see `DefaultShortcuts.js`) and customizable there. * The keys of the shortcuts are checked with the input manager of the game. */ class InGameEditorShortcuts { private _parsedShortcuts: { [commandName: string]: ParsedShortcut } = {}; - constructor() { - this.update({}); - } - /** - * Set the shortcuts sent by the IDE. The commands that are not listed keep - * their default shortcut. + * Set the shortcuts sent by the IDE. A command that is not listed, or + * whose shortcut can't be parsed, has no shortcut. */ update(shortcuts: { [commandName: string]: string }): void { - const shortcutsWithDefaults = { - ...defaultInGameEditorShortcuts, - ...shortcuts, - }; this._parsedShortcuts = {}; - for (const commandName in shortcutsWithDefaults) { - const parsedShortcut = parseShortcut( - shortcutsWithDefaults[commandName] - ); + for (const commandName in shortcuts) { + const parsedShortcut = parseShortcut(shortcuts[commandName]); if (parsedShortcut) this._parsedShortcuts[commandName] = parsedShortcut; } } @@ -470,7 +443,7 @@ namespace gdjs { isPressed( inputManager: gdjs.InputManager, commandName: string, - { ignoreShift }: { ignoreShift: boolean } = { ignoreShift: false } + ignoreShift: boolean = false ): boolean { const parsedShortcut = this._parsedShortcuts[commandName]; if (!parsedShortcut) return false; @@ -504,9 +477,11 @@ namespace gdjs { isShortcutKey(inputManager: gdjs.InputManager, keyCode: integer): boolean { for (const commandName in this._parsedShortcuts) { const parsedShortcut = this._parsedShortcuts[commandName]; + // Shift makes the camera move faster: it's not part of these shortcuts. + const ignoreShift = moveCameraCommandNames.indexOf(commandName) !== -1; if ( parsedShortcut.keyCode === keyCode && - this._areModifiersMatching(inputManager, parsedShortcut, false) + this._areModifiersMatching(inputManager, parsedShortcut, ignoreShift) ) { return true; } @@ -752,7 +727,7 @@ namespace gdjs { !isShiftPressed(inputManager) && arrowKeys.some((key) => inputManager.isKeyPressed(key))) || moveCameraCommandNames.some((commandName) => - shortcuts.isPressed(inputManager, commandName, { ignoreShift: true }) + shortcuts.isPressed(inputManager, commandName, true) ); const snap = (value: float, size: float, offset: float) => @@ -5218,9 +5193,7 @@ namespace gdjs { const shortcuts = this._editorCamera.editor.getShortcuts(); const isMoveCameraShortcutPressed = (commandName: string) => - shortcuts.isPressed(inputManager, commandName, { - ignoreShift: true, - }); + shortcuts.isPressed(inputManager, commandName, true); // Forward/back if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_FORWARD')) { moveCameraByVector(forward, moveSpeed); diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index daa5186d3a55..568c59fc7894 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -1,6 +1,7 @@ // @flow import { t } from '@lingui/macro'; import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; +import Window from '../Utils/Window'; export type CommandName = | 'QUIT_APP' @@ -106,7 +107,16 @@ export const commandAreas = { DEVELOPER: (t`Developer`: any), }; -type CommandArea = $Keys; +export type CommandArea = $Keys; + +/** + * The areas of the commands displayed to the user, in order: the developer + * area is only displayed in development mode. + */ +export const getDisplayedCommandAreaNames = (): Array => + Object.keys(commandAreas).filter( + areaName => areaName !== 'DEVELOPER' || Window.isDev() + ); type CommandMetadata = {| area: CommandArea, @@ -117,7 +127,8 @@ type CommandMetadata = {| /** * If true, the shortcut is handled by the in-game editor (the game running * in the embedded preview), which has the keyboard focus when it's used. - * The IDE ignores these shortcuts, and they can't clash with the IDE ones. + * The IDE ignores these shortcuts, and the ones without modifier can't + * clash with the IDE ones. */ handledByInGameEditor?: boolean, |}; @@ -518,6 +529,9 @@ const commandsList: { [CommandName]: CommandMetadata } = { OPEN_MEMORY_TRACKER_REGISTRY: { area: 'DEVELOPER', displayText: t`Open memory tracker registry`, + // A tool for the developers of GDevelop: hidden from the palette (and + // from the shortcuts list, like the whole area) in production. + ghost: !Window.isDev(), }, }; diff --git a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js index fdf5157dd035..a1bcbd536d2a 100644 --- a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js +++ b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js @@ -20,9 +20,11 @@ export type InGameEditorSettings = { * format as the IDE shortcuts (for example "Shift+KeyW"). An empty string * means that the command has no shortcut. */ - shortcuts: { [string]: string }, + shortcuts: { [CommandName]: string }, }; +const commandNamesHandledByInGameEditor = getCommandNamesHandledByInGameEditor(); + /** * Generate the settings sent to the in-game editor, either at preview launch * or when there is a change in the settings. @@ -36,18 +38,17 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { const toolbarSeparatorColor = gdevelopTheme.toolbar.separatorColor; const textColorPrimary = gdevelopTheme.text.color.primary; - // The shortcut map is a new object at each render: the shortcuts handled by - // the in-game editor are serialized so that the settings only change when - // one of these shortcuts really changed. const shortcutMap = useShortcutMap(); - const inGameEditorShortcutsJson = JSON.stringify( - getCommandNamesHandledByInGameEditor().reduce( - (shortcuts: { [CommandName]: string }, commandName) => { - shortcuts[commandName] = shortcutMap[commandName] || ''; - return shortcuts; - }, - {} - ) + const shortcuts = React.useMemo( + () => + commandNamesHandledByInGameEditor.reduce( + (shortcuts: { [CommandName]: string }, commandName) => { + shortcuts[commandName] = shortcutMap[commandName] || ''; + return shortcuts; + }, + {} + ), + [shortcutMap] ); const inGameEditorSettings = React.useMemo( @@ -59,7 +60,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarSeparatorColor, textColorPrimary, }, - shortcuts: JSON.parse(inGameEditorShortcutsJson), + shortcuts, }), [ iconButtonSelectedBackgroundColor, @@ -67,7 +68,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarBackgroundColor, toolbarSeparatorColor, textColorPrimary, - inGameEditorShortcutsJson, + shortcuts, ] ); diff --git a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js index 34e9db4741a9..14461f3d5ec1 100644 --- a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js +++ b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js @@ -784,45 +784,31 @@ const TileSetVisualizer = ({ // The painting tools, used both by the toolbar buttons and by the keyboard // shortcuts (commands). Selecting the current tool again deselects it. - const defaultPaintingCoordinates: TileMapCoordinates[] = lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }]; const isToolSelected = (kind: string): boolean => !!tileMapTileSelection && tileMapTileSelection.kind === kind; - const toggleFreehandBrush = () => { - if (isToolSelected('freehand')) onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'freehand', - coordinates: defaultPaintingCoordinates, - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - }; - const toggleRectanglePaint = () => { - if (isToolSelected('rectangle')) onSelectTileMapTile(null); - else - onSelectTileMapTile( - lastSelection && lastSelection.kind === 'rectangle' - ? lastSelection - : { - kind: 'rectangle', - coordinates: defaultPaintingCoordinates, - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - } - ); - }; - const toggleFillBucket = () => { - if (isToolSelected('floodfill')) onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'floodfill', - coordinates: defaultPaintingCoordinates, - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); + const togglePaintingTool = (kind: 'freehand' | 'rectangle' | 'floodfill') => { + if (isToolSelected(kind)) { + onSelectTileMapTile(null); + return; + } + // The rectangle tool restores its last selection of tiles. + if ( + kind === 'rectangle' && + lastSelection && + lastSelection.kind === 'rectangle' + ) { + onSelectTileMapTile(lastSelection); + return; + } + onSelectTileMapTile({ + kind: (kind: any), + coordinates: lastPaintingSelection + ? lastPaintingSelection.coordinates + : [{ x: 0, y: 0 }, { x: 0, y: 0 }], + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + }); }; const toggleTilePicker = () => { if (isToolSelected('picker')) { @@ -842,55 +828,81 @@ const TileSetVisualizer = ({ !!tileMapTileSelection && tileMapTileSelection.kind !== 'erase' && tileMapTileSelection.kind !== 'picker'; - const toggleFlipHorizontally = () => { - const newShouldFlipHorizontally = !shouldFlipHorizontally; - setShouldFlipHorizontally(newShouldFlipHorizontally); - if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { - kind: (tileMapPaintingSelection.kind: any), - coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: newShouldFlipHorizontally, - flipVertically: tileMapPaintingSelection.flipVertically, - }; - onSelectTileMapTile(selection); - } - }; - const toggleFlipVertically = () => { - const newShouldFlipVertically = !shouldFlipVertically; - setShouldFlipVertically(newShouldFlipVertically); + const toggleFlip = (axis: 'flipHorizontally' | 'flipVertically') => { + const isHorizontal = axis === 'flipHorizontally'; + const newShouldFlip = isHorizontal + ? !shouldFlipHorizontally + : !shouldFlipVertically; + if (isHorizontal) setShouldFlipHorizontally(newShouldFlip); + else setShouldFlipVertically(newShouldFlip); if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { + onSelectTileMapTile({ kind: (tileMapPaintingSelection.kind: any), coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: tileMapPaintingSelection.flipHorizontally, - flipVertically: newShouldFlipVertically, - }; - onSelectTileMapTile(selection); + flipHorizontally: isHorizontal + ? newShouldFlip + : tileMapPaintingSelection.flipHorizontally, + flipVertically: isHorizontal + ? tileMapPaintingSelection.flipVertically + : newShouldFlip, + }); } }; // Keyboard shortcuts of the painting tools, only when the toolbar is shown. + // `useCommand` registers a command again each time its handler changes, and + // this component renders at each mouse move on the atlas: the handlers are + // kept stable and read the (fresh) tools from a ref when they are called. + const paintingToolsRef = React.useRef<{| + togglePaintingTool: typeof togglePaintingTool, + toggleTilePicker: typeof toggleTilePicker, + toggleErase: typeof toggleErase, + toggleFlip: typeof toggleFlip, + |}>({ togglePaintingTool, toggleTilePicker, toggleErase, toggleFlip }); + paintingToolsRef.current = { + togglePaintingTool, + toggleTilePicker, + toggleErase, + toggleFlip, + }; + const paintingCommandHandlers = React.useMemo( + () => ({ + freehandBrush: () => + paintingToolsRef.current.togglePaintingTool('freehand'), + rectanglePaint: () => + paintingToolsRef.current.togglePaintingTool('rectangle'), + fillBucket: () => + paintingToolsRef.current.togglePaintingTool('floodfill'), + tilePicker: () => paintingToolsRef.current.toggleTilePicker(), + erase: () => paintingToolsRef.current.toggleErase(), + flipHorizontally: () => + paintingToolsRef.current.toggleFlip('flipHorizontally'), + flipVertically: () => + paintingToolsRef.current.toggleFlip('flipVertically'), + }), + [] + ); const arePaintingCommandsEnabled = showPaintingToolbar && isAtlasImageSet; useCommand('TILEMAP_FREEHAND_BRUSH', arePaintingCommandsEnabled, { - handler: toggleFreehandBrush, + handler: paintingCommandHandlers.freehandBrush, }); useCommand('TILEMAP_RECTANGLE_PAINT', arePaintingCommandsEnabled, { - handler: toggleRectanglePaint, + handler: paintingCommandHandlers.rectanglePaint, }); useCommand('TILEMAP_FILL_BUCKET', arePaintingCommandsEnabled, { - handler: toggleFillBucket, + handler: paintingCommandHandlers.fillBucket, }); useCommand('TILEMAP_TILE_PICKER', arePaintingCommandsEnabled, { - handler: toggleTilePicker, + handler: paintingCommandHandlers.tilePicker, }); useCommand('TILEMAP_ERASE', showPaintingToolbar, { - handler: toggleErase, + handler: paintingCommandHandlers.erase, }); useCommand('TILEMAP_FLIP_HORIZONTALLY', showPaintingToolbar && canFlip, { - handler: toggleFlipHorizontally, + handler: paintingCommandHandlers.flipHorizontally, }); useCommand('TILEMAP_FLIP_VERTICALLY', showPaintingToolbar && canFlip, { - handler: toggleFlipVertically, + handler: paintingCommandHandlers.flipVertically, }); return ( @@ -904,7 +916,7 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Freehand brush`} selected={isToolSelected('freehand')} - onClick={toggleFreehandBrush} + onClick={() => togglePaintingTool('freehand')} disabled={!isAtlasImageSet} > @@ -914,7 +926,7 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Rectangle paint`} selected={isToolSelected('rectangle')} - onClick={toggleRectanglePaint} + onClick={() => togglePaintingTool('rectangle')} disabled={!isAtlasImageSet} > @@ -924,7 +936,7 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Fill bucket`} selected={isToolSelected('floodfill')} - onClick={toggleFillBucket} + onClick={() => togglePaintingTool('floodfill')} disabled={!isAtlasImageSet} > @@ -956,7 +968,7 @@ const TileSetVisualizer = ({ tooltip={t`Horizontal flip`} selected={shouldFlipHorizontally} disabled={!canFlip} - onClick={toggleFlipHorizontally} + onClick={() => toggleFlip('flipHorizontally')} > @@ -966,7 +978,7 @@ const TileSetVisualizer = ({ tooltip={t`Vertical flip`} selected={shouldFlipVertically} disabled={!canFlip} - onClick={toggleFlipVertically} + onClick={() => toggleFlip('flipVertically')} > diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index 72b1fae5d67e..5474698fd440 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -11,9 +11,9 @@ import ShortcutsListRow from './ShortcutsListRow'; import commandsList, { type CommandName, commandAreas, + getDisplayedCommandAreaNames, } from '../CommandPalette/CommandsList'; import { ColumnStackLayout } from '../UI/Layout'; -import Window from '../Utils/Window'; const styles = { section: { @@ -43,16 +43,23 @@ const getPatchedShortcutString = ( /** * The shortcuts of the commands handled by the in-game editor are only active - * when the game preview has the focus, while the other ones are active in the - * IDE: two commands can only clash if they are in the same context. + * when the game preview has the focus, and the preview forwards to the IDE the + * keys pressed with a modifier. So a shortcut without modifier can't clash + * between the two contexts, while a shortcut with a modifier can (the in-game + * editor takes it over when it has the focus). */ const getShortcutContextKey = ( commandName: CommandName, shortcutString: string -): string => - (commandsList[commandName].handledByInGameEditor - ? 'in-game-editor:' - : 'ide:') + shortcutString; +): string => { + const isIsolatedInGameEditorShortcut = + !!commandsList[commandName].handledByInGameEditor && + !shortcutString.includes('+'); + return ( + (isIsolatedInGameEditorShortcut ? 'in-game-editor:' : 'ide:') + + shortcutString + ); +}; /** * Sorts all commands into an object keyed by area name, and also creates a @@ -128,9 +135,8 @@ export const getShortcutSections = ( // The areas are displayed in the order of `commandAreas`, which is also the // order of the areas list in the preferences dialog. - return Object.keys(commandAreas) + return getDisplayedCommandAreaNames() .filter(areaName => !!areaWiseCommands[areaName]) - .filter(areaName => areaName !== 'DEVELOPER' || Window.isDev()) .map( (areaName): ShortcutSectionData => { const rows: Array = areaWiseCommands[areaName] @@ -194,8 +200,6 @@ type Props = {| onEdit: (commandName: CommandName, shortcut: string) => void, /** Filter the displayed commands by name or by shortcut. */ searchText?: string, - /** Only display the commands of this area, without the area title. */ - areaName?: string, /** Give an id to the element of each area, to be able to scroll to it. */ getSectionElementId?: (areaName: string) => string, |}; @@ -214,7 +218,7 @@ const ShortcutsList = (props: Props): React.Node => { props.i18n, props.userShortcutMap, props.searchText || '' - ).filter(section => !props.areaName || section.areaName === props.areaName); + ); return ( @@ -227,9 +231,9 @@ const ShortcutsList = (props: Props): React.Node => { ? props.getSectionElementId(section.areaName) : undefined } - style={props.areaName ? styles.section : styles.areaSection} + style={styles.areaSection} > - {!props.areaName && {section.title}} + {section.title}
{section.rows.map(row => ( { const preferences = React.useContext(PreferencesContext); const userShortcutMap = preferences.values.userShortcutMap; - return { ...defaultShortcuts, ...userShortcutMap }; + return React.useMemo(() => ({ ...defaultShortcuts, ...userShortcutMap }), [ + userShortcutMap, + ]); }; type UseKeyboardShortcutsProps = {| @@ -274,11 +276,15 @@ export const useKeyboardShortcuts = ({ // the in-game editor are ignored: their shortcuts are only active // when the game preview has the focus, and are handled by the game. const commandName = - Object.keys(shortcutMap).find( - name => - !commandsList[name].handledByInGameEditor && + Object.keys(shortcutMap).find(name => { + // The user shortcut map can contain commands that don't exist anymore. + const command = commandsList[name]; + return ( + !!command && + !command.handledByInGameEditor && shortcutMap[name] === shortcutData.shortcutString - ) || + ); + }) || Object.keys(defaultSecondaryShortcuts).find( name => defaultSecondaryShortcuts[name] === shortcutData.shortcutString diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index d5ef1e7c9905..d6926c65151f 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -23,7 +23,7 @@ import Text from '../../UI/Text'; import EmptyMessage from '../../UI/EmptyMessage'; import { ColumnStackLayout } from '../../UI/Layout'; import { Tabs } from '../../UI/Tabs'; -import SettingsRow from '../../UI/SettingsRow'; +import SettingsRow, { useSettingsRowControlIds } from '../../UI/SettingsRow'; import VerticalTabButton from '../../UI/VerticalTabButton'; import SearchBar from '../../UI/SearchBar'; import DismissableAlertMessage from '../../UI/DismissableAlertMessage'; @@ -33,7 +33,12 @@ import ShortcutsList, { } from '../../KeyboardShortcuts/ShortcutsList'; import { LanguageSelectField } from './LanguageSelector'; import Link from '../../UI/Link'; -import { commandAreas } from '../../CommandPalette/CommandsList'; +import { + commandAreas, + getDisplayedCommandAreaNames, + type CommandArea, +} from '../../CommandPalette/CommandsList'; +import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; import { adaptAcceleratorString } from '../../UI/AcceleratorString'; import { @@ -88,8 +93,6 @@ export type PreferencesSectionName = export type PreferencesTabName = 'preferences' | 'shortcuts'; -type ShortcutArea = $Keys; - type GetIconFunction = ({ color: string, fontSize: 'inherit' | 'small', @@ -97,129 +100,113 @@ type GetIconFunction = ({ type PreferencesSection = {| name: PreferencesSectionName, - label: React.Node, - /** The label as a string, used by the search. */ - getSearchableLabel: (i18n: I18n) => string, + /** The label, displayed with `i18n._` and used by the search. */ + label: MessageDescriptor, getIcon: GetIconFunction, |}; const sections: Array = [ { name: 'general', - label: General, - getSearchableLabel: i18n => i18n._(t`General`), + label: t`General`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'updates', - label: Updates, - getSearchableLabel: i18n => i18n._(t`Updates`), + label: t`Updates`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'help', - label: Help and tutorials, - getSearchableLabel: i18n => i18n._(t`Help and tutorials`), + label: t`Help and tutorials`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'appearance', - label: Appearance, - getSearchableLabel: i18n => i18n._(t`Appearance`), + label: t`Appearance`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'layouts', - label: Layouts, - getSearchableLabel: i18n => i18n._(t`Layouts`), + label: t`Layouts`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'dialogs', - label: Dialogs, - getSearchableLabel: i18n => i18n._(t`Dialogs`), + label: t`Dialogs`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'events-sheet', - label: Events Sheet, - getSearchableLabel: i18n => i18n._(t`Events Sheet`), + label: t`Events Sheet`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'previews', - label: Previews & Saves, - getSearchableLabel: i18n => i18n._(t`Previews & Saves`), + label: t`Previews & Saves`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'scene-editor', - label: Scene editor, - getSearchableLabel: i18n => i18n._(t`Scene editor`), + label: t`Scene editor`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'ask-ai', - label: Ask AI, - getSearchableLabel: i18n => i18n._(t`Ask AI`), + label: t`Ask AI`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'extensions', - label: Extensions, - getSearchableLabel: i18n => i18n._(t`Extensions`), + label: t`Extensions`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'folders', - label: Folders, - getSearchableLabel: i18n => i18n._(t`Folders`), + label: t`Folders`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'contributor', - label: Contributor options, - getSearchableLabel: i18n => i18n._(t`Contributor options`), + label: t`Contributor options`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'experimental', - label: Experimental, - getSearchableLabel: i18n => i18n._(t`Experimental`), + label: t`Experimental`, getIcon: ({ color, fontSize }) => ( ), }, { name: 'other', - label: Advanced, - getSearchableLabel: i18n => i18n._(t`Advanced`), + label: t`Advanced`, getIcon: ({ color, fontSize }) => ( ), @@ -229,8 +216,7 @@ const sections: Array = [ ? [ { name: 'developer', - label: Developer, - getSearchableLabel: i18n => i18n._(t`Developer`), + label: t`Developer`, getIcon: ({ color, fontSize }) => ( ), @@ -240,62 +226,42 @@ const sections: Array = [ ]; type ShortcutAreaDefinition = {| - name: ShortcutArea, + name: CommandArea, getIcon: GetIconFunction, |}; /** - * The areas of the commands, displayed as the sections of the shortcuts tab. - * Their labels come from `commandAreas`. + * The icons of the areas of the commands. The areas themselves (and their + * labels and order) come from `commandAreas`, displayed as the sections of the + * shortcuts tab. */ -const shortcutAreas: Array = [ - { - name: 'GENERAL', - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'PROJECT', - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'SCENE', - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'SCENE_3D', - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'TILEMAP', - getIcon: ({ color, fontSize }) => ( - - ), - }, - { - name: 'EVENTS', - getIcon: ({ color, fontSize }) => ( - - ), - }, - ...(Window.isDev() - ? [ - { - name: 'DEVELOPER', - getIcon: ({ color, fontSize }) => ( - - ), - }, - ] - : []), -]; +const shortcutAreaIcons: { [CommandArea]: GetIconFunction } = { + GENERAL: ({ color, fontSize }) => ( + + ), + PROJECT: ({ color, fontSize }) => ( + + ), + SCENE: ({ color, fontSize }) => ( + + ), + SCENE_3D: ({ color, fontSize }) => ( + + ), + TILEMAP: ({ color, fontSize }) => ( + + ), + EVENTS: ({ color, fontSize }) => ( + + ), + DEVELOPER: ({ color, fontSize }) => ( + + ), +}; + +const shortcutAreas: Array = getDisplayedCommandAreaNames().map( + areaName => ({ name: areaName, getIcon: shortcutAreaIcons[areaName] }) +); /** * A setting displayed as a row: a translated label (also used by the search) @@ -386,6 +352,30 @@ type Props = {| isDesktop?: boolean, |}; +/** + * A toggle alone, labelled by the settings row containing it: clicking on the + * label of the row toggles it, and screen readers read the label. + */ +const SettingToggle = ({ + checked, + onCheck, +}: {| + checked: boolean, + onCheck: (newValue: boolean) => void, +|}) => { + const controlIds = useSettingsRowControlIds(); + return ( + + ); +}; + const PreferencesDialog = ({ i18n, onClose, @@ -406,7 +396,7 @@ const PreferencesDialog = ({ const [ currentShortcutArea, setCurrentShortcutArea, - ] = React.useState('GENERAL'); + ] = React.useState('GENERAL'); const [searchText, setSearchText] = React.useState(''); const [languageDidChange, setLanguageDidChange] = React.useState( false @@ -560,7 +550,7 @@ const PreferencesDialog = ({ if (currentTab === 'preferences') { const sectionNameAtTop = getEntryKeyAtTop( - getVisibleSections().map(section => section.name), + visibleSections.map(section => section.name), getSectionElementId ); if (sectionNameAtTop && sectionNameAtTop !== currentSection) { @@ -605,14 +595,7 @@ const PreferencesDialog = ({ const renderToggle = ( checked: boolean, onCheck: (newValue: boolean) => void - ) => ( - - ); + ) => ; const renderResetLayoutButton = (editorMosaicName: EditorMosaicName) => ( renderToggle( @@ -1052,6 +1035,16 @@ const PreferencesDialog = ({ }, ] : []), + { + id: 'display-save-reminder', + label: i18n._( + t`Display save reminder after significant changes in project` + ), + renderControl: () => + renderToggle(values.displaySaveReminder.activated, check => + setDisplaySaveReminder({ activated: check }) + ), + }, ], }; case 'scene-editor': @@ -1152,16 +1145,6 @@ const PreferencesDialog = ({ case 'other': return { settings: [ - { - id: 'display-save-reminder', - label: i18n._( - t`Display save reminder after significant changes in project` - ), - renderControl: () => - renderToggle(values.displaySaveReminder.activated, check => - setDisplaySaveReminder({ activated: check }) - ), - }, { id: 'use-background-serializer-for-saving', label: i18n._( @@ -1206,19 +1189,8 @@ const PreferencesDialog = ({ ], }; case 'folders': - if (!isDesktop) { - return { - settings: [], - renderHeader: () => ( - - - The folders can only be configured in the desktop version of - GDevelop. - - - ), - }; - } + // Only in the desktop version: the section is hidden otherwise. + if (!isDesktop) return { settings: [] }; return { settings: [ { @@ -1306,13 +1278,21 @@ const PreferencesDialog = ({ * (for example when its settings are only available on some platforms) is * hidden from the sections list and the content. */ - const getVisibleSections = (): Array => - sections.filter(section => { + const visibleSections: Array = sections.filter( + section => { const { settings, renderHeader, renderFooter } = getSectionContent( section.name ); return settings.length > 0 || !!renderHeader || !!renderFooter; - }); + } + ); + // The current section can be hidden (an initial section without any setting + // on this platform): the first visible section is highlighted instead. + const highlightedSectionName = visibleSections.some( + section => section.name === currentSection + ) + ? currentSection + : visibleSections[0].name; const renderSettingsRows = (settings: Array) => ( @@ -1341,7 +1321,7 @@ const PreferencesDialog = ({ > - {section.label} + {i18n._(section.label)} {renderHeader && renderHeader()} {settings.length > 0 && renderSettingsRows(settings)} @@ -1382,7 +1362,7 @@ const PreferencesDialog = ({ return ( - {getVisibleSections().map(renderSection)} + {visibleSections.map(renderSection)} ); }; @@ -1398,9 +1378,11 @@ const PreferencesDialog = ({ getShortcutSections(i18n, values.userShortcutMap, searchText).length > 0; const matchingSections = sections .map(section => { - const { settings } = getSectionContent(section.name); + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); const isSectionNameMatching = normalizeForSearch( - section.getSearchableLabel(i18n) + i18n._(section.label) ).includes(normalizedSearchText); const matchingSettings = isSectionNameMatching ? settings @@ -1408,7 +1390,7 @@ const PreferencesDialog = ({ normalizeForSearch(setting.label).includes(normalizedSearchText) ); return matchingSettings.length > 0 - ? { section, settings: matchingSettings } + ? { section, settings: matchingSettings, renderHeader, renderFooter } : null; }) .filter(Boolean); @@ -1423,12 +1405,16 @@ const PreferencesDialog = ({ return ( - {matchingSections.map(({ section, settings }) => ( - - {section.label} - {renderSettingsRows(settings)} - - ))} + {matchingSections.map( + ({ section, settings, renderHeader, renderFooter }) => ( + + {i18n._(section.label)} + {renderHeader && renderHeader()} + {renderSettingsRows(settings)} + {renderFooter && renderFooter()} + + ) + )} {hasMatchingShortcuts && ( @@ -1450,8 +1436,7 @@ const PreferencesDialog = ({ setSearchText(''); setCurrentTab(tabName); // Start from the top of the new tab. - if (tabName === 'preferences') - setCurrentSection(getVisibleSections()[0].name); + if (tabName === 'preferences') setCurrentSection(visibleSections[0].name); else setCurrentShortcutArea(shortcutAreas[0].name); pendingScrollElementIdRef.current = ''; }; @@ -1462,7 +1447,7 @@ const PreferencesDialog = ({ pendingScrollElementIdRef.current = getSectionElementId(sectionName); }; - const onSelectShortcutArea = (areaName: ShortcutArea) => { + const onSelectShortcutArea = (areaName: CommandArea) => { setSearchText(''); setCurrentShortcutArea(areaName); pendingScrollElementIdRef.current = getShortcutAreaElementId(areaName); @@ -1472,11 +1457,11 @@ const PreferencesDialog = ({ // preferences sections or the shortcut areas, depending on the current tab. const sectionListEntries = currentTab === 'preferences' - ? getVisibleSections().map(section => ({ + ? visibleSections.map(section => ({ key: section.name, - label: section.label, + label: i18n._(section.label), getIcon: section.getIcon, - isActive: !isSearching && currentSection === section.name, + isActive: !isSearching && highlightedSectionName === section.name, onSelect: () => onSelectSection(section.name), })) : shortcutAreas.map(area => ({ diff --git a/newIDE/app/src/UI/CompactToggleField/index.js b/newIDE/app/src/UI/CompactToggleField/index.js index 0f50d277f41e..fae25f724e67 100644 --- a/newIDE/app/src/UI/CompactToggleField/index.js +++ b/newIDE/app/src/UI/CompactToggleField/index.js @@ -20,6 +20,10 @@ type Props = {| label: string, markdownDescription?: ?string, id?: string, + /** The id of the checkbox itself, to be targeted by an external label. */ + inputId?: string, + /** The id of the element labelling the toggle, when `label` is empty. */ + ariaLabelledBy?: string, checked: boolean, onCheck: (newValue: boolean) => void, disabled?: boolean, @@ -61,6 +65,8 @@ export const CompactToggleField = (props: Props): React.MixedElement => {
props.onCheck(!props.checked)} disabled={props.disabled} diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index bb182dd9b049..0d4e0db55c89 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -9,17 +9,23 @@ import './SettingsRow.css'; // (toggles, select fields, buttons, shortcuts...) are aligned like in a table. // On mobile, the control takes the full width, on its own line. const controlColumnWidth = '40%'; +// Fixed height of the rows (except on mobile, where the control is on its own +// line), so that all the rows have the same height whatever their control: +// a toggle, a select field or a button. +const rowHeight = 40; const styles = { row: { display: 'flex', alignItems: 'center', + boxSizing: 'border-box', padding: `${marginsSize / 2}px ${marginsSize}px`, }, // On mobile, the control is displayed on its own line, below the label. rowOnMobile: { flexDirection: 'column', alignItems: 'stretch', + height: 'auto', }, labelColumn: { flex: 1, @@ -41,6 +47,25 @@ const styles = { }, }; +type SettingsRowControlIds = {| + /** The id to give to the control, so that clicking on the label focuses it. */ + controlId: string, + /** The id of the label, to use as `aria-labelledby` on the control. */ + labelId: string, +|}; + +const SettingsRowContext = React.createContext(null); + +/** + * The ids of the label and of the control of the enclosing settings row, so + * that a control can be labelled by the row (for accessibility and to be + * toggled by a click on the label). + */ +export const useSettingsRowControlIds = (): ?SettingsRowControlIds => + React.useContext(SettingsRowContext); + +let generatedRowIdsCount = 0; + type Props = {| id?: string, /** The name of the setting, displayed on the left. */ @@ -63,15 +88,34 @@ const SettingsRow = ({ children, }: Props): React.Node => { const { isMobile } = useResponsiveWindowSize(); + const generatedIdRef = React.useRef(''); + if (!generatedIdRef.current) { + generatedRowIdsCount++; + generatedIdRef.current = `settings-row-${generatedRowIdsCount}`; + } + const rowId = id || generatedIdRef.current; + const controlIds = React.useMemo( + () => ({ controlId: `${rowId}-control`, labelId: `${rowId}-label` }), + [rowId] + ); return (
- {label} + {description && ( {description} @@ -88,7 +132,9 @@ const SettingsRow = ({ } } > - {children} + + {children} +
); From ede3e1b4843cec09637b8dc6ba482e9ddcc0a359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 7 Sep 2026 01:18:42 +0200 Subject: [PATCH 05/14] Remove colors & dead code --- .../src/KeyboardShortcuts/ShortcutsList.js | 4 ++-- .../MainFrame/Preferences/LanguageSelector.js | 2 +- newIDE/app/src/UI/SettingsRow.css | 8 -------- newIDE/app/src/UI/SettingsRow.js | 20 ++----------------- 4 files changed, 5 insertions(+), 29 deletions(-) delete mode 100644 newIDE/app/src/UI/SettingsRow.css diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index 5474698fd440..df135cc70703 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -103,7 +103,7 @@ const sortCommandsIntoAreasAndGetReverseMap = ( export const normalizeForSearch = (text: string): string => text.toLowerCase().replace(/\s+/g, ''); -export type ShortcutRowData = {| +type ShortcutRowData = {| commandName: CommandName, commandDisplayText: string, shortcutDisplayName: string, @@ -112,7 +112,7 @@ export type ShortcutRowData = {| clashingCommandNames: Array, |}; -export type ShortcutSectionData = {| +type ShortcutSectionData = {| areaName: string, title: string, rows: Array, diff --git a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js index 4cf9fe93eeb1..d4bb0feedb78 100644 --- a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js +++ b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js @@ -107,7 +107,7 @@ export const LanguageSelectField = ({ /** * The text inviting to contribute to the translations. */ -export const TranslationContributionText = (): React.Node => ( +const TranslationContributionText = (): React.Node => ( You can{' '} diff --git a/newIDE/app/src/UI/SettingsRow.css b/newIDE/app/src/UI/SettingsRow.css deleted file mode 100644 index b68d98efca5d..000000000000 --- a/newIDE/app/src/UI/SettingsRow.css +++ /dev/null @@ -1,8 +0,0 @@ -/* Alternating ("zebra") row colors, like in the instances list (see - * Theme/Global/Table.css), instead of separators between the rows. */ -.settings-row:nth-child(odd) { - background-color: var(--table-row-even-background-color); -} -.settings-row:nth-child(even) { - background-color: var(--table-row-odd-background-color); -} diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index 0d4e0db55c89..786013de7f5c 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -3,7 +3,6 @@ import * as React from 'react'; import Text from './Text'; import { marginsSize } from './Grid'; import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; -import './SettingsRow.css'; // Width of the control column, shared by all rows so that the controls // (toggles, select fields, buttons, shortcuts...) are aligned like in a table. @@ -70,8 +69,6 @@ type Props = {| id?: string, /** The name of the setting, displayed on the left. */ label: React.Node, - /** An optional explanation, displayed below the label. */ - description?: React.Node, /** The control (toggle, select field, button...), aligned on the right. */ children?: React.Node, |}; @@ -79,14 +76,8 @@ type Props = {| /** * A row of a settings list: a label on the left and a control aligned on the * right, in a fixed width column shared by all the rows, like in a table. - * Rows must be direct siblings so that their background colors alternate. */ -const SettingsRow = ({ - id, - label, - description, - children, -}: Props): React.Node => { +const SettingsRow = ({ id, label, children }: Props): React.Node => { const { isMobile } = useResponsiveWindowSize(); const generatedIdRef = React.useRef(''); if (!generatedIdRef.current) { @@ -102,11 +93,9 @@ const SettingsRow = ({ return (
@@ -116,11 +105,6 @@ const SettingsRow = ({ {label} - {description && ( - - {description} - - )}
Date: Mon, 7 Sep 2026 16:05:48 +0200 Subject: [PATCH 06/14] remove props actionsFullWidthOnMobile on preference dialog --- newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js | 1 - 1 file changed, 1 deletion(-) diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index d6926c65151f..ba54081e7195 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -1518,7 +1518,6 @@ const PreferencesDialog = ({ open fullHeight flexColumnBody - actionsFullWidthOnMobile fixedContent={ Date: Tue, 8 Sep 2026 17:11:56 +0200 Subject: [PATCH 07/14] Label doesn't toggle the option --- newIDE/app/src/UI/SettingsRow.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index 786013de7f5c..010775cd7b7e 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -47,7 +47,7 @@ const styles = { }; type SettingsRowControlIds = {| - /** The id to give to the control, so that clicking on the label focuses it. */ + /** The id to give to the control. */ controlId: string, /** The id of the label, to use as `aria-labelledby` on the control. */ labelId: string, @@ -57,8 +57,9 @@ const SettingsRowContext = React.createContext(null); /** * The ids of the label and of the control of the enclosing settings row, so - * that a control can be labelled by the row (for accessibility and to be - * toggled by a click on the label). + * that a control can be labelled by the row (for accessibility). The label is + * not clickable on purpose: it would silently toggle the setting, which is too + * easy to do by mistake with a mouse or on a touchscreen. */ export const useSettingsRowControlIds = (): ?SettingsRowControlIds => React.useContext(SettingsRowContext); @@ -100,11 +101,11 @@ const SettingsRow = ({ id, label, children }: Props): React.Node => { }} >
- +
Date: Tue, 8 Sep 2026 17:12:12 +0200 Subject: [PATCH 08/14] font size --- newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index ba54081e7195..40f35df82493 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -1320,7 +1320,7 @@ const PreferencesDialog = ({ style={styles.section} > - + {i18n._(section.label)} {renderHeader && renderHeader()} @@ -1408,7 +1408,7 @@ const PreferencesDialog = ({ {matchingSections.map( ({ section, settings, renderHeader, renderFooter }) => ( - {i18n._(section.label)} + {i18n._(section.label)} {renderHeader && renderHeader()} {renderSettingsRows(settings)} {renderFooter && renderFooter()} @@ -1417,7 +1417,7 @@ const PreferencesDialog = ({ )} {hasMatchingShortcuts && ( - + Keyboard Shortcuts Date: Tue, 8 Sep 2026 17:13:01 +0200 Subject: [PATCH 09/14] rename option to simplier names --- newIDE/app/src/CommandPalette/CommandsList.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index 568c59fc7894..d92f509c1604 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -429,15 +429,15 @@ const commandsList: { [CommandName]: CommandMetadata } = { // Tile map painting tools (shown when painting a tile map in the scene editor) TILEMAP_FREEHAND_BRUSH: { area: 'TILEMAP', - displayText: t`Freehand brush`, + displayText: t`Freehand tile brush`, }, TILEMAP_RECTANGLE_PAINT: { area: 'TILEMAP', - displayText: t`Rectangle paint`, + displayText: t`Rectangle tile paint`, }, TILEMAP_FILL_BUCKET: { area: 'TILEMAP', - displayText: t`Fill bucket`, + displayText: t`Tile fill bucket`, }, TILEMAP_TILE_PICKER: { area: 'TILEMAP', @@ -445,15 +445,15 @@ const commandsList: { [CommandName]: CommandMetadata } = { }, TILEMAP_ERASE: { area: 'TILEMAP', - displayText: t`Erase`, + displayText: t`Tile eraser`, }, TILEMAP_FLIP_HORIZONTALLY: { area: 'TILEMAP', - displayText: t`Flip horizontally`, + displayText: t`Flip tile horizontally`, }, TILEMAP_FLIP_VERTICALLY: { area: 'TILEMAP', - displayText: t`Flip vertically`, + displayText: t`Flip tile vertically`, }, // In-game (3D) editor commands. Their shortcuts are handled by the game @@ -520,7 +520,7 @@ const commandsList: { [CommandName]: CommandMetadata } = { }, IN_GAME_EDITOR_ORBIT_CAMERA: { area: 'SCENE_3D', - displayText: t`Orbit the camera around the point in front of it`, + displayText: t`Orbit mode`, ghost: true, handledByInGameEditor: true, }, From 94b0f2a64c805a86f37ae2c517bbf6e7081dd770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Tue, 8 Sep 2026 17:23:16 +0200 Subject: [PATCH 10/14] search --- newIDE/app/src/KeyboardShortcuts/ShortcutsList.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index df135cc70703..fbfb6136d294 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -120,7 +120,9 @@ type ShortcutSectionData = {| /** * Get the shortcuts grouped by area, keeping only the commands matching the - * search text (by name or by shortcut). Sections without any match are omitted. + * search text (by name, by shortcut, or by area name: searching "tilemap" + * shows all the shortcuts of the "Tilemap tools" area). Sections without any + * match are omitted. */ export const getShortcutSections = ( i18n: I18n, @@ -139,6 +141,11 @@ export const getShortcutSections = ( .filter(areaName => !!areaWiseCommands[areaName]) .map( (areaName): ShortcutSectionData => { + const areaTitle = i18n._(commandAreas[areaName]); + // An area whose name matches shows all its shortcuts. + const isAreaNameMatching = + !!normalizedSearchText && + normalizeForSearch(areaTitle).includes(normalizedSearchText); const rows: Array = areaWiseCommands[areaName] .map( (commandName: CommandName): ShortcutRowData | null => { @@ -158,6 +165,7 @@ export const getShortcutSections = ( const matchesSearch = !normalizedSearchText || + isAreaNameMatching || normalizeForSearch(commandDisplayText).includes( normalizedSearchText ) || @@ -186,7 +194,7 @@ export const getShortcutSections = ( return { areaName, - title: i18n._(commandAreas[areaName]), + title: areaTitle, rows, }; } From d0f4dea21fb8ca30338a601aa8e05f4148049828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Tue, 8 Sep 2026 17:25:46 +0200 Subject: [PATCH 11/14] hover a line --- newIDE/app/src/UI/SettingsRow.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index 010775cd7b7e..6f1d30884438 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -3,6 +3,7 @@ import * as React from 'react'; import Text from './Text'; import { marginsSize } from './Grid'; import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; +import GDevelopThemeContext from './Theme/GDevelopThemeContext'; // Width of the control column, shared by all rows so that the controls // (toggles, select fields, buttons, shortcuts...) are aligned like in a table. @@ -19,6 +20,7 @@ const styles = { alignItems: 'center', boxSizing: 'border-box', padding: `${marginsSize / 2}px ${marginsSize}px`, + borderRadius: 4, }, // On mobile, the control is displayed on its own line, below the label. rowOnMobile: { @@ -80,6 +82,10 @@ type Props = {| */ const SettingsRow = ({ id, label, children }: Props): React.Node => { const { isMobile } = useResponsiveWindowSize(); + const gdevelopTheme = React.useContext(GDevelopThemeContext); + // The row is highlighted on hover: the label and its control are far apart, + // so the highlight is what shows which control belongs to which label. + const [isHovered, setIsHovered] = React.useState(false); const generatedIdRef = React.useRef(''); if (!generatedIdRef.current) { generatedRowIdsCount++; @@ -98,7 +104,16 @@ const SettingsRow = ({ id, label, children }: Props): React.Node => { ...styles.row, height: rowHeight, ...(isMobile ? styles.rowOnMobile : {}), + ...(isHovered + ? { backgroundColor: gdevelopTheme.list.hover.backgroundColor } + : {}), }} + // Only a mouse hovers: a touch would leave the row highlighted after + // the finger is lifted. + onPointerEnter={event => { + if (event.pointerType === 'mouse') setIsHovered(true); + }} + onPointerLeave={() => setIsHovered(false)} >
From d329d356da111b9aacde64d337de558f77a646da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Tue, 8 Sep 2026 17:49:59 +0200 Subject: [PATCH 12/14] color hover like everywhere else --- newIDE/app/src/UI/SettingsRow.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index 6f1d30884438..b5724c3041db 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -3,7 +3,7 @@ import * as React from 'react'; import Text from './Text'; import { marginsSize } from './Grid'; import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; -import GDevelopThemeContext from './Theme/GDevelopThemeContext'; +import { useTheme } from '@material-ui/styles'; // Width of the control column, shared by all rows so that the controls // (toggles, select fields, buttons, shortcuts...) are aligned like in a table. @@ -82,7 +82,7 @@ type Props = {| */ const SettingsRow = ({ id, label, children }: Props): React.Node => { const { isMobile } = useResponsiveWindowSize(); - const gdevelopTheme = React.useContext(GDevelopThemeContext); + const muiTheme = useTheme(); // The row is highlighted on hover: the label and its control are far apart, // so the highlight is what shows which control belongs to which label. const [isHovered, setIsHovered] = React.useState(false); @@ -105,7 +105,7 @@ const SettingsRow = ({ id, label, children }: Props): React.Node => { height: rowHeight, ...(isMobile ? styles.rowOnMobile : {}), ...(isHovered - ? { backgroundColor: gdevelopTheme.list.hover.backgroundColor } + ? { backgroundColor: muiTheme.palette.action.hover } : {}), }} // Only a mouse hovers: a touch would leave the row highlighted after From 2dfdb5b422c6144db1f11f90330c1abff471c0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Tue, 8 Sep 2026 17:55:14 +0200 Subject: [PATCH 13/14] fix flow --- newIDE/app/src/UI/SettingsRow.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/newIDE/app/src/UI/SettingsRow.js b/newIDE/app/src/UI/SettingsRow.js index b5724c3041db..bdeb624001cf 100644 --- a/newIDE/app/src/UI/SettingsRow.js +++ b/newIDE/app/src/UI/SettingsRow.js @@ -104,9 +104,7 @@ const SettingsRow = ({ id, label, children }: Props): React.Node => { ...styles.row, height: rowHeight, ...(isMobile ? styles.rowOnMobile : {}), - ...(isHovered - ? { backgroundColor: muiTheme.palette.action.hover } - : {}), + backgroundColor: isHovered ? muiTheme.palette.action.hover : undefined, }} // Only a mouse hovers: a touch would leave the row highlighted after // the finger is lifted. From 5971959a8d29f744aa98454f85969d7430c83359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Wed, 16 Sep 2026 11:46:56 +0200 Subject: [PATCH 14/14] Compact local folder --- .../Preferences/PreferencesDialog.js | 5 +- .../src/UI/CompactLocalFolderPicker/index.js | 123 ++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 newIDE/app/src/UI/CompactLocalFolderPicker/index.js diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index 40f35df82493..ea0c576af164 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -6,7 +6,7 @@ import React from 'react'; import { Divider } from '@material-ui/core'; import FlatButton from '../../UI/FlatButton'; import HelpButton from '../../UI/HelpButton'; -import LocalFolderPicker from '../../UI/LocalFolderPicker'; +import CompactLocalFolderPicker from '../../UI/CompactLocalFolderPicker'; import SelectOption from '../../UI/SelectOption'; import { CompactToggleField } from '../../UI/CompactToggleField'; import Dialog from '../../UI/Dialog'; @@ -1197,8 +1197,7 @@ const PreferencesDialog = ({ id: 'new-projects-default-folder', label: i18n._(t`Default folder for new projects`), renderControl: () => ( - void, + defaultPath?: string, + id?: string, + disabled?: boolean, +|}; + +type TitleAndMessage = {| + title: string, + message: string, +|}; + +const getTitleAndMessage = ( + i18n: I18nType, + type: 'export' | 'create-game' | 'default-workspace' +): TitleAndMessage => { + if (type === 'export') { + return { + title: i18n._(t`Choose an export folder`), + message: i18n._(t`Choose where to export the game`), + }; + } + if (type === 'default-workspace') { + return { + title: i18n._(t`Choose a workspace folder`), + message: i18n._(t`Choose where to create your projects`), + }; + } + return { + title: i18n._(t`Choose a folder for the new game`), + message: i18n._(t`Choose where to create the game`), + }; +}; + +/** + * A compact variant of `LocalFolderPicker`, to be used alongside other + * compact fields (`CompactTextField`, `CompactSelectField`...). + */ +const CompactLocalFolderPicker = ({ + type, + value, + onChange, + defaultPath, + id, + disabled, +}: Props): React.Node => { + // Use an internal state to avoid validating the value when the user + // is typing in the text field. This allows typing a "/" without the + // formatting kicking in. + const [textValue, setTextValue] = React.useState(value); + + const onChooseFolder = async ({ title, message }: TitleAndMessage) => { + if (!dialog || !electron) return; + + const browserWindow = remote.getCurrentWindow(); + const { filePaths } = await dialog.showOpenDialog(browserWindow, { + title, + properties: ['openDirectory', 'createDirectory'], + message, + defaultPath: defaultPath, + }); + + if (!filePaths || !filePaths.length) return; + + const filePath = filePaths[0]; + onChange(filePath); + setTextValue(filePath); + }; + + return ( + + {({ i18n }) => { + const titleAndMessage = getTitleAndMessage(i18n, type); + return ( + + setTextValue(newValue)} + onBlur={() => onChange(textValue)} + placeholder={titleAndMessage.title} + disabled={disabled} + /> + onChooseFolder(titleAndMessage)} + > + + + + ); + }} + + ); +}; + +export default CompactLocalFolderPicker;